EgeEken commited on
Commit
4502dde
·
1 Parent(s): 73b4927

fixes and sweep improvements.

Browse files
Files changed (6) hide show
  1. PBC3_animation.py +25 -12
  2. pbc3_sweep.py +36 -13
  3. server.py +4 -3
  4. static/app.js +17 -6
  5. static/index.html +4 -2
  6. static/sweep.js +6 -6
PBC3_animation.py CHANGED
@@ -98,38 +98,51 @@ def _fit(img, max_w, max_h):
98
 
99
  def _make_frame(canvas, color_space, patch_info, separated_channels, title, target=None, show_errors=False, output_size=(3840, 2160)):
100
  arr = np.clip(canvas, 0, 255).astype(np.uint8)
101
- rgb = Image.fromarray(arr, color_space).convert("RGB")
102
- if target is not None and target.shape[2] != arr.shape[2]:
103
- if arr.shape[2] == 4 and target.shape[2] == 3:
 
 
 
 
104
  target = np.dstack([target, np.full(target.shape[:2], 255, target.dtype)])
105
  else:
106
- target = target[:, :, :arr.shape[2]]
107
  error = None if target is None else target.astype(np.int32) - arr.astype(np.int32)
108
 
109
  if not separated_channels:
110
  panels = [(rgb, False)]
 
 
111
  if show_errors:
112
  if error is None:
113
  raise ValueError("show_errors=True requires original_image")
114
  panels.append((_error_image(error, color_space), True))
115
- cols, rows = 1, len(panels)
116
  else:
117
  panels = [
118
  (_colorize_channel(arr[:, :, 0], 0, color_space), False),
119
  (_colorize_channel(arr[:, :, 1], 1, color_space), False),
120
  (_colorize_channel(arr[:, :, 2], 2, color_space), False),
121
- (rgb, False),
122
  ]
 
 
 
 
123
  if show_errors:
124
  if error is None:
125
  raise ValueError("show_errors=True requires original_image")
126
- panels.extend([
127
  (_error_image(error[:, :, 0], color_space, 0), True),
128
  (_error_image(error[:, :, 1], color_space, 1), True),
129
  (_error_image(error[:, :, 2], color_space, 2), True),
130
- (_error_image(error, color_space), True),
131
- ])
132
- cols, rows = 4, 2 if show_errors else 1
 
 
 
 
133
 
134
  frame_w, frame_h = output_size
135
  frame = Image.new("RGB", output_size, "black")
