EgeEken commited on
Commit
f74f429
·
1 Parent(s): 43cead2

jpeg 2000 + magnifying glass fix

Browse files
Files changed (6) hide show
  1. pbc3_sweep.py +20 -6
  2. server.py +20 -5
  3. static/app.js +27 -13
  4. static/index.html +9 -8
  5. static/sweep.js +7 -7
  6. static/viewer_magnifier.js +13 -10
pbc3_sweep.py CHANGED
@@ -6,7 +6,7 @@ resolved config live in trial user_attrs so the analyzer can re-aggregate by
6
  megapixel range. JPEG/AVIF/WEBP baselines are logged once as tagged trials.
7
  Start/stop controlled from the web Sweep Runner."""
8
 
9
- import glob, io, itertools, os, threading, time
10
 
11
  import numpy as np
12
  import optuna
@@ -33,6 +33,7 @@ IMAGE_EXTS = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp")
33
  JPEG_QUALITIES = (1, 3, 5, 10, 20, 40, 70, 95)
34
  AVIF_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
35
  WEBP_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
 
36
  JXL_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
37
  DOWNSAMPLE_FACTORS = (1, 1.5, 2, 3, 4, 5, 8, 10, 15, 20, 32, 64, 128)
38
 
@@ -176,6 +177,20 @@ def _eval_pbc3(cfg, images, on_image=None):
176
  return rows
177
 
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  def _eval_codec(fmt, q, images, on_image=None):
180
  rows = []
181
  for im in images:
@@ -183,13 +198,12 @@ def _eval_codec(fmt, q, images, on_image=None):
183
  raise InterruptedError("sweep stopped")
184
  arr = im["arr"]
185
  pixels = arr.shape[0] * arr.shape[1]
186
- buf = io.BytesIO()
187
  t = time.perf_counter()
188
- Image.fromarray(arr).save(buf, format=fmt, quality=q)
189
  secs = time.perf_counter() - t
190
- recon = np.asarray(Image.open(io.BytesIO(buf.getvalue())).convert("RGB"))
191
  rows.append({"name": im["name"], "mp": im["mp"], "seconds": secs,
192
- "bpp": len(buf.getvalue()) * 8 / pixels, "mse": _mse(arr, recon)})
193
  if on_image:
194
  on_image()
195
  return rows
@@ -235,7 +249,7 @@ def _add_completed(study, values, attrs):
235
 
236
  def ensure_baselines(study, images):
237
  have = {t.user_attrs.get("baseline") for t in study.trials}
238
- for fmt, qs in (("JPEG", JPEG_QUALITIES), ("AVIF", AVIF_QUALITIES),
239
  ("WEBP", WEBP_QUALITIES), ("JXL", JXL_QUALITIES)):
240
  for q in qs:
241
  if _STOP.is_set():
 
6
  megapixel range. JPEG/AVIF/WEBP baselines are logged once as tagged trials.
7
  Start/stop controlled from the web Sweep Runner."""
8
 
9
+ import glob, io, itertools, math, os, threading, time
10
 
11
  import numpy as np
12
  import optuna
 
33
  JPEG_QUALITIES = (1, 3, 5, 10, 20, 40, 70, 95)
34
  AVIF_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
35
  WEBP_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
36
+ JP2_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
37
  JXL_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
38
  DOWNSAMPLE_FACTORS = (1, 1.5, 2, 3, 4, 5, 8, 10, 15, 20, 32, 64, 128)
39
 
 
177
  return rows
178
 
179
 
180
+ def _jp2_rate(q):
181
+ q = max(0.0, min(95.0, float(q))) / 95.0
182
+ return 200.0 ** (1.0 - q)
183
+
184
+
185
+ def _encode_codec_bytes(img, fmt, q):
186
+ buf = io.BytesIO()
187
+ if fmt == "JPEG2000":
188
+ img.save(buf, format="JPEG2000", quality_mode="rates", quality_layers=[_jp2_rate(q)])
189
+ else:
190
+ img.save(buf, format=fmt, quality=q)
191
+ return buf.getvalue()
192
+
193
+
194
  def _eval_codec(fmt, q, images, on_image=None):
195
  rows = []
196
  for im in images:
 
198
  raise InterruptedError("sweep stopped")
199
  arr = im["arr"]
200
  pixels = arr.shape[0] * arr.shape[1]
 
201
  t = time.perf_counter()
202
+ data = _encode_codec_bytes(Image.fromarray(arr), fmt, q)
203
  secs = time.perf_counter() - t
204
+ recon = np.asarray(Image.open(io.BytesIO(data)).convert("RGB"))
205
  rows.append({"name": im["name"], "mp": im["mp"], "seconds": secs,
206
+ "bpp": len(data) * 8 / pixels, "mse": _mse(arr, recon)})
207
  if on_image:
208
  on_image()
209
  return rows
 
249
 
250
  def ensure_baselines(study, images):
251
  have = {t.user_attrs.get("baseline") for t in study.trials}
252
+ for fmt, qs in (("JPEG", JPEG_QUALITIES), ("JPEG2000", JP2_QUALITIES), ("AVIF", AVIF_QUALITIES),
253
  ("WEBP", WEBP_QUALITIES), ("JXL", JXL_QUALITIES)):
254
  for q in qs:
255
  if _STOP.is_set():
