diff --git a/examples/web_scanner/README.md b/examples/web_scanner/README.md index 55f5e4a..889495e 100644 --- a/examples/web_scanner/README.md +++ b/examples/web_scanner/README.md @@ -18,7 +18,8 @@ Catalog v2 is the default: Append `?catalog=v1` to use the prepared static catalog bundle. This is the compatibility path, not the default. `?channel=testing` selects the separately -published testing model bundle. +published testing model bundle on the scanner, playground, and screen capture +monitor. Links between those pages preserve the selected channel. The standalone [`catalog_v2_example.html`](./catalog_v2_example.html) loads any published game catalog and displays its first record. diff --git a/examples/web_scanner/app.js b/examples/web_scanner/app.js index 462222b..c1ac3bc 100644 --- a/examples/web_scanner/app.js +++ b/examples/web_scanner/app.js @@ -1,3 +1,5 @@ +import { createProgressTracker } from "./lib/progress.mjs"; + // Replaced by the deploy-pages CI workflow with the actual short commit SHA. const BUILD_ID = "__BUILD_ID__"; @@ -1583,20 +1585,41 @@ function setupCornerConfidenceSlider(scannerWorker = null) { slider.addEventListener("input", update); } +const thresholdMeterPeaks = new Map(); + function updateThresholdMeter(name, threshold, current, maximum) { const fill = document.getElementById(`${name}-signal-fill`); + const peakMarker = document.getElementById(`${name}-signal-peak`); const marker = document.getElementById(`${name}-signal-threshold`); const value = document.getElementById(`${name}-signal-value`); - if (!fill || !marker || !value) return; + if (!fill || !peakMarker || !marker || !value) return; marker.style.left = `${(Math.min(1, Math.max(0, threshold / maximum)) * 100).toFixed(1)}%`; - if (!Number.isFinite(current)) { - fill.style.width = "0%"; - value.textContent = "Current —"; - return; - } - fill.style.width = `${(Math.min(1, Math.max(0, current / maximum)) * 100).toFixed(1)}%`; - value.textContent = `Current ${current.toFixed(3)}`; + const now = performance.now(); + const signal = Number.isFinite(current) ? Math.min(maximum, Math.max(0, current)) : 0; + const previous = thresholdMeterPeaks.get(name) ?? { + value: 0, + holdUntil: 0, + updatedAt: now, + }; + let peak = previous.value; + let holdUntil = previous.holdUntil; + if (signal >= peak) { + peak = signal; + holdUntil = now + 1200; + } else if (now > holdUntil) { + peak = Math.max(signal, peak - maximum * ((now - previous.updatedAt) / 2200)); + } + thresholdMeterPeaks.set(name, { value: peak, holdUntil, updatedAt: now }); + + fill.style.width = `${(signal / maximum * 100).toFixed(1)}%`; + peakMarker.style.left = `${(peak / maximum * 100).toFixed(1)}%`; + peakMarker.style.opacity = peak > 0 ? "1" : "0"; + value.textContent = Number.isFinite(current) + ? `Current ${current.toFixed(3)} · peak ${peak.toFixed(3)}` + : peak > 0 + ? `Current — · peak ${peak.toFixed(3)}` + : "Current —"; } function setupMinMatchesSlider() { @@ -1674,12 +1697,16 @@ class PerformanceOverlay { const threads = this.captureState?.numThreads ?? "—"; const mode = this.captureState?.inferenceMode ?? "—"; const resultGap = data?.resultGapMs ? formatMs(data.resultGapMs) : "—"; - const resultFps = data?.resultGapMs ? `${(1000 / data.resultGapMs).toFixed(1)} FPS` : "— FPS"; + const observedFps = data?.resultGapMs ? (1000 / data.resultGapMs).toFixed(1) : "—"; + const pipelineFps = timing.totalMs > 0 ? (1000 / timing.totalMs).toFixed(1) : "—"; + const intervalMs = getScanIntervalMs(); + const intervalCeiling = intervalMs > 0 ? (1000 / intervalMs).toFixed(1) : "max"; const score = Number.isFinite(data?.score) ? data.score.toFixed(3) : "—"; const card = data?.cardPresent ? (data.cornersValid ? "card" : "bad-quad") : "no-card"; const orientation = data?.orientation ? ` ${data.orientation}` : ""; this.el.textContent = [ - `minimum interval ${getScanIntervalMs()}ms result ${resultGap} ${resultFps}`, + `FPS observed ${observedFps} pipeline ${pipelineFps} interval ceiling ${intervalCeiling}`, + `minimum interval ${intervalMs}ms result gap ${resultGap}`, `total ${formatMs(timing.totalMs)} det ${formatMs(timing.detectMs)} (run ${formatMs(timing.detectorRunMs)})`, `dew ${formatMs(timing.dewarpMs)} (warp ${formatMs(timing.dewarpWarpMs)}) emb ${formatMs(timing.embedMs)} (run ${formatMs(timing.embedRunMs)})`, `prep det ${formatMs(timing.detectorInputMs)} prep emb ${formatMs(timing.embedInputMs)} lookup ${formatMs(timing.searchMs)}`, @@ -2050,6 +2077,18 @@ function resolveAssetChannel() { return Object.hasOwn(ASSET_CHANNELS, requested) ? requested : "stable"; } +function preserveAssetChannel(channel) { + for (const link of document.querySelectorAll("a[data-preserve-channel]")) { + const url = new URL(link.href, location.href); + if (channel === "testing") { + url.searchParams.set("channel", channel); + } else { + url.searchParams.delete("channel"); + } + link.href = url.href; + } +} + function resolveCatalogMode() { const requested = new URLSearchParams(location.search).get("catalog") ?? "v2"; if (requested !== "v1" && requested !== "v2") { @@ -2156,6 +2195,7 @@ async function boot() { // Load the manifest on the main thread first — it drives both the loading // screen text and the worker init message. const channel = resolveAssetChannel(); + preserveAssetChannel(channel); const catalogMode = resolveCatalogMode(); const { assetBasePath, manifest } = await loadManifest(channel); const catalogLimit = getCatalogLimitFromQuery(); @@ -2189,6 +2229,10 @@ async function boot() { // Wire up init-phase progress messages before posting 'init'. const scannerReady = new Promise((resolve, reject) => { + const bundledCatalogBytes = Object.values(manifest.catalog?.asset_sizes ?? {}) + .reduce((total, size) => total + (Number.isSafeInteger(size) && size > 0 ? size : 0), 0); + const displayProgress = createProgressTracker({ catalogMode, bundledCatalogBytes }); + function onInitMessage({ data }) { if (data.type === "progress") { recordBootTrace("worker:progress", data, { debugOnly: data.ratio < 1 }); @@ -2204,19 +2248,24 @@ async function boot() { debugLog.info("dewarp ready"); } else { const ranges = { detector: [24, 44], embedder: [44, 60], catalog: [60, 96] }; + const progress = displayProgress(data); const [start, end] = ranges[data.stage] ?? [0, 0]; - const percent = start + (end - start) * data.ratio; + const percent = start + (end - start) * progress.ratio; const note = data.cached ? "Cached" - : data.total > 0 - ? `${formatBytes(data.loaded)} / ${formatBytes(data.total)}` - : `${formatBytes(data.loaded)} downloaded`; + : progress.total > 0 + ? `${formatBytes(progress.loaded)} / ${formatBytes(progress.total)}` + : progress.loaded > 0 + ? `${formatBytes(progress.loaded)} downloaded` + : data.ratio >= 1 + ? "Ready" + : "Starting"; const label = { detector: "Loading corner detector", embedder: "Loading embedder", catalog: "Loading card catalog", }[data.stage]; - setPhase(data.stage, percent, label, note, data.ratio >= 1 ? "done" : "active"); + setPhase(data.stage, percent, label, note, progress.ratio >= 1 ? "done" : "active"); } } else if (data.type === "ready") { recordBootTrace("worker:ready", { @@ -2253,7 +2302,11 @@ async function boot() { loadingScreen.step("dewarp", "active", "Queued"); loadingScreen.step("detector", "active", "Queued"); loadingScreen.step("embedder", "active", "Queued"); - loadingScreen.step("catalog", "active", "Waiting for models"); + loadingScreen.step( + "catalog", + "active", + catalogMode === "v2" ? "Downloading and indexing" : "Waiting for models", + ); setText("models-status", "Loading models"); scannerWorker.postMessage({ diff --git a/examples/web_scanner/applet_example.css b/examples/web_scanner/applet_example.css index 123b990..96e6848 100644 --- a/examples/web_scanner/applet_example.css +++ b/examples/web_scanner/applet_example.css @@ -32,6 +32,24 @@ h2 { p { line-height: 1.55; } +.playground-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.playground-heading nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.playground-heading nav a { + color: inherit; + font-weight: 700; +} + code { border-radius: 0.35rem; padding: 0.1rem 0.3rem; @@ -226,6 +244,19 @@ tr:last-child td { border-bottom: 0; } transform: translateX(-1px); } +.scan-settings-meter__peak { + position: absolute; + top: 0.05rem; + bottom: 0.05rem; + left: 0; + width: 2px; + background: #fff; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.7); + opacity: 0; + transform: translateX(-1px); + transition: left 80ms linear, opacity 80ms linear; +} + .scan-settings-meter-value { font-size: 0.76rem; color: #81796f; diff --git a/examples/web_scanner/applet_example.html b/examples/web_scanner/applet_example.html index 4318ca3..5ab4b0b 100644 --- a/examples/web_scanner/applet_example.html +++ b/examples/web_scanner/applet_example.html @@ -47,7 +47,13 @@
-

