Spaces:
Running on Zero
Running on Zero
Accept larger custom uploads with explicit paired demo resizing
Browse files- README.md +10 -4
- app.py +54 -18
- test_app.py +100 -0
README.md
CHANGED
|
@@ -25,10 +25,16 @@ Upload an aligned reference/distorted pair, select JLD or JLD-fast, and obtain
|
|
| 25 |
a scalar distance plus a local lens response overlay. Lower means more similar.
|
| 26 |
The overlay excludes the global CLS term and is normalized separately for each pair.
|
| 27 |
|
| 28 |
-
Both images must have the same
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
startup, while the fitted lens is included in the package. The hosted Space uses
|
| 33 |
free shared ZeroGPU access; queueing and daily usage limits may apply.
|
| 34 |
|
|
|
|
| 25 |
a scalar distance plus a local lens response overlay. Lower means more similar.
|
| 26 |
The overlay excludes the global CLS term and is normalized separately for each pair.
|
| 27 |
|
| 28 |
+
Both images must be aligned and have the same size. Uploads may be up to
|
| 29 |
+
24 megapixels, 8192 pixels on the longest side, and 10 MB each. By default,
|
| 30 |
+
large pairs are reduced together with the same Lanczos filter to fit the
|
| 31 |
+
hosted scoring limit of 262,144 pixels and a longest side of 1024 pixels.
|
| 32 |
+
The result reports the original, resized, and scored dimensions. This score
|
| 33 |
+
is for the resized pair. Disable **Resize large pairs for this demo** to
|
| 34 |
+
require native resolution; large pairs then receive an explanatory error.
|
| 35 |
+
Small pairs keep their native pixels. RGB conversion and a center crop to
|
| 36 |
+
multiples of 14 pixels follow the official scorer. Use the local package for
|
| 37 |
+
native-resolution scoring of larger images. The pretrained encoder loads on hosted
|
| 38 |
startup, while the fitted lens is included in the package. The hosted Space uses
|
| 39 |
free shared ZeroGPU access; queueing and daily usage limits may apply.
|
| 40 |
|
app.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from functools import lru_cache
|
|
|
|
| 5 |
import os
|
| 6 |
from pathlib import Path
|
| 7 |
import threading
|
|
@@ -23,9 +24,13 @@ HERE = Path(__file__).resolve().parent
|
|
| 23 |
EXAMPLES = HERE / "examples"
|
| 24 |
MAX_PIXELS = 262_144
|
| 25 |
MAX_SIDE = 1024
|
|
|
|
|
|
|
| 26 |
MAX_FILE_BYTES = 10 * 1024 * 1024
|
| 27 |
PATCH = 14
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
warnings.filterwarnings("error", category=Image.DecompressionBombWarning)
|
| 30 |
torch.set_num_threads(min(4, max(1, int(os.environ.get("JLD_CPU_THREADS", "2")))))
|
| 31 |
MODEL_LOCK = threading.Lock()
|
|
@@ -58,9 +63,9 @@ def load_image(path: str | None) -> Image.Image:
|
|
| 58 |
width, height = image.size
|
| 59 |
if width < PATCH or height < PATCH:
|
| 60 |
raise gr.Error("Each image must be at least 14 pixels wide and high.")
|
| 61 |
-
if width * height >
|
| 62 |
raise gr.Error(
|
| 63 |
-
"This demo accepts
|
| 64 |
"Use the local package to score larger images at their native resolution."
|
| 65 |
)
|
| 66 |
if getattr(image, "n_frames", 1) > 1:
|
|
@@ -69,7 +74,7 @@ def load_image(path: str | None) -> Image.Image:
|
|
| 69 |
except gr.Error:
|
| 70 |
raise
|
| 71 |
except (Image.DecompressionBombWarning, Image.DecompressionBombError):
|
| 72 |
-
raise gr.Error("
|
| 73 |
except (OSError, ValueError):
|
| 74 |
raise gr.Error("Could not read the image. Please use a PNG, JPEG, or WebP image.") from None
|
| 75 |
|
|
@@ -111,24 +116,52 @@ def map_overlay(reference: Image.Image, patch_map: np.ndarray) -> Image.Image:
|
|
| 111 |
return Image.blend(gray, heat, 0.6)
|
| 112 |
|
| 113 |
|
| 114 |
-
def
|
| 115 |
-
|
| 116 |
-
raise gr.Error("Choose full or fast.")
|
| 117 |
-
reference, distorted = load_image(reference_path), load_image(distorted_path)
|
| 118 |
if reference.size != distorted.size:
|
| 119 |
raise gr.Error(
|
| 120 |
-
f"Images must have exactly the same
|
| 121 |
-
f"distorted {distorted.size}.
|
| 122 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
# Serialize calls even through direct API usage, including first model initialization.
|
| 124 |
with MODEL_LOCK:
|
| 125 |
value, patch_map = compute_distance_and_map(reference, distorted, variant)
|
| 126 |
height, width = patch_map.shape[0] * PATCH, patch_map.shape[1] * PATCH
|
| 127 |
name = "JLD" if variant == "full" else "JLD-fast"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
details = (
|
| 129 |
f"**{name}: {value:.6f}**. Lower means more similar to the reference. "
|
| 130 |
-
f"Scored RGB crop: **{width} × {height} pixels**
|
| 131 |
-
f"{reference.width} × {reference.height}; no resizing.\n\n"
|
| 132 |
"The overlay shows **local projected patch displacement**, after the chroma prefilter. "
|
| 133 |
"It excludes the global CLS term used by full JLD. Brighter colours mark larger local "
|
| 134 |
"displacements. Colours are normalized to this pair's maximum, so do not compare colour "
|
|
@@ -147,26 +180,29 @@ def build_demo() -> gr.Blocks:
|
|
| 147 |
)
|
| 148 |
with gr.Row():
|
| 149 |
reference = gr.Image(value=str(EXAMPLES / "parrots.png"), label="Reference image",
|
| 150 |
-
type="filepath", sources=["upload"], height=280)
|
| 151 |
distorted = gr.Image(value=str(EXAMPLES / "parrots_jpeg_q8.png"), label="Distorted image",
|
| 152 |
-
type="filepath", sources=["upload"], height=280)
|
| 153 |
variant = gr.Radio(
|
| 154 |
choices=[("JLD: lens + global CLS term", "full"), ("JLD-fast: lens term only", "fast")],
|
| 155 |
value="fast", label="Variant",
|
| 156 |
)
|
| 157 |
gr.Markdown(
|
| 158 |
-
"
|
| 159 |
-
"
|
| 160 |
-
"
|
|
|
|
| 161 |
"The hosted demo uses free shared GPU access; queueing and daily usage limits may apply."
|
| 162 |
)
|
|
|
|
|
|
|
| 163 |
run = gr.Button("Compute distance and local map", variant="primary")
|
| 164 |
with gr.Row():
|
| 165 |
distance = gr.Number(value=None, label="Distance (lower means more similar)", precision=6,
|
| 166 |
placeholder="Compute a pair to see its distance", interactive=False)
|
| 167 |
response = gr.Image(label="Local lens response overlay (excludes CLS)", type="pil", height=300)
|
| 168 |
explanation = gr.Markdown()
|
| 169 |
-
run.click(score_pair, [reference, distorted, variant], [distance, response, explanation],
|
| 170 |
api_name="score_pair", concurrency_limit=1, concurrency_id="jld")
|
| 171 |
gr.Examples(
|
| 172 |
examples=[
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from functools import lru_cache
|
| 5 |
+
import math
|
| 6 |
import os
|
| 7 |
from pathlib import Path
|
| 8 |
import threading
|
|
|
|
| 24 |
EXAMPLES = HERE / "examples"
|
| 25 |
MAX_PIXELS = 262_144
|
| 26 |
MAX_SIDE = 1024
|
| 27 |
+
MAX_UPLOAD_PIXELS = 24_000_000
|
| 28 |
+
MAX_UPLOAD_SIDE = 8192
|
| 29 |
MAX_FILE_BYTES = 10 * 1024 * 1024
|
| 30 |
PATCH = 14
|
| 31 |
+
# Gradio opens images before load_image runs. Its decoding limit must allow
|
| 32 |
+
# uploads larger than the bounded scoring resolution.
|
| 33 |
+
Image.MAX_IMAGE_PIXELS = MAX_UPLOAD_PIXELS
|
| 34 |
warnings.filterwarnings("error", category=Image.DecompressionBombWarning)
|
| 35 |
torch.set_num_threads(min(4, max(1, int(os.environ.get("JLD_CPU_THREADS", "2")))))
|
| 36 |
MODEL_LOCK = threading.Lock()
|
|
|
|
| 63 |
width, height = image.size
|
| 64 |
if width < PATCH or height < PATCH:
|
| 65 |
raise gr.Error("Each image must be at least 14 pixels wide and high.")
|
| 66 |
+
if width * height > MAX_UPLOAD_PIXELS or max(width, height) > MAX_UPLOAD_SIDE:
|
| 67 |
raise gr.Error(
|
| 68 |
+
"This demo accepts uploads up to 24 megapixels and a longest side of 8192 pixels. "
|
| 69 |
"Use the local package to score larger images at their native resolution."
|
| 70 |
)
|
| 71 |
if getattr(image, "n_frames", 1) > 1:
|
|
|
|
| 74 |
except gr.Error:
|
| 75 |
raise
|
| 76 |
except (Image.DecompressionBombWarning, Image.DecompressionBombError):
|
| 77 |
+
raise gr.Error("Upload exceeds the 24-megapixel image limit.") from None
|
| 78 |
except (OSError, ValueError):
|
| 79 |
raise gr.Error("Could not read the image. Please use a PNG, JPEG, or WebP image.") from None
|
| 80 |
|
|
|
|
| 116 |
return Image.blend(gray, heat, 0.6)
|
| 117 |
|
| 118 |
|
| 119 |
+
def prepare_pair(reference: Image.Image, distorted: Image.Image, resize_large: bool):
|
| 120 |
+
"""Apply the same explicit demo reduction to an aligned image pair."""
|
|
|
|
|
|
|
| 121 |
if reference.size != distorted.size:
|
| 122 |
raise gr.Error(
|
| 123 |
+
f"Images must have exactly the same size: reference {reference.size}, "
|
| 124 |
+
f"distorted {distorted.size}. Upload an aligned pair; resizing does not align different images."
|
| 125 |
)
|
| 126 |
+
width, height = reference.size
|
| 127 |
+
scale = min(1.0, math.sqrt(MAX_PIXELS / (width * height)), MAX_SIDE / width, MAX_SIDE / height)
|
| 128 |
+
if scale == 1.0:
|
| 129 |
+
return reference, distorted
|
| 130 |
+
if not resize_large:
|
| 131 |
+
raise gr.Error(
|
| 132 |
+
"This pair is too large for native-resolution scoring in the hosted demo. "
|
| 133 |
+
"Enable 'Resize large pairs for this demo' or use the local package at native resolution."
|
| 134 |
+
)
|
| 135 |
+
size = (int(width * scale), int(height * scale))
|
| 136 |
+
if min(size) < PATCH:
|
| 137 |
+
raise gr.Error("This aspect ratio is too narrow for the demo. Crop both images to the same region.")
|
| 138 |
+
return (reference.resize(size, Image.Resampling.LANCZOS),
|
| 139 |
+
distorted.resize(size, Image.Resampling.LANCZOS))
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def score_pair(reference_path: str | None, distorted_path: str | None, variant: str,
|
| 143 |
+
resize_large: bool = True):
|
| 144 |
+
if variant not in ("full", "fast"):
|
| 145 |
+
raise gr.Error("Choose full or fast.")
|
| 146 |
+
reference, distorted = load_image(reference_path), load_image(distorted_path)
|
| 147 |
+
native_size = reference.size
|
| 148 |
+
reference, distorted = prepare_pair(reference, distorted, resize_large)
|
| 149 |
# Serialize calls even through direct API usage, including first model initialization.
|
| 150 |
with MODEL_LOCK:
|
| 151 |
value, patch_map = compute_distance_and_map(reference, distorted, variant)
|
| 152 |
height, width = patch_map.shape[0] * PATCH, patch_map.shape[1] * PATCH
|
| 153 |
name = "JLD" if variant == "full" else "JLD-fast"
|
| 154 |
+
if reference.size == native_size:
|
| 155 |
+
resolution = f"Native image size: {native_size[0]} × {native_size[1]}; no resizing."
|
| 156 |
+
else:
|
| 157 |
+
resolution = (
|
| 158 |
+
f"**Demo resize:** both images reduced from {native_size[0]} × {native_size[1]} "
|
| 159 |
+
f"to {reference.width} × {reference.height} with the same Lanczos filter. "
|
| 160 |
+
"This distance is measured on the resized pair. Use the local package for native-resolution scores."
|
| 161 |
+
)
|
| 162 |
details = (
|
| 163 |
f"**{name}: {value:.6f}**. Lower means more similar to the reference. "
|
| 164 |
+
f"Scored RGB crop: **{width} × {height} pixels**. {resolution}\n\n"
|
|
|
|
| 165 |
"The overlay shows **local projected patch displacement**, after the chroma prefilter. "
|
| 166 |
"It excludes the global CLS term used by full JLD. Brighter colours mark larger local "
|
| 167 |
"displacements. Colours are normalized to this pair's maximum, so do not compare colour "
|
|
|
|
| 180 |
)
|
| 181 |
with gr.Row():
|
| 182 |
reference = gr.Image(value=str(EXAMPLES / "parrots.png"), label="Reference image",
|
| 183 |
+
type="filepath", sources=["upload"], height=280, interactive=True)
|
| 184 |
distorted = gr.Image(value=str(EXAMPLES / "parrots_jpeg_q8.png"), label="Distorted image",
|
| 185 |
+
type="filepath", sources=["upload"], height=280, interactive=True)
|
| 186 |
variant = gr.Radio(
|
| 187 |
choices=[("JLD: lens + global CLS term", "full"), ("JLD-fast: lens term only", "fast")],
|
| 188 |
value="fast", label="Variant",
|
| 189 |
)
|
| 190 |
gr.Markdown(
|
| 191 |
+
"Upload **aligned images of the same size**, up to 24 megapixels, 8192 pixels on the longest "
|
| 192 |
+
"side, and 10 MB each. Large pairs can be resized together for this demo; the result shows "
|
| 193 |
+
"the actual scoring resolution. Smaller pairs keep their native resolution. "
|
| 194 |
+
"Scoring converts to RGB and center-crops to multiples of 14 pixels. "
|
| 195 |
"The hosted demo uses free shared GPU access; queueing and daily usage limits may apply."
|
| 196 |
)
|
| 197 |
+
resize_large = gr.Checkbox(value=True, label="Resize large pairs for this demo",
|
| 198 |
+
info="Uses the same scale for both images, up to 262,144 pixels and a longest side of 1024.")
|
| 199 |
run = gr.Button("Compute distance and local map", variant="primary")
|
| 200 |
with gr.Row():
|
| 201 |
distance = gr.Number(value=None, label="Distance (lower means more similar)", precision=6,
|
| 202 |
placeholder="Compute a pair to see its distance", interactive=False)
|
| 203 |
response = gr.Image(label="Local lens response overlay (excludes CLS)", type="pil", height=300)
|
| 204 |
explanation = gr.Markdown()
|
| 205 |
+
run.click(score_pair, [reference, distorted, variant, resize_large], [distance, response, explanation],
|
| 206 |
api_name="score_pair", concurrency_limit=1, concurrency_id="jld")
|
| 207 |
gr.Examples(
|
| 208 |
examples=[
|
test_app.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public-demo input checks and native-resolution rendering behavior."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import gradio as gr
|
| 5 |
+
import numpy as np
|
| 6 |
+
from PIL import Image
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
import app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def image_file(tmp_path: Path, size: tuple[int, int], name: str = "image.png") -> str:
|
| 13 |
+
path = tmp_path / name
|
| 14 |
+
Image.new("RGB", size, color=(60, 80, 100)).save(path)
|
| 15 |
+
return str(path)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_missing_upload():
|
| 19 |
+
with pytest.raises(gr.Error, match="Choose both"):
|
| 20 |
+
app.load_image(None)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.mark.parametrize("size", [(13, 100), (100, 13), (8193, 14)])
|
| 24 |
+
def test_image_resource_limits(tmp_path, size):
|
| 25 |
+
with pytest.raises(gr.Error):
|
| 26 |
+
app.load_image(image_file(tmp_path, size))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_upload_pixel_limit_checked_before_decoding(tmp_path, monkeypatch):
|
| 30 |
+
monkeypatch.setattr(app, "MAX_UPLOAD_PIXELS", 100)
|
| 31 |
+
with pytest.raises(gr.Error, match="24 megapixels"):
|
| 32 |
+
app.load_image(image_file(tmp_path, (14, 14)))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_gradio_preprocessing_accepts_large_custom_upload(tmp_path):
|
| 36 |
+
component = gr.Image(type="filepath")
|
| 37 |
+
payload = component.data_model(path=image_file(tmp_path, (1400, 1120)))
|
| 38 |
+
path = component.preprocess(payload)
|
| 39 |
+
assert app.load_image(path).size == (1400, 1120)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_large_aligned_pair_reduced_with_identical_filter():
|
| 43 |
+
image = Image.new("RGB", (1400, 1120), color=(60, 80, 100))
|
| 44 |
+
reference, distorted = app.prepare_pair(image, image.copy(), True)
|
| 45 |
+
assert reference.size == distorted.size
|
| 46 |
+
assert reference.width * reference.height <= app.MAX_PIXELS
|
| 47 |
+
assert max(reference.size) <= app.MAX_SIDE
|
| 48 |
+
assert abs(reference.width / reference.height - 1400 / 1120) < 0.01
|
| 49 |
+
assert np.array_equal(np.asarray(reference), np.asarray(distorted))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_native_scoring_rejects_large_pair_before_model_load(tmp_path, monkeypatch):
|
| 53 |
+
def unexpected_score(*args):
|
| 54 |
+
raise AssertionError("Do not score a pair that exceeds the hosted resolution limit")
|
| 55 |
+
monkeypatch.setattr(app, "compute_distance_and_map", unexpected_score)
|
| 56 |
+
path = image_file(tmp_path, (1400, 1120))
|
| 57 |
+
with pytest.raises(gr.Error, match="Enable.*Resize large pairs"):
|
| 58 |
+
app.score_pair(path, path, "fast", False)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_small_pair_keeps_original_pixels():
|
| 62 |
+
image = Image.new("RGB", (31, 43))
|
| 63 |
+
reference, distorted = app.prepare_pair(image, image, True)
|
| 64 |
+
assert reference is image and distorted is image
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_resized_result_reports_actual_scoring_grid(tmp_path, monkeypatch):
|
| 68 |
+
def score(reference, distorted, variant):
|
| 69 |
+
assert reference.size == distorted.size
|
| 70 |
+
return 0.25, np.zeros((reference.height // app.PATCH, reference.width // app.PATCH))
|
| 71 |
+
monkeypatch.setattr(app, "compute_distance_and_map", score)
|
| 72 |
+
path = image_file(tmp_path, (1400, 1120))
|
| 73 |
+
value, overlay, details = app.score_pair(path, path, "fast", True)
|
| 74 |
+
assert value == 0.25
|
| 75 |
+
assert "Demo resize" in details and "1400 × 1120" in details
|
| 76 |
+
assert "resized pair" in details
|
| 77 |
+
assert overlay.width * overlay.height <= app.MAX_PIXELS
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_mismatched_native_sizes_rejected_before_metric_load(tmp_path, monkeypatch):
|
| 81 |
+
def unexpected_model():
|
| 82 |
+
raise AssertionError("Do not load the encoder for an invalid pair")
|
| 83 |
+
monkeypatch.setattr(app, "metrics", unexpected_model)
|
| 84 |
+
with pytest.raises(gr.Error, match="same size"):
|
| 85 |
+
app.score_pair(image_file(tmp_path, (28, 28), "ref.png"),
|
| 86 |
+
image_file(tmp_path, (29, 28), "dist.png"), "fast")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_overlay_uses_native_center_crop():
|
| 90 |
+
reference = Image.new("RGB", (31, 43))
|
| 91 |
+
overlay = app.map_overlay(reference, np.zeros((3, 2), dtype=np.float32))
|
| 92 |
+
assert overlay.size == (28, 42)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_supported_format_conversion_retains_dimensions(tmp_path):
|
| 96 |
+
path = tmp_path / "gray.png"
|
| 97 |
+
Image.new("L", (31, 43), color=80).save(path)
|
| 98 |
+
image = app.load_image(str(path))
|
| 99 |
+
assert image.mode == "RGB"
|
| 100 |
+
assert image.size == (31, 43)
|