EgeEken commited on
Commit
3f9ec55
·
1 Parent(s): 1e30010

sweep analyzer improvements 4

Browse files
Files changed (3) hide show
  1. server.py +36 -0
  2. static/app.js +148 -37
  3. static/sweep.js +88 -12
server.py CHANGED
@@ -606,6 +606,25 @@ def sweeps_codec_baseline(mp: str = ""):
606
  "source": os.path.relpath(src, PROJECT_DIR), "jpeg": jpeg, "avif": avif, "png": png}
607
 
608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  @app.get("/api/sweeps/artifact")
610
  def sweeps_artifact(study_name: str = "", trial_number: int = 0, image_index: int = 0, mp: str = ""):
611
  mp = mp or _mp_of(study_name)
@@ -723,11 +742,28 @@ async def sweeps_generate_artifact(
723
  diff = (diff / max(1.0, float(diff.max())) * 255.0).astype(np.uint8)
724
  heat = cv2.cvtColor(cv2.applyColorMap(diff, cv2.COLORMAP_INFERNO), cv2.COLOR_BGR2RGB)
725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
  return {
727
  "original_image": _png_b64(img),
728
  "recon_image": _png_b64(recon),
729
  "diff_image": _png_b64(Image.fromarray(heat)),
730
  "metrics": metrics,
 
731
  "image_index": image_index,
732
  "image_count": len(paths),
733
  }
 
606
  "source": os.path.relpath(src, PROJECT_DIR), "jpeg": jpeg, "avif": avif, "png": png}
607
 
608
 
609
+ def _encode_to_bpp(img, fmt, target_bpp, pixels):
610
+ """Encode `img` at the codec quality whose bpp lands closest to target_bpp.
611
+
612
+ Returns (quality, bpp, encoded_bytes) or None if the format is unsupported.
613
+ """
614
+ best = None
615
+ for q in range(4, 96, 3):
616
+ buf = io.BytesIO()
617
+ try:
618
+ img.save(buf, format=fmt, quality=q)
619
+ except Exception:
620
+ return None
621
+ data = buf.getvalue()
622
+ bpp = len(data) * 8 / pixels
623
+ if best is None or abs(bpp - target_bpp) < abs(best[1] - target_bpp):
624
+ best = (q, bpp, data)
625
+ return best
626
+
627
+
628
  @app.get("/api/sweeps/artifact")
629
  def sweeps_artifact(study_name: str = "", trial_number: int = 0, image_index: int = 0, mp: str = ""):
630
  mp = mp or _mp_of(study_name)
 
742
  diff = (diff / max(1.0, float(diff.max())) * 255.0).astype(np.uint8)
743
  heat = cv2.cvtColor(cv2.applyColorMap(diff, cv2.COLORMAP_INFERNO), cv2.COLOR_BGR2RGB)
744
 
745
+ # Codec reconstructions encoded at (as close as possible to) PBC's bpp, for a fair
746
+ # side-by-side comparison at matched compression.
747
+ comparisons = [{
748
+ "name": "PBC", "image": _png_b64(recon_full),
749
+ "bpp": metrics["bpp"], "quality": metrics["quality"],
750
+ }]
751
+ for label in ("JPEG", "AVIF"):
752
+ enc = _encode_to_bpp(img, label, metrics["bpp"], pixels)
753
+ if not enc:
754
+ continue
755
+ rec = Image.open(io.BytesIO(enc[2])).convert("RGB")
756
+ comparisons.append({
757
+ "name": label, "image": _png_b64(rec), "bpp": enc[1],
758
+ "quality": composite_quality(img, rec), "q": enc[0],
759
+ })
760
+
761
  return {
762
  "original_image": _png_b64(img),
763
  "recon_image": _png_b64(recon),
764
  "diff_image": _png_b64(Image.fromarray(heat)),
765
  "metrics": metrics,
766
+ "comparisons": comparisons,
767
  "image_index": image_index,
768
  "image_count": len(paths),
769
  }
static/app.js CHANGED
@@ -396,6 +396,7 @@ function renderParams() {
396
  paramsBody.innerHTML = `<p class="card-date">Auto mode — every parameter is derived from the image. Switch to Semi-Auto or Manual to take control.</p>`;
397
  return;
398
  }
 
399
  const items = PARAMS.filter(p => p.modes.includes(paramMode));
400
  let html = `<div class="param-grid">`, group = null;