Candidate API

+
+

Candidate API · stable assets

+ +

Scanner Playground

Try preset card handlers, edit the JavaScript, and see how the applet can fit into your own page. This playground keeps everything local in your browser: apply a handler, then scan a card. Need to disable cross-origin isolation for debugging? Open with ?coi=0.

@@ -59,10 +65,11 @@

Scanner Playground

Scan settings
diff --git a/examples/web_scanner/applet_example.js b/examples/web_scanner/applet_example.js index d59e537..ef804b0 100644 --- a/examples/web_scanner/applet_example.js +++ b/examples/web_scanner/applet_example.js @@ -30,6 +30,16 @@ function resolveAssetChannel() { const assetChannel = resolveAssetChannel(); const assetBasePath = ASSET_CHANNELS[assetChannel]; +document.getElementById("asset-channel").textContent = `${assetChannel} assets`; +for (const link of document.querySelectorAll("a[data-preserve-channel]")) { + const url = new URL(link.href, location.href); + if (assetChannel === "testing") { + url.searchParams.set("channel", assetChannel); + } else { + url.searchParams.delete("channel"); + } + link.href = url.href; +} const PRESETS = [ { @@ -116,9 +126,11 @@ const presetSelect = document.getElementById("preset-code"); const cornerThresholdInput = document.getElementById("scan-corner-threshold"); const cornerThresholdLabel = document.getElementById("scan-corner-threshold-label"); const cornerSignalFill = document.getElementById("scan-corner-signal-fill"); +const cornerSignalPeak = document.getElementById("scan-corner-signal-peak"); const cornerSignalThreshold = document.getElementById("scan-corner-signal-threshold"); const cornerSignalValue = document.getElementById("scan-corner-signal-value"); const matchSignalFill = document.getElementById("scan-match-signal-fill"); +const matchSignalPeak = document.getElementById("scan-match-signal-peak"); const matchSignalThreshold = document.getElementById("scan-match-signal-threshold"); const matchSignalValue = document.getElementById("scan-match-signal-value"); const thresholdInput = document.getElementById("scan-threshold"); @@ -199,27 +211,51 @@ function updateCornerThresholdUi(value) { cornerSignalThreshold.style.left = `${(ratio * 100).toFixed(1)}%`; } +const signalPeaks = new Map(); + +function updateSignalMeter(name, current, maximum, fill, peakMarker, value) { + const now = performance.now(); + const signal = Number.isFinite(current) ? clamp(current, 0, maximum) : 0; + const previous = signalPeaks.get(name) ?? { + value: 0, + holdUntil: 0, + updatedAt: now, + }; + let peak = previous.value; + let holdUntil = previous.holdUntil; + if (signal >= peak) { + peak = signal; + holdUntil = now + 1200; + } else if (now > holdUntil) { + peak = Math.max(signal, peak - maximum * ((now - previous.updatedAt) / 2200)); + } + signalPeaks.set(name, { value: peak, holdUntil, updatedAt: now }); + + fill.style.width = `${(signal / maximum * 100).toFixed(1)}%`; + peakMarker.style.left = `${(peak / maximum * 100).toFixed(1)}%`; + peakMarker.style.opacity = peak > 0 ? "1" : "0"; + value.textContent = Number.isFinite(current) + ? `Current ${current.toFixed(3)} · peak ${peak.toFixed(3)}` + : peak > 0 + ? `Current — · peak ${peak.toFixed(3)}` + : "Current —"; +} + function updateCornerSignal(confidence) { - const raw = Math.max(0, Number(confidence) || 0); - const current = clamp(raw, 0, MAX_GUI_CORNER_CONFIDENCE); - const ratio = current / MAX_GUI_CORNER_CONFIDENCE; - cornerSignalFill.style.width = `${(ratio * 100).toFixed(1)}%`; - cornerSignalValue.textContent = raw > MAX_GUI_CORNER_CONFIDENCE - ? `Current ${MAX_GUI_CORNER_CONFIDENCE.toFixed(2)}+` - : `Current ${current.toFixed(2)}`; + updateSignalMeter( + "corner", + Number(confidence), + MAX_GUI_CORNER_CONFIDENCE, + cornerSignalFill, + cornerSignalPeak, + cornerSignalValue, + ); } function updateMatchSignal(score) { const threshold = clamp(Number(thresholdInput.value), 0, 1); matchSignalThreshold.style.left = `${(threshold * 100).toFixed(1)}%`; - if (!Number.isFinite(score)) { - matchSignalFill.style.width = "0%"; - matchSignalValue.textContent = "Current —"; - return; - } - const current = clamp(score, 0, 1); - matchSignalFill.style.width = `${(current * 100).toFixed(1)}%`; - matchSignalValue.textContent = `Current ${current.toFixed(3)}`; + updateSignalMeter("match", score, 1, matchSignalFill, matchSignalPeak, matchSignalValue); } function scanSettingsFromInputs() { diff --git a/examples/web_scanner/index.html b/examples/web_scanner/index.html index 7aee0b8..7c0384b 100644 --- a/examples/web_scanner/index.html +++ b/examples/web_scanner/index.html @@ -112,8 +112,8 @@

CollectorVision

- @@ -220,7 +220,7 @@

Performance Overlay

Show scan timing overlay -

Shows detector, dewarp, embed, lookup, thread, and memory estimates in the camera corner. You can also enable it with ?fps=1.

+

Shows observed and pipeline FPS plus detector, dewarp, embed, lookup, thread, and memory estimates in the camera corner. You can also enable it with ?fps=1.

@@ -231,7 +231,8 @@

Screen Capture Monitor

Watch a shared tab, window, or screen, crop to a region of interest, and emit card events for overlays or CSV/JSONL export.

- ▶ Open Screen Capture Monitor + ▶ Open Screen Capture Monitor + ▶ Open Scanner Playground
@@ -249,8 +250,8 @@

Model Benchmark

-

Recognition

-

Match Threshold

+

Detector and recognition

+

Detection Thresholds