@@ -156,7 +169,7 @@ def _make_frame(canvas, color_space, patch_info, separated_channels, title, targ
156
  py = y0 + (cell_h - fitted.height) // 2
157
  frame.paste(fitted, (px, py))
158
 
159
- is_rgb_panel = (separated_channels and col == 3) or (not separated_channels and i == 0)
160
  is_active_channel_panel = separated_channels and col == active_channel
161
  if patch_box is not None and not is_error and (is_rgb_panel or is_active_channel_panel):
162
  _draw_patch(draw, patch_box, scale, (px, py))
@@ -249,7 +262,7 @@ def animate_pbc3(
249
  (downsampled, original_w, original_h, w, h, color_space, channels,
250
  channel_bits, positive_bias, has_alpha, patch_count, base_values,
251
  warmup_on, warm_w, warm_h, warmup_split) = PBC3._read_header(br)
252
- if channels != 3 and separated_channels:
253
  separated_channels = False
254
  if show_errors and original_image is None:
255
  raise ValueError("show_errors=True requires original_image=...")
 
98
 
99
  def _make_frame(canvas, color_space, patch_info, separated_channels, title, target=None, show_errors=False, output_size=(3840, 2160)):
100
  arr = np.clip(canvas, 0, 255).astype(np.uint8)
101
+ n_ch = arr.shape[2]
102
+ has_alpha = n_ch == 4
103
+ rgb = Image.fromarray(np.ascontiguousarray(arr[:, :, :3]), color_space).convert("RGB")
104
+ alpha_img = Image.fromarray(arr[:, :, 3], "L").convert("RGB") if has_alpha else None
105
+
106
+ if target is not None and target.shape[2] != n_ch:
107
+ if n_ch == 4 and target.shape[2] == 3:
108
  target = np.dstack([target, np.full(target.shape[:2], 255, target.dtype)])
109
  else:
110
+ target = target[:, :, :n_ch]
111
  error = None if target is None else target.astype(np.int32) - arr.astype(np.int32)
112
 
113
  if not separated_channels:
114
  panels = [(rgb, False)]
115
+ if has_alpha:
116
+ panels.append((alpha_img, False))
117
  if show_errors:
118
  if error is None:
119
  raise ValueError("show_errors=True requires original_image")
120
  panels.append((_error_image(error, color_space), True))
121
+ cols, rows = len(panels), 1
122
  else:
123
  panels = [
124
  (_colorize_channel(arr[:, :, 0], 0, color_space), False),
125
  (_colorize_channel(arr[:, :, 1], 1, color_space), False),
126
  (_colorize_channel(arr[:, :, 2], 2, color_space), False),
 
127
  ]
128
+ if has_alpha:
129
+ panels.append((alpha_img, False))
130
+ panels.append((rgb, False))
131
+ cols, rows = len(panels), 1
132
  if show_errors:
133
  if error is None:
134
  raise ValueError("show_errors=True requires original_image")
135
+ err_panels = [
136
  (_error_image(error[:, :, 0], color_space, 0), True),
137
  (_error_image(error[:, :, 1], color_space, 1), True),
138
  (_error_image(error[:, :, 2], color_space, 2), True),
139
+ ]
140
+ if has_alpha:
141
+ a_err = np.clip(np.abs(error[:, :, 3]), 0, 255).astype(np.uint8)
142
+ err_panels.append((Image.fromarray(np.stack([a_err] * 3, axis=2), "RGB"), True))
143
+ err_panels.append((_error_image(error, color_space), True))
144
+ panels.extend(err_panels)
145
+ rows = 2
146
 
147
  frame_w, frame_h = output_size
148
  frame = Image.new("RGB", output_size, "black")
 
169
  py = y0 + (cell_h - fitted.height) // 2
170
  frame.paste(fitted, (px, py))
171
 
172
+ is_rgb_panel = (separated_channels and col == cols - 1) or (not separated_channels and i == 0)
173
  is_active_channel_panel = separated_channels and col == active_channel
174
  if patch_box is not None and not is_error and (is_rgb_panel or is_active_channel_panel):
175
  _draw_patch(draw, patch_box, scale, (px, py))
 
262
  (downsampled, original_w, original_h, w, h, color_space, channels,
263
  channel_bits, positive_bias, has_alpha, patch_count, base_values,
264
  warmup_on, warm_w, warm_h, warmup_split) = PBC3._read_header(br)
265
+ if channels not in (3, 4) and separated_channels:
266
  separated_channels = False
267
  if show_errors and original_image is None:
268
  raise ValueError("show_errors=True requires original_image=...")
pbc3_sweep.py CHANGED
@@ -3,8 +3,8 @@
3
  One mixed Optuna study ("pbc3") in pbc3_sweep.db, three MINIMIZED objectives
4
  (seconds, bpp, mse) averaged over every image in hpt_data/. Per-image rows and the
5
  resolved config live in trial user_attrs so the analyzer can re-aggregate by
6
- megapixel range. JPEG/AVIF baselines are logged once as tagged trials. Start/stop
7
- controlled from the web Sweep Runner."""
8
 
9
  import glob, io, itertools, os, threading, time
10
 
@@ -25,8 +25,10 @@ DIRECTIONS = ("minimize", "minimize", "minimize")
25
  METRIC_NAMES = ("Seconds", "Compression (bpp)", "MSE")
26
  IMAGE_EXTS = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp")
27
 
 
28
  JPEG_QUALITIES = (1, 2, 3, 5, 8, 10, 15, 20, 30, 40, 60, 80, 95)
29
  AVIF_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 15, 20, 30, 40, 60, 80, 95)
 
30
 
31
  # Optimizer search space. (name, kind, *args). Ranges are a starting guess — tweak freely.
32
  SEARCH_SPACE = [
@@ -59,10 +61,11 @@ def ensure_pbc_baselines(study, images):
59
  tag = f"preset_{name}"
60
  if tag in have:
61
  continue
62
- _log(f"PBC preset baseline: {name}")
63
  cfg = dict(vars(getattr(PBC3Config, name)()))
64
  try:
65
- rows = _eval_pbc3(cfg, images)
 
66
  except Exception as e:
67
  _log(f"preset {name} failed: {e}")
68
  continue
@@ -117,7 +120,7 @@ def _avg(rows):
117
  return [float(np.mean([r[k] for r in rows])) for k in ("seconds", "bpp", "mse")]
118
 
119
 
120
- def _eval_pbc3(cfg, images):
121
  rows = []
122
  for im in images:
123
  arr = im["arr"]
@@ -126,10 +129,12 @@ def _eval_pbc3(cfg, images):
126
  recon = np.asarray(res.image.convert("RGB").resize((arr.shape[1], arr.shape[0])))
127
  rows.append({"name": im["name"], "mp": im["mp"], "seconds": float(res.encode_seconds),
128
  "bpp": float(res.total_bits / pixels), "mse": _mse(arr, recon)})
 
 
129
  return rows
130
 
131
 
132
- def _eval_codec(fmt, q, images):
133
  rows = []
134
  for im in images:
135
  arr = im["arr"]
@@ -141,6 +146,8 @@ def _eval_codec(fmt, q, images):
141
  recon = np.asarray(Image.open(io.BytesIO(buf.getvalue())).convert("RGB"))
142
  rows.append({"name": im["name"], "mp": im["mp"], "seconds": secs,
143
  "bpp": len(buf.getvalue()) * 8 / pixels, "mse": _mse(arr, recon)})
 
 
144
  return rows
145
 
146
 
@@ -162,14 +169,15 @@ def _add_completed(study, values, attrs):
162
 
163
  def ensure_baselines(study, images):
164
  have = {t.user_attrs.get("baseline") for t in study.trials}
165
- for fmt, qs in (("JPEG", JPEG_QUALITIES), ("AVIF", AVIF_QUALITIES)):
166
  for q in qs:
167
  tag = f"{fmt.lower()}_q{q}"
168
  if tag in have:
169
  continue
170
- _log(f"baseline {tag}")
171
  try:
172
- rows = _eval_codec(fmt, q, images)
 
173
  except Exception as e:
174
  _log(f"baseline {tag} failed: {e}")
175
  continue
@@ -192,6 +200,19 @@ def _log(msg):
192
  print(f"[sweep] {msg}", flush=True)
193
 
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  def status():
196
  with _LOCK:
197
  s = dict(_STATE)
@@ -248,18 +269,21 @@ def _run(mode, spec):
248
  cfg = _coerce(params)
249
  with _LOCK:
250
  _STATE["current"] = cfg
251
- rows = _eval_pbc3(cfg, images)
 
 
252
  _add_completed(study, _avg(rows),
253
  {"kind": "pbc3", "grid": True, "config": cfg, "per_image": rows})
254
  with _LOCK:
255
  _STATE["done"] = i + 1
256
- _log(f"grid trial {i + 1}/{len(trials)} done")
257
  else:
258
  def objective(trial):
259
  cfg = suggest_config(trial)
260
  with _LOCK:
261
  _STATE["current"] = cfg
262
- rows = _eval_pbc3(cfg, images)
 
 
263
  trial.set_user_attr("kind", "pbc3")
264
  trial.set_user_attr("config", cfg)
265
  trial.set_user_attr("per_image", rows)
@@ -270,7 +294,6 @@ def _run(mode, spec):
270
  n += 1
271
  with _LOCK:
272
  _STATE["done"] = n
273
- _log(f"optimizer trial {n} done")
274
  except Exception as e:
275
  with _LOCK:
276
  _STATE["error"] = str(e)
 
3
  One mixed Optuna study ("pbc3") in pbc3_sweep.db, three MINIMIZED objectives
4
  (seconds, bpp, mse) averaged over every image in hpt_data/. Per-image rows and the
5
  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
 
 
25
  METRIC_NAMES = ("Seconds", "Compression (bpp)", "MSE")
26
  IMAGE_EXTS = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp")
27
 
28
+ # JPEG is a 1-95 scale (q=0 clamps to 1); AVIF/WEBP are true 0-100.
29
  JPEG_QUALITIES = (1, 2, 3, 5, 8, 10, 15, 20, 30, 40, 60, 80, 95)
30
  AVIF_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 15, 20, 30, 40, 60, 80, 95)
31
+ WEBP_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 15, 20, 30, 40, 60, 80, 95)
32
 
33
  # Optimizer search space. (name, kind, *args). Ranges are a starting guess — tweak freely.
34
  SEARCH_SPACE = [
 
61
  tag = f"preset_{name}"
62
  if tag in have:
63
  continue
64
+ _log(f"PBC preset baseline: {name} started")
65
  cfg = dict(vars(getattr(PBC3Config, name)()))
66
  try:
67
+ _new_bar()
68
+ rows = _eval_pbc3(cfg, images, on_image=_bar_tick)
69
  except Exception as e:
70
  _log(f"preset {name} failed: {e}")
71
  continue
 
120
  return [float(np.mean([r[k] for r in rows])) for k in ("seconds", "bpp", "mse")]
121
 
122
 
123
+ def _eval_pbc3(cfg, images, on_image=None):
124
  rows = []
125
  for im in images:
126
  arr = im["arr"]
 
129
  recon = np.asarray(res.image.convert("RGB").resize((arr.shape[1], arr.shape[0])))
130
  rows.append({"name": im["name"], "mp": im["mp"], "seconds": float(res.encode_seconds),
131
  "bpp": float(res.total_bits / pixels), "mse": _mse(arr, recon)})
132
+ if on_image:
133
+ on_image()
134
  return rows
135
 
136
 
137
+ def _eval_codec(fmt, q, images, on_image=None):
138
  rows = []
139
  for im in images:
140
  arr = im["arr"]
 
146
  recon = np.asarray(Image.open(io.BytesIO(buf.getvalue())).convert("RGB"))
147
  rows.append({"name": im["name"], "mp": im["mp"], "seconds": secs,
148
  "bpp": len(buf.getvalue()) * 8 / pixels, "mse": _mse(arr, recon)})
149
+ if on_image:
150
+ on_image()
151
  return rows
152
 
153
 
 
169
 
170
  def ensure_baselines(study, images):
171
  have = {t.user_attrs.get("baseline") for t in study.trials}
172
+ for fmt, qs in (("JPEG", JPEG_QUALITIES), ("AVIF", AVIF_QUALITIES), ("WEBP", WEBP_QUALITIES)):
173
  for q in qs:
174
  tag = f"{fmt.lower()}_q{q}"
175
  if tag in have:
176
  continue
177
+ _log(f"baseline {tag} started")
178
  try:
179
+ _new_bar()
180
+ rows = _eval_codec(fmt, q, images, on_image=_bar_tick)
181
  except Exception as e:
182
  _log(f"baseline {tag} failed: {e}")
183
  continue
 
200
  print(f"[sweep] {msg}", flush=True)
201
 
202
 
203
+ def _new_bar():
204
+ """Append a fresh, empty progress line that _bar_tick grows in place."""
205
+ with _LOCK:
206
+ _STATE["log"] = (_STATE["log"] + [""])[-60:]
207
+
208
+
209
+ def _bar_tick():
210
+ """Add one '|' to the current progress line (called once per image)."""
211
+ with _LOCK:
212
+ if _STATE["log"]:
213
+ _STATE["log"][-1] += "|"
214
+
215
+
216
  def status():
217
  with _LOCK:
218
  s = dict(_STATE)
 
269
  cfg = _coerce(params)
270
  with _LOCK:
271
  _STATE["current"] = cfg
272
+ _log(f"grid trial {i + 1}/{len(trials)} started")
273
+ _new_bar()
274
+ rows = _eval_pbc3(cfg, images, on_image=_bar_tick)
275
  _add_completed(study, _avg(rows),
276
  {"kind": "pbc3", "grid": True, "config": cfg, "per_image": rows})
277
  with _LOCK:
278
  _STATE["done"] = i + 1
 
279
  else:
280
  def objective(trial):
281
  cfg = suggest_config(trial)
282
  with _LOCK:
283
  _STATE["current"] = cfg
284
+ _log(f"optimizer trial {trial.number} started")
285
+ _new_bar()
286
+ rows = _eval_pbc3(cfg, images, on_image=_bar_tick)
287
  trial.set_user_attr("kind", "pbc3")
288
  trial.set_user_attr("config", cfg)
289
  trial.set_user_attr("per_image", rows)
 
294
  n += 1
295
  with _LOCK:
296
  _STATE["done"] = n
 
297
  except Exception as e:
298
  with _LOCK:
299
  _STATE["error"] = str(e)
server.py CHANGED
@@ -356,11 +356,12 @@ async def decode(file: UploadFile = File(...)):
356
  BPP_GUIDE = {
357
  "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)],
358
  "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)],
 
359
  }
360
  # Quality samples for the tradeoff baselines (AVIF goes down to 0, JPEG stops at 1).
361
- JPEG_QUALITIES = (1, 2, 3, 5, 8, 10, 15, 20, 30, 40, 60, 80, 95)
362
- AVIF_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 15, 20, 30, 40, 60, 80, 95)
363
-
364
 
365
  def _q_bounds(fmt):
366
  return (1, 95) if fmt == "JPEG" else (0, 95)
 
356
  BPP_GUIDE = {
357
  "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)],
358
  "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)],
359
+ "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)],
360
  }
361
  # Quality samples for the tradeoff baselines (AVIF goes down to 0, JPEG stops at 1).
362
+ JPEG_QUALITIES = (1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
363
+ AVIF_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
364
+ WEBP_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
365
 
366
  def _q_bounds(fmt):
367
  return (1, 95) if fmt == "JPEG" else (0, 95)
static/app.js CHANGED
@@ -317,6 +317,7 @@ function enterApp() {
317
  document.getElementById("app").hidden = false;
318
  landingActive = false; // stop the teaser — no wasted compute
319
  if (streamAbort) streamAbort.abort();
 
320
  }
321
  function exitToLanding() {
322
  document.getElementById("app").hidden = true;
@@ -750,7 +751,7 @@ function renderRegistry() {
750
  // raw (3 bytes RGB) / bpp.
751
  const bppToRate = (bpp) => 24 / bpp;
752
 
753
- const CODEC_COLOR = { jpeg: "#7d8cff", avif: "#34d39a" };
754
  const PBC_COLOR = "#ff3b41";
755
 
756
  function card(d) {
@@ -774,12 +775,13 @@ function card(d) {
774
  : "";
775
 
776
  // Only show codecs that haven't been generated yet; drop the whole row once both exist.
777
- const haveJ = !!d.jpeg_image, haveA = !!d.avif_image;
778
- const compareBlock = (!d.decoded && d.original_image && !(haveJ && haveA))
779
  ? `<div class="foot-row" style="align-items:center;gap:8px">
780
  <span class="k">Compare to:</span>
781
  ${haveJ ? "" : `<button class="chip" data-act="cmp-jpeg" style="color:#7d8cff;border-color:#7d8cff">JPEG</button>`}
782
  ${haveA ? "" : `<button class="chip" data-act="cmp-avif" style="color:#34d39a;border-color:#34d39a">AVIF</button>`}
 
783
  </div>`
784
  : "";
785
  const haveAnim = !!d.animation_url;
@@ -815,8 +817,10 @@ function card(d) {
815
  el.querySelector('[data-act="pbc"]').onclick = () => savePbc(d);
816
  const cmpJ = el.querySelector('[data-act="cmp-jpeg"]');
817
  const cmpA = el.querySelector('[data-act="cmp-avif"]');
 
818
  if (cmpJ) cmpJ.onclick = () => compareCodec(d, "jpeg", cmpJ);
819
  if (cmpA) cmpA.onclick = () => compareCodec(d, "avif", cmpA);
 
820
  const animBtn = el.querySelector('[data-act="anim"]');
821
  if (animBtn) animBtn.onclick = () => createAnimation(d, animBtn);
822
  el.querySelector('[data-act="delete"]').onclick = () => {
@@ -992,6 +996,7 @@ const viewerStage = document.getElementById("viewer-stage");
992
  const holdBtn = document.getElementById("hold-original");
993
  const holdJpeg = document.getElementById("hold-jpeg");
994
  const holdAvif = document.getElementById("hold-avif");
 
995
  const sbsToggle = document.getElementById("viewer-sbs");
996
  const prevBtn = document.getElementById("viewer-prev");
997
  const nextBtn = document.getElementById("viewer-next");
@@ -1025,7 +1030,10 @@ function step(dir) {
1025
  }
1026
 
1027
  function compareSrcOf(d, kind) {
1028
- return kind === "original" ? d.original_image : kind === "jpeg" ? d.jpeg_image : kind === "avif" ? d.avif_image : null;
 
 
 
1029
  }
1030
 
1031
  function viewerCaption(d) {
@@ -1062,7 +1070,7 @@ function codecCompareCaptionHTML(d, codec) {
1062
  function updateCaption() {
1063
  const d = registry[viewIndex];
1064
  if (!d) return;
1065
- if (sideBySide && (activeCompare === "jpeg" || activeCompare === "avif"))
1066
  capEl.innerHTML = `<div>${codecCompareParts(d, activeCompare).sentence}</div>`; // stats moved on top
1067
  else
1068
  capEl.textContent = viewerCaption(d);
@@ -1099,12 +1107,15 @@ function renderViewer() {
1099
  holdBtn.hidden = !d.original_image;
1100
  holdJpeg.hidden = !d.jpeg_image;
1101
  holdAvif.hidden = !d.avif_image;
 
1102
  holdBtn.textContent = sideBySide ? "Original" : "Hold for original";
1103
  holdJpeg.textContent = sideBySide ? "JPEG" : "Hold for JPEG";
1104
  holdAvif.textContent = sideBySide ? "AVIF" : "Hold for AVIF";
 
1105
  holdBtn.classList.toggle("holding", sideBySide && activeCompare === "original");
1106
  holdJpeg.classList.toggle("holding", sideBySide && activeCompare === "jpeg");
1107
  holdAvif.classList.toggle("holding", sideBySide && activeCompare === "avif");
 
1108
 
1109
  updateCaption();
1110
  prevBtn.disabled = viewIndex <= 0;
@@ -1144,7 +1155,7 @@ function wireHold(btn, kind, getCaptionHTML) {
1144
  wireHold(holdBtn, "original");
1145
  wireHold(holdJpeg, "jpeg", d => codecCompareCaptionHTML(d, "jpeg"));
1146
  wireHold(holdAvif, "avif", d => codecCompareCaptionHTML(d, "avif"));
1147
-
1148
  /* ============================================================
1149
  DOWNLOAD
1150
  ============================================================ */
 
317
  document.getElementById("app").hidden = false;
318
  landingActive = false; // stop the teaser — no wasted compute
319
  if (streamAbort) streamAbort.abort();
320
+ gotoView("compress");
321
  }
322
  function exitToLanding() {
323
  document.getElementById("app").hidden = true;
 
751
  // raw (3 bytes RGB) / bpp.
752
  const bppToRate = (bpp) => 24 / bpp;
753
 
754
+ const CODEC_COLOR = { jpeg: "#7d8cff", avif: "#34d39a", webp: "#ffb454"};
755
  const PBC_COLOR = "#ff3b41";
756
 
757
  function card(d) {
 
775
  : "";
776
 
777
  // Only show codecs that haven't been generated yet; drop the whole row once both exist.
778
+ const haveJ = !!d.jpeg_image, haveA = !!d.avif_image, haveW = !!d.webp_image;
779
+ const compareBlock = (!d.decoded && d.original_image && !(haveJ && haveA && haveW))
780
  ? `<div class="foot-row" style="align-items:center;gap:8px">
781
  <span class="k">Compare to:</span>
782
  ${haveJ ? "" : `<button class="chip" data-act="cmp-jpeg" style="color:#7d8cff;border-color:#7d8cff">JPEG</button>`}
783
  ${haveA ? "" : `<button class="chip" data-act="cmp-avif" style="color:#34d39a;border-color:#34d39a">AVIF</button>`}
784
+ ${haveW ? "" : `<button class="chip" data-act="cmp-webp" style="color:#ffb454;border-color:#ffb454">WebP</button>`}
785
  </div>`
786
  : "";
787
  const haveAnim = !!d.animation_url;
 
817
  el.querySelector('[data-act="pbc"]').onclick = () => savePbc(d);
818
  const cmpJ = el.querySelector('[data-act="cmp-jpeg"]');
819
  const cmpA = el.querySelector('[data-act="cmp-avif"]');
820
+ const cmpW = el.querySelector('[data-act="cmp-webp"]');
821
  if (cmpJ) cmpJ.onclick = () => compareCodec(d, "jpeg", cmpJ);
822
  if (cmpA) cmpA.onclick = () => compareCodec(d, "avif", cmpA);
823
+ if (cmpW) cmpW.onclick = () => compareCodec(d, "webp", cmpW);
824
  const animBtn = el.querySelector('[data-act="anim"]');
825
  if (animBtn) animBtn.onclick = () => createAnimation(d, animBtn);
826
  el.querySelector('[data-act="delete"]').onclick = () => {
 
996
  const holdBtn = document.getElementById("hold-original");
997
  const holdJpeg = document.getElementById("hold-jpeg");
998
  const holdAvif = document.getElementById("hold-avif");
999
+ const holdWebp = document.getElementById("hold-webp");
1000
  const sbsToggle = document.getElementById("viewer-sbs");
1001
  const prevBtn = document.getElementById("viewer-prev");
1002
  const nextBtn = document.getElementById("viewer-next");
 
1030
  }
1031
 
1032
  function compareSrcOf(d, kind) {
1033
+ return kind === "original" ? d.original_image
1034
+ : kind === "jpeg" ? d.jpeg_image
1035
+ : kind === "avif" ? d.avif_image
1036
+ : kind === "webp" ? d.webp_image : null;
1037
  }
1038
 
1039
  function viewerCaption(d) {
 
1070
  function updateCaption() {
1071
  const d = registry[viewIndex];
1072
  if (!d) return;
1073
+ if (sideBySide && (["jpeg", "avif", "webp"].includes(activeCompare)))
1074
  capEl.innerHTML = `<div>${codecCompareParts(d, activeCompare).sentence}</div>`; // stats moved on top
1075
  else
1076
  capEl.textContent = viewerCaption(d);
 
1107
  holdBtn.hidden = !d.original_image;
1108
  holdJpeg.hidden = !d.jpeg_image;
1109
  holdAvif.hidden = !d.avif_image;
1110
+ holdWebp.hidden = !d.webp_image;
1111
  holdBtn.textContent = sideBySide ? "Original" : "Hold for original";
1112
  holdJpeg.textContent = sideBySide ? "JPEG" : "Hold for JPEG";
1113
  holdAvif.textContent = sideBySide ? "AVIF" : "Hold for AVIF";
1114
+ holdWebp.textContent = sideBySide ? "WebP" : "Hold for WebP";
1115
  holdBtn.classList.toggle("holding", sideBySide && activeCompare === "original");
1116
  holdJpeg.classList.toggle("holding", sideBySide && activeCompare === "jpeg");
1117
  holdAvif.classList.toggle("holding", sideBySide && activeCompare === "avif");
1118
+ holdWebp.classList.toggle("holding", sideBySide && activeCompare === "webp");
1119
 
1120
  updateCaption();
1121
  prevBtn.disabled = viewIndex <= 0;
 
1155
  wireHold(holdBtn, "original");
1156
  wireHold(holdJpeg, "jpeg", d => codecCompareCaptionHTML(d, "jpeg"));
1157
  wireHold(holdAvif, "avif", d => codecCompareCaptionHTML(d, "avif"));
1158
+ wireHold(holdWebp, "webp", d => codecCompareCaptionHTML(d, "webp"));
1159
  /* ============================================================
1160
  DOWNLOAD
1161
  ============================================================ */
static/index.html CHANGED
@@ -13,8 +13,9 @@
13
  /* JPEG/AVIF hold buttons reuse the codec colors from the Sweep Analyzer. */
14
  #hold-jpeg { --hc:#7d8cff; }
15
  #hold-avif { --hc:#34d39a; }
16
- #hold-jpeg, #hold-avif { border-color: var(--hc); color: var(--hc); }
17
- #hold-jpeg.holding, #hold-avif.holding { background: var(--hc); border-color: var(--hc); color: #fff; }
 
18
  </style>
19
  <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>👁️</text></svg>">
20
  </head>
@@ -177,6 +178,7 @@
177
  <button id="hold-original" class="hold-btn" hidden>Hold for original</button>
178
  <button id="hold-jpeg" class="hold-btn" hidden>Hold for JPEG</button>
179
  <button id="hold-avif" class="hold-btn" hidden>Hold for AVIF</button>
 
180
  </div>
181
  </div>
182
 
 
13
  /* JPEG/AVIF hold buttons reuse the codec colors from the Sweep Analyzer. */
14
  #hold-jpeg { --hc:#7d8cff; }
15
  #hold-avif { --hc:#34d39a; }
16
+ #hold-webp { --hc:#ffb454; }
17
+ #hold-jpeg, #hold-avif, #hold-webp { border-color: var(--hc); color: var(--hc); }
18
+ #hold-jpeg.holding, #hold-avif.holding, #hold-webp.holding { background: var(--hc); border-color: var(--hc); color: #fff; }
19
  </style>
20
  <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>👁️</text></svg>">
21
  </head>
 
178
  <button id="hold-original" class="hold-btn" hidden>Hold for original</button>
179
  <button id="hold-jpeg" class="hold-btn" hidden>Hold for JPEG</button>
180
  <button id="hold-avif" class="hold-btn" hidden>Hold for AVIF</button>
181
+ <button id="hold-webp" class="hold-btn" hidden>Hold for WEBP</button>
182
  </div>
183
  </div>
184
 
static/sweep.js CHANGED
@@ -16,15 +16,15 @@
16
  study: null, trials: [], filtered: [],
17
  selected: null, codec: null, built: false, fsKind: null,
18
  sort: { key: "mse", dir: 1 }, // MSE ascending = best first
19
- show: { pbcAll: true, pbcPareto: true, jpeg: false, avif: false },
20
  dyn: [], mpMin: null, mpMax: null,
21
  };
22
 
23
  const COLORS = {
24
  gray: "#56565d", pareto: "#ff9b2e", sel: "#ff3b41", base: "#34c6ff",
25
- jpeg: "#7d8cff", avif: "#34d39a",
26
  };
27
- const TOGGLES = [["pbcAll", "PBC All"], ["pbcPareto", "PBC Pareto"], ["jpeg", "JPEG"], ["avif", "AVIF"]];
28
  // Recommendation weight presets: [speed, compression, mse].
29
  const PRESETS = { "Fast": [80, 15, 5], "Tiny File": [10, 80, 10], "Balanced": [33, 33, 34], "Best Quality": [5, 15, 80] };
30
 
@@ -242,8 +242,8 @@
242
  function buildCodec(all) {
243
  const pick = (c) => all.filter((t) => t.codec === c)
244
  .map((t) => ({ q: t.q, bpp: t.bpp, mse: t.mse, speed: t.speed, eff: t.eff })).sort((a, b) => a.q - b.q);
245
- const jpeg = pick("JPEG"), avif = pick("AVIF");
246
- S.codec = (jpeg.length || avif.length) ? { jpeg, avif } : null;
247
  }
248
 
249
  function renderSummary() {
@@ -407,7 +407,7 @@
407
  if (S.show.pbcAll) traces.push(ptTrace(all, xKey, yKey, { name: "PBC all", marker: { size: 5, color: COLORS.gray, opacity: .55 } }));
408
  if (S.show.pbcPareto) traces.push(ptTrace(all.filter((t) => t.pareto), xKey, yKey, { name: "PBC Pareto", marker: { size: 8, color: COLORS.pareto } }));
409
 
410
- [["jpeg", COLORS.jpeg], ["avif", COLORS.avif]].forEach(([k, c]) => {
411
  if (!S.show[k]) return;
412
  const pts = codecPoints(k);
413
  if (pts && pts.length) traces.push(codecTrace([...pts].sort((a, b) => a[xKey] - b[xKey]), xKey, yKey, k.toUpperCase(), c));
 
16
  study: null, trials: [], filtered: [],
17
  selected: null, codec: null, built: false, fsKind: null,
18
  sort: { key: "mse", dir: 1 }, // MSE ascending = best first
19
+ show: { pbcAll: true, pbcPareto: true, jpeg: false, avif: false, webp: false },
20
  dyn: [], mpMin: null, mpMax: null,
21
  };
22
 
23
  const COLORS = {
24
  gray: "#56565d", pareto: "#ff9b2e", sel: "#ff3b41", base: "#34c6ff",
25
+ jpeg: "#7d8cff", avif: "#34d39a", webp: "#ffb454",
26
  };
27
+ const TOGGLES = [["pbcAll", "PBC All"], ["pbcPareto", "PBC Pareto"], ["jpeg", "JPEG"], ["avif", "AVIF"], ["webp", "WebP"]];
28
  // Recommendation weight presets: [speed, compression, mse].
29
  const PRESETS = { "Fast": [80, 15, 5], "Tiny File": [10, 80, 10], "Balanced": [33, 33, 34], "Best Quality": [5, 15, 80] };
30
 
 
242
  function buildCodec(all) {
243
  const pick = (c) => all.filter((t) => t.codec === c)
244
  .map((t) => ({ q: t.q, bpp: t.bpp, mse: t.mse, speed: t.speed, eff: t.eff })).sort((a, b) => a.q - b.q);
245
+ const jpeg = pick("JPEG"), avif = pick("AVIF"), webp = pick("WEBP");
246
+ S.codec = (jpeg.length || avif.length || webp.length) ? { jpeg, avif, webp } : null;
247
  }
248
 
249
  function renderSummary() {
 
407
  if (S.show.pbcAll) traces.push(ptTrace(all, xKey, yKey, { name: "PBC all", marker: { size: 5, color: COLORS.gray, opacity: .55 } }));
408
  if (S.show.pbcPareto) traces.push(ptTrace(all.filter((t) => t.pareto), xKey, yKey, { name: "PBC Pareto", marker: { size: 8, color: COLORS.pareto } }));
409
 
410
+ [["jpeg", COLORS.jpeg], ["avif", COLORS.avif], ["webp", COLORS.webp]].forEach(([k, c]) => {
411
  if (!S.show[k]) return;
412
  const pts = codecPoints(k);
413
  if (pts && pts.length) traces.push(codecTrace([...pts].sort((a, b) => a[xKey] - b[xKey]), xKey, yKey, k.toUpperCase(), c));