401
  items.forEach(p => {
@@ -457,7 +458,7 @@ function control(p) {
457
  </div></div>`;
458
  }
459
 
460
- /* ---- Paste from Optuna ---- */
461
  const OPTUNA_MAP = {
462
  stroke_count: "stroke_count",
463
  size_start: "size_range_start",
@@ -501,12 +502,17 @@ function parseOptuna(text) {
501
  return out;
502
  }
503
 
504
- async function applyOptuna(text) {
 
 
 
505
  const p = parseOptuna(text);
506
- if (!Object.keys(p).length) return toast("Couldn't parse anything");
507
 
508
- paramMode = "Manual";
509
- document.querySelectorAll("#param-mode .seg-btn").forEach(b => b.classList.toggle("active", b.dataset.mode === "Manual"));
 
 
510
 
511
  for (const [k, v] of Object.entries(p)) if (OPTUNA_MAP[k]) paramState[OPTUNA_MAP[k]] = v;
512
  if (p.resample) paramState.downsample_alg = RESAMPLE_CAP[p.resample.toLowerCase()] || "Bicubic";
@@ -533,25 +539,140 @@ async function applyOptuna(text) {
533
  } catch {}
534
  }
535
  }
536
- renderParams();
537
- toast("Loaded Optuna config");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  }
539
 
540
- (() => {
541
  const bar = document.getElementById("param-mode");
542
- if (!bar) return;
543
  const btn = document.createElement("button");
544
- btn.textContent = "Paste from Optuna";
545
  btn.className = "seg-btn";
 
 
546
  btn.style.marginLeft = "auto";
547
  btn.addEventListener("click", () => {
548
- const t = prompt("Paste the Optuna parameter block:");
549
- if (t) applyOptuna(t);
 
 
 
550
  });
551
  bar.appendChild(btn);
552
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
 
554
  renderParams();
 
555
 
556
  /* ============================================================
557
  IMAGE INPUT (compress)
@@ -609,11 +730,13 @@ compressBtn.addEventListener("click", async () => {
609
  compressBtn.classList.add("busy");
610
  orbStatus.textContent = "compressing…";
611
 
 
 
612
  const fd = new FormData();
613
  fd.append("image", currentFile);
614
- fd.append("mode", paramMode);
615
- if (paramMode !== "Auto") {
616
- PARAMS.filter(p => p.modes.includes(paramMode)).forEach(p => {
617
  const v = paramState[p.id];
618
  fd.append(p.id, p.type === "check" ? (v ? "true" : "false") : v);
619
  });
@@ -622,18 +745,14 @@ compressBtn.addEventListener("click", async () => {
622
 
623
  try {
624
  const res = await fetch("/api/compress", { method: "POST", body: fd });
625
- const data = await res.json().catch(() => ({ error: res.statusText }));
626
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
627
  addToRegistry(data);
628
  showLastResult(data);
629
- orbStatus.style.color = "";
630
  orbStatus.textContent = `done · ${data.compression_rate}x`;
631
  } catch (err) {
632
- orbStatus.style.color = "var(--red)";
633
- orbStatus.style.whiteSpace = "normal";
634
- orbStatus.title = err.message;
635
- orbStatus.textContent = "failed — " + err.message;
636
- toast("Compression failed: " + err.message);
637
  console.error(err);
638
  } finally {
639
  compressBtn.classList.remove("busy");
@@ -650,21 +769,16 @@ decodeBtn.addEventListener("click", async () => {
650
  const fd = new FormData();
651
  fd.append("file", decodeFile);
652
 
653
- const dStatus = document.getElementById("decode-status");
654
  try {
655
  const res = await fetch("/api/decode", { method: "POST", body: fd });
656
- const data = await res.json().catch(() => ({ error: res.statusText }));
657
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
658
  addToRegistry(data);
659
  showLastResult(data);
660
- dStatus.style.color = "";
661
- dStatus.textContent = `done · ${data.compression_rate}x`;
662
  } catch (err) {
663
- dStatus.style.color = "var(--red)";
664
- dStatus.style.whiteSpace = "normal";
665
- dStatus.title = err.message;
666
- dStatus.textContent = "failed — " + err.message;
667
- toast("Decode failed: " + err.message);
668
  console.error(err);
669
  } finally {
670
  decodeBtn.classList.remove("busy");
@@ -854,6 +968,3 @@ function toast(msg) {
854
  clearTimeout(toastTimer);
855
  toastTimer = setTimeout(() => t.classList.remove("show"), 2600);
856
  }
857
-
858
- /* ============================================================ */
859
- initRoster();
 
396
  paramsBody.innerHTML = `<p class="card-date">Auto mode — every parameter is derived from the image. Switch to Semi-Auto or Manual to take control.</p>`;
397
  return;
398
  }
399
+ if (paramMode === "Suggest") { renderSuggest(); return; }
400
  const items = PARAMS.filter(p => p.modes.includes(paramMode));
401
  let html = `<div class="param-grid">`, group = null;
402
  items.forEach(p => {
 
458
  </div></div>`;
459
  }
460
 
461
+ /* ---- Optuna form mapping (used by the Sweep Analyzer "Load into Demo" and the suggestor) ---- */
462
  const OPTUNA_MAP = {
463
  stroke_count: "stroke_count",
464
  size_start: "size_range_start",
 
502
  return out;
503
  }
504
 
505
+ // opts.keepMode: apply values to paramState without switching the mode, re-rendering
506
+ // the form, or toasting (used by the live suggestor as the user drags the sliders).
507
+ async function applyOptuna(text, opts = {}) {
508
+ const keepMode = !!opts.keepMode;
509
  const p = parseOptuna(text);
510
+ if (!Object.keys(p).length) { if (!keepMode) toast("Couldn't parse anything"); return; }
511
 
512
+ if (!keepMode) {
513
+ paramMode = "Manual";
514
+ document.querySelectorAll("#param-mode .seg-btn").forEach(b => b.classList.toggle("active", b.dataset.mode === "Manual"));
515
+ }
516
 
517
  for (const [k, v] of Object.entries(p)) if (OPTUNA_MAP[k]) paramState[OPTUNA_MAP[k]] = v;
518
  if (p.resample) paramState.downsample_alg = RESAMPLE_CAP[p.resample.toLowerCase()] || "Bicubic";
 
539
  } catch {}
540
  }
541
  }
