shreshthsaini commited on
Commit
5a57b41
·
verified ·
1 Parent(s): 10f105c

Accept larger custom uploads with explicit paired demo resizing

Browse files
Files changed (3) hide show
  1. README.md +10 -4
  2. app.py +54 -18
  3. 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 native size. The demo converts to RGB and
29
- center-crops to multiples of 14 pixels without resizing. Public demo resource
30
- limits are 262,144 pixels, a longest side of 1024 pixels, and 10 MB per image.
31
- Use the local package for larger images. The pretrained encoder loads on hosted
 
 
 
 
 
 
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
- Image.MAX_IMAGE_PIXELS = MAX_PIXELS
 
 
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 > MAX_PIXELS or max(width, height) > MAX_SIDE:
62
  raise gr.Error(
63
- "This demo accepts at most 262,144 pixels per image and a longest side of 1024 pixels. "
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("Image dimensions exceed this demo's memory limit.") from None
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 score_pair(reference_path: str | None, distorted_path: str | None, variant: str):
115
- if variant not in ("full", "fast"):
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 native size: reference {reference.size}, "
121
- f"distorted {distorted.size}. This demo does not resize or align your images."
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**, from native "
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
- "Use **aligned images of the same native size**. Both are converted to RGB and center-cropped "
159
- "to multiples of 14 pixels. The demo keeps image resolution and accepts up to 262,144 pixels "
160
- "per image, a longest side of 1024 pixels, and files up to 10 MB. "
 
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)