Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/web_scanner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
85 changes: 69 additions & 16 deletions examples/web_scanner/app.js
Original file line number Diff line number Diff line change
@@ -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__";

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand All @@ -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", {
Expand Down Expand Up @@ -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({
Expand Down
31 changes: 31 additions & 0 deletions examples/web_scanner/applet_example.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions examples/web_scanner/applet_example.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@
<div id="effects-layer" aria-hidden="true"></div>
<main>
<header>
<p class="eyebrow">Candidate API</p>
<div class="playground-heading">
<p class="eyebrow">Candidate API · <span id="asset-channel">stable assets</span></p>
<nav aria-label="Related pages">
<a data-preserve-channel href="./">Scanner</a>
<a data-preserve-channel href="./screen_capture_monitor.html">Screen capture monitor</a>
</nav>
</div>
<h1>Scanner Playground</h1>
<p>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 <code>?coi=0</code>.</p>
</header>
Expand All @@ -59,10 +65,11 @@ <h1>Scanner Playground</h1>
<summary>Scan settings</summary>
<div class="scan-settings-grid">
<label class="scan-settings-mic" for="scan-corner-threshold">
<span>Corner threshold <span id="scan-corner-threshold-label">0.02</span></span>
<span>Minimum corner sharpness <span id="scan-corner-threshold-label">0.02</span></span>
<input id="scan-corner-threshold" type="range" min="0" max="0.1" step="0.01" />
<span class="scan-settings-meter" aria-hidden="true">
<span id="scan-corner-signal-fill" class="scan-settings-meter__fill"></span>
<span id="scan-corner-signal-peak" class="scan-settings-meter__peak"></span>
<span id="scan-corner-signal-threshold" class="scan-settings-meter__threshold"></span>
</span>
<span id="scan-corner-signal-value" class="scan-settings-meter-value">Current 0.00</span>
Expand All @@ -72,6 +79,7 @@ <h1>Scanner Playground</h1>
<span>Match score <span id="scan-match-signal-value">Current —</span></span>
<span class="scan-settings-meter" aria-hidden="true">
<span id="scan-match-signal-fill" class="scan-settings-meter__fill"></span>
<span id="scan-match-signal-peak" class="scan-settings-meter__peak"></span>
<span id="scan-match-signal-threshold" class="scan-settings-meter__threshold"></span>
</span>
</div>
Expand Down
66 changes: 51 additions & 15 deletions examples/web_scanner/applet_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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() {
Expand Down
15 changes: 9 additions & 6 deletions examples/web_scanner/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ <h1 class="app-bar__title">CollectorVision</h1>
<button class="icon-button icon-button--debug" id="debug-toggle" type="button" aria-label="Show debug panel" aria-expanded="false">
Debug
</button>
<button class="icon-button icon-button--gear" id="settings-toggle" type="button" aria-label="Toggle settings">
<button class="icon-button icon-button--gear" id="settings-toggle" type="button" aria-label="Open scanner settings">
<span aria-hidden="true">⚙</span> Settings
</button>
</div>
</header>
Expand Down Expand Up @@ -220,7 +220,7 @@ <h2>Performance Overlay</h2>
<span class="toggle-row__label">Show scan timing overlay</span>
<input type="checkbox" id="perf-overlay-toggle" class="toggle-row__input" />
</label>
<p class="capture-hint">Shows detector, dewarp, embed, lookup, thread, and memory estimates in the camera corner. You can also enable it with <code>?fps=1</code>.</p>
<p class="capture-hint">Shows observed and pipeline FPS plus detector, dewarp, embed, lookup, thread, and memory estimates in the camera corner. You can also enable it with <code>?fps=1</code>.</p>
</section>

<section class="panel panel--nested">
Expand All @@ -231,7 +231,8 @@ <h2>Screen Capture Monitor</h2>
</div>
</header>
<p class="capture-hint">Watch a shared tab, window, or screen, crop to a region of interest, and emit card events for overlays or CSV/JSONL export.</p>
<a class="action-button action-button--block" href="./screen_capture_monitor.html">▶ Open Screen Capture Monitor</a>
<a class="action-button action-button--block" data-preserve-channel href="./screen_capture_monitor.html">▶ Open Screen Capture Monitor</a>
<a class="action-button action-button--block" data-preserve-channel href="./applet_example.html">▶ Open Scanner Playground</a>
</section>

<section class="panel panel--nested">
Expand All @@ -249,8 +250,8 @@ <h2>Model Benchmark</h2>
<section class="panel panel--nested">
<header class="panel-header">
<div>
<p class="section-kicker">Recognition</p>
<h2>Match Threshold</h2>
<p class="section-kicker">Detector and recognition</p>
<h2>Detection Thresholds</h2>
</div>
</header>
<label class="slider-row" for="match-score-slider">
Expand All @@ -262,6 +263,7 @@ <h2>Match Threshold</h2>
min="0.40" max="0.85" step="0.01" value="0.50" />
<div class="threshold-meter" aria-live="polite">
<span class="threshold-meter__fill" id="match-score-signal-fill"></span>
<span class="threshold-meter__peak" id="match-score-signal-peak"></span>
<span class="threshold-meter__threshold" id="match-score-signal-threshold"></span>
</div>
<p class="threshold-meter__value" id="match-score-signal-value">Current —</p>
Expand All @@ -275,6 +277,7 @@ <h2>Match Threshold</h2>
min="0" max="0.10" step="0.01" value="0.02" />
<div class="threshold-meter" aria-live="polite">
<span class="threshold-meter__fill" id="corner-confidence-signal-fill"></span>
<span class="threshold-meter__peak" id="corner-confidence-signal-peak"></span>
<span class="threshold-meter__threshold" id="corner-confidence-signal-threshold"></span>
</div>
<p class="threshold-meter__value" id="corner-confidence-signal-value">Current —</p>
Expand Down
Loading