542
+ if (!keepMode) {
543
+ renderParams();
544
+ toast("Loaded Optuna config");
545
+ }
546
+ }
547
+
548
+ /* ============================================================
549
+ SEMI-AUTO SUGGESTOR (interpolation over a tuning sweep)
550
+ Appears as a "Suggest" parameter mode only when the backend
551
+ recognizes a tuning .db. Three priority sliders pick an
552
+ operating point interpolated along the study's stroke-count
553
+ response curves; the result is loaded as a Manual config.
554
+ ============================================================ */
555
+ let sweepDb = null; // { db_path, studies:[...] } once a .db is recognized
556
+ const sweepTrialCache = {}; // study_name -> trials[]
557
+
558
+ async function initSuggestor() {
559
+ try {
560
+ const d = await (await fetch("/api/sweeps/studies?db_path=")).json();
561
+ if (d && Array.isArray(d.studies) && d.studies.length) { sweepDb = d; addSuggestMode(); }
562
+ } catch {}
563
  }
564
 
565
+ function addSuggestMode() {
566
  const bar = document.getElementById("param-mode");
567
+ if (!bar || bar.querySelector('[data-mode="Suggest"]')) return;
568
  const btn = document.createElement("button");
 
569
  btn.className = "seg-btn";
570
+ btn.dataset.mode = "Suggest";
571
+ btn.textContent = "Suggest";
572
  btn.style.marginLeft = "auto";
573
  btn.addEventListener("click", () => {
574
+ document.querySelectorAll("#param-mode .seg-btn").forEach(x => x.classList.remove("active"));
575
+ btn.classList.add("active");
576
+ // Compress submits Manual params; the suggestor just fills them in.
577
+ paramMode = "Suggest";
578
+ renderSuggest();
579
  });
580
  bar.appendChild(btn);
581
+ }
582
+
583
+ function sgSlider(id, label, val) {
584
+ return `<div class="param full"><label>${label} <i id="${id}-v">${val}</i></label>
585
+ <div class="slider-row"><input type="range" id="${id}" min="0" max="100" value="${val}"></div></div>`;
586
+ }
587
+
588
+ function renderSuggest() {
589
+ const studies = sweepDb ? sweepDb.studies : [];
590
+ paramsBody.innerHTML = `
591
+ <p class="card-date">Interpolation-based suggestion from a tuning sweep. Set your priorities and the closest stroke-count operating point on the study's Pareto response curves is interpolated, then loaded as a Manual config you can still tweak before compressing.</p>
592
+ <div class="param-grid">
593
+ <div class="param-group-title">Sweep study</div>
594
+ <div class="param full"><label>Study</label>
595
+ <select id="sg-study">${studies.map(s => `<option value="${s.study_name}">${s.study_name} (${s.n_trials})</option>`).join("")}</select></div>
596
+ <div class="param-group-title">Priorities</div>
597
+ ${sgSlider("sg-speed", "Speed priority", 33)}
598
+ ${sgSlider("sg-comp", "Compression priority", 33)}
599
+ ${sgSlider("sg-qual", "Quality priority", 34)}
600
+ </div>
601
+ <div id="sg-out" class="card-date" style="margin-top:10px"></div>`;
602
+
603
+ document.getElementById("sg-study").addEventListener("change", recomputeSuggestion);
604
+ ["sg-speed", "sg-comp", "sg-qual"].forEach(id => {
605
+ const el = document.getElementById(id);
606
+ el.addEventListener("input", () => { document.getElementById(id + "-v").textContent = el.value; recomputeSuggestion(); });
607
+ });
608
+ recomputeSuggestion();
609
+ }
610
+
611
+ async function loadSweepTrials(name) {
612
+ if (sweepTrialCache[name]) return sweepTrialCache[name];
613
+ try {
614
+ const d = await (await fetch(`/api/sweeps/study?db_path=${encodeURIComponent(sweepDb.db_path)}&study_name=${encodeURIComponent(name)}`)).json();
615
+ if (d.error) return null;
616
+ sweepTrialCache[name] = d.trials || [];
617
+ return sweepTrialCache[name];
618
+ } catch { return null; }
619
+ }
620
+
621
+ function computeSuggestion(trials, ws, wc, wq) {
622
+ let set = trials.filter(t => t.pareto);
623
+ if (!set.length) set = trials;
624
+ const pts = set.map(t => ({ s: +t.params.stroke_count, q: t.quality, bpp: t.bpp, sp: t.speed, t }))
625
+ .filter(p => !isNaN(p.s)).sort((a, b) => a.s - b.s);
626
+ if (!pts.length) return null;
627
+
628
+ const sum = (ws + wc + wq) || 1; ws /= sum; wc /= sum; wq /= sum;
629
+ const rng = (key) => { const v = pts.map(p => p[key]); return [Math.min(...v), Math.max(...v)]; };
630
+ const [qlo, qhi] = rng("q"), [blo, bhi] = rng("bpp"), [slo, shi] = rng("sp");
631
+ const nrm = (x, lo, hi) => hi === lo ? 0.5 : (x - lo) / (hi - lo);
632
+ const xs = pts.map(p => p.s);
633
+ const interpKey = (s, key) => {
634
+ if (s <= xs[0]) return pts[0][key];
635
+ if (s >= xs[xs.length - 1]) return pts[xs.length - 1][key];
636
+ for (let i = 1; i < xs.length; i++)
637
+ if (s <= xs[i]) { const f = (s - xs[i - 1]) / ((xs[i] - xs[i - 1]) || 1); return pts[i - 1][key] + f * (pts[i][key] - pts[i - 1][key]); }
638
+ return pts[pts.length - 1][key];
639
+ };
640
+
641
+ let best = null;
642
+ const lo = xs[0], hi = xs[xs.length - 1], steps = 80;
643
+ for (let i = 0; i <= steps; i++) {
644
+ const s = lo + (hi - lo) * i / steps;
645
+ const q = interpKey(s, "q"), bpp = interpKey(s, "bpp"), sp = interpKey(s, "sp");
646
+ const score = wq * nrm(q, qlo, qhi) - wc * nrm(bpp, blo, bhi) - ws * nrm(sp, slo, shi);
647
+ if (!best || score > best.score) best = { score, s, q, bpp, sp };
648
+ }
649
+ const nearest = pts.reduce((a, b) => Math.abs(b.s - best.s) < Math.abs(a.s - best.s) ? b : a);
650
+ return { stroke: Math.round(best.s), trial: nearest.t, q: best.q, bpp: best.bpp, sp: best.sp };
651
+ }
652
+
653
+ function suggestionToOptuna(t, stroke) {
654
+ const lines = Object.entries(t.params).map(([k, v]) =>
655
+ k === "stroke_count" ? `stroke_count ${stroke}` : `${k} ${v === true ? "True" : v === false ? "False" : v}`);
656
+ return lines.join("\n");
657
+ }
658
+
659
+ async function recomputeSuggestion() {
660
+ const out = document.getElementById("sg-out");
661
+ if (!out) return;
662
+ const name = document.getElementById("sg-study").value;
663
+ const trials = await loadSweepTrials(name);
664
+ if (!trials) { out.textContent = "Could not load study."; return; }
665
+ const ws = +document.getElementById("sg-speed").value,
666
+ wc = +document.getElementById("sg-comp").value,
667
+ wq = +document.getElementById("sg-qual").value;
668
+ const sug = computeSuggestion(trials, ws, wc, wq);
669
+ if (!sug) { out.textContent = "No usable trials in this study."; return; }
670
+ await applyOptuna(suggestionToOptuna(sug.trial, sug.stroke), { keepMode: true });
671
+ out.innerHTML = `→ suggested <b>stroke_count=${sug.stroke}</b> (interpolated; companion params from nearest trial #${sug.trial.number}) · predicted quality ${sug.q.toFixed(4)} · bpp ${sug.bpp.toFixed(4)} · ${sug.sp.toFixed(3)} sec/MP. Loaded as Manual params — press Compress.`;
672
+ }
673
 
