sweep analyzer improvements 4
Browse files- server.py +36 -0
- static/app.js +148 -37
- 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 |
-
/* ----
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 505 |
const p = parseOptuna(text);
|
| 506 |
-
if (!Object.keys(p).length)
|
| 507 |
|
| 508 |
-
|
| 509 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 549 |
-
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 615 |
-
if (
|
| 616 |
-
PARAMS.filter(p => p.modes.includes(
|
| 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 |
-
|
| 626 |
-
|
| 627 |
addToRegistry(data);
|
| 628 |
showLastResult(data);
|
| 629 |
-
orbStatus.style.color = "";
|
| 630 |
orbStatus.textContent = `done · ${data.compression_rate}x`;
|
| 631 |
} catch (err) {
|
| 632 |
-
orbStatus.
|
| 633 |
-
|
| 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 |
-
|
| 657 |
-
|
| 658 |
addToRegistry(data);
|
| 659 |
showLastResult(data);
|
| 660 |
-
|
| 661 |
-
dStatus.textContent = `done · ${data.compression_rate}x`;
|
| 662 |
} catch (err) {
|
| 663 |
-
|
| 664 |
-
|
| 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,
|
| 7 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
|
|
|
|
|
|
| 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 |
+
})();
|