server.py CHANGED
@@ -430,18 +430,34 @@ BPP_GUIDE = {
430
  "JPEG": [(1, 0.17), (5, 0.21), (10, 0.29), (20, 0.44), (40, 0.65), (60, 0.85), (80, 1.26), (95, 2.77)],
431
  "AVIF": [(0, 0.01), (2, 0.02), (5, 0.04), (10, 0.06), (20, 0.12), (40, 0.30), (60, 0.68), (80, 1.16), (95, 2.69)],
432
  "WEBP": [(0, 0.06), (5, 0.13), (10, 0.17), (20, 0.25), (40, 0.40), (60, 0.55), (80, 0.85), (95, 1.80)],
 
433
  "JXL": [(0, 0.02), (2, 0.03), (5, 0.05), (10, 0.08), (20, 0.15), (40, 0.34), (60, 0.70), (80, 1.20), (95, 2.60)],
434
  }
435
  # Quality samples for the tradeoff baselines (AVIF goes down to 0, JPEG stops at 1).
436
  JPEG_QUALITIES = (1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
437
  AVIF_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
438
  WEBP_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
 
439
  JXL_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
440
 
441
  def _q_bounds(fmt):
442
  return (1, 95) if fmt == "JPEG" else (0, 95)
443
 
444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  def _guess_quality(fmt, target_bpp):
446
  """Inverse-interpolate the guideline table to pick a starting quality for `target_bpp`."""
447
  g = BPP_GUIDE[fmt]
@@ -478,13 +494,12 @@ def _match_codec_gen(img, fmt, target_bpp, pixels, max_iters=12):
478
  q = int(max(qmin, min(qmax, round(q))))
479
  if q in tried:
480
  return tried[q], False
481
- buf = io.BytesIO()
482
  try:
483
- img.save(buf, format=fmt, quality=q)
484
  except Exception:
485
  tried[q] = None
486
  return None, False
487
- r = {"q": q, "bpp": len(buf.getvalue()) * 8 / pixels, "data": buf.getvalue()}
488
  tried[q] = r
489
  if better(r):
490
  best["ref"] = r
@@ -712,9 +727,9 @@ async def match_codec(image: UploadFile = File(...), codec: str = Form("jpeg"),
712
  img = src.convert("RGBA") if has_alpha else src.convert("RGB")
713
  except Exception as exc:
714
  return JSONResponse({"error": f"Could not read image: {exc}"}, status_code=400)
715
- fmt = {"jpeg": "JPEG", "avif": "AVIF", "webp": "WEBP", "jxl": "JXL"}.get(codec.lower(), "JPEG")
716
  pixels = img.size[0] * img.size[1]
717
- enc_img = img if (has_alpha and fmt in ("WEBP", "AVIF", "JXL")) else img.convert("RGB")
718
 
719
  def gen():
720
  best = None
 
430
  "JPEG": [(1, 0.17), (5, 0.21), (10, 0.29), (20, 0.44), (40, 0.65), (60, 0.85), (80, 1.26), (95, 2.77)],
431
  "AVIF": [(0, 0.01), (2, 0.02), (5, 0.04), (10, 0.06), (20, 0.12), (40, 0.30), (60, 0.68), (80, 1.16), (95, 2.69)],
432
  "WEBP": [(0, 0.06), (5, 0.13), (10, 0.17), (20, 0.25), (40, 0.40), (60, 0.55), (80, 0.85), (95, 1.80)],
433
+ "JPEG2000": [(0, 0.03), (5, 0.05), (10, 0.08), (20, 0.14), (40, 0.30), (60, 0.65), (80, 1.20), (95, 2.50)],
434
  "JXL": [(0, 0.02), (2, 0.03), (5, 0.05), (10, 0.08), (20, 0.15), (40, 0.34), (60, 0.70), (80, 1.20), (95, 2.60)],
435
  }
436
  # Quality samples for the tradeoff baselines (AVIF goes down to 0, JPEG stops at 1).
437
  JPEG_QUALITIES = (1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
438
  AVIF_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
439
  WEBP_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
440
+ JP2_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
441
  JXL_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
442
 
443
  def _q_bounds(fmt):
444
  return (1, 95) if fmt == "JPEG" else (0, 95)
445
 
446
 
447
+ def _jp2_rate(q):
448
+ q = max(0.0, min(95.0, float(q))) / 95.0
449
+ return 200.0 ** (1.0 - q)
450
+
451
+
452
+ def _encode_codec_bytes(img, fmt, q):
453
+ buf = io.BytesIO()
454
+ if fmt == "JPEG2000":
455
+ img.save(buf, format="JPEG2000", quality_mode="rates", quality_layers=[_jp2_rate(q)])
456
+ else:
457
+ img.save(buf, format=fmt, quality=int(q))
458
+ return buf.getvalue()
459
+
460
+
461
  def _guess_quality(fmt, target_bpp):
462
  """Inverse-interpolate the guideline table to pick a starting quality for `target_bpp`."""
463
  g = BPP_GUIDE[fmt]
 
494
  q = int(max(qmin, min(qmax, round(q))))
495
  if q in tried:
496
  return tried[q], False
 
497
  try:
498
+ data = _encode_codec_bytes(img, fmt, q)
499
  except Exception:
500
  tried[q] = None
501
  return None, False
502
+ r = {"q": q, "bpp": len(data) * 8 / pixels, "data": data}
503
  tried[q] = r
504
  if better(r):
505
  best["ref"] = r
 
727
  img = src.convert("RGBA") if has_alpha else src.convert("RGB")
728
  except Exception as exc:
729
  return JSONResponse({"error": f"Could not read image: {exc}"}, status_code=400)
730
+ fmt = {"jpeg": "JPEG", "jp2": "JPEG2000", "jpeg2000": "JPEG2000", "avif": "AVIF", "webp": "WEBP", "jxl": "JXL"}.get(codec.lower(), "JPEG")
731
  pixels = img.size[0] * img.size[1]
732
+ enc_img = img if (has_alpha and fmt in ("WEBP", "AVIF", "JXL", "JPEG2000")) else img.convert("RGB")
733
 
734
  def gen():
735
  best = None
static/app.js CHANGED
@@ -784,7 +784,8 @@ function renderRegistry() {
784
  // raw (3 bytes RGB) / bpp.
785
  const bppToRate = (bpp) => 24 / bpp;
786
 
787
- const CODEC_COLOR = { jpeg: "#7d8cff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc" };
 
788
  const PBC_COLOR = "#ff3b41";
789
 
790
  function card(d) {
@@ -808,14 +809,19 @@ function card(d) {
808
  : "";
809
 
810
  // Only show codecs that haven't been generated yet; drop the whole row once both exist.
811
- const haveJ = !!d.jpeg_image, haveA = !!d.avif_image, haveW = !!d.webp_image, haveX = !!d.jxl_image;
812
- const compareBlock = (!d.decoded && d.original_image && !(haveJ && haveA && haveW && haveX))
813
- ? `<div class="foot-row" style="align-items:center;gap:8px">
814
  <span class="k">Compare to:</span>
815
- ${haveJ ? "" : `<button class="chip" data-act="cmp-jpeg" style="color:#7d8cff;border-color:#7d8cff">JPEG</button>`}
816
- ${haveW ? "" : `<button class="chip" data-act="cmp-webp" style="color:#54c0ff;border-color:#54c0ff">WebP</button>`}
817
- ${haveX ? "" : `<button class="chip" data-act="cmp-jxl" style="color:#c084fc;border-color:#c084fc">JXL</button>`}
818
- ${haveA ? "" : `<button class="chip" data-act="cmp-avif" style="color:#34d39a;border-color:#34d39a">AVIF</button>`}
 
 
 
 
 
819
  </div>`
820
  : "";
821
  const haveAnim = !!d.animation_url;
@@ -850,10 +856,12 @@ function card(d) {
850
  el.querySelector('[data-act="png"]').onclick = () => savePng(d);
851
  el.querySelector('[data-act="pbc"]').onclick = () => savePbc(d);
852
  const cmpJ = el.querySelector('[data-act="cmp-jpeg"]');
 
853
  const cmpA = el.querySelector('[data-act="cmp-avif"]');
854
  const cmpW = el.querySelector('[data-act="cmp-webp"]');
855
  const cmpX = el.querySelector('[data-act="cmp-jxl"]');
856
  if (cmpJ) cmpJ.onclick = () => compareCodec(d, "jpeg", cmpJ);
 
857
  if (cmpA) cmpA.onclick = () => compareCodec(d, "avif", cmpA);
858
  if (cmpW) cmpW.onclick = () => compareCodec(d, "webp", cmpW);
859
  if (cmpX) cmpX.onclick = () => compareCodec(d, "jxl", cmpX);
@@ -872,7 +880,7 @@ function card(d) {
872
  // codec is cached on the entry, so the chip is removed and a repeat click is impossible.
873
  async function compareCodec(d, codec, btn) {
874
  if (!d.original_image || d[codec + "_image"]) return;
875
- const label = codec.toUpperCase();
876
  btn.disabled = true;
877
  btn.textContent = label + "…";
878
  const job = createJob(`${label} match`);
@@ -1031,6 +1039,7 @@ const viewer = document.getElementById("viewer");
1031
  const viewerStage = document.getElementById("viewer-stage");
1032
  const holdBtn = document.getElementById("hold-original");
1033
  const holdJpeg = document.getElementById("hold-jpeg");
 
1034
  const holdAvif = document.getElementById("hold-avif");
1035
  const holdWebp = document.getElementById("hold-webp");
1036
  const holdJxl = document.getElementById("hold-jxl");
@@ -1040,7 +1049,7 @@ const nextBtn = document.getElementById("viewer-next");
1040
  const capEl = document.getElementById("viewer-caption");
1041
  let viewIndex = -1;
1042
  let sideBySide = false;
1043
- let activeCompare = null; // 'original' | 'jpeg' | 'avif' (side-by-side mode only)
1044
  let viewerDownTarget = null; // where the last pointerdown started (backdrop-close guard)
1045
 
1046
  document.getElementById("viewer-close").addEventListener("click", closeViewer);
@@ -1069,6 +1078,7 @@ function step(dir) {
1069
  function compareSrcOf(d, kind) {
1070
  return kind === "original" ? d.original_image
1071
  : kind === "jpeg" ? d.jpeg_image
 
1072
  : kind === "avif" ? d.avif_image
1073
  : kind === "webp" ? d.webp_image
1074
  : kind === "jxl" ? d.jxl_image : null;
@@ -1082,7 +1092,7 @@ function viewerCaption(d) {
1082
  // Two-line, colored comparison summary (size + MSE only; no composite quality).
1083
  // Reusable pieces of the comparison summary (size + MSE only).
1084
  function codecCompareParts(d, codec) {
1085
- const label = codec.toUpperCase();
1086
  const cc = CODEC_COLOR[codec];
1087
  const fmtSize = d[codec + "_size_kb"], fmtMse = d[codec + "_mse"], q = d[codec + "_q"];
1088
  const pct = (a, b) => Math.abs((a - b) / (b || 1e-9) * 100).toFixed(0);
@@ -1108,7 +1118,7 @@ function codecCompareCaptionHTML(d, codec) {
1108
  function updateCaption() {
1109
  const d = registry[viewIndex];
1110
  if (!d) return;
1111
- if (sideBySide && (["jpeg", "avif", "webp", "jxl"].includes(activeCompare)))
1112
  capEl.innerHTML = `<div>${codecCompareParts(d, activeCompare).sentence}</div>`; // stats moved on top
1113
  else
1114
  capEl.textContent = viewerCaption(d);
@@ -1126,7 +1136,7 @@ function renderViewer() {
1126
  viewerStage.className = "viewer-stage dual " + (colScale > rowScale ? "col" : "row");
1127
 
1128
  let leftCap, rightCap;
1129
- if (["jpeg", "avif", "webp", "jxl"].includes(activeCompare)) {
1130
  const p = codecCompareParts(d, activeCompare);
1131
  leftCap = `<figcaption class="cmp-stat">${p.codecStat}</figcaption>`;
1132
  rightCap = `<figcaption class="cmp-stat">${p.pbcStat}</figcaption>`;
@@ -1144,16 +1154,19 @@ function renderViewer() {
1144
 
1145
  holdBtn.hidden = !d.original_image;
1146
  holdJpeg.hidden = !d.jpeg_image;
 
1147
  holdAvif.hidden = !d.avif_image;
1148
  holdWebp.hidden = !d.webp_image;
1149
  holdJxl.hidden = !d.jxl_image;
1150
  holdBtn.textContent = sideBySide ? "Original" : "Hold for original";
1151
  holdJpeg.textContent = sideBySide ? "JPEG" : "Hold for JPEG";
 
1152
  holdAvif.textContent = sideBySide ? "AVIF" : "Hold for AVIF";
1153
  holdWebp.textContent = sideBySide ? "WebP" : "Hold for WebP";
1154
  holdJxl.textContent = sideBySide ? "JXL" : "Hold for JXL";
1155
  holdBtn.classList.toggle("holding", sideBySide && activeCompare === "original");
1156
  holdJpeg.classList.toggle("holding", sideBySide && activeCompare === "jpeg");
 
1157
  holdAvif.classList.toggle("holding", sideBySide && activeCompare === "avif");
1158
  holdWebp.classList.toggle("holding", sideBySide && activeCompare === "webp");
1159
  holdJxl.classList.toggle("holding", sideBySide && activeCompare === "jxl");
@@ -1195,6 +1208,7 @@ function wireHold(btn, kind, getCaptionHTML) {
1195
  }
1196
  wireHold(holdBtn, "original");
1197
  wireHold(holdJpeg, "jpeg", d => codecCompareCaptionHTML(d, "jpeg"));
 
1198
  wireHold(holdAvif, "avif", d => codecCompareCaptionHTML(d, "avif"));
1199
  wireHold(holdWebp, "webp", d => codecCompareCaptionHTML(d, "webp"));
1200
  wireHold(holdJxl, "jxl", d => codecCompareCaptionHTML(d, "jxl"));
 
784
  // raw (3 bytes RGB) / bpp.
785
  const bppToRate = (bpp) => 24 / bpp;
786
 
787
+ const CODEC_COLOR = { jpeg: "#7d8cff", jp2: "#4fa3ff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc" };
788
+ const CODEC_LABEL = { jpeg: "JPEG", jp2: "JPEG2000", webp: "WebP", jxl: "JPEG XL", avif: "AVIF" };
789
  const PBC_COLOR = "#ff3b41";
790
 
791
  function card(d) {
 
809
  : "";
810
 
811
  // Only show codecs that haven't been generated yet; drop the whole row once both exist.
812
+ const haveJ = !!d.jpeg_image, haveP = !!d.jp2_image, haveA = !!d.avif_image, haveW = !!d.webp_image, haveX = !!d.jxl_image;
813
+ const compareBlock = (!d.decoded && d.original_image && !(haveJ && haveP && haveA && haveW && haveX))
814
+ ? `<div class="compare-codecs">
815
  <span class="k">Compare to:</span>
816
+ <div class="compare-codec-row">
817
+ ${haveJ ? "" : `<button class="chip" data-act="cmp-jpeg" style="color:#7d8cff;border-color:#7d8cff">JPEG</button>`}
818
+ ${haveP ? "" : `<button class="chip" data-act="cmp-jp2" style="color:#4fa3ff;border-color:#4fa3ff">JPEG2000</button>`}
819
+ ${haveW ? "" : `<button class="chip" data-act="cmp-webp" style="color:#54c0ff;border-color:#54c0ff">WebP</button>`}
820
+ </div>
821
+ <div class="compare-codec-row">
822
+ ${haveX ? "" : `<button class="chip" data-act="cmp-jxl" style="color:#c084fc;border-color:#c084fc">JPEG XL</button>`}
823
+ ${haveA ? "" : `<button class="chip" data-act="cmp-avif" style="color:#34d39a;border-color:#34d39a">AVIF</button>`}
824
+ </div>
825
  </div>`
826
  : "";
827
  const haveAnim = !!d.animation_url;
 
856
  el.querySelector('[data-act="png"]').onclick = () => savePng(d);
857
  el.querySelector('[data-act="pbc"]').onclick = () => savePbc(d);
858
  const cmpJ = el.querySelector('[data-act="cmp-jpeg"]');
859
+ const cmpP = el.querySelector('[data-act="cmp-jp2"]');
860
  const cmpA = el.querySelector('[data-act="cmp-avif"]');
861
  const cmpW = el.querySelector('[data-act="cmp-webp"]');
862
  const cmpX = el.querySelector('[data-act="cmp-jxl"]');
863
  if (cmpJ) cmpJ.onclick = () => compareCodec(d, "jpeg", cmpJ);
864
+ if (cmpP) cmpP.onclick = () => compareCodec(d, "jp2", cmpP);
865
  if (cmpA) cmpA.onclick = () => compareCodec(d, "avif", cmpA);
866
  if (cmpW) cmpW.onclick = () => compareCodec(d, "webp", cmpW);
867
  if (cmpX) cmpX.onclick = () => compareCodec(d, "jxl", cmpX);
 
880
  // codec is cached on the entry, so the chip is removed and a repeat click is impossible.
881
  async function compareCodec(d, codec, btn) {
882
  if (!d.original_image || d[codec + "_image"]) return;
883
+ const label = CODEC_LABEL[codec] || codec.toUpperCase();
884
  btn.disabled = true;
885
  btn.textContent = label + "…";
886
  const job = createJob(`${label} match`);
 
1039
  const viewerStage = document.getElementById("viewer-stage");
1040
  const holdBtn = document.getElementById("hold-original");
1041
  const holdJpeg = document.getElementById("hold-jpeg");
1042
+ const holdJp2 = document.getElementById("hold-jp2");
1043
  const holdAvif = document.getElementById("hold-avif");
1044
  const holdWebp = document.getElementById("hold-webp");
1045
  const holdJxl = document.getElementById("hold-jxl");
 
1049
  const capEl = document.getElementById("viewer-caption");
1050
  let viewIndex = -1;
1051
  let sideBySide = false;
1052
+ let activeCompare = null; // side-by-side selected comparison key
1053
  let viewerDownTarget = null; // where the last pointerdown started (backdrop-close guard)
1054
 
1055
  document.getElementById("viewer-close").addEventListener("click", closeViewer);
 
1078
  function compareSrcOf(d, kind) {
1079
  return kind === "original" ? d.original_image
1080
  : kind === "jpeg" ? d.jpeg_image
1081
+ : kind === "jp2" ? d.jp2_image
1082
  : kind === "avif" ? d.avif_image
1083
  : kind === "webp" ? d.webp_image
1084
  : kind === "jxl" ? d.jxl_image : null;
 
1092
  // Two-line, colored comparison summary (size + MSE only; no composite quality).
1093
  // Reusable pieces of the comparison summary (size + MSE only).
1094
  function codecCompareParts(d, codec) {
1095
+ const label = CODEC_LABEL[codec] || codec.toUpperCase();
1096
  const cc = CODEC_COLOR[codec];
1097
  const fmtSize = d[codec + "_size_kb"], fmtMse = d[codec + "_mse"], q = d[codec + "_q"];
1098
  const pct = (a, b) => Math.abs((a - b) / (b || 1e-9) * 100).toFixed(0);
 
1118
  function updateCaption() {
1119
  const d = registry[viewIndex];
1120
  if (!d) return;
1121
+ if (sideBySide && (["jpeg", "jp2", "avif", "webp", "jxl"].includes(activeCompare)))
1122
  capEl.innerHTML = `<div>${codecCompareParts(d, activeCompare).sentence}</div>`; // stats moved on top
1123
  else
1124
  capEl.textContent = viewerCaption(d);
 
1136
  viewerStage.className = "viewer-stage dual " + (colScale > rowScale ? "col" : "row");
1137
 
1138
  let leftCap, rightCap;
1139
+ if (["jpeg", "jp2", "avif", "webp", "jxl"].includes(activeCompare)) {
1140
  const p = codecCompareParts(d, activeCompare);
1141
  leftCap = `<figcaption class="cmp-stat">${p.codecStat}</figcaption>`;
1142
  rightCap = `<figcaption class="cmp-stat">${p.pbcStat}</figcaption>`;
 
1154
 
1155
  holdBtn.hidden = !d.original_image;
1156
  holdJpeg.hidden = !d.jpeg_image;
1157
+ holdJp2.hidden = !d.jp2_image;
1158
  holdAvif.hidden = !d.avif_image;
1159
  holdWebp.hidden = !d.webp_image;
1160
  holdJxl.hidden = !d.jxl_image;
1161
  holdBtn.textContent = sideBySide ? "Original" : "Hold for original";
1162
  holdJpeg.textContent = sideBySide ? "JPEG" : "Hold for JPEG";
1163
+ holdJp2.textContent = sideBySide ? "JPEG2000" : "Hold for JPEG2000";
1164
  holdAvif.textContent = sideBySide ? "AVIF" : "Hold for AVIF";
1165
  holdWebp.textContent = sideBySide ? "WebP" : "Hold for WebP";
1166
  holdJxl.textContent = sideBySide ? "JXL" : "Hold for JXL";
1167
  holdBtn.classList.toggle("holding", sideBySide && activeCompare === "original");
1168
  holdJpeg.classList.toggle("holding", sideBySide && activeCompare === "jpeg");
1169
+ holdJp2.classList.toggle("holding", sideBySide && activeCompare === "jp2");
1170
  holdAvif.classList.toggle("holding", sideBySide && activeCompare === "avif");
1171
  holdWebp.classList.toggle("holding", sideBySide && activeCompare === "webp");
1172
  holdJxl.classList.toggle("holding", sideBySide && activeCompare === "jxl");
 
1208
  }
1209
  wireHold(holdBtn, "original");
1210
  wireHold(holdJpeg, "jpeg", d => codecCompareCaptionHTML(d, "jpeg"));
1211
+ wireHold(holdJp2, "jp2", d => codecCompareCaptionHTML(d, "jp2"));
1212
  wireHold(holdAvif, "avif", d => codecCompareCaptionHTML(d, "avif"));
1213
  wireHold(holdWebp, "webp", d => codecCompareCaptionHTML(d, "webp"));
1214
  wireHold(holdJxl, "jxl", d => codecCompareCaptionHTML(d, "jxl"));
static/index.html CHANGED
@@ -12,22 +12,22 @@
12
  <link rel="stylesheet" href="/viewer_magnifier.css?v=20260622b">
13
  <style>
14
  #hold-jpeg { --hc:#7d8cff; }
 
15
  #hold-webp { --hc:#54c0ff; }
16
  #hold-jxl { --hc:#c084fc; }
17
  #hold-avif { --hc:#34d39a; }
18
- #hold-jpeg, #hold-webp, #hold-jxl, #hold-avif { border-color: var(--hc); color: var(--hc); }
19
  #hold-jpeg.holding, #hold-webp.holding, #hold-jxl.holding, #hold-avif.holding { background: var(--hc); border-color: var(--hc); color: #fff; }
20
- .foot-row [data-act="cmp-jpeg"] { order: 1; }
21
- .foot-row [data-act="cmp-webp"] { order: 2; }
22
- .foot-row [data-act="cmp-jxl"] { order: 3; }
23
- .foot-row [data-act="cmp-avif"] { order: 4; }
24
 
25
  .viewer-controls #viewer-sbs { order: 0; }
26
  .viewer-controls #hold-original { order: 1; }
27
  .viewer-controls #hold-jpeg { order: 2; }
28
- .viewer-controls #hold-webp { order: 3; }
29
- .viewer-controls #hold-jxl { order: 4; }
30
- .viewer-controls #hold-avif { order: 5; }
 
31
 
32
  /* Training Lab */
33
  .tr-fields { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; }
@@ -216,6 +216,7 @@
216
  <label class="sbs-toggle"><input type="checkbox" id="viewer-sbs"> Side by side</label>
217
  <button id="hold-original" class="hold-btn" hidden>Hold for original</button>
218
  <button id="hold-jpeg" class="hold-btn" hidden>Hold for JPEG</button>
 
219
  <button id="hold-webp" class="hold-btn" hidden>Hold for WEBP</button>
220
  <button id="hold-jxl" class="hold-btn" hidden>Hold for JXL</button>
221
  <button id="hold-avif" class="hold-btn" hidden>Hold for AVIF</button>
 
12
  <link rel="stylesheet" href="/viewer_magnifier.css?v=20260622b">
13
  <style>
14
  #hold-jpeg { --hc:#7d8cff; }
15
+ #hold-jp2 { --hc:#4fa3ff; }
16
  #hold-webp { --hc:#54c0ff; }
17
  #hold-jxl { --hc:#c084fc; }
18
  #hold-avif { --hc:#34d39a; }
19
+ #hold-jpeg, #hold-jp2, #hold-webp, #hold-jxl, #hold-avif { border-color: var(--hc); color: var(--hc); }
20
  #hold-jpeg.holding, #hold-webp.holding, #hold-jxl.holding, #hold-avif.holding { background: var(--hc); border-color: var(--hc); color: #fff; }
21
+ .compare-codecs { display:flex; flex-direction:column; gap:7px; margin-top:2px; }
22
+ .compare-codec-row { display:flex; flex-wrap:wrap; gap:8px; }
 
 
23
 
24
  .viewer-controls #viewer-sbs { order: 0; }
25
  .viewer-controls #hold-original { order: 1; }
26
  .viewer-controls #hold-jpeg { order: 2; }
27
+ .viewer-controls #hold-jp2 { order: 3; }
28
+ .viewer-controls #hold-webp { order: 4; }
29
+ .viewer-controls #hold-jxl { order: 5; }
30
+ .viewer-controls #hold-avif { order: 6; }
31
 
32
  /* Training Lab */
33
  .tr-fields { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; }
 
216
  <label class="sbs-toggle"><input type="checkbox" id="viewer-sbs"> Side by side</label>
217
  <button id="hold-original" class="hold-btn" hidden>Hold for original</button>
218
  <button id="hold-jpeg" class="hold-btn" hidden>Hold for JPEG</button>
219
+ <button id="hold-jp2" class="hold-btn" hidden>Hold for JPEG2000</button>
220
  <button id="hold-webp" class="hold-btn" hidden>Hold for WEBP</button>
221
  <button id="hold-jxl" class="hold-btn" hidden>Hold for JXL</button>
222
  <button id="hold-avif" class="hold-btn" hidden>Hold for AVIF</button>
static/sweep.js CHANGED
@@ -18,16 +18,16 @@
18
  study: null, trials: [], filtered: [],
19
  selected: null, codec: null, built: false, fsKind: null,
20
  sort: { key: "mse", dir: 1 }, // MSE ascending = best first
21
- show: { pbcAll: true, pbcPareto: true, presets: true, jpeg: false, avif: false, webp: false, jxl: false, downsample: false, png: false },
22
  dyn: [], mpMin: null, mpMax: null, logx: true, logy: false, psnr: false,
23
  fnum: { mse: NaN, bpp: NaN, sp: NaN }, // numeric metric filters (apply to PBC + codec curves)
24
  };
25
 
26
  const COLORS = {
27
  gray: "#5c3839", pareto: "#ff3b41", sel: "#ffce2e", base: "#ff280c",
28
- jpeg: "#7d8cff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc", downsample: "#e6e6e6", png: "#ef19ef",
29
  };
30
- const TOGGLES = [["pbcAll", "PBC All"], ["pbcPareto", "PBC Pareto"], ["presets", "PBC Presets"], ["jpeg", "JPEG"], ["avif", "AVIF"], ["webp", "WebP"], ["jxl", "JXL"], ["downsample", "Downsample"], ["png", "PNG"]];
31
 
32
  // 2D plot defs. efficiency = 1/(bpp·mse) (higher is better).
33
  const P2D = {
@@ -239,8 +239,8 @@
239
  function buildCodec(all) {
240
  const pick = (c) => all.filter((t) => t.codec === c)
241
  .map((t) => ({ q: t.q, bpp: t.bpp, mse: t.mse, speed: t.speed, eff: t.eff })).sort((a, b) => a.q - b.q);
242
- const jpeg = pick("JPEG"), avif = pick("AVIF"), webp = pick("WEBP"), jxl = pick("JXL"), downsample = pick("DOWNSAMPLE"), png = pick("PNG");
243
- S.codec = (jpeg.length || avif.length || webp.length || jxl.length || downsample.length || png.length) ? { jpeg, avif, webp, jxl, downsample, png } : null;
244
  }
245
 
246
  function renderSummary() {
@@ -420,7 +420,7 @@
420
 
421
  function scatter2d(xKey, yKey, usePsnr = false) {
422
  const all = S.filtered, traces = [];
423
- const CODECS = ["jpeg", "avif", "webp", "jxl", "downsample", "png"];
424
  const yfn = usePsnr ? mseToPsnr : null;
425
  const logy = S.logy && !usePsnr;
426
  const plotFloor = (key) => {
@@ -436,7 +436,7 @@
436
  if (S.show.pbcAll) traces.push(ptTrace(all, xKey, yKey, { name: "PBC all", marker: { size: 5, color: COLORS.gray, opacity: .55 } }, fx, fy, yfn, logy));
437
  if (S.show.pbcPareto) traces.push(ptTrace(all.filter((t) => t.pareto), xKey, yKey, { name: "PBC Pareto", marker: { size: 8, color: COLORS.pareto } }, fx, fy, yfn, logy));
438
 
439
- [["jpeg", COLORS.jpeg], ["avif", COLORS.avif], ["webp", COLORS.webp], ["jxl", COLORS.jxl], ["downsample", COLORS.downsample], ["png", COLORS.png]].forEach(([k, c]) => {
440
  if (!S.show[k]) return;
441
  const pts = codecPoints(k);
442
  if (pts && pts.length) traces.push(codecTrace([...pts].sort((a, b) => a[xKey] - b[xKey]), xKey, yKey, k.toUpperCase(), c, fx, fy, yfn, logy));
 
18
  study: null, trials: [], filtered: [],
19
  selected: null, codec: null, built: false, fsKind: null,
20
  sort: { key: "mse", dir: 1 }, // MSE ascending = best first
21
+ show: { pbcAll: true, pbcPareto: true, presets: true, jpeg: false, jp2: false, avif: false, webp: false, jxl: false, downsample: false, png: false },
22
  dyn: [], mpMin: null, mpMax: null, logx: true, logy: false, psnr: false,
23
  fnum: { mse: NaN, bpp: NaN, sp: NaN }, // numeric metric filters (apply to PBC + codec curves)
24
  };
25
 
26
  const COLORS = {
27
  gray: "#5c3839", pareto: "#ff3b41", sel: "#ffce2e", base: "#ff280c",
28
+ jpeg: "#7d8cff", jp2: "#4fa3ff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc", downsample: "#e6e6e6", png: "#ef19ef",
29
  };
30
+ const TOGGLES = [["pbcAll", "PBC All"], ["pbcPareto", "PBC Pareto"], ["presets", "PBC Presets"], ["jpeg", "JPEG"], ["jp2", "JPEG2000"], ["webp", "WebP"], ["jxl", "JXL"], ["avif", "AVIF"], ["downsample", "Downsample"], ["png", "PNG"]];
31
 
32
  // 2D plot defs. efficiency = 1/(bpp·mse) (higher is better).
33
  const P2D = {
 
239
  function buildCodec(all) {
240
  const pick = (c) => all.filter((t) => t.codec === c)
241
  .map((t) => ({ q: t.q, bpp: t.bpp, mse: t.mse, speed: t.speed, eff: t.eff })).sort((a, b) => a.q - b.q);
242
+ const jpeg = pick("JPEG"), jp2 = pick("JPEG2000"), avif = pick("AVIF"), webp = pick("WEBP"), jxl = pick("JXL"), downsample = pick("DOWNSAMPLE"), png = pick("PNG");
243
+ S.codec = (jpeg.length || jp2.length || avif.length || webp.length || jxl.length || downsample.length || png.length) ? { jpeg, jp2, avif, webp, jxl, downsample, png } : null;
244
  }
245
 
246
  function renderSummary() {
 
420
 
421
  function scatter2d(xKey, yKey, usePsnr = false) {
422
  const all = S.filtered, traces = [];
423
+ const CODECS = ["jpeg", "jp2", "webp", "jxl", "avif", "downsample", "png"];
424
  const yfn = usePsnr ? mseToPsnr : null;
425
  const logy = S.logy && !usePsnr;
426
  const plotFloor = (key) => {
 
436
  if (S.show.pbcAll) traces.push(ptTrace(all, xKey, yKey, { name: "PBC all", marker: { size: 5, color: COLORS.gray, opacity: .55 } }, fx, fy, yfn, logy));
437
  if (S.show.pbcPareto) traces.push(ptTrace(all.filter((t) => t.pareto), xKey, yKey, { name: "PBC Pareto", marker: { size: 8, color: COLORS.pareto } }, fx, fy, yfn, logy));
438
 
439
+ [["jpeg", COLORS.jpeg], ["jp2", COLORS.jp2], ["webp", COLORS.webp], ["jxl", COLORS.jxl], ["avif", COLORS.avif], ["downsample", COLORS.downsample], ["png", COLORS.png]].forEach(([k, c]) => {
440
  if (!S.show[k]) return;
441
  const pts = codecPoints(k);
442
  if (pts && pts.length) traces.push(codecTrace([...pts].sort((a, b) => a[xKey] - b[xKey]), xKey, yKey, k.toUpperCase(), c, fx, fy, yfn, logy));
static/viewer_magnifier.js CHANGED
@@ -6,6 +6,7 @@
6
 
7
  const SCALE = 4;
8
  let activePointer = null;
 
9
 
10
  const clamp = (v) => Math.max(0, Math.min(1, v));
11
 
@@ -23,24 +24,24 @@
23
  return e.target.closest ? e.target.closest(".viewer-img-frame img") : null;
24
  }
25
 
26
- function setZoom(e) {
27
- const img = eventImage(e);
28
- if (!img) return;
29
 
30
- const rect = img.getBoundingClientRect();
31
  const x = clamp((e.clientX - rect.left) / rect.width);
32
  const y = clamp((e.clientY - rect.top) / rect.height);
33
  const origin = `${(x * 100).toFixed(2)}% ${(y * 100).toFixed(2)}%`;
34
 
35
  stage.querySelectorAll(".viewer-img-frame").forEach((frame) => frame.classList.add("magnifying"));
36
- stage.querySelectorAll(".viewer-img-frame img").forEach((im) => {
37
- im.style.transformOrigin = origin;
38
- im.style.transform = `scale(${SCALE})`;
39
  });
40
  }
41
 
42
  function clearZoom() {
43
  activePointer = null;
 
44
  stage.querySelectorAll(".viewer-img-frame").forEach((frame) => frame.classList.remove("magnifying"));
45
  stage.querySelectorAll(".viewer-img-frame img").forEach((img) => {
46
  img.style.transform = "";
@@ -52,12 +53,14 @@
52
  wrapImages();
53
 
54
  stage.addEventListener("pointerdown", (e) => {
55
- if (!eventImage(e)) return;
 
56
  e.preventDefault();
57
  e.stopPropagation();
58
  activePointer = e.pointerId;
 
59
  try { stage.setPointerCapture(e.pointerId); } catch (_) {}
60
- setZoom(e);
61
  });
62
 
63
  stage.addEventListener("pointermove", (e) => {
@@ -71,4 +74,4 @@
71
  if (activePointer === e.pointerId) clearZoom();
72
  });
73
  window.addEventListener("blur", clearZoom);
74
- })();
 
6
 
7
  const SCALE = 4;
8
  let activePointer = null;
9
+ let activeImage = null;
10
 
11
  const clamp = (v) => Math.max(0, Math.min(1, v));
12
 
 
24
  return e.target.closest ? e.target.closest(".viewer-img-frame img") : null;
25
  }
26
 
27
+ function setZoom(e, sourceImage = activeImage || eventImage(e)) {
28
+ if (!sourceImage) return;
 
29
 
30
+ const rect = sourceImage.getBoundingClientRect();
31
  const x = clamp((e.clientX - rect.left) / rect.width);
32
  const y = clamp((e.clientY - rect.top) / rect.height);
33
  const origin = `${(x * 100).toFixed(2)}% ${(y * 100).toFixed(2)}%`;
34
 
35
  stage.querySelectorAll(".viewer-img-frame").forEach((frame) => frame.classList.add("magnifying"));
36
+ stage.querySelectorAll(".viewer-img-frame img").forEach((img) => {
37
+ img.style.transformOrigin = origin;
38
+ img.style.transform = `scale(${SCALE})`;
39
  });
40
  }
41
 
42
  function clearZoom() {
43
  activePointer = null;
44
+ activeImage = null;
45
  stage.querySelectorAll(".viewer-img-frame").forEach((frame) => frame.classList.remove("magnifying"));
46
  stage.querySelectorAll(".viewer-img-frame img").forEach((img) => {
47
  img.style.transform = "";
 
53
  wrapImages();
54
 
55
  stage.addEventListener("pointerdown", (e) => {
56
+ const img = eventImage(e);
57
+ if (!img) return;
58
  e.preventDefault();
59
  e.stopPropagation();
60
  activePointer = e.pointerId;
61
+ activeImage = img;
62
  try { stage.setPointerCapture(e.pointerId); } catch (_) {}
63
+ setZoom(e, img);
64
  });
65
 
66
  stage.addEventListener("pointermove", (e) => {
 
74
  if (activePointer === e.pointerId) clearZoom();
75
  });
76
  window.addEventListener("blur", clearZoom);
77
+ })();