674
  renderParams();
675
+ initSuggestor();
676
 
677
  /* ============================================================
678
  IMAGE INPUT (compress)
 
730
  compressBtn.classList.add("busy");
731
  orbStatus.textContent = "compressing…";
732
 
733
+ // The suggestor fills the Manual parameters, so it submits as a Manual run.
734
+ const fillMode = paramMode === "Suggest" ? "Manual" : paramMode;
735
  const fd = new FormData();
736
  fd.append("image", currentFile);
737
+ fd.append("mode", fillMode);
738
+ if (fillMode !== "Auto") {
739
+ PARAMS.filter(p => p.modes.includes(fillMode)).forEach(p => {
740
  const v = paramState[p.id];
741
  fd.append(p.id, p.type === "check" ? (v ? "true" : "false") : v);
742
  });
 
745
 
746
  try {
747
  const res = await fetch("/api/compress", { method: "POST", body: fd });
748
+ if (!res.ok) throw new Error(await res.text());
749
+ const data = await res.json();
750
  addToRegistry(data);
751
  showLastResult(data);
 
752
  orbStatus.textContent = `done · ${data.compression_rate}x`;
753
  } catch (err) {
754
+ orbStatus.textContent = "failed";
755
+ toast("Compression failed");
 
 
 
756
  console.error(err);
757
  } finally {
758
  compressBtn.classList.remove("busy");
 
769
  const fd = new FormData();
770
  fd.append("file", decodeFile);
771
 
 
772
  try {
773
  const res = await fetch("/api/decode", { method: "POST", body: fd });
774
+ if (!res.ok) throw new Error(await res.text());
775
+ const data = await res.json();
776
  addToRegistry(data);
777
  showLastResult(data);
778
+ document.getElementById("decode-status").textContent = `done · ${data.compression_rate}x`;
 
779
  } catch (err) {
780
+ document.getElementById("decode-status").textContent = "failed";
781
+ toast("Decode failed — is it a valid .pbc?");
 
 
 
782
  console.error(err);
783
  } finally {
784
  decodeBtn.classList.remove("busy");
 
968
  clearTimeout(toastTimer);
969
  toastTimer = setTimeout(() => t.classList.remove("show"), 2600);
970
  }
 
 
 
static/sweep.js CHANGED
@@ -3,8 +3,9 @@
3
  PBC SWEEP ANALYZER
4
  Reads an Optuna SQLite study via the backend, plots PBC Pareto
5
  fronts (2D + optional 3D), overlays JPEG/AVIF/PNG codec baselines,
6
- supports per-graph fullscreen, and lets you inspect / copy / load
7
- any trial's config into the demo. Wraps window.gotoView so view
 
8
  switching stays consistent.
9
  ============================================================ */
10
  (function () {
@@ -15,6 +16,7 @@
15
  dbPath: "", dbName: "",
16
  study: null, trials: [], filtered: [],
17
  selected: null, codec: null, built: false, fsKind: null,
 
18
  sort: { key: "quality", dir: -1 },
19
  show: { pbcAll: true, pbcPareto: true, jpeg: false, avif: false, png: false },
20
  dyn: [], // dynamic parameter filters
@@ -48,7 +50,20 @@
48
  border-radius:16px;display:flex;flex-direction:column;padding:14px 16px;box-shadow:0 24px 70px rgba(0,0,0,.55);}
49
  .sw-fs-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}
50
  .sw-fs-bar span{font-size:13px;font-weight:600;color:var(--text);}
51
- #sw-fs-plot{flex:1;min-height:0;}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  const fmt = (v, d = 4) => (v === null || v === undefined || isNaN(v)) ? "—" : (+v).toFixed(d);
54
 
@@ -91,6 +106,7 @@
91
  <option value="auto">auto baseline only</option>
92
  <option value="downsample">downsample baseline only</option>
93
  <option value="no_downsample">no downsample baseline</option>
 
94
  </select></div>
95
  </div>
96
  <div id="sw-dyn" style="margin-top:12px;display:flex;flex-direction:column;gap:8px"></div>
@@ -181,6 +197,14 @@
181
  <div id="sw-fs-plot"></div>
182
  </div>
183
  </div>
 
 
 
 
 
 
 
 
184
  </div>`;
185
 
186
  $("sw-load").onclick = () => loadDb($("sw-dbpath").value.trim());
@@ -214,7 +238,21 @@
214
 
215
  $("view-sweep").querySelectorAll(".sw-fs-btn").forEach((b) => b.onclick = () => openFs(b.dataset.fs));
216
  $("sw-fs-close").onclick = closeFs;
217
- document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("sw-fs").hidden) closeFs(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  buildToggles();
220
  S.built = true;
@@ -392,6 +430,7 @@
392
  if (bl === "auto" && t.baseline !== "auto") return false;
393
  if (bl === "downsample" && !isDownsampleBase(t)) return false;
394
  if (bl === "no_downsample" && isDownsampleBase(t)) return false;
 
395
  for (const d of S.dyn) {
396
  const v = t.params[d.param];
397
  if (d.kind === "num") {
@@ -603,7 +642,7 @@
603
  if (S.fsKind === "3d") { render3dInto("sw-fs-plot"); resizeFs(); }
604
  }
605
 
606
- /* -------------------- fullscreen -------------------- */
607
  function openFs(kind) {
608
  if (typeof PLOT === "undefined") return toastMsg("Plotly unavailable");
609
  S.fsKind = kind;
@@ -626,6 +665,22 @@
626
  if (el && el.data && !$("sw-fs").hidden) PLOT.Plots.resize(el);
627
  }
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  /* -------------------- recommendation -------------------- */
630
  function recommend() {
631
  const set = S.filtered.filter((t) => t.pareto);
@@ -825,12 +880,33 @@
825
  const metricRow = Object.keys(m).length
826
  ? `<div class="sw-kv" style="margin-top:8px">${keys.filter((k) => m[k] != null)
827
  .map((k) => `<div class="k">${k}</div><div class="v">${fmt(m[k], 3)}</div>`).join("")}</div>` : "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
828
  $("sw-art-body").innerHTML = `
829
- <div class="sw-artifact">
830
- <figure><figcaption>Original</figcaption><img src="${d.original_image}"></figure>
831
- <figure><figcaption>PBC reconstruction</figcaption><img src="${d.recon_image}"></figure>
832
- ${d.diff_image ? `<figure><figcaption>Difference heatmap</figcaption><img src="${d.diff_image}"></figure>` : ""}
833
- </div>${metricRow}`;
 
 
834
  fillIdxSelect(d.image_count != null ? d.image_count : (S.study.dataset_count || 0), d.image_index != null ? d.image_index : 0);
835
  $("sw-art-idx").onchange = () => generateArtifact(t, +$("sw-art-idx").value);
836
  $("sw-art-gen").disabled = !S.study.has_dataset;
@@ -848,7 +924,7 @@
848
 
849
  async function generateArtifact(t, idx) {
850
  const status = $("sw-art-status");
851
- status.innerHTML = `<span class="sw-spin"></span> running PBC.compress…`;
852
  $("sw-art-gen").disabled = true;
853
  const fd = new FormData();
854
  fd.append("db_path", S.dbPath);
@@ -892,4 +968,4 @@
892
  if (typeof _origGoto === "function") _origGoto(v);
893
  if (v === "sweep") window.SweepAnalyzer.onShow();
894
  };
895
- })();
 
3
  PBC SWEEP ANALYZER
4
  Reads an Optuna SQLite study via the backend, plots PBC Pareto
5
  fronts (2D + optional 3D), overlays JPEG/AVIF/PNG codec baselines,
6
+ supports per-graph fullscreen, inspects artifacts (with matched-bpp
7
+ JPEG/AVIF comparisons + a navigable image viewer), and loads any
8
+ trial's config into the demo. Wraps window.gotoView so view
9
  switching stays consistent.
10
  ============================================================ */
11
  (function () {
 
16
  dbPath: "", dbName: "",
17
  study: null, trials: [], filtered: [],
18
  selected: null, codec: null, built: false, fsKind: null,
19
+ artImages: [], ivIndex: 0,
20
  sort: { key: "quality", dir: -1 },
21
  show: { pbcAll: true, pbcPareto: true, jpeg: false, avif: false, png: false },
22
  dyn: [], // dynamic parameter filters
 
50
  border-radius:16px;display:flex;flex-direction:column;padding:14px 16px;box-shadow:0 24px 70px rgba(0,0,0,.55);}
51
  .sw-fs-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}
52
  .sw-fs-bar span{font-size:13px;font-weight:600;color:var(--text);}
53
+ #sw-fs-plot{flex:1;min-height:0;}
54
+ .sw-artifact figure{cursor:zoom-in;}
55
+ #sw-imgfs{position:fixed;inset:0;z-index:1001;background:rgba(0,0,0,.9);backdrop-filter:blur(4px);
56
+ display:flex;align-items:center;justify-content:center;animation:swfs .14s ease;}
57
+ #sw-imgfs[hidden]{display:none;}
58
+ #sw-imgfs img{max-width:90vw;max-height:84vh;border-radius:10px;box-shadow:0 24px 70px rgba(0,0,0,.6);}
59
+ .sw-iv-cap{position:absolute;bottom:22px;left:0;right:0;text-align:center;color:#d2d2d8;font-size:13px;padding:0 24px;}
60
+ .sw-iv-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(20,20,22,.72);
61
+ border:1px solid var(--border2);color:#fff;width:46px;height:64px;border-radius:11px;font-size:26px;
62
+ cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.15s;}
63
+ .sw-iv-nav:hover:not(:disabled){border-color:var(--red-dim);} .sw-iv-nav:disabled{opacity:.25;cursor:default;}
64
+ .sw-iv-nav.prev{left:24px;} .sw-iv-nav.next{right:24px;}
65
+ .sw-iv-close{position:absolute;top:20px;right:24px;background:rgba(20,20,22,.72);border:1px solid var(--border2);
66
+ color:#fff;width:40px;height:40px;border-radius:9px;font-size:20px;cursor:pointer;}`;
67
 
68
  const fmt = (v, d = 4) => (v === null || v === undefined || isNaN(v)) ? "—" : (+v).toFixed(d);
69
 
 
106
  <option value="auto">auto baseline only</option>
107
  <option value="downsample">downsample baseline only</option>
108
  <option value="no_downsample">no downsample baseline</option>
109
+ <option value="any_baseline">baselines only</option>
110
  </select></div>
111
  </div>
112
  <div id="sw-dyn" style="margin-top:12px;display:flex;flex-direction:column;gap:8px"></div>
 
197
  <div id="sw-fs-plot"></div>
198
  </div>
199
  </div>
200
+
201
+ <div id="sw-imgfs" hidden>
202
+ <button class="sw-iv-close" id="sw-iv-close">×</button>
203
+ <button class="sw-iv-nav prev" id="sw-iv-prev">‹</button>
204
+ <button class="sw-iv-nav next" id="sw-iv-next">›</button>
205
+ <img id="sw-iv-img">
206
+ <div class="sw-iv-cap" id="sw-iv-cap"></div>
207
+ </div>
208
  </div>`;
209
 
210
  $("sw-load").onclick = () => loadDb($("sw-dbpath").value.trim());
 
238
 
239
  $("view-sweep").querySelectorAll(".sw-fs-btn").forEach((b) => b.onclick = () => openFs(b.dataset.fs));
240
  $("sw-fs-close").onclick = closeFs;
241
+
242
+ $("sw-iv-close").onclick = closeImgViewer;
243
+ $("sw-iv-prev").onclick = () => stepImg(-1);
244
+ $("sw-iv-next").onclick = () => stepImg(1);
245
+ $("sw-imgfs").addEventListener("click", (e) => { if (e.target.id === "sw-imgfs") closeImgViewer(); });
246
+
247
+ document.addEventListener("keydown", (e) => {
248
+ if (!$("sw-imgfs").hidden) {
249
+ if (e.key === "Escape") closeImgViewer();
250
+ else if (e.key === "ArrowLeft") stepImg(-1);
251
+ else if (e.key === "ArrowRight") stepImg(1);
252
+ return;
253
+ }
254
+ if (e.key === "Escape" && !$("sw-fs").hidden) closeFs();
255
+ });
256
 
257
  buildToggles();
258
  S.built = true;
 
430
  if (bl === "auto" && t.baseline !== "auto") return false;
431
  if (bl === "downsample" && !isDownsampleBase(t)) return false;
432
  if (bl === "no_downsample" && isDownsampleBase(t)) return false;
433
+ if (bl === "any_baseline" && !t.baseline) return false;
434
  for (const d of S.dyn) {
435
  const v = t.params[d.param];
436
  if (d.kind === "num") {
 
642
  if (S.fsKind === "3d") { render3dInto("sw-fs-plot"); resizeFs(); }
643
  }
644
 
645
+ /* -------------------- graph fullscreen -------------------- */
646
  function openFs(kind) {
647
  if (typeof PLOT === "undefined") return toastMsg("Plotly unavailable");
648
  S.fsKind = kind;
 
665
  if (el && el.data && !$("sw-fs").hidden) PLOT.Plots.resize(el);
666
  }
667
 
668
+ /* -------------------- artifact image viewer -------------------- */
669
+ function openImgViewer(i) { S.ivIndex = i; renderImgViewer(); $("sw-imgfs").hidden = false; }
670
+ function closeImgViewer() { $("sw-imgfs").hidden = true; }
671
+ function stepImg(d) {
672
+ const i = S.ivIndex + d;
673
+ if (i >= 0 && i < S.artImages.length) { S.ivIndex = i; renderImgViewer(); }
674
+ }
675
+ function renderImgViewer() {
676
+ const it = S.artImages[S.ivIndex];
677
+ if (!it) return;
678
+ $("sw-iv-img").src = it.src;
679
+ $("sw-iv-cap").textContent = it.cap;
680
+ $("sw-iv-prev").disabled = S.ivIndex <= 0;
681
+ $("sw-iv-next").disabled = S.ivIndex >= S.artImages.length - 1;
682
+ }
683
+
684
  /* -------------------- recommendation -------------------- */
685
  function recommend() {
686
  const set = S.filtered.filter((t) => t.pareto);
 
880
  const metricRow = Object.keys(m).length
881
  ? `<div class="sw-kv" style="margin-top:8px">${keys.filter((k) => m[k] != null)
882
  .map((k) => `<div class="k">${k}</div><div class="v">${fmt(m[k], 3)}</div>`).join("")}</div>` : "";
883
+
884
+ const top = [
885
+ { src: d.original_image, cap: "Original" },
886
+ { src: d.recon_image, cap: "PBC reconstruction" },
887
+ ];
888
+ if (d.diff_image) top.push({ src: d.diff_image, cap: "Difference heatmap" });
889
+
890
+ const comps = (d.comparisons || []).map((c) =>
891
+ ({ src: c.image, cap: `${c.name} · bpp ${fmt(c.bpp, 4)} · quality ${fmt(c.quality, 4)}${c.q ? " · q" + c.q : ""}` }));
892
+
893
+ S.artImages = [...top, ...comps];
894
+ const figs = (arr, start) => arr.map((it, i) =>
895
+ `<figure data-ai="${start + i}"><figcaption>${it.cap}</figcaption><img src="${it.src}"></figure>`).join("");
896
+
897
+ const cmpBlock = comps.length
898
+ ? `<div class="sw-muted" style="margin:14px 0 6px">Codec comparison at matched bpp (~${fmt(m.bpp, 4)}) — click any image to compare fullscreen (← → to switch)</div>
899
+ <div class="sw-artifact">${figs(comps, top.length)}</div>`
900
+ : "";
901
+
902
  $("sw-art-body").innerHTML = `
903
+ <div class="sw-artifact">${figs(top, 0)}</div>
904
+ ${metricRow}
905
+ ${cmpBlock}`;
906
+
907
+ $("sw-art-body").querySelectorAll("figure[data-ai]").forEach((f) =>
908
+ f.querySelector("img").onclick = () => openImgViewer(+f.dataset.ai));
909
+
910
  fillIdxSelect(d.image_count != null ? d.image_count : (S.study.dataset_count || 0), d.image_index != null ? d.image_index : 0);
911
  $("sw-art-idx").onchange = () => generateArtifact(t, +$("sw-art-idx").value);
912
  $("sw-art-gen").disabled = !S.study.has_dataset;
 
924
 
925
  async function generateArtifact(t, idx) {
926
  const status = $("sw-art-status");
927
+ status.innerHTML = `<span class="sw-spin"></span> running PBC.compress + codec comparisons…`;
928
  $("sw-art-gen").disabled = true;
929
  const fd = new FormData();
930
  fd.append("db_path", S.dbPath);
 
968
  if (typeof _origGoto === "function") _origGoto(v);
969
  if (v === "sweep") window.SweepAnalyzer.onShow();
970
  };
971
+ })();