This commit is contained in:
Kalle
2026-09-24 17:32:36 +03:00
parent d4f23b92d4
commit 95bb2a31e4
46 changed files with 4640 additions and 1253 deletions

View File

@@ -141,12 +141,14 @@ pnpm test:unit:browser # includes tests/logic/ — the fixture-
pnpm scanner:report # accuracy table + name character error rate across fixtures
pnpm scanner:fixtures [name-substring] # run detectors over matching fixtures, verbose
pnpm scanner:replay <dir> <startT> <fps> # replay ffmpeg-extracted frames through the scheduler+detectors
pnpm scanner:scan-vod <video> # VoD scan as a CLI (ffmpeg): video in, events CSV out
pnpm scanner:scan-vod <video> # VoD scan as a CLI (ffmpeg): video in, events CSV out (--gpu, --record, see "WebGPU")
pnpm scanner:status-audit <events.csv> # diff the CSV's timeline vs scoreboard D/S, rank fixture candidates
pnpm scanner:bootstrap-atlas # harvest labeled fixture crops into the glyph atlases
pnpm scanner:build-glyph-atlas # add the font-rendered charset (fonts required, see below)
pnpm scanner:build-localized-entries # regen localized closed sets from ../splat3
pnpm scanner:build-planner-signatures # regen the minimap stage-ID atlas from the assets repo
pnpm scanner:gpu-parity # every fixture: OpenCV vs WebGPU parse decisions + GPU upscale pixels
pnpm scanner:gpu-replay <corpus-dir> # replay recorded match requests on WebGPU, timed, exact-checked
```
Scanner scripts run through `vite-node -c scripts/scanner/vite-node.config.ts`:
@@ -374,8 +376,78 @@ sequenceDiagram
names a `RECTIFY` quad: its ROIs are in the frame warped by that
homography, which the debug overlays map back onto the raw frame.
- New event types implement `Detector` (`core/detectors/types.ts`): a cheap
`gate(mat)` at sample rate plus `parse(mat, t)` when the gate fires.
Register in `core/detectors/registry.ts`.
`gate(mat)` at sample rate plus `parseSteps` (match steps, see "WebGPU")
when the gate fires, `parse` being `runSync` of it. Register in
`core/detectors/registry.ts`. Gates and parses read the frame's gray/RGB/HSV
through `frameGray`/`frameRgb`/`frameHsv` (`core/image.ts`), converted once
per frame and shared: never delete or write them, and never pass them a
derived mat.
## WebGPU
Template matching (every `TM_CCOEFF_NORMED` the recognizers run) and the
sub-1080p frame upscale can run on the GPU; every other step stays on the CPU.
The GPU is an accelerator only: the same algorithms make the same decisions.
- **Match steps** (`core/match-steps.ts`): recognizers are generators that
yield every match their next decision needs (`MatchRequest`: an image,
templates, a placement window per template, optionally a content `key`) and
resume with the max scores. `runSync` answers lazily on the calling thread
and is what `Detector.parse` runs; `all` steps generators in lockstep so
independent reads share a round trip. Every detector implements
`parseSteps`; within a parse, reads are lockstepped wherever the sequential
code's consumption order, memo reads/writes (death tag, kill rows: the kill
parse predicts its memo misses on a copy of the memo and reads only those
ahead) and detector state stay exactly as before. `speculative`
additionally prefetches merge / recut candidate sets in lockstep (batching
drivers only; on the CPU it is wasted work).
- **Exact scores on both drivers**: a score is TM_CCOEFF_NORMED computed
exactly — integer cross, window and square sums, one f64 normalization with
OpenCV's guards (`normalizeNcc`), f32 result — so the CPU and the GPU give
bit-identical scores and the same events. OpenCV's own `matchTemplate` (the
pre-migration CPU path) runs a float DFT that wanders up to ~3e-4 from the
exact score: never a decision on the fixtures or the VoD test slices, but
enough to reorder a near-tie (seen once in a browser scan: an 8th-ranked
strip-weapon candidate, scores 7e-7 apart), hence exact on both. On the CPU
the cross sums run in WebAssembly SIMD (`core/cross-sums.c`, compiled into
`cross-sums.ts`; regeneration steps in the C file), large jobs as one f64
`filter2D` (rounded: exact), and the window sums come from integral
images — faster than the `matchTemplate` path it replaced.
- **Frame pass** (`core/detectors/frame-pass.ts`): the worker and the CLI gate
every due detector in registry order, then run all approved parses — one
lockstep of their steps on the GPU — and record results in registry order.
Scheduler decisions within a frame depend only on each detector's own
state, so this equals gating and parsing one detector at a time.
- **Matcher** (`worker/gpu-matcher.ts`): each step is one submit of three
passes — per-image integral images (window sums), a score pass (one thread
per 4 vertically adjacent placements; cross sums as packed u8 dot products;
an f32 estimate folded into the job's max) and a select pass returning the
exact 64-bit integer sums of the placements within `EPS` of that max. The
CPU finishes those with `normalizeNcc` — the exact score, identical on
every GPU and to `runSync`'s. More than `K` near-tied placements, or a
request the kernel cannot take, are finished on the CPU with the same exact
arithmetic. Scores are cached per run by (`key`, template, window) — the
window is part of a score's identity.
- **Frame upscale** (`worker/gpu-frame-scaler.ts`): `normalizeFrame`'s
INTER_CUBIC upscale of sub-1080p pictures (13-25 ms of WASM per 720p frame)
as an integer kernel reproducing OpenCV's 8-bit cubic resize bit for bit;
1080p copies and INTER_AREA downscales stay on the CPU. Importing the
VideoFrame as a GPU texture was rejected: its YUV→RGB conversion differs
from the 2D canvas readback the CPU path sees.
- **Worker** (`worker/analyzer.worker.ts`): creates the matcher (and scaler on
its device) at init when enabled and an adapter exists; a failed creation
or a device lost mid-run (`device.lost`, or a failed readback) hands the
pending step to `runSync`, so the generators still run exactly once and no
event is dropped or duplicated, and later frames stay on the CPU.
- **Node** (`node/webgpu.ts`): scripts get WebGPU from Dawn, the `webgpu` npm
package — deliberately not a dependency: `npm i webgpu` anywhere and point
`WEBGPU_NODE` at its package dir. `scanner:scan-vod --gpu` scans on it
(the CSV must stay byte-identical to a CPU scan), `--record <dir>` writes a
replay corpus (`scripts/scanner/match-corpus.ts`), `scanner:gpu-replay`
times it and checks every score against an exact JS reference and, with
`--cpu`, against `runSync` (0 mismatches is the bar for any kernel change),
and `scanner:gpu-parity` requires byte-identical events from both paths on
every fixture.
## Assets (CDN) and fonts

View File

@@ -1,6 +1,6 @@
/**
* The settings popover, opened from ⚙ on the landing and the live header:
* the upload and clip toggles, the retention notes, and the debug tools
* the upload, clip and GPU toggles, the retention notes, and the debug tools
* (enabling debug mode, saving the live frame, the fixtures link in development). The source lives on the landing's Live card, the one place
* it must be right.
*/
@@ -43,6 +43,7 @@ export function SettingsPopover({
const [, setDebugParam] = useSearchParam(scannerSearchParams, "debug");
const showFixturesLink = process.env.NODE_ENV === "development";
const showSaveFrame = debug && onSaveFrame !== undefined;
const gpuSupported = "gpu" in navigator;
return (
<SendouPopover
@@ -78,6 +79,16 @@ export function SettingsPopover({
>
Save clips
</SendouSwitch>
<SendouSwitch
size="small"
isSelected={gpuSupported && settings.webgpu}
isDisabled={!gpuSupported}
onChange={(webgpu) => updateSettings({ webgpu })}
>
{gpuSupported
? "Use the graphics card (faster scans)"
: "Use the graphics card (not supported by this browser)"}
</SendouSwitch>
<div className={styles.row}>
<span className={styles.rowLabel}>Clip on splats in a row</span>
<SendouChipRadioGroup>

View File

@@ -344,6 +344,9 @@ function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) {
{telemetry.wallMs > 0
? ` · ${formatTime(telemetry.wallMs / 1000)} cpu`
: null}
{telemetry.gpuScans > 0
? ` · WebGPU in ${telemetry.gpuScans} workers, ${formatTime(telemetry.gpuWaitMs / 1000)} waited`
: null}
</summary>
<table>
<thead>

View File

@@ -239,6 +239,7 @@ export async function startCapture(): Promise<void> {
// the worker first: a failed init must not leave the camera on
client = new AnalyzerClient(onResult, onWorkerError, undefined, {
frameQueueLimit: FRAME_QUEUE_LIMIT,
webgpu: settings.webgpu,
});
try {
await client.whenReady();

View File

@@ -1,7 +1,7 @@
/**
* The scanner's settings, kept in localStorage: the capture source, what
* clips hear, whether results upload to sendou.ink and whether live clips
* are saved. Read through a store so the controllers (outside React) and
* clips hear, whether results upload to sendou.ink, whether live clips are
* saved and whether matching runs on the GPU. Read through a store so the controllers (outside React) and
* the views see one value.
*/
import { useSyncExternalStore } from "react";
@@ -21,6 +21,8 @@ export interface ScannerSettings {
clipMinKills: ClipMinKills;
/** milliseconds the clips' sound is moved later (negative: earlier) against the picture */
audioOffsetMs: number;
/** match on the GPU (WebGPU) when the browser has one; results are identical either way */
webgpu: boolean;
}
export const CLIP_MIN_KILLS_OPTIONS = [3, 4, 5] as const;
@@ -43,6 +45,7 @@ const DEFAULT_SETTINGS: ScannerSettings = {
saveClips: true,
clipMinKills: 4,
audioOffsetMs: 0,
webgpu: true,
};
let settings: ScannerSettings | null = null;
@@ -119,6 +122,10 @@ function load(): ScannerSettings {
Math.min(AUDIO_OFFSET_LIMIT_MS, parsed.audioOffsetMs),
)
: DEFAULT_SETTINGS.audioOffsetMs,
webgpu:
typeof parsed.webgpu === "boolean"
? parsed.webgpu
: DEFAULT_SETTINGS.webgpu,
};
} catch {
return DEFAULT_SETTINGS;

View File

@@ -257,7 +257,7 @@ export async function startVodScan(
seek.frameDone?.();
seek.frameDone = null;
},
{ collectTelemetry: telemetry },
{ collectTelemetry: telemetry, webgpu: readSettings().webgpu },
),
);
await Promise.all(clients.map((c) => c.whenReady()));

View File

@@ -0,0 +1,46 @@
/*
* Exact template-match cross sums for core/match-steps.ts: for every
* placement (x, y), x in [lo, hi] and y in [0, rows), the sum of
* template·image products over the template's rows (samples interleaved, so
* all channels at once). u8 inputs widened to i16 lanes and multiplied with
* i32x4.dot_i16x8_s; sums stay exact below 2^32 (the caller's size limit).
*
* Regenerate CROSS_SUMS_WASM in cross-sums.ts after an edit (Apple clang
* ships the wasm32 target; no linker is needed):
* clang --target=wasm32 -O3 -msimd128 -nostdlib -c cross-sums.c -o cross-sums.o
* base64 -i cross-sums.o | tr -d '\n'
* The object is instantiated as is: it imports only its memory (and an unused
* table), so keep the function free of stack use, data and calls.
*/
#include <wasm_simd128.h>
typedef unsigned char u8;
typedef unsigned int u32;
__attribute__((export_name("crossSums")))
void crossSums(const u8* image, u32 imageRowLength, const u8* tpl, u32 tRows,
u32 tRowLength, u32 rows, u32 lo, u32 hi, u32 ch, u32* out) {
u32 width = hi - lo + 1;
for (u32 y = 0; y < rows; y++) {
for (u32 x = lo; x <= hi; x++) {
v128_t acc = wasm_i32x4_splat(0);
u32 tail = 0;
for (u32 ty = 0; ty < tRows; ty++) {
const u8* ip = image + (y + ty) * imageRowLength + x * ch;
const u8* tp = tpl + ty * tRowLength;
u32 k = 0;
for (; k + 16 <= tRowLength; k += 16) {
v128_t a = wasm_v128_load(ip + k);
v128_t b = wasm_v128_load(tp + k);
acc = wasm_i32x4_add(acc, wasm_i32x4_dot_i16x8(wasm_u16x8_extend_low_u8x16(a), wasm_u16x8_extend_low_u8x16(b)));
acc = wasm_i32x4_add(acc, wasm_i32x4_dot_i16x8(wasm_u16x8_extend_high_u8x16(a), wasm_u16x8_extend_high_u8x16(b)));
}
for (; k < tRowLength; k++) tail += (u32)ip[k] * tp[k];
}
out[y * width + (x - lo)] = tail + (u32)wasm_i32x4_extract_lane(acc, 0) +
(u32)wasm_i32x4_extract_lane(acc, 1) +
(u32)wasm_i32x4_extract_lane(acc, 2) +
(u32)wasm_i32x4_extract_lane(acc, 3);
}
}
}

View File

@@ -0,0 +1,88 @@
/**
* Exact template-match cross sums in WebAssembly SIMD (cross-sums.c, compiled
* to a wasm32 object; regeneration steps there): for every placement of a
* window, the sum of template·image products over all channels, ~10x the
* speed of the same loop in JS. Null where WebAssembly SIMD is unavailable.
*/
/** cross-sums.c compiled with clang --target=wasm32 -O3 -msimd128, base64 */
const CROSS_SUMS_WASM =
"AGFzbQEAAAABjoCAgAABYAp/f39/f39/f39/AAK6gICAAAIDZW52D19fbGluZWFyX21lbW9yeQIAAANlbnYZX19pbmRpcmVjdF9mdW5jdGlvbl90YWJsZQFwAAADgoCAgAABAAeNgICAAAEJY3Jvc3NTdW1zAAAKgIyAgAAB/QsEFH8BewJ/AnsCQCAFRQ0AIAcgBkkNACAHIAZrQQFqIQoCQCADDQAgB0EBaiIAIAZBAWoiCyAAIAtLGyAGa0ECdCEAIAVBA3EhDEEAIQ0CQCAFQQRJDQAgB0ECdCAGQQJ0a0EEaiEOIAdBA3QgBkEDdGtBCGohDyAHQQxsIAZBDGxrQQxqIRAgCkEEdCERIAVBfHEhBCAJIQtBACENA0ACQCAARSISDQAgC0EAIAD8CwALAkAgEg0AIAsgDmpBACAA/AsACwJAIBINACALIA9qQQAgAPwLAAsCQCASDQAgCyAQakEAIAD8CwALIAsgEWohCyAEIA1BBGoiDUcNAAsLIAxFDQEgCkECdCESIAkgDSAKbEECdGohCwNAAkAgAEUNACALQQAgAPwLAAsgCyASaiELIAxBf2oiDA0ADAILCwJAIARBEEkNACACQRBqIRMgBEEDcSEUIARBfHFBcGohFSAAIAggBmxqIhZBEGohF0EAIRgDQCAJIBggCmxBAnRqIRkgFyEaIBYhGyAGIRwDQEEAIR39DAAAAAAAAAAAAAAAAAAAAAAhHiATIR8gGiEgIBshDiACIQ9BACEQA0AgHyESICAhDEEAIQsDQCAOIAsiAGr9AAAAIiH9iQEgDyAAav0AAAAiIv2JAf26ASAe/a4BICH9igEgIv2KAf26Af2uASEeIBIiDUEQaiESIAwiEUEQaiEMIABBEGohCyAAQSBqIARNDQALAkAgBCALTQ0AAkAgBCALayISQQRJDQAgFSAAayEA/QwAAAAAAAAAAAAAAAAAAAAAIBD9HAAhISALIBIgFGtqIQsDQCAN/VwAAP2JASAR/VwAAP2JAf2+ASAh/a4BISEgDUEEaiENIBFBBGohESAAQXxqIgANAAsgISAhIB79DQgJCgsMDQ4PAAECAwABAgP9rgEiISAhIB79DQQFBgcAAQIDAAECAwABAgP9rgH9GwAhECAURQ0BCwNAIA8gC2otAAAgDiALai0AAGwgEGohECAEIAtBAWoiC0cNAAsLIB8gBGohHyAgIAFqISAgDiABaiEOIA8gBGohDyAdQQFqIh0gA0cNAAsgGSAcIAZrQQJ0aiAe/RsAIBBqIB79GwFqIB79GwJqIB79GwNqNgIAIBogCGohGiAbIAhqIRsgHEEBaiIcIAdNDQALIBcgAWohFyAWIAFqIRYgGEEBaiIYIAVHDQAMAgsLAkAgBEUNACAEQQxxIQ4gBEEDcSEQIAAgCCAGbGohFUEAIRogBEEESSEfA0AgCSAaIApsQQJ0aiEUIBUhICAGIR0DQEEAIQ8gICENIAIhEUEAIQsDQAJAAkAgH0UNAEEAIQAMAQtBACEAA0AgESAAaiISQQNqLQAAIA0gAGoiDEEDai0AAGwgEkECai0AACAMQQJqLQAAbCASQQFqLQAAIAxBAWotAABsIBItAAAgDC0AAGwgC2pqamohCyAOIABBBGoiAEcNAAsLAkAgEEUNACAQIRIDQCARIABqLQAAIA0gAGotAABsIAtqIQsgAEEBaiEAIBJBf2oiEg0ACwsgDSABaiENIBEgBGohESAPQQFqIg8gA0cNAAsgFCAdIAZrQQJ0aiALNgIAICAgCGohICAdQQFqIh0gB00NAAsgFSABaiEVIBpBAWoiGiAFRw0ADAILCyAHQQFqIgAgBkEBaiILIAAgC0sbIAZrQQJ0IQAgBUEDcSEMQQAhDQJAIAVBBEkNACAHQQJ0IAZBAnRrQQRqIQ4gB0EDdCAGQQN0a0EIaiEPIAdBDGwgBkEMbGtBDGohECAKQQR0IREgBUF8cSEEIAkhC0EAIQ0DQAJAIABFIhINACALQQAgAPwLAAsCQCASDQAgCyAOakEAIAD8CwALAkAgEg0AIAsgD2pBACAA/AsACwJAIBINACALIBBqQQAgAPwLAAsgCyARaiELIAQgDUEEaiINRw0ACwsgDEUNACAKQQJ0IRIgCSANIApsQQJ0aiELA0ACQCAARQ0AIAtBACAA/AsACyALIBJqIQsgDEF/aiIMDQALCwsAooCAgAAHbGlua2luZwIIk4CAgAACAKQBAAljcm9zc1N1bXMFkAEAAMCAgIAACXByb2R1Y2VycwEMcHJvY2Vzc2VkLWJ5AQtBcHBsZSBjbGFuZxoyMS4wLjAgKGNsYW5nLTIxMDAuMy4zNC4yKQCdgYCAAA90YXJnZXRfZmVhdHVyZXMJKwtidWxrLW1lbW9yeSsPYnVsay1tZW1vcnktb3B0KxZjYWxsLWluZGlyZWN0LW92ZXJsb25nKwptdWx0aXZhbHVlKw9tdXRhYmxlLWdsb2JhbHMrE25vbnRyYXBwaW5nLWZwdG9pbnQrD3JlZmVyZW5jZS10eXBlcysIc2lnbi1leHQrB3NpbWQxMjg=";
const PAGE_BYTES = 65536;
interface Pixels {
rows: number;
cols: number;
ch: number;
data: Uint8Array;
}
export interface CrossSums {
/**
* Cross sums of `template` against `image` for placements x in [lo, hi] of
* every row: entry (y · (hi − lo + 1) + x − lo). A view into the module's
* memory, valid until the next call.
*/
compute(image: Pixels, template: Pixels, lo: number, hi: number): Uint32Array;
}
let loaded: CrossSums | null | undefined;
/** The SIMD kernel, instantiated on first use; null when this runtime cannot run it. */
export function simdCrossSums(): CrossSums | null {
if (loaded !== undefined) return loaded;
try {
const memory = new WebAssembly.Memory({ initial: 1 });
const instance = new WebAssembly.Instance(
new WebAssembly.Module(
Uint8Array.from(atob(CROSS_SUMS_WASM), (c) => c.charCodeAt(0)),
),
{
env: {
__linear_memory: memory,
__indirect_function_table: new WebAssembly.Table({
initial: 0,
element: "anyfunc",
}),
},
},
);
const crossSums = instance.exports.crossSums as (...args: number[]) => void;
loaded = {
compute(image, template, lo, hi) {
const rows = image.rows - template.rows + 1;
const width = hi - lo + 1;
const templateOffset = align(image.data.length);
const outOffset = align(templateOffset + template.data.length);
const end = outOffset + rows * width * 4;
if (end > memory.buffer.byteLength) {
memory.grow(Math.ceil((end - memory.buffer.byteLength) / PAGE_BYTES));
}
const bytes = new Uint8Array(memory.buffer);
bytes.set(image.data, 0);
bytes.set(template.data, templateOffset);
crossSums(
0,
image.cols * image.ch,
templateOffset,
template.rows,
template.cols * template.ch,
rows,
lo,
hi,
image.ch,
outOffset,
);
return new Uint32Array(memory.buffer, outOffset, rows * width);
},
};
} catch {
loaded = null;
}
return loaded;
}
function align(offset: number): number {
return Math.ceil(offset / 16) * 16;
}

View File

@@ -18,14 +18,22 @@ import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
recognizeTextSteps,
scaleGlyphSet,
} from "../../glyphs";
import { copyRoi, cropRoi, meanBrightness, type Roi } from "../../image";
import {
copyRoi,
cropRoi,
frameGray,
frameRgb,
meanBrightness,
type Roi,
} from "../../image";
import { all, done, type MatchSteps, runSync } from "../../match-steps";
import { closestEntry, matchKey, rankBy, rankByRead } from "../../text";
import type { ScoreboardResources } from "../scoreboard/index";
import { parseName } from "../scoreboard/names";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import { type ParsedName, parseNameSteps } from "../scoreboard/names";
import { matchWeaponSteps, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
DEATH_MESSAGE_TEMPLATES,
@@ -211,8 +219,7 @@ export function createDeathDetector(
if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
const line1 = copyRoi(gray, SPLAT_LINE1_ROI);
const { maxVal } = minMaxLoc(line1);
const bin = new cv.Mat();
@@ -240,7 +247,6 @@ export function createDeathDetector(
probe.delete();
if (maxCh > GATE_ICON_MIN_MAX) iconOk++;
}
gray.delete();
const score =
(darkOk / darkProbes.length + (textOk ? 1 : 0) + iconOk / 3) / 3;
@@ -473,11 +479,174 @@ export function createDeathDetector(
labels.delete();
}
function parse(frame: Mat, t: number): DetectedEvent<DeathData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
/** Splash-tag name read on a band's ink: against the banner color, then refined against the text color estimated from that ink. */
function* readWithBackground(
inner: Mat,
backgrounds: readonly [number, number, number][],
spaceGap: number,
speculative: boolean,
): MatchSteps<{
parsed: ParsedName;
background: [number, number, number];
textColor: [number, number, number] | null;
}> {
const band = distanceBand(inner, backgrounds, false);
cv.normalize(band, band, 0, 255, cv.NORM_MINMAX);
clearBorderBlobs(band, TAG_NAME_BIN_THRESHOLD);
let textColor: [number, number, number] | null = null;
let refined: Mat | null = null;
const ink = band.data;
let inkCount = 0;
for (let i = 0; i < ink.length; i++)
if (ink[i]! > TAG_NAME_BIN_THRESHOLD) inkCount++;
if (inkCount >= TAG_NAME_REFINE_MIN_INK) {
textColor = medianColor(inner, (i) => ink[i]! > TAG_NAME_BIN_THRESHOLD);
refined = distanceBand(inner, [textColor], true);
clearBorderBlobs(refined, TAG_NAME_REFINE_BIN_THRESHOLD);
}
const [bandParse, refinedParse] = yield* all([
parseNameSteps(
band,
tagNameGlyphs!,
{ spaceGap, binThreshold: TAG_NAME_BIN_THRESHOLD },
speculative,
),
refined
? parseNameSteps(
refined,
tagNameGlyphs!,
{ spaceGap, binThreshold: TAG_NAME_REFINE_BIN_THRESHOLD },
speculative,
)
: done(null),
]);
refined?.delete();
band.delete();
const parsed =
refinedParse && refinedParse.confidence > bandParse.confidence
? refinedParse
: bandParse;
return { parsed, background: backgrounds[0]!, textColor };
}
/**
* 4. splash-tag name: read against the banner color, then (busy art survives
* that as fake glyphs) against closeness to the text color estimated from
* pass 1's ink; the more confident read wins. Same for each background estimator.
*/
function* readTagName(
rgb: Mat,
speculative: boolean,
): MatchSteps<{ read: TagNameRead; memoHit: boolean }> {
const spaceGap = Math.max(7, Math.round(tagNameGlyphs!.medianWidth * 0.55));
const inner = levelTagInner(rgb);
const signature = tagSignature(inner);
let read = tagMemoLookup(signature);
const memoHit = read !== null;
if (read === null) {
const median = medianColor(inner);
const dominants = dominantColors(inner, 2);
const dominant = dominants[0]!.color;
const candidates: [number, number, number][][] = [[median]];
if (dominant.some((c, i) => Math.abs(c - median[i]!) > 8))
candidates.push([dominant]);
const second = dominants[1];
if (
second &&
second.fraction >= TAG_SPLIT_MIN_FRACTION &&
second.color.some(
(c, i) => Math.abs(c - dominant[i]!) > TAG_SPLIT_MIN_CHANNEL_DISTANCE,
)
) {
candidates.push([dominant, second.color]);
}
// an empty read never beats one with glyphs (a blanked band scores confidence 1);
// near ties go to the longer read since confidence is the *min* char score
// and erasing most of a name can still read the survivors immaculately
const NEAR_TIE = 0.03;
const beats = (
a: { parsed: { name: string; confidence: number } },
b: typeof a,
) => {
const aRead = a.parsed.name.length > 0 ? 1 : 0;
const bRead = b.parsed.name.length > 0 ? 1 : 0;
if (aRead !== bRead) return aRead - bRead;
if (Math.abs(a.parsed.confidence - b.parsed.confidence) <= NEAR_TIE) {
return a.parsed.name.length - b.parsed.name.length;
}
return a.parsed.confidence - b.parsed.confidence;
};
const reads = yield* all(
candidates.map((backgrounds) =>
readWithBackground(inner, backgrounds, spaceGap, speculative),
),
);
let best = reads[0]!;
for (const alt of reads.slice(1)) {
if (beats(alt, best) > 0) best = alt;
}
read = {
name: best.parsed.name.length > 0 ? best.parsed.name : null,
confidence: best.parsed.confidence,
raw: best.parsed.raw.text,
background: best.background,
textColor: best.textColor,
};
if (read.confidence >= TAG_MEMO_MIN_CONFIDENCE && read.name !== null) {
tagMemoStore(signature, read);
}
}
inner.delete();
return { read, memoHit };
}
/** 3. ability grid; rows carry 1-3 left-aligned sub circles, so a sub box without badge ink ends the row. */
function* readAbilities(rgb: Mat): MatchSteps<WeaponMatch[][]> {
const rows = Array.from({ length: ABILITY_ROWS }, (_, row) => {
const crops = [cropRoi(rgb, abilityMainRoi(row))];
for (let slot = 0; slot < ABILITY_SUB_XS.length; slot++) {
const crop = copyRoi(rgb, abilitySubRoi(row, slot));
const d = crop.data;
const n = crop.rows * crop.cols;
let ink = 0;
for (let i = 0; i < n; i++) {
const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!);
if (v > ABILITY_INK_THRESHOLD) ink++;
}
if (ink < ABILITY_SLOT_MIN_INK) {
crop.delete();
break;
}
crops.push(crop);
}
return crops;
});
const matches = yield* all(
rows.map((crops) =>
all(
crops.map((crop, slot) =>
matchWeaponSteps(
crop,
slot === 0 ? abilities!.mains : abilities!.subs,
{ inkThreshold: ABILITY_INK_THRESHOLD },
),
),
),
),
);
for (const crop of rows.flat()) crop.delete();
return matches;
}
function* parseSteps(
frame: Mat,
t: number,
_gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<DeathData>[]> {
const gray = frameGray(frame);
const rgb = frameRgb(frame);
const confidences: number[] = [];
@@ -491,17 +660,24 @@ export function createDeathDetector(
let template: DeathMessageTemplate | null = null;
let line1Score = 0;
if (weaponGlyphs) {
const readLine = (roi: Roi, glyphs: GlyphSet) => {
const readLine = function* (
roi: Roi,
glyphs: GlyphSet,
): MatchSteps<RecognizedText> {
const crop = cropRoi(gray, roi);
const read = recognizeText(crop, glyphs, {
binThreshold: SPLAT_TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
const read = yield* recognizeTextSteps(
crop,
glyphs,
{ binThreshold: SPLAT_TEXT_BIN_THRESHOLD, minCharScore: 0.3 },
speculative,
);
crop.delete();
return read;
};
line1 = readLine(SPLAT_LINE1_ROI, weaponGlyphs);
line2 = readLine(WEAPON_LINE_ROI, weaponGlyphs);
[line1, line2] = yield* all([
readLine(SPLAT_LINE1_ROI, weaponGlyphs),
readLine(WEAPON_LINE_ROI, weaponGlyphs),
]);
for (const candidate of DEATH_MESSAGE_TEMPLATES) {
if (isJaTemplate(candidate)) continue;
const constReading =
@@ -515,8 +691,10 @@ export function createDeathDetector(
}
// JA line reads cost ~2x, so they only run when no Latin template owns the frame
if (jaGlyphs && line1Score < LATIN_DECISIVE_SCORE) {
jaWeaponLine = readLine(JA_WEAPON_LINE_ROI, jaGlyphs);
jaConstLine = readLine(JA_CONST_LINE_ROI, jaGlyphs);
[jaWeaponLine, jaConstLine] = yield* all([
readLine(JA_WEAPON_LINE_ROI, jaGlyphs),
readLine(JA_CONST_LINE_ROI, jaGlyphs),
]);
for (const candidate of DEATH_MESSAGE_TEMPLATES) {
if (!isJaTemplate(candidate)) continue;
const score =
@@ -528,8 +706,6 @@ export function createDeathDetector(
}
}
if (!template || line1Score < LINE1_MIN_SCORE) {
gray.delete();
rgb.delete();
return [];
}
}
@@ -567,12 +743,19 @@ export function createDeathDetector(
// 2b. the WIPEOUT banner can cover the text while the burst icon stays intact:
// match it against the main-weapon set. Fixture positives score 0.55+, best
// off-target frame (icon displaced by a rainmaker line) 0.48.
let burstIcon: WeaponMatch | null = null;
if (weapon === null && burstWeapons) {
const crop = cropRoi(rgb, BURST_ICON_ROI);
burstIcon = matchWeapon(crop, burstWeapons);
crop.delete();
// off-target frame (icon displaced by a rainmaker line) 0.48. The ability
// grid (3.) and the tag name (4.) read in the same lockstep.
const burstCrop =
weapon === null && burstWeapons ? cropRoi(rgb, BURST_ICON_ROI) : null;
const [burstIcon, abilityMatches, tag] = yield* all([
burstCrop && burstWeapons
? matchWeaponSteps(burstCrop, burstWeapons)
: done(null),
abilities ? readAbilities(rgb) : done(null),
tagNameGlyphs ? readTagName(rgb, speculative) : done(null),
]);
burstCrop?.delete();
if (burstIcon) {
const entry =
burstIcon.score >= BURST_ICON_MIN_SCORE
? mainById.get(burstIcon.id)
@@ -642,163 +825,36 @@ export function createDeathDetector(
}
if (weaponGlyphs && template) confidences.push(weaponScore);
// 3. ability grid; rows carry 1-3 left-aligned sub circles, so a sub box without badge ink ends the row
const abilityRows: AbilityWithUnknown[][] = [];
const abilityDebug: (WeaponMatch | null)[][] = [];
if (abilities) {
for (let row = 0; row < ABILITY_ROWS; row++) {
const ids: AbilityWithUnknown[] = [];
const debug: (WeaponMatch | null)[] = [];
const mainCrop = cropRoi(rgb, abilityMainRoi(row));
const main = matchWeapon(mainCrop, abilities.mains, {
inkThreshold: ABILITY_INK_THRESHOLD,
});
mainCrop.delete();
ids.push(toAbilityWithUnknown(main.id) ?? "UNKNOWN");
debug.push(main);
confidences.push(Math.max(0, main.score));
for (let slot = 0; slot < ABILITY_SUB_XS.length; slot++) {
const crop = copyRoi(rgb, abilitySubRoi(row, slot));
const d = crop.data;
const n = crop.rows * crop.cols;
let ink = 0;
for (let i = 0; i < n; i++) {
const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!);
if (v > ABILITY_INK_THRESHOLD) ink++;
}
if (ink < ABILITY_SLOT_MIN_INK) {
crop.delete();
break;
}
const sub = matchWeapon(crop, abilities.subs, {
inkThreshold: ABILITY_INK_THRESHOLD,
});
crop.delete();
ids.push(toAbilityWithUnknown(sub.id) ?? "UNKNOWN");
debug.push(sub);
confidences.push(Math.max(0, sub.score));
}
abilityRows.push(ids);
abilityDebug.push(debug);
for (const matches of abilityMatches ?? []) {
const ids: AbilityWithUnknown[] = [];
const debug: (WeaponMatch | null)[] = [];
for (const match of matches) {
ids.push(toAbilityWithUnknown(match.id) ?? "UNKNOWN");
debug.push(match);
confidences.push(Math.max(0, match.score));
}
abilityRows.push(ids);
abilityDebug.push(debug);
}
// 4. splash-tag name: read against the banner color, then (busy art survives
// that as fake glyphs) against closeness to the text color estimated from
// pass 1's ink; the more confident read wins. Same for each background estimator.
let name: string | null = null;
let nameConfidence = 0;
let nameRaw = "";
let tagBackground: [number, number, number] | null = null;
let tagTextColor: [number, number, number] | null = null;
let nameMemoHit = false;
if (tagNameGlyphs) {
const spaceGap = Math.max(
7,
Math.round(tagNameGlyphs.medianWidth * 0.55),
);
const inner = levelTagInner(rgb);
const signature = tagSignature(inner);
const memoized = tagMemoLookup(signature);
nameMemoHit = memoized !== null;
const readWithBackground = (
backgrounds: readonly [number, number, number][],
) => {
const band = distanceBand(inner, backgrounds, false);
cv.normalize(band, band, 0, 255, cv.NORM_MINMAX);
clearBorderBlobs(band, TAG_NAME_BIN_THRESHOLD);
let parsed = parseName(band, tagNameGlyphs, {
spaceGap,
binThreshold: TAG_NAME_BIN_THRESHOLD,
});
let textColor: [number, number, number] | null = null;
const ink = band.data;
let inkCount = 0;
for (let i = 0; i < ink.length; i++)
if (ink[i]! > TAG_NAME_BIN_THRESHOLD) inkCount++;
if (inkCount >= TAG_NAME_REFINE_MIN_INK) {
textColor = medianColor(
inner,
(i) => ink[i]! > TAG_NAME_BIN_THRESHOLD,
);
const refined = distanceBand(inner, [textColor], true);
clearBorderBlobs(refined, TAG_NAME_REFINE_BIN_THRESHOLD);
const reparsed = parseName(refined, tagNameGlyphs, {
spaceGap,
binThreshold: TAG_NAME_REFINE_BIN_THRESHOLD,
});
refined.delete();
if (reparsed.confidence > parsed.confidence) parsed = reparsed;
}
band.delete();
return { parsed, background: backgrounds[0]!, textColor };
};
let read = memoized;
if (read === null) {
const median = medianColor(inner);
const dominants = dominantColors(inner, 2);
const dominant = dominants[0]!.color;
const candidates: [number, number, number][][] = [[median]];
if (dominant.some((c, i) => Math.abs(c - median[i]!) > 8))
candidates.push([dominant]);
const second = dominants[1];
if (
second &&
second.fraction >= TAG_SPLIT_MIN_FRACTION &&
second.color.some(
(c, i) =>
Math.abs(c - dominant[i]!) > TAG_SPLIT_MIN_CHANNEL_DISTANCE,
)
) {
candidates.push([dominant, second.color]);
}
// an empty read never beats one with glyphs (a blanked band scores confidence 1);
// near ties go to the longer read since confidence is the *min* char score
// and erasing most of a name can still read the survivors immaculately
const NEAR_TIE = 0.03;
const beats = (
a: { parsed: { name: string; confidence: number } },
b: typeof a,
) => {
const aRead = a.parsed.name.length > 0 ? 1 : 0;
const bRead = b.parsed.name.length > 0 ? 1 : 0;
if (aRead !== bRead) return aRead - bRead;
if (Math.abs(a.parsed.confidence - b.parsed.confidence) <= NEAR_TIE) {
return a.parsed.name.length - b.parsed.name.length;
}
return a.parsed.confidence - b.parsed.confidence;
};
let best = readWithBackground(candidates[0]!);
for (const backgrounds of candidates.slice(1)) {
const alt = readWithBackground(backgrounds);
if (beats(alt, best) > 0) best = alt;
}
read = {
name: best.parsed.name.length > 0 ? best.parsed.name : null,
confidence: best.parsed.confidence,
raw: best.parsed.raw.text,
background: best.background,
textColor: best.textColor,
};
if (read.confidence >= TAG_MEMO_MIN_CONFIDENCE && read.name !== null) {
tagMemoStore(signature, read);
}
}
inner.delete();
tagBackground = read.background;
tagTextColor = read.textColor;
nameRaw = read.raw;
name = read.name;
nameConfidence = read.confidence;
if (tag) {
nameMemoHit = tag.memoHit;
tagBackground = tag.read.background;
tagTextColor = tag.read.textColor;
nameRaw = tag.read.raw;
name = tag.read.name;
nameConfidence = tag.read.confidence;
confidences.push(nameConfidence);
}
gray.delete();
rgb.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
@@ -855,6 +911,8 @@ export function createDeathDetector(
rearmCooldownS: 4,
maxStagnantParses: 3,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}

View File

@@ -0,0 +1,117 @@
/**
* One scheduled pass of the detector registry over a frame, shared by the
* analyzer worker and the CLI scan. Gates run in registry order first, then
* every approved parse runs — sequentially on the calling thread, or, given a
* batching driver (worker/gpu-matcher.ts), as one lockstep of every parse's
* match steps so the frame costs one chain of round trips instead of one per
* detector. Parse results are recorded with the scheduler in registry order
* afterwards; scheduler decisions within a frame depend only on each
* detector's own state, so this ordering is equivalent to gating and parsing
* one detector at a time.
*/
import type { Mat } from "../cv";
import { all, type MatchScores, type MatchSteps } from "../match-steps";
import type { DetectorScheduler } from "./scheduler";
import { detectorTelemetry, type ScanTelemetry } from "./telemetry";
import type { DetectedEvent, Detector, GateResult } from "./types";
export interface DetectorOutcome {
detector: Detector<unknown>;
gate: GateResult;
/** the gate passed and the scheduler let the parse run */
parsed: boolean;
/** empty when the gate failed or the scheduler suppressed the parse */
events: DetectedEvent<unknown>[];
}
/** Runs match steps to completion, answering requests in batches (a GPU driver's `run`). */
export type StepsRunner = <T>(steps: MatchSteps<T>) => Promise<T>;
/** Gates and parses the `due` detectors over `frame`; outcomes come back in registry order. */
export async function runDetectorPass({
frame,
t,
detectors,
due,
scheduler,
telemetry,
runSteps,
speculative = true,
}: {
frame: Mat;
t: number;
detectors: readonly Detector<unknown>[];
due: readonly string[];
scheduler: DetectorScheduler;
telemetry: ScanTelemetry | null;
/** batching driver; omitted = every parse runs synchronously */
runSteps?: StepsRunner;
/** prefetch candidate sets in lockstep (batching drivers only) */
speculative?: boolean;
}): Promise<DetectorOutcome[]> {
const gated: DetectorOutcome[] = [];
for (const detector of detectors) {
if (!due.includes(detector.id)) continue;
const counters = telemetry
? detectorTelemetry(telemetry, detector.id)
: null;
const gateStart = counters ? performance.now() : 0;
const gate = detector.gate(frame);
if (counters) {
counters.checks++;
counters.gateMs += performance.now() - gateStart;
}
scheduler.recordGate(detector.id, t, gate.pass, gate.signature);
if (counters && gate.pass) counters.gatePasses++;
const parsed = gate.pass && scheduler.shouldParse(detector.id, t);
if (counters && gate.pass && !parsed) counters.suppressedParses++;
gated.push({ detector, gate, parsed, events: [] });
}
const parsing = gated.filter((outcome) => outcome.parsed);
const addParseMs = (detector: Detector<unknown>, ms: number) => {
if (telemetry) detectorTelemetry(telemetry, detector.id).parseMs += ms;
};
if (runSteps && parsing.length > 0) {
const results = await runSteps(
all(
parsing.map(({ detector, gate }) =>
timed(detector.parseSteps(frame, t, gate, speculative), (ms) =>
addParseMs(detector, ms),
),
),
),
);
for (const [i, outcome] of parsing.entries()) {
outcome.events = results[i]!;
}
} else {
for (const outcome of parsing) {
const parseStart = telemetry ? performance.now() : 0;
outcome.events = outcome.detector.parse(frame, t, outcome.gate);
if (telemetry)
addParseMs(outcome.detector, performance.now() - parseStart);
}
}
for (const { detector, events } of parsing) {
if (telemetry) detectorTelemetry(telemetry, detector.id).parses++;
scheduler.recordParse(detector.id, t, events);
}
return gated;
}
/** `steps` with the time spent inside its own resumptions reported (the parse's CPU share; batched matching is not attributed). */
function* timed<T>(
steps: MatchSteps<T>,
report: (ms: number) => void,
): MatchSteps<T> {
let scores: MatchScores[] | undefined;
for (;;) {
const start = performance.now();
const step = scores === undefined ? steps.next() : steps.next(scores);
report(performance.now() - start);
if (step.done) return step.value;
scores = yield step.value;
}
}

View File

@@ -14,18 +14,21 @@ import { getCV, type Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import {
copyRoi,
frameGray,
maxBrightness,
meanBrightness,
type Roi,
roiSignature,
} from "../../image";
import { all, done, type MatchSteps, runSync } from "../../match-steps";
import {
readMatchTimer,
readMatchTimerSteps,
type TimerRead,
timerBoxChecks,
timerGlyphSets,
} from "../objective/timer";
import type { ScoreboardResources } from "../scoreboard/index";
import { type ParsedName, parseName } from "../scoreboard/names";
import { type ParsedName, parseNameSteps } from "../scoreboard/names";
import type { DetectedEvent, Detector, GateResult } from "../types";
import { matchKillMessage } from "./message";
import {
@@ -115,25 +118,11 @@ export function createKillDetector(
/** Memoized read of a band within the signature caps, freshened to the list's end. */
function rowMemoLookup(signature: number[]): ParsedName | null {
for (let i = 0; i < rowMemo.length; i++) {
const entry = rowMemo[i]!;
let sum = 0;
let cell = 0;
for (let k = 0; k < signature.length; k++) {
const diff = Math.abs(signature[k]! - entry.signature[k]!);
sum += diff;
if (diff > cell) cell = diff;
}
if (
cell <= ROW_MEMO_MAX_CELL_DIFF &&
sum / signature.length <= ROW_MEMO_MAX_MEAN_DIFF
) {
rowMemo.splice(i, 1);
rowMemo.push(entry);
return entry.read;
}
}
return null;
const index = rowMemoIndex(rowMemo, signature);
if (index === -1) return null;
const [entry] = rowMemo.splice(index, 1);
rowMemo.push(entry!);
return entry!.read;
}
function rowMemoStore(signature: number[], read: ParsedName): void {
@@ -141,6 +130,27 @@ export function createKillDetector(
if (rowMemo.length > ROW_MEMO_MAX_ENTRIES) rowMemo.shift();
}
/**
* Rows the memo will miss if the stack is read through `signatures` in
* order: the lookups and stores replayed on a copy of the memo's signatures,
* so the misses can be read ahead in one lockstep.
*/
function predictedMemoMisses(signatures: number[][]): number[] {
const memo = rowMemo.map(({ signature }) => ({ signature }));
const misses: number[] = [];
for (const [row, signature] of signatures.entries()) {
const index = rowMemoIndex(memo, signature);
if (index !== -1) {
memo.push(...memo.splice(index, 1));
continue;
}
misses.push(row);
memo.push({ signature });
if (memo.length > ROW_MEMO_MAX_ENTRIES) memo.shift();
}
return misses;
}
function whiteFraction(gray: Mat, roi: Roi): number {
const crop = copyRoi(gray, roi);
const bin = new cv.Mat();
@@ -171,48 +181,82 @@ export function createKillDetector(
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
const checks = rowChecks(gray, 0);
gray.delete();
const passed = checks.filter(Boolean).length;
return { pass: passed === checks.length, score: passed / checks.length };
}
function readRow(
function* readBand(
gray: Mat,
row: number,
): { parsed: ParsedName; memoized: boolean } {
const roi = textRoi(row);
const signature = roiSignature(gray, roi, ROW_MEMO_COLS, ROW_MEMO_ROWS);
const memoized = rowMemoLookup(signature);
if (memoized) return { parsed: memoized, memoized: true };
const band = copyRoi(gray, roi);
const parsed = parseName(band, glyphs!, {
binThreshold: KILL_TEXT_BIN_THRESHOLD,
spaceGap: Math.max(6, Math.round(glyphs!.medianWidth * 0.55)),
plainTieMargin: PLAIN_TIE_MARGIN,
});
speculative: boolean,
): MatchSteps<ParsedName> {
const band = copyRoi(gray, textRoi(row));
const parsed = yield* parseNameSteps(
band,
glyphs!,
{
binThreshold: KILL_TEXT_BIN_THRESHOLD,
spaceGap: Math.max(6, Math.round(glyphs!.medianWidth * 0.55)),
plainTieMargin: PLAIN_TIE_MARGIN,
},
speculative,
);
band.delete();
rowMemoStore(signature, parsed);
return { parsed, memoized: false };
return parsed;
}
function parse(frame: Mat, t: number): DetectedEvent<KillData>[] {
function* parseSteps(
frame: Mat,
t: number,
_gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<KillData>[]> {
if (!glyphs) return [];
// reads carry forward in time only: a clock that stands still or rewinds
// (a fresh scan, the fixture harness) starts from a blank memo
if (t <= lastParseT) rowMemo.length = 0;
lastParseT = t;
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
// the rows a sequential read can reach, and their memo signatures
const signatures: number[][] = [];
for (let row = 0; row < MAX_ROWS; row++) {
if (row > 0 && !rowChecks(gray, row).every(Boolean)) break;
signatures.push(
roiSignature(gray, textRoi(row), ROW_MEMO_COLS, ROW_MEMO_ROWS),
);
}
const timerVisible = timerBoxChecks(gray).every(Boolean);
// batching drivers read every memo miss and the timer ahead in one
// lockstep; the sequential pass below then only consults the memo
const prefetched = new Map<number, ParsedName>();
let prefetchedTimer: TimerRead | null = null;
if (speculative) {
const misses = predictedMemoMisses(signatures);
const [reads, timer] = yield* all([
all(misses.map((row) => readBand(gray, row, speculative))),
timerVisible
? readMatchTimerSteps(gray, timerSets, speculative)
: noTimer(),
]);
for (const [i, row] of misses.entries()) prefetched.set(row, reads[i]!);
prefetchedTimer = timer;
}
const names: (string | null)[] = [];
const confidences: number[] = [];
const rows: Record<string, unknown>[] = [];
for (let row = 0; row < MAX_ROWS; row++) {
if (row > 0 && !rowChecks(gray, row).every(Boolean)) break;
const { parsed, memoized } = readRow(gray, row);
for (const [row, signature] of signatures.entries()) {
const memoized = rowMemoLookup(signature);
let parsed = memoized;
if (!parsed) {
parsed =
prefetched.get(row) ?? (yield* readBand(gray, row, speculative));
rowMemoStore(signature, parsed);
}
const message = matchKillMessage(parsed.name);
rows.push({
raw: parsed.raw.text,
@@ -220,21 +264,21 @@ export function createKillDetector(
readScore: parsed.confidence,
messageLangs: message?.template.langs,
messageScore: message?.score,
memoized,
memoized: memoized !== null,
});
if (!message || message.score < MESSAGE_MIN_SCORE) break;
names.push(message.name);
confidences.push((message.score + parsed.confidence) / 2);
}
if (names.length === 0) {
gray.delete();
return [];
}
const timer = timerBoxChecks(gray).every(Boolean)
? readMatchTimer(gray, timerSets)
: { value: null, reading: "" };
gray.delete();
const timer =
prefetchedTimer ??
(timerVisible
? yield* readMatchTimerSteps(gray, timerSets, speculative)
: yield* noTimer());
return [
{
@@ -255,6 +299,31 @@ export function createKillDetector(
id: "kill",
checkIntervalS: 0.5,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}
function noTimer(): MatchSteps<TimerRead> {
return done({ value: null, reading: "" });
}
function rowMemoIndex(
memo: readonly { signature: number[] }[],
signature: number[],
): number {
return memo.findIndex((entry) => {
let sum = 0;
let cell = 0;
for (let k = 0; k < signature.length; k++) {
const diff = Math.abs(signature[k]! - entry.signature[k]!);
sum += diff;
if (diff > cell) cell = diff;
}
return (
cell <= ROW_MEMO_MAX_CELL_DIFF &&
sum / signature.length <= ROW_MEMO_MAX_MEAN_DIFF
);
});
}

View File

@@ -12,15 +12,16 @@ import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
recognizeTextSteps,
scaleGlyphSet,
} from "../../glyphs";
import { copyRoi, meanBrightness, minChannel } from "../../image";
import { copyRoi, frameGray, meanBrightness, minChannel } from "../../image";
import {
ALL_MODE_ENTRIES,
ALL_MODE_LABELS,
ALL_STAGE_ENTRIES,
} from "../../localized";
import { all, done, type MatchSteps, runSync } from "../../match-steps";
import { closestBy } from "../../text";
import type { ScoreboardResources } from "../scoreboard/index";
import type { DetectedEvent, Detector, GateResult } from "../types";
@@ -175,8 +176,7 @@ export function createMapStartDetector(
if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
const label = copyRoi(gray, MODE_LABEL_ROI);
const { maxVal } = minMaxLoc(label);
@@ -206,7 +206,6 @@ export function createMapStartDetector(
const inkOk =
brightFraction <= GATE_INK_BAND_MAX_BRIGHT &&
darkFraction >= GATE_INK_BAND_MIN_DARK;
gray.delete();
const score =
(darkOk / GATE_DARK_PROBES.length + (textOk ? 1 : 0) + (inkOk ? 1 : 0)) /
@@ -217,63 +216,137 @@ export function createMapStartDetector(
};
}
function parse(frame: Mat, t: number): DetectedEvent<MapStartData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
/** 2. mode title: find the 1-2 text lines, OCR each (in lockstep), snap the joined text. */
function* readModeLines(
gray: Mat,
glyphs: GlyphSet,
speculative: boolean,
): MatchSteps<string[]> {
const block = copyRoi(gray, MODE_BLOCK_ROI);
// find bands on the masked block (bright background merges/invents bands)
// but OCR the raw crop (masking clips strokes)
const masked = maskNearDark(block, BLOCK_MASK_RADIUS);
const binary = new cv.Mat();
cv.threshold(masked, binary, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
masked.delete();
const lineCrops: Mat[] = [];
for (const band of findLineBands(binary)) {
const extent = bandExtent(binary, band);
if (!extent) continue;
const pad = 3;
const y0 = Math.max(0, band.y0 - pad);
const x0 = Math.max(0, extent.x0 - pad);
lineCrops.push(
copyRoi(block, {
x: x0,
y: y0,
w: Math.min(block.cols, extent.x1 + 1 + pad) - x0,
h: Math.min(block.rows, band.y1 + pad) - y0,
}),
);
}
binary.delete();
block.delete();
const reads = yield* all(
lineCrops.map((line) =>
recognizeTextSteps(
line,
glyphs,
{ binThreshold: TEXT_BIN_THRESHOLD, minCharScore: 0.3 },
speculative,
),
),
);
for (const line of lineCrops) line.delete();
return reads.map((read) => read.text.trim()).filter((text) => text);
}
/**
* 3. stage name over live gameplay: no single binarization works everywhere,
* so try the masked crop plus raw crop at rising thresholds (in lockstep), keep
* the best snap.
*/
function* readStage(
frame: Mat,
glyphs: GlyphSet,
speculative: boolean,
): MatchSteps<{
stage: StageId | null;
stageScore: number;
stageReading: string;
}> {
const rgbaCrop = copyRoi(frame, STAGE_ROI);
const bright = minChannel(rgbaCrop);
rgbaCrop.delete();
const masked = maskNearDark(bright, STAGE_MASK_RADIUS);
const attempts: [Mat, number][] = [
[masked, STAGE_BIN_THRESHOLD],
...STAGE_RAW_BIN_THRESHOLDS.map((thr): [Mat, number] => [bright, thr]),
];
const reads = yield* all(
attempts.map(([input, binThreshold]) =>
recognizeTextSteps(
input,
glyphs,
{ binThreshold, minCharScore: 0.3 },
speculative,
),
),
);
let stage: StageId | null = null;
let stageScore = 0;
let stageReading = "";
for (const read of reads) {
const match = read.text
? closestBy(read.text, ALL_STAGE_ENTRIES, (e) => e.text)
: null;
if (match && match.score > stageScore) {
stageScore = match.score;
stageReading = read.text;
if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId;
}
}
masked.delete();
bright.delete();
return { stage, stageScore, stageReading };
}
function* parseSteps(
frame: Mat,
t: number,
_gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<MapStartData>[]> {
const gray = frameGray(frame);
// 1. confirm the constant label — a gate hit without it is a lookalike
let label: RecognizedText | null = null;
let labelScore = 0;
if (labelGlyphs) {
const crop = copyRoi(gray, MODE_LABEL_ROI);
label = recognizeText(crop, labelGlyphs, {
binThreshold: TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
label = yield* recognizeTextSteps(
crop,
labelGlyphs,
{ binThreshold: TEXT_BIN_THRESHOLD, minCharScore: 0.3 },
speculative,
);
crop.delete();
labelScore = closestBy(label.text, ALL_MODE_LABELS, (l) => l)?.score ?? 0;
if (labelScore < LABEL_MIN_SCORE) {
gray.delete();
return [];
}
}
// 2. mode title: find the 1-2 text lines, OCR each, snap the joined text
const [modeLines, stageRead] = yield* all([
modeGlyphs ? readModeLines(gray, modeGlyphs, speculative) : done(null),
stageGlyphs ? readStage(frame, stageGlyphs, speculative) : done(null),
]);
let mode: ModeShort | null = null;
let modeScore = 0;
let modeReading = "";
if (modeGlyphs) {
const block = copyRoi(gray, MODE_BLOCK_ROI);
// find bands on the masked block (bright background merges/invents bands)
// but OCR the raw crop (masking clips strokes)
const masked = maskNearDark(block, BLOCK_MASK_RADIUS);
const binary = new cv.Mat();
cv.threshold(masked, binary, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
masked.delete();
const bands = findLineBands(binary);
const lines: string[] = [];
for (const band of bands) {
const extent = bandExtent(binary, band);
if (!extent) continue;
const pad = 3;
const y0 = Math.max(0, band.y0 - pad);
const x0 = Math.max(0, extent.x0 - pad);
const line = copyRoi(block, {
x: x0,
y: y0,
w: Math.min(block.cols, extent.x1 + 1 + pad) - x0,
h: Math.min(block.rows, band.y1 + pad) - y0,
});
const read = recognizeText(line, modeGlyphs, {
binThreshold: TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
line.delete();
if (read.text.trim()) lines.push(read.text.trim());
}
binary.delete();
block.delete();
modeReading = lines.join(" ");
if (modeLines) {
modeReading = modeLines.join(" ");
const match = modeReading
? closestBy(modeReading, ALL_MODE_ENTRIES, (e) => e.text)
: null;
@@ -282,40 +355,7 @@ export function createMapStartDetector(
if (match.score >= MIN_MATCH_SCORE) mode = match.entry.mode;
}
}
// 3. stage name over live gameplay: no single binarization works everywhere,
// so try the masked crop plus raw crop at rising thresholds, keep the best snap
let stage: StageId | null = null;
let stageScore = 0;
let stageReading = "";
if (stageGlyphs) {
const rgbaCrop = copyRoi(frame, STAGE_ROI);
const bright = minChannel(rgbaCrop);
rgbaCrop.delete();
const masked = maskNearDark(bright, STAGE_MASK_RADIUS);
const attempts: [Mat, number][] = [
[masked, STAGE_BIN_THRESHOLD],
...STAGE_RAW_BIN_THRESHOLDS.map((thr): [Mat, number] => [bright, thr]),
];
for (const [input, binThreshold] of attempts) {
const read = recognizeText(input, stageGlyphs, {
binThreshold,
minCharScore: 0.3,
});
const match = read.text
? closestBy(read.text, ALL_STAGE_ENTRIES, (e) => e.text)
: null;
if (match && match.score > stageScore) {
stageScore = match.score;
stageReading = read.text;
if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId;
}
}
masked.delete();
bright.delete();
}
gray.delete();
const { stage = null, stageScore = 0, stageReading = "" } = stageRead ?? {};
return [
{
@@ -342,6 +382,8 @@ export function createMapStartDetector(
id: "map-start",
sufficientConfidence: 0.79,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}

View File

@@ -19,25 +19,29 @@ import type {
StageId,
} from "~/modules/in-game-lists/types";
import { toAbilityWithUnknown, toMainWeaponId } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import type { Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import {
copyRoi,
cropRoi,
frameGray,
frameHsv,
frameRgb,
laplacianAbs,
maxBrightness,
meanBrightness,
type Roi,
} from "../../image";
import { type InkRgb, meanInkColor } from "../../ink-color";
import { all, done, type MatchSteps, runSync } from "../../match-steps";
import type { ScoreboardResources } from "../scoreboard/index";
import { type ParsedName, parseName } from "../scoreboard/names";
import { type ParsedName, parseNameSteps } from "../scoreboard/names";
import {
disambiguateWeaponBySub,
matchSpecial,
matchSpecialSteps,
tiedWeaponsWithDistinctSubs,
} from "../scoreboard/specials";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import { matchWeaponSteps, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
badgeRoi,
@@ -199,8 +203,6 @@ function saturatedFraction(hsv: Mat, roi: Roi): number {
export function createMinimapDetector(
resources: ScoreboardResources,
): Detector<MinimapData> {
const cv = getCV();
const nameGlyphs: GlyphSet | null = resources.nameGlyphs
? scaleGlyphSet(
resources.nameGlyphs,
@@ -281,11 +283,9 @@ export function createMinimapDetector(
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
const overlay = overlayGate(gray);
const spectator = spectatorGate(gray);
gray.delete();
return {
pass: overlay.pass || spectator.pass,
score: Math.max(overlay.score, spectator.score),
@@ -293,18 +293,29 @@ export function createMinimapDetector(
};
}
function matchBadges(
/** Badge matches in `centers` order (null when no badge templates), read in one lockstep. */
function* matchBadgesSteps(
rgb: Mat,
centers: readonly (readonly [number, number])[],
inkThreshold: number,
): MatchSteps<WeaponMatch[] | null> {
if (!badges) return null;
const crops = centers.map(([cx, cy]) => cropRoi(rgb, badgeRoi(cx, cy)));
const matches = yield* all(
crops.map((crop) => matchWeaponSteps(crop, badges, { inkThreshold })),
);
for (const crop of crops) crop.delete();
return matches;
}
/** Badge matches as abilities, their confidences and debug appended in order. */
function badgeAbilities(
matches: WeaponMatch[] | null,
confidences: number[],
debugRow: (WeaponMatch | null)[],
): (AbilityWithUnknown | null)[] {
if (!badges) return [null, null, null];
return centers.map(([cx, cy]) => {
const crop = cropRoi(rgb, badgeRoi(cx, cy));
const match = matchWeapon(crop, badges, { inkThreshold });
crop.delete();
if (!matches) return [null, null, null];
return matches.map((match) => {
debugRow.push(match);
confidences.push(Math.max(0, match.score));
return match.score >= ABILITY_MIN_SCORE
@@ -316,13 +327,17 @@ export function createMinimapDetector(
/**
* Weapon match against the composite set for the surface behind it; on a
* bright-bleed surface (WEAPON_BLEED_MIN_CORNER_MEAN) both sets, better wins.
* Near-tied icons whose kits differ by sub (plain vs Custom Dualie Squelchers)
* are then re-decided by the sub tile (`tile`); shape-only matching survives
* tint, camo and cross-out.
*/
function matchSurfaceWeapon(
function* matchSurfaceWeaponSteps(
rgb: Mat,
roi: Roi,
tile: Roi,
lightSurface: boolean,
cornerMin: number,
): WeaponMatch | null {
): MatchSteps<WeaponMatch | null> {
const darkThreshold = Math.max(
MINIMAP_WEAPON_INK_THRESHOLD,
Math.round(cornerMin) + 50,
@@ -331,23 +346,32 @@ export function createMinimapDetector(
let match: WeaponMatch | null = null;
if (lightSurface) {
match = lightWeapons
? matchWeapon(crop, lightWeapons, {
? yield* matchWeaponSteps(crop, lightWeapons, {
inkThreshold: SPECIAL_READY_INK_THRESHOLD,
})
: null;
} else {
match = cardWeapons
? matchWeapon(crop, cardWeapons, { inkThreshold: darkThreshold })
: null;
if (lightWeapons && cornerMin >= WEAPON_BLEED_MIN_CORNER_MEAN) {
const bleed = matchWeapon(crop, lightWeapons, {
inkThreshold: SPECIAL_READY_INK_THRESHOLD,
});
if (match === null || bleed.score > match.score) match = bleed;
}
const [card, bleed] = yield* all([
cardWeapons
? matchWeaponSteps(crop, cardWeapons, { inkThreshold: darkThreshold })
: done(null),
lightWeapons && cornerMin >= WEAPON_BLEED_MIN_CORNER_MEAN
? matchWeaponSteps(crop, lightWeapons, {
inkThreshold: SPECIAL_READY_INK_THRESHOLD,
})
: done(null),
]);
match = card;
if (bleed && (match === null || bleed.score > match.score)) match = bleed;
}
crop.delete();
return match;
if (!match || !subWeapons?.length || !tiedWeaponsWithDistinctSubs(match)) {
return match;
}
const tileCrop = cropRoi(rgb, tile);
const sub = yield* matchSpecialSteps(tileCrop, subWeapons);
tileCrop.delete();
return disambiguateWeaponBySub(match, sub);
}
/** The score floor for the surface the weapon was matched over. */
@@ -357,45 +381,57 @@ export function createMinimapDetector(
: WEAPON_MIN_SCORE;
}
/**
* Near-tied icons whose kits differ by sub (plain vs Custom Dualie Squelchers):
* the sub tile breaks the tie; shape-only matching survives tint, camo and cross-out.
*/
function resolveTieBySubTile(
rgb: Mat,
weapon: WeaponMatch,
tile: Roi,
): WeaponMatch {
if (!subWeapons?.length || !tiedWeaponsWithDistinctSubs(weapon))
return weapon;
const crop = cropRoi(rgb, tile);
const sub = matchSpecial(crop, subWeapons);
crop.delete();
return disambiguateWeaponBySub(weapon, sub);
function* readCardName(
gray: Mat,
roi: Roi,
glyphs: GlyphSet,
speculative: boolean,
): MatchSteps<ParsedName> {
const band = copyRoi(gray, roi);
const parsed = yield* parseNameSteps(
band,
glyphs,
{ binThreshold: NAME_BIN_THRESHOLD },
speculative,
);
band.delete();
return parsed;
}
/** Try the name band at each spectator glyph height; best read wins. */
function bestNameRead(gray: Mat, roi: Roi): ParsedName | null {
/** Try the name band at each spectator glyph height (in lockstep); best read wins. */
function* bestNameRead(
gray: Mat,
roi: Roi,
speculative: boolean,
): MatchSteps<ParsedName | null> {
const band = copyRoi(gray, roi);
const reads = yield* all(
spectatorNameGlyphs.map((set) =>
parseNameSteps(
band,
set,
{ binThreshold: NAME_BIN_THRESHOLD },
speculative,
),
),
);
band.delete();
let best: ParsedName | null = null;
for (const set of spectatorNameGlyphs) {
const band = copyRoi(gray, roi);
const parsed = parseName(band, set, { binThreshold: NAME_BIN_THRESHOLD });
band.delete();
for (const parsed of reads) {
if (!best || parsed.confidence > best.confidence) best = parsed;
}
return best;
}
/** The spectator 8-card grid has its own ROIs (the overlay parse reads phantom cards on it). */
function parseSpectator(
/** The spectator 8-card grid has its own ROIs (the overlay parse reads phantom cards on it); every card reads in one lockstep. */
function* parseSpectatorSteps(
frame: Mat,
gray: Mat,
t: number,
): DetectedEvent<MinimapData>[] {
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
speculative: boolean,
): MatchSteps<DetectedEvent<MinimapData>[]> {
const rgb = frameRgb(frame);
const hsv = frameHsv(frame);
const lap = laplacianAbs(gray);
const confidences: number[] = [];
@@ -405,87 +441,117 @@ export function createMinimapDetector(
const enemies: MinimapEnemy[] = [];
const sideSubTiles: [Roi[], Roi[]] = [[], []];
const cardDebug: Record<string, unknown>[] = [];
for (const dx of [0, SPECTATOR_ENEMY_DX]) {
const isTeammate = dx === 0;
for (let row = 0; row < 4; row++) {
const cards = [0, SPECTATOR_ENEMY_DX].flatMap((dx) =>
[0, 1, 2, 3].map((row) => {
const layout = spectatorCardLayout(row, dx);
const presence = meanBrightness(lap, layout.name);
if (presence < PRESENCE_MIN_LAPLACIAN) {
cardDebug.push({ dx, row, presence, skipped: true });
continue;
return { dx, row, layout, presence, probes: null };
}
sideSubTiles[isTeammate ? 0 : 1].push(layout.subTile);
const crossFraction = saturatedFraction(hsv, layout.cross);
const crossLap = meanBrightness(lap, layout.cross);
const occluded =
crossFraction >= CROSS_MIN_FRACTION &&
crossLap >= CROSS_MIN_LAPLACIAN;
const corner = minTopCorner(gray, hsv, layout.weapon);
const cornerMin = corner.mean;
const lightSurface =
corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN &&
corner.saturation <= SPECIAL_READY_MAX_CORNER_SATURATION;
let name: string | null = null;
let nameRaw = "";
let weapon: WeaponMatch | null = null;
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (AbilityWithUnknown | null)[] = [];
// the spectator cross-out sits clear of the weapon ROI, so it stays readable when struck
weapon = matchSurfaceWeapon(
rgb,
layout.weapon,
lightSurface,
cornerMin,
);
if (weapon) {
weapon = resolveTieBySubTile(rgb, weapon, layout.subTile);
confidences.push(Math.max(0, weapon.score));
}
if (!occluded) {
const parsed = bestNameRead(gray, layout.name);
if (parsed) {
nameRaw = parsed.raw.text;
if (parsed.name.length > 0) name = parsed.name;
confidences.push(parsed.confidence);
}
abilities = matchBadges(
rgb,
layout.badges,
Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50),
confidences,
badgeDebug,
);
}
cardDebug.push({
return {
dx,
row,
layout,
presence,
crossFraction,
crossLap,
occluded,
cornerMin,
lightSurface,
nameRaw,
weapon,
badges: badgeDebug,
});
const floor = weaponScoreFloor(lightSurface, cornerMin);
const matched =
weapon !== null && weapon.score >= floor ? weapon : null;
const fields = {
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
probes: {
crossFraction,
crossLap,
occluded,
cornerMin: corner.mean,
lightSurface,
},
};
if (isTeammate) {
teammates.push({ self: false, ...fields });
} else {
enemies.push(fields);
}),
);
const reads = yield* all(
cards.map(({ layout, probes }) =>
all([
// the spectator cross-out sits clear of the weapon ROI, so it stays readable when struck
probes
? matchSurfaceWeaponSteps(
rgb,
layout.weapon,
layout.subTile,
probes.lightSurface,
probes.cornerMin,
)
: done(null),
probes && !probes.occluded
? bestNameRead(gray, layout.name, speculative)
: done(null),
probes && !probes.occluded
? matchBadgesSteps(
rgb,
layout.badges,
Math.max(
MINIMAP_ABILITY_INK_THRESHOLD,
Math.round(probes.cornerMin) + 50,
),
)
: done(null),
]),
),
);
for (const [i, { dx, row, layout, presence, probes }] of cards.entries()) {
if (!probes) {
cardDebug.push({ dx, row, presence, skipped: true });
continue;
}
const isTeammate = dx === 0;
sideSubTiles[isTeammate ? 0 : 1].push(layout.subTile);
const { crossFraction, crossLap, occluded, cornerMin, lightSurface } =
probes;
const [weapon, parsed, badgeMatches] = reads[i]!;
let name: string | null = null;
let nameRaw = "";
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (AbilityWithUnknown | null)[] = [];
if (weapon) confidences.push(Math.max(0, weapon.score));
if (!occluded) {
if (parsed) {
nameRaw = parsed.raw.text;
if (parsed.name.length > 0) name = parsed.name;
confidences.push(parsed.confidence);
}
abilities = badgeAbilities(badgeMatches, confidences, badgeDebug);
}
cardDebug.push({
dx,
row,
presence,
crossFraction,
crossLap,
occluded,
cornerMin,
lightSurface,
nameRaw,
weapon,
badges: badgeDebug,
});
const floor = weaponScoreFloor(lightSurface, cornerMin);
const matched = weapon !== null && weapon.score >= floor ? weapon : null;
const fields = {
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
};
if (isTeammate) {
teammates.push({ self: false, ...fields });
} else {
enemies.push(fields);
}
}
debug.cards = cardDebug;
@@ -498,8 +564,6 @@ export function createMinimapDetector(
const stageMatch = detectStage(frame, confidences);
debug.stage = stageMatch;
rgb.delete();
hsv.delete();
lap.delete();
const confidence =
@@ -524,91 +588,157 @@ export function createMinimapDetector(
];
}
function parse(
function* parseSteps(
frame: Mat,
t: number,
gateResult?: GateResult,
): DetectedEvent<MinimapData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
gateResult: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<MinimapData>[]> {
const gray = frameGray(frame);
const isSpectator = gateResult?.variant
? gateResult.variant === "spectator"
: spectatorGate(gray).pass;
if (isSpectator) {
const events = parseSpectator(frame, gray, t);
gray.delete();
const events = yield* parseSpectatorSteps(frame, gray, t, speculative);
return events;
}
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
const rgb = frameRgb(frame);
const hsv = frameHsv(frame);
const lap = laplacianAbs(gray);
const confidences: number[] = [];
const debug: Record<string, unknown> = {};
// 1. own-team callout cards
const teammates: MinimapTeammate[] = [];
const sideSubTiles: [Roi[], Roi[]] = [[], []];
const cardDebug: Record<string, unknown>[] = [];
for (const layout of CARD_LAYOUTS) {
// 1. own-team callout cards and 2. enemy panel rows, probed first, then
// every field of every card in one lockstep
const cards = CARD_LAYOUTS.map((layout) => {
// presence: the card is crisp UI, an absent card shows blurred scene
const presence = meanBrightness(lap, layout.name);
if (presence < PRESENCE_MIN_LAPLACIAN) {
cardDebug.push({ self: layout.self, presence, skipped: true });
continue;
return { layout, presence, probes: null };
}
const crossFraction = saturatedFraction(hsv, layout.cross);
const crossLap = meanBrightness(lap, layout.cross);
const occluded =
crossFraction >= CROSS_MIN_FRACTION && crossLap >= CROSS_MIN_LAPLACIAN;
const corner = minTopCorner(gray, hsv, layout.weapon);
const cornerMin = corner.mean;
const lightSurface =
corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN &&
corner.saturation <= SPECIAL_READY_MAX_CORNER_SATURATION;
return {
layout,
presence,
probes: { crossFraction, crossLap, occluded, corner, lightSurface },
};
});
const rows = ENEMY_ROW_CYS.map((cy) => {
const weaponRoi = enemyWeaponRoi(cy);
const presence = meanBrightness(lap, weaponRoi);
if (presence < PRESENCE_MIN_LAPLACIAN) {
return { cy, weaponRoi, presence, probes: null };
}
const crossFraction = saturatedFraction(hsv, enemyCrossRoi(cy));
const crossLap = meanBrightness(lap, enemyCrossRoi(cy));
const occluded =
crossFraction >= CROSS_MIN_FRACTION && crossLap >= CROSS_MIN_LAPLACIAN;
// light camo rows: pick template variant by corner brightness, raise ink threshold past it
const corner = minTopCorner(gray, hsv, weaponRoi);
const lightSurface =
corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN &&
corner.saturation <= SPECIAL_READY_MAX_CORNER_SATURATION;
return {
cy,
weaponRoi,
presence,
probes: { crossFraction, crossLap, occluded, corner, lightSurface },
};
});
const [cardReads, rowReads] = yield* all([
all(
cards.map(({ layout, probes }) =>
probes && !probes.occluded
? all([
nameGlyphs
? readCardName(gray, layout.name, nameGlyphs, speculative)
: done(null),
matchSurfaceWeaponSteps(
rgb,
layout.weapon,
layout.subTile,
probes.lightSurface,
probes.corner.mean,
),
matchBadgesSteps(
rgb,
layout.badges,
probes.lightSurface
? Math.max(
MINIMAP_ABILITY_INK_THRESHOLD,
Math.round(probes.corner.mean) + 50,
)
: MINIMAP_ABILITY_INK_THRESHOLD,
),
])
: done(null),
),
),
all(
rows.map(({ cy, weaponRoi, probes }) =>
probes
? all([
matchSurfaceWeaponSteps(
rgb,
weaponRoi,
enemySubTileRoi(cy),
probes.lightSurface,
probes.corner.mean,
),
probes.occluded
? done(null)
: matchBadgesSteps(
rgb,
ENEMY_BADGE_XS.map((cx) => [cx, cy] as const),
Math.max(
MINIMAP_ABILITY_INK_THRESHOLD,
Math.round(probes.corner.mean) + 50,
),
),
])
: done(null),
),
),
]);
const teammates: MinimapTeammate[] = [];
const sideSubTiles: [Roi[], Roi[]] = [[], []];
const cardDebug: Record<string, unknown>[] = [];
for (const [i, { layout, presence, probes }] of cards.entries()) {
if (!probes) {
cardDebug.push({ self: layout.self, presence, skipped: true });
continue;
}
const { crossFraction, crossLap, occluded, corner, lightSurface } =
probes;
const cornerMin = corner.mean;
let name: string | null = null;
let nameRaw = "";
let weapon: WeaponMatch | null = null;
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (AbilityWithUnknown | null)[] = [];
if (!occluded) {
if (nameGlyphs) {
const band = copyRoi(gray, layout.name);
const parsed = parseName(band, nameGlyphs, {
binThreshold: NAME_BIN_THRESHOLD,
});
band.delete();
const read = cardReads[i];
if (read) {
const [parsed, matchedWeapon, badgeMatches] = read;
if (parsed) {
nameRaw = parsed.raw.text;
if (parsed.name.length > 0) name = parsed.name;
confidences.push(parsed.confidence);
}
weapon = matchSurfaceWeapon(
rgb,
layout.weapon,
lightSurface,
cornerMin,
);
if (weapon) {
weapon = resolveTieBySubTile(rgb, weapon, layout.subTile);
confidences.push(Math.max(0, weapon.score));
}
abilities = matchBadges(
rgb,
layout.badges,
lightSurface
? Math.max(
MINIMAP_ABILITY_INK_THRESHOLD,
Math.round(cornerMin) + 50,
)
: MINIMAP_ABILITY_INK_THRESHOLD,
confidences,
badgeDebug,
);
weapon = matchedWeapon;
if (weapon) confidences.push(Math.max(0, weapon.score));
abilities = badgeAbilities(badgeMatches, confidences, badgeDebug);
}
cardDebug.push({
self: layout.self,
@@ -645,42 +775,23 @@ export function createMinimapDetector(
}
debug.cards = cardDebug;
// 2. enemy panel rows
const enemies: MinimapEnemy[] = [];
const enemyDebug: Record<string, unknown>[] = [];
for (const cy of ENEMY_ROW_CYS) {
const weaponRoi = enemyWeaponRoi(cy);
const presence = meanBrightness(lap, weaponRoi);
if (presence < PRESENCE_MIN_LAPLACIAN) {
for (const [i, { cy, presence, probes }] of rows.entries()) {
const read = rowReads[i];
if (!probes || !read) {
enemyDebug.push({ cy, presence, skipped: true });
continue;
}
const crossFraction = saturatedFraction(hsv, enemyCrossRoi(cy));
const crossLap = meanBrightness(lap, enemyCrossRoi(cy));
const occluded =
crossFraction >= CROSS_MIN_FRACTION && crossLap >= CROSS_MIN_LAPLACIAN;
// light camo rows: pick template variant by corner brightness, raise ink threshold past it
const corner = minTopCorner(gray, hsv, weaponRoi);
const { crossFraction, crossLap, occluded, corner, lightSurface } =
probes;
const cornerMin = corner.mean;
const lightSurface =
corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN &&
corner.saturation <= SPECIAL_READY_MAX_CORNER_SATURATION;
let weapon = matchSurfaceWeapon(rgb, weaponRoi, lightSurface, cornerMin);
if (weapon) {
weapon = resolveTieBySubTile(rgb, weapon, enemySubTileRoi(cy));
confidences.push(Math.max(0, weapon.score));
}
const [weapon, badgeMatches] = read;
if (weapon) confidences.push(Math.max(0, weapon.score));
const badgeDebug: (WeaponMatch | null)[] = [];
const abilities: (AbilityWithUnknown | null)[] = occluded
? []
: matchBadges(
rgb,
ENEMY_BADGE_XS.map((cx) => [cx, cy] as const),
Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50),
confidences,
badgeDebug,
);
: badgeAbilities(badgeMatches, confidences, badgeDebug);
enemyDebug.push({
cy,
presence,
@@ -715,9 +826,6 @@ export function createMinimapDetector(
const stageMatch = detectStage(frame, confidences);
debug.stage = stageMatch;
gray.delete();
rgb.delete();
hsv.delete();
lap.delete();
const confidence =
@@ -754,6 +862,8 @@ export function createMinimapDetector(
sufficientConfidence: 0.69,
rearmCooldownS: 5,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}

View File

@@ -10,6 +10,7 @@
import type { StageId } from "~/modules/in-game-lists/types";
import { getCV, type Mat } from "../../cv";
import type { FrameData } from "../../image";
import { frameGray, frameHsv } from "../../image";
/** Downscaled signature dimensions (canonical 1920x1080 / 16). */
export const PLANNER_SIG_W = 120;
@@ -47,17 +48,11 @@ export interface StageMatch {
*/
export function plannerSignature(frame: Mat): Float32Array {
const cv = getCV();
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
rgb.delete();
const gray = frameGray(frame);
const hsv = frameHsv(frame);
const lap = new cv.Mat();
cv.Laplacian(gray, lap, cv.CV_16S, 3);
gray.delete();
const edges = new cv.Mat();
cv.convertScaleAbs(lap, edges);
lap.delete();
@@ -72,7 +67,6 @@ export function plannerSignature(frame: Mat): Float32Array {
for (let i = 0; i < n; i++) {
if (hd[i * 3 + 1]! >= INK_SATURATION_MIN) md[i] = 0;
}
hsv.delete();
const down = new cv.Mat();
cv.resize(
@@ -109,16 +103,14 @@ function shiftedDot(
dy: number,
): number {
let dot = 0;
for (let y = 0; y < PLANNER_SIG_H; y++) {
const sy = y + dy;
if (sy < 0 || sy >= PLANNER_SIG_H) continue;
const y0 = Math.max(0, -dy);
const y1 = Math.min(PLANNER_SIG_H, PLANNER_SIG_H - dy);
const x0 = Math.max(0, -dx);
const x1 = Math.min(PLANNER_SIG_W, PLANNER_SIG_W - dx);
for (let y = y0; y < y1; y++) {
const ar = y * PLANNER_SIG_W;
const br = sy * PLANNER_SIG_W;
for (let x = 0; x < PLANNER_SIG_W; x++) {
const sx = x + dx;
if (sx < 0 || sx >= PLANNER_SIG_W) continue;
dot += a[ar + x]! * b[br + sx]!;
}
const br = (y + dy) * PLANNER_SIG_W + dx;
for (let x = x0; x < x1; x++) dot += a[ar + x]! * b[br + x]!;
}
return dot;
}

View File

@@ -9,10 +9,17 @@
* fixtures. Each read also emits a PlayerStatus event (player-status.ts) off
* the same frame, paired downstream by the shared timer value.
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import { copyRoi, maxChannel, minChannel, type Roi } from "../../image";
import { type Mat, minMaxLoc } from "../../cv";
import { type GlyphSet, recognizeTextSteps, scaleGlyphSet } from "../../glyphs";
import {
copyRoi,
frameGray,
maxChannel,
minChannel,
type Roi,
} from "../../image";
import { type InkRgb, meanInkColor } from "../../ink-color";
import { all, type MatchSteps, runSync } from "../../match-steps";
import {
type BannerScoreRead,
isBetterRead,
@@ -44,8 +51,8 @@ import {
STATUS_LAYOUT_STICKY_MAX_GAP_S,
STRIP_WEAPON_SAMPLE_INTERVAL,
} from "./rois";
import { parseStripWeapons, type StripWeaponsData } from "./strip-weapons";
import { readMatchTimer, timerBoxChecks, timerGlyphSets } from "./timer";
import { parseStripWeaponsSteps, type StripWeaponsData } from "./strip-weapons";
import { readMatchTimerSteps, timerBoxChecks, timerGlyphSets } from "./timer";
export type ObjectiveData = SplatZonesObjectiveData;
@@ -102,7 +109,6 @@ interface SideRead {
export function createObjectiveDetector(
resources: ScoreboardResources,
): Detector<ObjectiveData | PlayerStatusData | StripWeaponsData> {
const cv = getCV();
let lastStatus: { layout: PlayerStatusLayout; t: number } | undefined;
// primed so the very first read samples (short matches, single-frame fixtures)
let readsSinceWeaponSample = STRIP_WEAPON_SAMPLE_INTERVAL;
@@ -148,8 +154,7 @@ export function createObjectiveDetector(
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
const checks = [
...timerBoxChecks(gray),
plateProbeOk(gray, PLATE_PROBE_ROIS[0]),
@@ -157,13 +162,17 @@ export function createObjectiveDetector(
scoreInkOk(frame, SCORE_ROIS[0]),
scoreInkOk(frame, SCORE_ROIS[1]),
];
gray.delete();
const passed = checks.filter(Boolean).length;
return { pass: passed === checks.length, score: passed / checks.length };
}
/** Best trailing-digit read across channel extractions, thresholds and glyph sizes. */
function readScore(frame: Mat, gray: Mat, roi: Roi): BannerScoreRead {
/** Best trailing-digit read across channel extractions, thresholds and glyph sizes; every combination reads in one lockstep. */
function* readScore(
frame: Mat,
gray: Mat,
roi: Roi,
speculative: boolean,
): MatchSteps<BannerScoreRead> {
let best: BannerScoreRead = {
value: null,
confidence: 0,
@@ -175,25 +184,39 @@ export function createObjectiveDetector(
minChannel(frame, roi),
maxChannel(frame, roi),
];
for (const band of bands) {
for (const set of scoreSets) {
for (const binThreshold of SCORE_BIN_THRESHOLDS) {
const raw = recognizeText(band, set, {
const reads = bands.flatMap((band) =>
scoreSets.flatMap((set) =>
SCORE_BIN_THRESHOLDS.map((binThreshold) => ({
band,
set,
binThreshold,
})),
),
);
const raws = yield* all(
reads.map(({ band, set, binThreshold }) =>
recognizeTextSteps(
band,
set,
{
binThreshold,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
// the band holds only the count, so a leading digit under the
// extension floor voids the read instead of truncating it
const read = trailingDigitRun(raw, set, {
extendMinScore: SCORE_EXTEND_MIN_CONF,
rejectTruncated: true,
});
if (isBetterRead(read, best)) best = read;
}
}
band.delete();
},
speculative,
),
),
);
for (const [i, { set }] of reads.entries()) {
// the band holds only the count, so a leading digit under the
// extension floor voids the read instead of truncating it
const read = trailingDigitRun(raws[i]!, set, {
extendMinScore: SCORE_EXTEND_MIN_CONF,
rejectTruncated: true,
});
if (isBetterRead(read, best)) best = read;
}
for (const band of bands) band.delete();
return best;
}
@@ -202,11 +225,12 @@ export function createObjectiveDetector(
* "+N" digits. A nameplate badge can cover one end, so a lone pill-like
* probe still reads but the digits must be confident on their own.
*/
function readPenalty(
function* readPenalty(
frame: Mat,
gray: Mat,
side: 0 | 1,
): BannerScoreRead | null {
speculative: boolean,
): MatchSteps<BannerScoreRead | null> {
if (!penaltySet) return null;
const pillLikeProbes = PENALTY_PROBE_ROIS[side].filter((roi) => {
const { mean, std } = meanStd(gray, roi);
@@ -214,11 +238,16 @@ export function createObjectiveDetector(
}).length;
if (pillLikeProbes === 0) return null;
const band = minChannel(frame, PENALTY_ROIS[side]);
const raw = recognizeText(band, penaltySet, {
binThreshold: PENALTY_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const raw = yield* recognizeTextSteps(
band,
penaltySet,
{
binThreshold: PENALTY_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
},
speculative,
);
band.delete();
const read = trailingDigitRun(raw, penaltySet);
if (
@@ -253,16 +282,29 @@ export function createObjectiveDetector(
return { mean: sum / count, saturation: satSum / count };
}
function parse(
function* parseSteps(
frame: Mat,
t: number,
): DetectedEvent<ObjectiveData | PlayerStatusData | StripWeaponsData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
_gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<
DetectedEvent<ObjectiveData | PlayerStatusData | StripWeaponsData>[]
> {
const gray = frameGray(frame);
const [scoreL, scoreR, penaltyL, penaltyR, timer] = yield* all([
readScore(frame, gray, SCORE_ROIS[0], speculative),
readScore(frame, gray, SCORE_ROIS[1], speculative),
readPenalty(frame, gray, 0, speculative),
readPenalty(frame, gray, 1, speculative),
readMatchTimerSteps(gray, timerSets, speculative),
]);
const reads = [
{ score: scoreL, penalty: penaltyL },
{ score: scoreR, penalty: penaltyR },
];
const sides = [0 as const, 1 as const].map((side): SideRead => {
const score = readScore(frame, gray, SCORE_ROIS[side]);
const penalty = readPenalty(frame, gray, side);
const { score, penalty } = reads[side]!;
const fill = plateFill(frame, side);
return {
score,
@@ -278,8 +320,6 @@ export function createObjectiveDetector(
]),
};
}) as [SideRead, SideRead];
const timer = readMatchTimer(gray, timerSets);
gray.delete();
// no readable count on either side = the gate hit a lookalike
if (sides.every((side) => side.score.value === null)) return [];
@@ -303,7 +343,7 @@ export function createObjectiveDetector(
readsSinceWeaponSample >= STRIP_WEAPON_SAMPLE_INTERVAL
) {
readsSinceWeaponSample = 0;
stripWeapons = parseStripWeapons(
stripWeapons = yield* parseStripWeaponsSteps(
frame,
t,
playerStatus.data,
@@ -351,6 +391,8 @@ export function createObjectiveDetector(
checkIntervalS: CHECK_INTERVAL_SECONDS,
attachFrame: false,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}

View File

@@ -13,7 +13,8 @@ import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { getCV, type Mat } from "../../cv";
import { copyRoi } from "../../image";
import { hueDistance, hueOf } from "../../ink-color";
import { matchWeapon, type WeaponTemplate } from "../scoreboard/weapons";
import { all, type MatchSteps } from "../../match-steps";
import { matchWeaponSteps, type WeaponTemplate } from "../scoreboard/weapons";
import type { DetectedEvent } from "../types";
import type { PlayerStatusData, PlayerStatusLayout } from "./player-status";
import {
@@ -45,19 +46,28 @@ export interface StripWeaponsData {
slots: [(StripWeaponCandidate[] | null)[], (StripWeaponCandidate[] | null)[]];
}
/** Match every alive slot's icon; `status` (same frame) supplies slot centers and dead flags. */
export function parseStripWeapons(
/** Match every alive slot's icon (all slots in one lockstep); `status` (same frame) supplies slot centers and dead flags. */
export function* parseStripWeaponsSteps(
frame: Mat,
t: number,
status: PlayerStatusData,
templates: WeaponTemplate[],
): DetectedEvent<StripWeaponsData> {
): MatchSteps<DetectedEvent<StripWeaponsData>> {
const centers = slotCenters(status.layout);
const alive = centers.flatMap((sideCenters, side) =>
sideCenters.flatMap((cx, slot) =>
status.dead[side as 0 | 1][slot] ? [] : [{ side, slot, cx }],
),
);
const matched = yield* all(
alive.map(({ cx }) => matchSlot(frame, cx, templates)),
);
const scores: number[] = [];
const slots = centers.map((sideCenters, side) =>
sideCenters.map((cx, slot): StripWeaponCandidate[] | null => {
if (status.dead[side as 0 | 1][slot]) return null;
const candidates = matchSlot(frame, cx, templates);
sideCenters.map((_, slot): StripWeaponCandidate[] | null => {
const index = alive.findIndex((a) => a.side === side && a.slot === slot);
if (index === -1) return null;
const candidates = matched[index]!;
if (candidates.length > 0) scores.push(candidates[0]!.score);
return candidates;
}),
@@ -87,11 +97,11 @@ function slotCenters(
: STATUS_SLOT_CENTERS_NARROW_LEFT;
}
function matchSlot(
function* matchSlot(
frame: Mat,
cx: number,
templates: WeaponTemplate[],
): StripWeaponCandidate[] {
): MatchSteps<StripWeaponCandidate[]> {
const cv = getCV();
const crop = copyRoi(frame, {
x: cx + STRIP_WEAPON_BOX.dx,
@@ -103,7 +113,7 @@ function matchSlot(
cv.cvtColor(crop, search, cv.COLOR_RGBA2RGB);
crop.delete();
knockoutPlate(search);
const match = matchWeapon(search, templates, {
const match = yield* matchWeaponSteps(search, templates, {
inkThreshold: STRIP_WEAPON_INK_THRESHOLD,
topN: STRIP_WEAPON_TOP_K,
});

View File

@@ -9,10 +9,11 @@ import type { Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
recognizeText,
recognizeTextSteps,
scaleGlyphSet,
} from "../../glyphs";
import { copyRoi, maxBrightness, meanBrightness } from "../../image";
import { all, type MatchSteps } from "../../match-steps";
import type { ScoreboardResources } from "../scoreboard/index";
import {
GATE_TIMER_MAX_MEAN,
@@ -51,22 +52,34 @@ export function timerBoxChecks(gray: Mat): boolean[] {
];
}
export function readMatchTimer(
/** The match timer at every glyph size (read in one lockstep), best read kept. */
export function* readMatchTimerSteps(
gray: Mat,
timerSets: readonly GlyphSet[],
): TimerRead {
speculative = false,
): MatchSteps<TimerRead> {
const band = copyRoi(gray, TIMER_DIGIT_ROI);
let best: TimerRead & { score: number } = {
value: null,
reading: "",
score: 0,
};
for (const timerSet of timerSets) {
const raw = recognizeText(band, timerSet, {
binThreshold: TIMER_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const raws = yield* all(
timerSets.map((timerSet) =>
recognizeTextSteps(
band,
timerSet,
{
binThreshold: TIMER_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
},
speculative,
),
),
);
for (const [setIndex, timerSet] of timerSets.entries()) {
const raw = raws[setIndex]!;
if (!best.reading) best = { ...best, reading: raw.text };
const isTimerDigit = (c: RecognizedChar) =>
c.score >= TIMER_DIGIT_MIN_CONF &&

View File

@@ -6,9 +6,10 @@ import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
recognizeTextSteps,
} from "../../glyphs";
import { cropRoi } from "../../image";
import type { MatchSteps } from "../../match-steps";
import { REPLAY_CODE_ROI } from "./rois";
/**
@@ -81,7 +82,7 @@ const CODE_RE = /^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$/;
/** Glyph set restricted to code characters; a shallow view, so dispose only the source set. */
export function codeCharsetOf(set: GlyphSet): GlyphSet {
const glyphs = set.glyphs.filter((g) => /^[0-9A-Z-]$/.test(g.char));
const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b);
const widths = glyphs.map((g) => g.cols).sort((a, b) => a - b);
return {
glyphs,
height: set.height,
@@ -90,7 +91,11 @@ export function codeCharsetOf(set: GlyphSet): GlyphSet {
}
/** rgb: full normalized frame in RGB (not RGBA). */
export function parseReplayCode(rgb: Mat, glyphs: GlyphSet): ParsedReplayCode {
export function* parseReplayCodeSteps(
rgb: Mat,
glyphs: GlyphSet,
speculative = false,
): MatchSteps<ParsedReplayCode> {
const cv = getCV();
const view = cropRoi(rgb, REPLAY_CODE_ROI);
const channels = new cv.MatVector();
@@ -102,10 +107,15 @@ export function parseReplayCode(rgb: Mat, glyphs: GlyphSet): ParsedReplayCode {
channels.delete();
view.delete();
const raw = recognizeText(green, glyphs, {
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const raw = yield* recognizeTextSteps(
green,
glyphs,
{
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
},
speculative,
);
const resolved = resolveUsByTopRightInk(raw, green);
green.delete();

View File

@@ -9,8 +9,9 @@ import type { Roi } from "../../canonical";
import type { Mat } from "../../cv";
import type { GlyphSet } from "../../glyphs";
import { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
import { all, type MatchSteps } from "../../match-steps";
import { closestBy } from "../../text";
import { readTagBand } from "../scoreboard/header";
import { readTagBandSteps } from "../scoreboard/header";
import { HEADER_BOTTOM_BAND, HEADER_TOP_BAND } from "./rois";
export interface ParsedReplayHeader {
@@ -114,44 +115,69 @@ const REPLAY_BANDS: ReplayHeaderBands = {
bottom: HEADER_BOTTOM_BAND,
};
export function parseReplayHeader(
/** The top and bottom bands read in one lockstep; each band's lifted-ceiling retry follows its own first read. */
export function* parseReplayHeaderSteps(
gray: Mat,
topGlyphs: GlyphSet,
bottomGlyphs: GlyphSet,
bands: ReplayHeaderBands = REPLAY_BANDS,
): ParsedReplayHeader {
speculative = false,
): MatchSteps<ParsedReplayHeader> {
const leadIn = {
tagLeadInMax: bands.tagLeadInMax,
tagColumnFraction: bands.tagColumnFraction,
};
let top = parseTopBand(readTagBand(gray, bands.top, topGlyphs, leadIn));
if (top.stage === null) {
const retry = parseTopBand(
readTagBand(gray, bands.top, topGlyphs, {
...leadIn,
tagDarkMax: TAG_DARK_MAX_LIFTED,
}),
const readTop = function* (): MatchSteps<ReturnType<typeof parseTopBand>> {
let top = parseTopBand(
yield* readTagBandSteps(gray, bands.top, topGlyphs, leadIn, speculative),
);
if (retry.stageScore >= top.stageScore) top = retry;
}
let bottomReading = readTagBand(gray, bands.bottom, bottomGlyphs, leadIn);
let bottomMatch = bottomReading
? closestBy(bottomReading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if (!bottomMatch || bottomMatch.score < MIN_MATCH_SCORE) {
const reading = readTagBand(gray, bands.bottom, bottomGlyphs, {
...leadIn,
tagDarkMax: TAG_DARK_MAX_LIFTED,
});
const match = reading
? closestBy(reading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if ((match?.score ?? 0) >= (bottomMatch?.score ?? 0)) {
bottomReading = reading;
bottomMatch = match;
if (top.stage === null) {
const retry = parseTopBand(
yield* readTagBandSteps(
gray,
bands.top,
topGlyphs,
{ ...leadIn, tagDarkMax: TAG_DARK_MAX_LIFTED },
speculative,
),
);
if (retry.stageScore >= top.stageScore) top = retry;
}
}
return top;
};
const readBottom = function* () {
let bottomReading = yield* readTagBandSteps(
gray,
bands.bottom,
bottomGlyphs,
leadIn,
speculative,
);
let bottomMatch = bottomReading
? closestBy(bottomReading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if (!bottomMatch || bottomMatch.score < MIN_MATCH_SCORE) {
const reading = yield* readTagBandSteps(
gray,
bands.bottom,
bottomGlyphs,
{ ...leadIn, tagDarkMax: TAG_DARK_MAX_LIFTED },
speculative,
);
const match = reading
? closestBy(reading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if ((match?.score ?? 0) >= (bottomMatch?.score ?? 0)) {
bottomReading = reading;
bottomMatch = match;
}
}
return { bottomReading, bottomMatch };
};
const [top, { bottomReading, bottomMatch }] = yield* all([
readTop(),
readBottom(),
]);
let lobby: ScannerLobby | null = null;
let mode: ModeShort | null = null;

View File

@@ -6,9 +6,11 @@
* scoreboard helpers with glyph sets rescaled to this screen.
*/
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import { type GlyphSet, recognizeTextSteps, scaleGlyphSet } from "../../glyphs";
import {
cropRoi,
frameGray,
frameRgb,
maxBrightness,
maxChannel,
meanBrightness,
@@ -16,13 +18,14 @@ import {
roiSignature,
} from "../../image";
import { RESULT_TAG_ENTRIES } from "../../localized";
import { all, done, type MatchSteps, runSync } from "../../match-steps";
import { closestBy } from "../../text";
import {
FULL_COUNT_TEAM_SCORE,
KO_MATCH_SCORE,
MATCH_SCORE_MIN_CONF,
} from "../scoreboard/banner";
import { type ParsedNumber, parseNumber } from "../scoreboard/digits";
import { type ParsedNumber, parseNumberSteps } from "../scoreboard/digits";
import type {
ScoreboardData,
ScoreboardPlayer,
@@ -30,10 +33,10 @@ import type {
ScoreboardRowDebug,
} from "../scoreboard/index";
import { findPovIndex } from "../scoreboard/pov";
import { parseScoreboardRow, type RowRois } from "../scoreboard/row";
import { parseScoreboardRowSteps, type RowRois } from "../scoreboard/row";
import type { DetectedEvent, Detector, GateResult } from "../types";
import { codeCharsetOf, type ParsedReplayCode, parseReplayCode } from "./code";
import { type ParsedReplayHeader, parseReplayHeader } from "./header";
import { codeCharsetOf, parseReplayCodeSteps } from "./code";
import { parseReplayHeaderSteps } from "./header";
import {
CODE_TEXT_HEIGHT,
GATE_CODE_BLUE_MAX,
@@ -129,8 +132,6 @@ function greenFraction(frame: Mat, roi: Roi): number {
export function createScoreboardBattleLogReplayDetector(
resources: ScoreboardResources,
): Detector<ScoreboardBattleLogReplayData> {
const cv = getCV();
const scaled = (set: GlyphSet | null, height: number): GlyphSet | null =>
set ? scaleGlyphSet(set, height / set.height) : null;
@@ -160,8 +161,7 @@ export function createScoreboardBattleLogReplayDetector(
: null;
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
let flatOk = 0;
let suffixOk = 0;
@@ -194,7 +194,6 @@ export function createScoreboardBattleLogReplayDetector(
// browsing between replays never drops this gate, so fingerprint what
// differs between battles (timestamp, code, names) for the scheduler
const signature = pass ? contentSignature(gray) : undefined;
gray.delete();
return { pass, score, signature };
}
@@ -209,11 +208,13 @@ export function createScoreboardBattleLogReplayDetector(
return signature;
}
function parsePanel(gray: Mat, rgb: Mat, dx: number): PanelParse {
const players: ScoreboardPlayer[] = [];
const rows: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
/** One panel's rows, totals and result tag, all read in one lockstep. */
function* parsePanelSteps(
gray: Mat,
rgb: Mat,
dx: number,
speculative: boolean,
): MatchSteps<PanelParse> {
const rowRois: RowRois = {
weapon: (cy) => weaponRoi(cy, dx),
specialIcon: (cy) => specialIconRoi(cy, dx),
@@ -229,54 +230,81 @@ export function createScoreboardBattleLogReplayDetector(
statDigits,
nameGlyphs,
};
for (const cy of ROW_CENTERS) {
// a short team (7-player private battle) renders no pill for the unused
// bottom row (gate's flatOk >= 7 tolerates it); skip it, no phantom player
// a short team (7-player private battle) renders no pill for the unused
// bottom row (gate's flatOk >= 7 tolerates it); skip it, no phantom player
const rowCenters = ROW_CENTERS.filter((cy) => {
const flat = meanBrightness(rgb, gateFlatProbe(cy, dx));
if (flat < GATE_FLAT_MIN_MEAN || flat > GATE_FLAT_MAX_MEAN) continue;
// paint is left-aligned so the "p" suffix lands inside the ROI on short paints
const row = parseScoreboardRow(
gray,
rgb,
cy,
rowRois,
rowResources,
confidences,
{
weaponInkThreshold: REPLAY_INK_THRESHOLD,
paintDropLoweredTrailing: true,
},
);
players.push(row.player);
rows.push(row.debug);
}
return flat >= GATE_FLAT_MIN_MEAN && flat <= GATE_FLAT_MAX_MEAN;
});
// the point total is read only to recognize a knockout below (only a
// knockout's full count reaches 500); never emitted as a score
let teamScore: ParsedNumber | null = null;
if (teamDigits) {
const crop = cropRoi(gray, teamScoreRoi(dx));
teamScore = parseNumber(crop, teamDigits, {
binThreshold: BANNER_BIN_THRESHOLD,
});
crop.delete();
confidences.push(teamScore.confidence);
}
const teamCrop = teamDigits ? cropRoi(gray, teamScoreRoi(dx)) : null;
const matchCrop = matchScoreDigits
? cropRoi(gray, MATCH_SCORE_ROIS[dx === 0 ? 0 : 1]!)
: null;
const bright = resultGlyphs ? maxChannel(rgb, resultTagRoi(dx)) : null;
const [rowReads, teamScore, matchRead, resultRaw] = yield* all([
all(
rowCenters.map((cy) =>
// paint is left-aligned so the "p" suffix lands inside the ROI on short paints
parseScoreboardRowSteps(
gray,
rgb,
cy,
rowRois,
rowResources,
{
weaponInkThreshold: REPLAY_INK_THRESHOLD,
paintDropLoweredTrailing: true,
},
speculative,
),
),
),
teamDigits && teamCrop
? parseNumberSteps(
teamCrop,
teamDigits,
{ binThreshold: BANNER_BIN_THRESHOLD },
speculative,
)
: done(null),
matchScoreDigits && matchCrop
? parseNumberSteps(
matchCrop,
matchScoreDigits,
{ binThreshold: BANNER_BIN_THRESHOLD },
speculative,
)
: done(null),
resultGlyphs && bright
? recognizeTextSteps(
bright,
resultGlyphs,
{
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
},
speculative,
)
: done(null),
]);
teamCrop?.delete();
matchCrop?.delete();
bright?.delete();
let matchScore: ParsedNumber | null = null;
if (matchScoreDigits) {
const crop = cropRoi(gray, MATCH_SCORE_ROIS[dx === 0 ? 0 : 1]!);
matchScore = parseNumber(crop, matchScoreDigits, {
binThreshold: BANNER_BIN_THRESHOLD,
});
const confidences = rowReads.flatMap((row) => row.confidences);
if (teamScore) confidences.push(teamScore.confidence);
let matchScore: ParsedNumber | null = matchRead;
if (matchScore) {
if (
matchScore.confidence < MATCH_SCORE_MIN_CONF ||
(matchScore.value !== null && matchScore.value > KO_MATCH_SCORE)
) {
matchScore = { ...matchScore, value: null };
}
crop.delete();
confidences.push(matchScore.confidence);
// no number + a full team count = the KNOCKOUT! burst sits where the score
// would be; an unreadable banner on a lesser total stays null
@@ -291,15 +319,8 @@ export function createScoreboardBattleLogReplayDetector(
let result: PanelParse["result"] = null;
let resultReading = "";
let resultScore = 0;
if (resultGlyphs) {
const bright = maxChannel(rgb, resultTagRoi(dx));
const raw = recognizeText(bright, resultGlyphs, {
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
});
bright.delete();
resultReading = raw.text;
if (resultRaw) {
resultReading = resultRaw.text;
if (resultReading) {
const match = closestBy(
resultReading,
@@ -315,8 +336,8 @@ export function createScoreboardBattleLogReplayDetector(
}
return {
players,
rows,
players: rowReads.map((row) => row.player),
rows: rowReads.map((row) => row.debug),
teamScore,
matchScore,
result,
@@ -326,19 +347,31 @@ export function createScoreboardBattleLogReplayDetector(
};
}
function parse(
function* parseSteps(
frame: Mat,
t: number,
): DetectedEvent<ScoreboardBattleLogReplayData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
_gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<ScoreboardBattleLogReplayData>[]> {
const gray = frameGray(frame);
const rgb = frameRgb(frame);
const [left, right] = PANEL_XS.map((dx) => parsePanel(gray, rgb, dx)) as [
PanelParse,
PanelParse,
];
const [left, right, header, code] = yield* all([
parsePanelSteps(gray, rgb, PANEL_XS[0]!, speculative),
parsePanelSteps(gray, rgb, PANEL_XS[1]!, speculative),
headerTopGlyphs && headerBottomGlyphs
? parseReplayHeaderSteps(
gray,
headerTopGlyphs,
headerBottomGlyphs,
undefined,
speculative,
)
: done(null),
codeGlyphs
? parseReplayCodeSteps(rgb, codeGlyphs, speculative)
: done(null),
]);
// winners first: confident VICTORY/DEFEAT tag, else the higher "Score:"
// banner, else left
@@ -357,19 +390,6 @@ export function createScoreboardBattleLogReplayDetector(
[...winner.rows, ...loser.rows].map((r) => r.povFraction),
);
let header: ParsedReplayHeader | null = null;
if (headerTopGlyphs && headerBottomGlyphs) {
header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs);
}
let code: ParsedReplayCode | null = null;
if (codeGlyphs) {
code = parseReplayCode(rgb, codeGlyphs);
}
gray.delete();
rgb.delete();
const confidences = [
...winner.confidences,
...loser.confidences,
@@ -435,6 +455,8 @@ export function createScoreboardBattleLogReplayDetector(
id: "scoreboard-battle-log-replay",
sufficientConfidence: 0.8,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}

View File

@@ -16,9 +16,11 @@ import {
unionRoi,
} from "../../canonical";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import { type GlyphSet, recognizeTextSteps, scaleGlyphSet } from "../../glyphs";
import {
cropRoi,
frameGray,
frameRgb,
maxBrightness,
maxChannel,
meanBrightness,
@@ -26,15 +28,16 @@ import {
warpPerspective,
} from "../../image";
import { RESULT_TAG_ENTRIES } from "../../localized";
import { all, done, type MatchSteps, runSync } from "../../match-steps";
import { homographyFromQuad, type PerspectiveQuad } from "../../rectify";
import { closestBy } from "../../text";
import {
type BannerScoreRead,
FULL_COUNT_TEAM_SCORE,
parseBannerScore,
parseBannerScoreSteps,
resolveMatchScores,
} from "../scoreboard/banner";
import { type ParsedNumber, parseNumber } from "../scoreboard/digits";
import { type ParsedNumber, parseNumberSteps } from "../scoreboard/digits";
import type {
ScoreboardData,
ScoreboardPlayer,
@@ -42,11 +45,8 @@ import type {
ScoreboardRowDebug,
} from "../scoreboard/index";
import { findPovIndex } from "../scoreboard/pov";
import { parseScoreboardRow, type RowRois } from "../scoreboard/row";
import {
type ParsedReplayHeader,
parseReplayHeader,
} from "../scoreboard-battle-log-replay/header";
import { parseScoreboardRowSteps, type RowRois } from "../scoreboard/row";
import { parseReplayHeaderSteps } from "../scoreboard-battle-log-replay/header";
import type { DetectedEvent, Detector, GateResult } from "../types";
export interface ScoreboardBattleLogData extends ScoreboardData {
@@ -188,13 +188,27 @@ export function createBattleLogDetector(
/**
* `frame` as the ROIs see it: itself for a flat layout, else `region` of it
* rectified into a region-sized mat, `local` shifting a ROI into it.
* rectified into a region-sized mat, `local` shifting a ROI into it. Its
* gray/RGB conversions (the frame's shared ones when flat) live until release.
*/
function rectifiedView(frame: Mat, region: Roi) {
if (!homography) {
return { mat: frame, local: (roi: Roi) => roi, release: () => {} };
return {
mat: frame,
local: (roi: Roi) => roi,
gray: () => frameGray(frame),
rgb: () => frameRgb(frame),
release: () => {},
};
}
const mat = warpPerspective(frame, homography, region);
const converted: Mat[] = [];
const convert = (code: number) => {
const out = new cv.Mat();
cv.cvtColor(mat, out, code);
converted.push(out);
return out;
};
return {
mat,
local: (roi: Roi): Roi => ({
@@ -202,19 +216,18 @@ export function createBattleLogDetector(
x: roi.x - region.x,
y: roi.y - region.y,
}),
release: () => mat.delete(),
gray: () => convert(cv.COLOR_RGBA2GRAY),
rgb: () => convert(cv.COLOR_RGBA2RGB),
release: () => {
mat.delete();
for (const out of converted) out.delete();
},
};
}
function toGray(rgba: Mat): Mat {
const gray = new cv.Mat();
cv.cvtColor(rgba, gray, cv.COLOR_RGBA2GRAY);
return gray;
}
function gate(frame: Mat): GateResult {
const probes = rectifiedView(frame, gateRegion);
const gray = toGray(probes.mat);
const gray = probes.gray();
let darkOk = 0;
let suffixOk = 0;
@@ -257,14 +270,11 @@ export function createBattleLogDetector(
let signature: number[] | undefined;
if (pass && homography) {
const flat = rectifiedView(frame, FULL_FRAME);
const flatGray = toGray(flat.mat);
signature = contentSignature(flatGray);
flatGray.delete();
signature = contentSignature(flat.gray());
flat.release();
} else if (pass) {
signature = contentSignature(gray);
}
gray.delete();
probes.release();
return { pass, score, signature };
}
@@ -279,11 +289,13 @@ export function createBattleLogDetector(
return signature;
}
function parsePanel(gray: Mat, rgb: Mat, panel: PanelIndex): PanelParse {
const players: ScoreboardPlayer[] = [];
const rows: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
/** One panel's rows, team total and result tag, all read in one lockstep. */
function* parsePanelSteps(
gray: Mat,
rgb: Mat,
panel: PanelIndex,
speculative: boolean,
): MatchSteps<PanelParse> {
const rowRois: RowRois = {
weapon: rois.weaponRoi,
specialIcon: rois.specialIconRoi,
@@ -293,43 +305,60 @@ export function createBattleLogDetector(
povArrow: rois.povArrowRoi,
};
const dy = rois.PANEL_DYS[panel];
for (const base of rois.ROW_CENTERS) {
const row = parseScoreboardRow(
gray,
rgb,
base + dy,
rowRois,
resources,
confidences,
);
players.push(row.player);
rows.push(row.debug);
}
// the point total is read only to recognize a knockout (only a knockout's
// full count reaches 500); never emitted as a score
let teamScore: ParsedNumber | null = null;
if (teamDigits) {
const crop = cropRoi(gray, rois.teamScoreRoi(panel));
teamScore = parseNumber(crop, teamDigits, {
binThreshold: TEAM_SCORE_BIN_THRESHOLD,
});
crop.delete();
confidences.push(teamScore.confidence);
}
const teamCrop = teamDigits
? cropRoi(gray, rois.teamScoreRoi(panel))
: null;
const bright = resultGlyphs
? maxChannel(rgb, rois.resultTagRoi(panel))
: null;
const [rowReads, teamScore, resultRaw] = yield* all([
all(
rois.ROW_CENTERS.map((base) =>
parseScoreboardRowSteps(
gray,
rgb,
base + dy,
rowRois,
resources,
{},
speculative,
),
),
),
teamDigits && teamCrop
? parseNumberSteps(
teamCrop,
teamDigits,
{ binThreshold: TEAM_SCORE_BIN_THRESHOLD },
speculative,
)
: done(null),
resultGlyphs && bright
? recognizeTextSteps(
bright,
resultGlyphs,
{
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
},
speculative,
)
: done(null),
]);
teamCrop?.delete();
bright?.delete();
const confidences = rowReads.flatMap((row) => row.confidences);
if (teamScore) confidences.push(teamScore.confidence);
let result: PanelParse["result"] = null;
let resultReading = "";
let resultScore = 0;
if (resultGlyphs) {
const bright = maxChannel(rgb, rois.resultTagRoi(panel));
const raw = recognizeText(bright, resultGlyphs, {
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
});
bright.delete();
resultReading = raw.text;
if (resultRaw) {
resultReading = resultRaw.text;
if (resultReading) {
const match = closestBy(
resultReading,
@@ -345,8 +374,8 @@ export function createBattleLogDetector(
}
return {
players,
rows,
players: rowReads.map((row) => row.player),
rows: rowReads.map((row) => row.debug),
teamScore,
result,
resultReading,
@@ -355,25 +384,52 @@ export function createBattleLogDetector(
};
}
function parse(
function* parseSteps(
frame: Mat,
t: number,
): DetectedEvent<ScoreboardBattleLogData>[] {
_gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<ScoreboardBattleLogData>[]> {
const flat = rectifiedView(frame, FULL_FRAME);
const gray = toGray(flat.mat);
const rgb = new cv.Mat();
cv.cvtColor(flat.mat, rgb, cv.COLOR_RGBA2RGB);
flat.release();
const gray = flat.gray();
const rgb = flat.rgb();
const top = parsePanel(gray, rgb, 0);
const bottom = parsePanel(gray, rgb, 1);
let left: BannerScoreRead | null = null;
let right: BannerScoreRead | null = null;
if (matchScoreSets.length > 0) {
left = parseBannerScore(gray, rois.MATCH_SCORE_ROIS[0], matchScoreSets);
right = parseBannerScore(gray, rois.MATCH_SCORE_ROIS[1], matchScoreSets);
}
const [top, bottom, banners, header] = yield* all([
parsePanelSteps(gray, rgb, 0, speculative),
parsePanelSteps(gray, rgb, 1, speculative),
matchScoreSets.length > 0
? all([
parseBannerScoreSteps(
gray,
rois.MATCH_SCORE_ROIS[0],
matchScoreSets,
speculative,
),
parseBannerScoreSteps(
gray,
rois.MATCH_SCORE_ROIS[1],
matchScoreSets,
speculative,
),
])
: done(null),
headerTopGlyphs && headerBottomGlyphs
? parseReplayHeaderSteps(
gray,
headerTopGlyphs,
headerBottomGlyphs,
{
top: rois.HEADER_TOP_BAND,
bottom: rois.HEADER_BOTTOM_BAND,
tagLeadInMax: rois.HEADER_TAG_LEAD_IN_MAX,
tagColumnFraction: rois.HEADER_TAG_COLUMN_FRACTION,
},
speculative,
)
: done(null),
]);
const [left, right]: [BannerScoreRead | null, BannerScoreRead | null] =
banners ?? [null, null];
const swapped = decideSwapped(top, bottom, left, right);
const [winner, loser] = swapped ? [bottom, top] : [top, bottom];
@@ -391,18 +447,7 @@ export function createBattleLogDetector(
bannerDebug = { left, right, knockout };
}
let header: ParsedReplayHeader | null = null;
if (headerTopGlyphs && headerBottomGlyphs) {
header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs, {
top: rois.HEADER_TOP_BAND,
bottom: rois.HEADER_BOTTOM_BAND,
tagLeadInMax: rois.HEADER_TAG_LEAD_IN_MAX,
tagColumnFraction: rois.HEADER_TAG_COLUMN_FRACTION,
});
}
gray.delete();
rgb.delete();
flat.release();
const confidences = [
...winner.confidences,
@@ -460,7 +505,9 @@ export function createBattleLogDetector(
id: layout.id,
sufficientConfidence: 0.8,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}

View File

@@ -17,15 +17,23 @@ import {
toAbilityWithUnknown,
toMainWeaponId,
} from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import { copyRoi, cropRoi, maxBrightness, meanBrightness } from "../../image";
import type { Mat } from "../../cv";
import { type GlyphSet, recognizeTextSteps, scaleGlyphSet } from "../../glyphs";
import {
copyRoi,
cropRoi,
frameGray,
frameRgb,
maxBrightness,
meanBrightness,
} from "../../image";
import { all, done, type MatchSteps, runSync } from "../../match-steps";
import { closestBy, matchKey } from "../../text";
import { LOCALIZED_WEAPON_NAMES } from "../death/localized-messages";
import { ALL_WEAPON_ENTRIES, type WeaponEntry } from "../death/weapon-names";
import { type ParsedHeader, parseHeader } from "../scoreboard/header";
import { parseHeaderSteps } from "../scoreboard/header";
import type { ScoreboardResources } from "../scoreboard/index";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import { matchWeaponSteps, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
GATE_PANEL_MAX_MEAN,
@@ -89,8 +97,6 @@ function mainWeaponCandidates(): WeaponCandidate[] {
export function createScoreboardOwnDetector(
resources: ScoreboardResources,
): Detector<ScoreboardOwnData> {
const cv = getCV();
const titleGlyphs: GlyphSet | null = resources.deathWeaponGlyphs
? scaleGlyphSet(
resources.deathWeaponGlyphs,
@@ -105,13 +111,11 @@ export function createScoreboardOwnDetector(
if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
let textOk = 0;
for (const roi of GATE_TITLE_TEXT_PROBES) {
if (maxBrightness(gray, roi) > GATE_TEXT_MIN_MAX) textOk++;
}
gray.delete();
let stripOk = 0;
for (let row = 0; row < GEAR_ROWS; row++) {
@@ -131,40 +135,75 @@ export function createScoreboardOwnDetector(
return { pass, score };
}
function parse(frame: Mat, t: number): DetectedEvent<ScoreboardOwnData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const confidences: number[] = [];
// header tags sit at the live scoreboard's positions — shared parser
let header: ParsedHeader | null = null;
if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) {
header = parseHeader(
gray,
resources.headerLobbyGlyphs,
resources.headerLineGlyphs,
);
confidences.push(header.confidence);
}
function* parseSteps(
frame: Mat,
t: number,
_gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<ScoreboardOwnData>[]> {
const gray = frameGray(frame);
const rgb = frameRgb(frame);
const { headerLobbyGlyphs, headerLineGlyphs } = resources;
// weapon card title, recognized whole and NOT via readTagBand: the tag is
// fixed-width, and long names render condensed, whose dense antialiased
// columns fail the tag-column test and truncate the read mid-name
const band = titleGlyphs ? copyRoi(gray, WEAPON_TITLE_BAND) : null;
// gear-card ability strips: [head, clothes, shoes] x [main, sub, sub, sub]
const abilityCrops = abilities
? Array.from({ length: GEAR_ROWS }, (_, row) => [
cropRoi(rgb, gearMainRoi(row)),
...[0, 1, 2].map((slot) => cropRoi(rgb, gearSubRoi(row, slot))),
])
: [];
const [header, title, abilityMatches] = yield* all([
// header tags sit at the live scoreboard's positions — shared parser
headerLobbyGlyphs && headerLineGlyphs
? parseHeaderSteps(
gray,
headerLobbyGlyphs,
headerLineGlyphs,
speculative,
)
: done(null),
titleGlyphs && band
? recognizeTextSteps(
band,
titleGlyphs,
{
binThreshold: WEAPON_TITLE_BIN_THRESHOLD,
spaceGap: 9,
minCharScore: 0.3,
},
speculative,
)
: done(null),
all(
abilityCrops.map((crops) =>
all(
crops.map((crop, slot) =>
matchWeaponSteps(
crop,
slot === 0 ? abilities!.mains : abilities!.subs,
{ inkThreshold: OWN_ABILITY_INK_THRESHOLD },
),
),
),
),
),
]);
band?.delete();
for (const crop of abilityCrops.flat()) crop.delete();
const confidences: number[] = [];
if (header) confidences.push(header.confidence);
let weapon: string | null = null;
let weaponId: MainWeaponId | null = null;
let weaponScore = 0;
let weaponReading = "";
if (titleGlyphs) {
const band = copyRoi(gray, WEAPON_TITLE_BAND);
weaponReading = recognizeText(band, titleGlyphs, {
binThreshold: WEAPON_TITLE_BIN_THRESHOLD,
spaceGap: 9,
minCharScore: 0.3,
}).text.trim();
band.delete();
if (title) {
weaponReading = title.text.trim();
const match = weaponReading
? closestBy(weaponReading, mainWeaponCandidates(), (c) => c.text)
: null;
@@ -178,39 +217,20 @@ export function createScoreboardOwnDetector(
confidences.push(weaponScore);
}
// gear-card ability strips: [head, clothes, shoes] x [main, sub, sub, sub]
const abilityRows: AbilityWithUnknown[][] = [];
const abilityDebug: (WeaponMatch | null)[][] = [];
if (abilities) {
for (let row = 0; row < GEAR_ROWS; row++) {
const ids: AbilityWithUnknown[] = [];
const debug: (WeaponMatch | null)[] = [];
const mainCrop = cropRoi(rgb, gearMainRoi(row));
const main = matchWeapon(mainCrop, abilities.mains, {
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
});
mainCrop.delete();
ids.push(toAbilityWithUnknown(main.id) ?? "UNKNOWN");
debug.push(main);
confidences.push(Math.max(0, main.score));
for (let slot = 0; slot < 3; slot++) {
const crop = cropRoi(rgb, gearSubRoi(row, slot));
const sub = matchWeapon(crop, abilities.subs, {
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
});
crop.delete();
ids.push(toAbilityWithUnknown(sub.id) ?? "UNKNOWN");
debug.push(sub);
confidences.push(Math.max(0, sub.score));
}
abilityRows.push(ids);
abilityDebug.push(debug);
for (const matches of abilityMatches) {
const ids: AbilityWithUnknown[] = [];
const debug: (WeaponMatch | null)[] = [];
for (const match of matches) {
ids.push(toAbilityWithUnknown(match.id) ?? "UNKNOWN");
debug.push(match);
confidences.push(Math.max(0, match.score));
}
abilityRows.push(ids);
abilityDebug.push(debug);
}
gray.delete();
rgb.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
@@ -247,6 +267,8 @@ export function createScoreboardOwnDetector(
id: "scoreboard-own",
sufficientConfidence: 0.55,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}

View File

@@ -12,9 +12,10 @@ import {
type GlyphSet,
type RecognizedChar,
type RecognizedText,
recognizeText,
recognizeTextSteps,
} from "../../glyphs";
import { copyRoi, type Roi } from "../../image";
import { all, type MatchSteps } from "../../match-steps";
/** The count a knockout wins at — the burst hides it, so it is never read. */
export const KO_MATCH_SCORE = 100;
@@ -83,31 +84,41 @@ const EMPTY_READ: BannerScoreRead = {
/**
* One banner side's score: each digit set at each threshold, best read kept
* (isBetterRead). The score is the trailing run of full-height, confident
* digits; label and burst leftovers fail at least one of those tests.
* (isBetterRead); every combination reads in one lockstep. The score is the
* trailing run of full-height, confident digits; label and burst leftovers
* fail at least one of those tests.
*/
export function parseBannerScore(
export function* parseBannerScoreSteps(
gray: Mat,
roi: Roi,
sets: readonly GlyphSet[],
): BannerScoreRead {
speculative = false,
): MatchSteps<BannerScoreRead> {
const crop = copyRoi(gray, roi);
clearShortBlobs(crop);
let best = EMPTY_READ;
for (const binThreshold of [
const reads = [
BANNER_SCORE_BIN_THRESHOLD,
BANNER_SCORE_BRIGHT_BIN_THRESHOLD,
BANNER_SCORE_BRIGHTEST_BIN_THRESHOLD,
]) {
for (const set of sets) {
const raw = recognizeText(crop, set, {
binThreshold,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const read = trailingDigitRun(raw, set);
if (isBetterRead(read, best)) best = read;
}
].flatMap((binThreshold) => sets.map((set) => ({ binThreshold, set })));
const raws = yield* all(
reads.map(({ binThreshold, set }) =>
recognizeTextSteps(
crop,
set,
{
binThreshold,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
},
speculative,
),
),
);
let best = EMPTY_READ;
for (const [i, { set }] of reads.entries()) {
const read = trailingDigitRun(raws[i]!, set);
if (isBetterRead(read, best)) best = read;
}
crop.delete();
return best;

View File

@@ -3,8 +3,9 @@ import type { Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
recognizeTextSteps,
} from "../../glyphs";
import type { MatchSteps } from "../../match-steps";
export interface ParsedNumber {
value: number | null;
@@ -18,16 +19,22 @@ export interface ParsedNumber {
/** A lowercase "p" suffix starts ~7px below the digits' cap line; a trailing char this far down is the suffix. */
const LOWERED_TRAILING_MIN_PX = 5;
export function parseNumber(
export function* parseNumberSteps(
gray: Mat,
digits: GlyphSet,
options: { binThreshold?: number; dropLoweredTrailing?: boolean } = {},
): ParsedNumber {
const raw = recognizeText(gray, digits, {
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
binThreshold: options.binThreshold,
});
speculative = false,
): MatchSteps<ParsedNumber> {
const raw = yield* recognizeTextSteps(
gray,
digits,
{
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
binThreshold: options.binThreshold,
},
speculative,
);
// the replay paint's "p" suffix can land inside the ROI and misread as a "6"
let chars = raw.chars;
if (options.dropLoweredTrailing && chars.length > 1) {

View File

@@ -10,10 +10,11 @@ import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizeOptions,
recognizeText,
recognizeTextSteps,
} from "../../glyphs";
import { copyRoi } from "../../image";
import { ALL_LOBBY_ENTRIES, MODE_STAGE_COMBOS } from "../../localized";
import { all, type MatchSteps } from "../../match-steps";
import { closestBy } from "../../text";
import { HEADER_LINE_BAND, HEADER_LOBBY_BAND } from "./rois";
@@ -99,12 +100,13 @@ export interface TagBandOptions extends RecognizeOptions {
}
/** OCR one header band: trim to the black-tag extent, recognize as a single line. */
export function readTagBand(
export function* readTagBandSteps(
gray: Mat,
band: { x: number; y: number; w: number; h: number },
glyphs: GlyphSet,
options: TagBandOptions = {},
): string {
speculative = false,
): MatchSteps<string> {
const crop = copyRoi(gray, band);
const { start, end } = tagExtent(
crop,
@@ -122,22 +124,30 @@ export function readTagBand(
view.copyTo(trimmed);
view.delete();
crop.delete();
const result = recognizeText(trimmed, glyphs, {
spaceGap: 9,
minCharScore: 0.3,
...options,
});
const result = yield* recognizeTextSteps(
trimmed,
glyphs,
{
spaceGap: 9,
minCharScore: 0.3,
...options,
},
speculative,
);
trimmed.delete();
return result.text.trim();
}
export function parseHeader(
export function* parseHeaderSteps(
gray: Mat,
lobbyGlyphs: GlyphSet,
lineGlyphs: GlyphSet,
): ParsedHeader {
const lobbyReading = readTagBand(gray, HEADER_LOBBY_BAND, lobbyGlyphs);
const lineReading = readTagBand(gray, HEADER_LINE_BAND, lineGlyphs);
speculative = false,
): MatchSteps<ParsedHeader> {
const [lobbyReading, lineReading] = yield* all([
readTagBandSteps(gray, HEADER_LOBBY_BAND, lobbyGlyphs, {}, speculative),
readTagBandSteps(gray, HEADER_LINE_BAND, lineGlyphs, {}, speculative),
]);
const lobbyMatch = lobbyReading
? closestBy(lobbyReading, ALL_LOBBY_ENTRIES, (e) => e.text)

View File

@@ -8,17 +8,24 @@ import type {
StageId,
} from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import type { Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import { cropRoi, maxBrightness, meanBrightness } from "../../image";
import {
cropRoi,
frameGray,
frameRgb,
maxBrightness,
meanBrightness,
} from "../../image";
import { all, done, type MatchSteps, runSync } from "../../match-steps";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
FULL_COUNT_TEAM_SCORE,
parseBannerScore,
parseBannerScoreSteps,
resolveMatchScores,
} from "./banner";
import { parseNumber } from "./digits";
import { type ParsedHeader, parseHeader } from "./header";
import { parseNumberSteps } from "./digits";
import { parseHeaderSteps } from "./header";
import { findPovIndex } from "./pov";
import {
GATE_DARK_MAX_MEAN,
@@ -40,7 +47,7 @@ import {
TEAM_SCORE_ROIS,
weaponRoi,
} from "./rois";
import { parseScoreboardRow, type RowRois } from "./row";
import { parseScoreboardRowSteps, type RowRois } from "./row";
import type { SpecialMatch, SpecialTemplate } from "./specials";
import type { WeaponMatch, WeaponTemplate } from "./weapons";
@@ -159,7 +166,6 @@ export const SCOREBOARD_EVENT_TYPE = "Scoreboard";
export function createScoreboardDetector(
resources: ScoreboardResources,
): Detector<ScoreboardData> {
const cv = getCV();
const teamDigits =
resources.teamDigits ??
(resources.paintDigits
@@ -175,8 +181,7 @@ export function createScoreboardDetector(
: [];
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const gray = frameGray(frame);
let darkOk = 0;
let suffixOk = 0;
@@ -190,7 +195,6 @@ export function createScoreboardDetector(
for (const roi of GATE_PANEL_PROBES) {
if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++;
}
gray.delete();
const score =
(darkOk / ROW_CENTERS.length +
@@ -201,15 +205,14 @@ export function createScoreboardDetector(
return { pass, score };
}
function parse(frame: Mat, t: number): DetectedEvent<ScoreboardData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const players: ScoreboardPlayer[] = [];
const rowDebug: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
function* parseSteps(
frame: Mat,
t: number,
_gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<ScoreboardData>[]> {
const gray = frameGray(frame);
const rgb = frameRgb(frame);
const rowRois: RowRois = {
weapon: weaponRoi,
@@ -219,58 +222,79 @@ export function createScoreboardDetector(
stat: statRoi,
povArrow: povArrowRoi,
};
for (const cy of ROW_CENTERS) {
const row = parseScoreboardRow(
gray,
rgb,
cy,
rowRois,
resources,
confidences,
);
players.push(row.player);
rowDebug.push(row.debug);
}
const { headerLobbyGlyphs, headerLineGlyphs } = resources;
// the total sits on the team-colored swirl box, so binarize higher than on black pills
const totalCrop = teamDigits ? cropRoi(gray, TEAM_SCORE_ROIS[0]) : null;
const [rows, header, winnerTotal, banners] = yield* all([
all(
ROW_CENTERS.map((cy) =>
parseScoreboardRowSteps(
gray,
rgb,
cy,
rowRois,
resources,
{},
speculative,
),
),
),
headerLobbyGlyphs && headerLineGlyphs
? parseHeaderSteps(
gray,
headerLobbyGlyphs,
headerLineGlyphs,
speculative,
)
: done(null),
teamDigits && totalCrop
? parseNumberSteps(
totalCrop,
teamDigits,
{ binThreshold: 175 },
speculative,
)
: done(null),
matchScoreSets.length > 0
? all([
parseBannerScoreSteps(
gray,
MATCH_SCORE_ROIS[0],
matchScoreSets,
speculative,
),
parseBannerScoreSteps(
gray,
MATCH_SCORE_ROIS[1],
matchScoreSets,
speculative,
),
])
: done(null),
]);
totalCrop?.delete();
const players = rows.map((row) => row.player);
const rowDebug = rows.map((row) => row.debug);
const confidences = rows.flatMap((row) => row.confidences);
const povIndex = findPovIndex(rowDebug.map((r) => r.povFraction));
let header: ParsedHeader | null = null;
if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) {
header = parseHeader(
gray,
resources.headerLobbyGlyphs,
resources.headerLineGlyphs,
);
confidences.push(header.confidence);
}
if (header) confidences.push(header.confidence);
// the winner's total is read only to recognize a knockout: only a full 100
// count reaches 500, and the banner value is hidden under the KNOCKOUT! burst
let knockout = false;
let winnerTotalConf = 0;
if (teamDigits) {
// the total sits on the team-colored swirl box, so binarize higher than on black pills
const crop = cropRoi(gray, TEAM_SCORE_ROIS[0]);
const winnerTotal = parseNumber(crop, teamDigits, {
binThreshold: 175,
});
crop.delete();
knockout = winnerTotal.value === FULL_COUNT_TEAM_SCORE;
winnerTotalConf = winnerTotal.confidence;
}
const knockout = winnerTotal?.value === FULL_COUNT_TEAM_SCORE;
const winnerTotalConf = winnerTotal?.confidence ?? 0;
let matchScores: [number | null, number | null] = [null, null];
let bannerDebug: object | undefined;
if (matchScoreSets.length > 0) {
const left = parseBannerScore(gray, MATCH_SCORE_ROIS[0], matchScoreSets);
const right = parseBannerScore(gray, MATCH_SCORE_ROIS[1], matchScoreSets);
if (banners) {
const [left, right] = banners;
matchScores = resolveMatchScores({ left, right, knockout });
confidences.push(left.confidence, right.confidence);
bannerDebug = { left, right, knockout, winnerTotalConf };
}
gray.delete();
rgb.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
@@ -303,6 +327,8 @@ export function createScoreboardDetector(
id: "scoreboard",
sufficientConfidence: 0.79,
gate,
parse,
parse: (frame, t, gateResult) =>
runSync(parseSteps(frame, t, gateResult, false)),
parseSteps,
};
}

View File

@@ -4,8 +4,9 @@ import {
type GlyphSet,
type RecognizedChar,
type RecognizedText,
recognizeText,
recognizeTextSteps,
} from "../../glyphs";
import type { MatchSteps } from "../../match-steps";
export interface ParsedName {
name: string;
@@ -516,7 +517,7 @@ function preferPlainTies(raw: RecognizedText, margin: number): RecognizedText {
return { ...raw, text: retext(raw.text, chars), chars };
}
export function parseName(
export function* parseNameSteps(
gray: Mat,
glyphs: GlyphSet,
options: {
@@ -525,14 +526,20 @@ export function parseName(
/** re-decide near-tie homoglyphs toward the plain form (preferPlainTies) */
plainTieMargin?: number;
} = {},
): ParsedName {
speculative = false,
): MatchSteps<ParsedName> {
const binThreshold = options.binThreshold ?? DEFAULT_BIN_THRESHOLD;
const recognized = recognizeText(gray, glyphs, {
spaceGap: options.spaceGap ?? 7,
binThreshold,
minCharScore: 0.35,
maxCandidates: NAME_MAX_CANDIDATES,
});
const recognized = yield* recognizeTextSteps(
gray,
glyphs,
{
spaceGap: options.spaceGap ?? 7,
binThreshold,
minCharScore: 0.35,
maxCandidates: NAME_MAX_CANDIDATES,
},
speculative,
);
const raw =
options.plainTieMargin === undefined
? recognized

View File

@@ -7,18 +7,23 @@ import { toMainWeaponId } from "../../../scanner-types";
import type { Mat } from "../../cv";
import type { GlyphSet } from "../../glyphs";
import { cropRoi, type Roi } from "../../image";
import { type ParsedNumber, parseNumber } from "./digits";
import { all, type MatchSteps } from "../../match-steps";
import { type ParsedNumber, parseNumberSteps } from "./digits";
import type { ScoreboardPlayer, ScoreboardRowDebug } from "./index";
import { parseName } from "./names";
import { type ParsedName, parseNameSteps } from "./names";
import { povYellowFraction } from "./pov";
import {
disambiguateWeaponBySpecial,
matchSpecial,
matchSpecialSteps,
type SpecialMatch,
type SpecialTemplate,
tiedWeaponsWithDistinctSpecials,
} from "./specials";
import { matchWeapon, type WeaponMatch, type WeaponTemplate } from "./weapons";
import {
matchWeaponSteps,
type WeaponMatch,
type WeaponTemplate,
} from "./weapons";
/** Per-row ROI geometry; the replay detector closes these over its panel dx. */
export interface RowRois {
@@ -45,77 +50,41 @@ export interface RowOptions {
paintDropLoweredTrailing?: boolean;
}
/** Parses one player row; per-field confidences append to `confidences`. */
export function parseScoreboardRow(
/**
* Parses one player row; its per-field confidences come back in field order
* (weapon, paint, name, stats). Weapon, paint→name and the stats read in one
* lockstep.
*/
export function* parseScoreboardRowSteps(
gray: Mat,
rgb: Mat,
cy: number,
rois: RowRois,
resources: RowResources,
confidences: number[],
options: RowOptions = {},
): { player: ScoreboardPlayer; debug: ScoreboardRowDebug } {
let weapon: WeaponMatch | null = null;
let special: SpecialMatch | undefined;
if (resources.weapons.length > 0) {
const crop = cropRoi(rgb, rois.weapon(cy));
weapon = matchWeapon(
crop,
resources.weapons,
options.weaponInkThreshold !== undefined
? { inkThreshold: options.weaponInkThreshold }
: {},
);
crop.delete();
// near-tied icons with different kit specials: the row's special icon breaks the tie
if (resources.specials?.length && tiedWeaponsWithDistinctSpecials(weapon)) {
const spCrop = cropRoi(rgb, rois.specialIcon(cy));
special = matchSpecial(spCrop, resources.specials);
spCrop.delete();
weapon = disambiguateWeaponBySpecial(weapon, special);
}
confidences.push(Math.max(0, weapon.score));
}
speculative = false,
): MatchSteps<{
player: ScoreboardPlayer;
debug: ScoreboardRowDebug;
confidences: number[];
}> {
const [weaponRead, { paint, name }, stats] = yield* all([
readWeapon(rgb, cy, rois, resources, options),
readPaintAndName(gray, cy, rois, resources, options, speculative),
readStats(gray, cy, rois, resources, speculative),
]);
const { weapon, special } = weaponRead ?? { weapon: null };
// paint (parse first so the name region can be trimmed at the digits)
let paint: ParsedNumber | null = null;
const pRoi = rois.paint(cy);
if (resources.paintDigits) {
const crop = cropRoi(gray, pRoi);
paint = parseNumber(crop, resources.paintDigits, {
dropLoweredTrailing: options.paintDropLoweredTrailing,
});
crop.delete();
confidences.push(paint.confidence);
}
// name, trimmed at the leftmost paint digit
let name: ReturnType<typeof parseName> | null = null;
if (resources.nameGlyphs) {
const base = rois.name(cy);
const paintLeftAbs =
paint && paint.leftX !== null ? pRoi.x + paint.leftX : pRoi.x + pRoi.w;
const w = Math.min(base.w, Math.max(0, paintLeftAbs - 6 - base.x));
if (w > 8) {
const crop = cropRoi(gray, { ...base, w });
name = parseName(crop, resources.nameGlyphs);
crop.delete();
confidences.push(name.confidence);
}
}
// stat counters
const confidences: number[] = [];
if (weapon) confidences.push(Math.max(0, weapon.score));
if (paint) confidences.push(paint.confidence);
if (name) confidences.push(name.confidence);
const statValues: (number | null)[] = [null, null, null];
const statScores: [number, number, number] = [0, 0, 0];
if (resources.statDigits) {
for (const i of [0, 1, 2] as const) {
const crop = cropRoi(gray, rois.stat(cy, i));
const parsed = parseNumber(crop, resources.statDigits);
crop.delete();
statValues[i] = parsed.value;
statScores[i] = parsed.confidence;
confidences.push(parsed.confidence);
}
for (const [i, parsed] of (stats ?? []).entries()) {
statValues[i] = parsed.value;
statScores[i] = parsed.confidence;
confidences.push(parsed.confidence);
}
return {
@@ -135,5 +104,90 @@ export function parseScoreboardRow(
statScores,
povFraction: povYellowFraction(rgb, rois.povArrow(cy)),
},
confidences,
};
}
function* readWeapon(
rgb: Mat,
cy: number,
rois: RowRois,
resources: RowResources,
options: RowOptions,
): MatchSteps<{ weapon: WeaponMatch; special?: SpecialMatch } | null> {
if (resources.weapons.length === 0) return null;
const crop = cropRoi(rgb, rois.weapon(cy));
let weapon = yield* matchWeaponSteps(
crop,
resources.weapons,
options.weaponInkThreshold !== undefined
? { inkThreshold: options.weaponInkThreshold }
: {},
);
crop.delete();
// near-tied icons with different kit specials: the row's special icon breaks the tie
let special: SpecialMatch | undefined;
if (resources.specials?.length && tiedWeaponsWithDistinctSpecials(weapon)) {
const spCrop = cropRoi(rgb, rois.specialIcon(cy));
special = yield* matchSpecialSteps(spCrop, resources.specials);
spCrop.delete();
weapon = disambiguateWeaponBySpecial(weapon, special);
}
return { weapon, special };
}
/** Paint first, so the name region can be trimmed at the leftmost paint digit. */
function* readPaintAndName(
gray: Mat,
cy: number,
rois: RowRois,
resources: RowResources,
options: RowOptions,
speculative: boolean,
): MatchSteps<{ paint: ParsedNumber | null; name: ParsedName | null }> {
let paint: ParsedNumber | null = null;
const pRoi = rois.paint(cy);
if (resources.paintDigits) {
const crop = cropRoi(gray, pRoi);
paint = yield* parseNumberSteps(
crop,
resources.paintDigits,
{ dropLoweredTrailing: options.paintDropLoweredTrailing },
speculative,
);
crop.delete();
}
let name: ParsedName | null = null;
if (resources.nameGlyphs) {
const base = rois.name(cy);
const paintLeftAbs =
paint && paint.leftX !== null ? pRoi.x + paint.leftX : pRoi.x + pRoi.w;
const w = Math.min(base.w, Math.max(0, paintLeftAbs - 6 - base.x));
if (w > 8) {
const crop = cropRoi(gray, { ...base, w });
name = yield* parseNameSteps(crop, resources.nameGlyphs, {}, speculative);
crop.delete();
}
}
return { paint, name };
}
function* readStats(
gray: Mat,
cy: number,
rois: RowRois,
resources: RowResources,
speculative: boolean,
): MatchSteps<ParsedNumber[] | null> {
const { statDigits } = resources;
if (!statDigits) return null;
const crops = ([0, 1, 2] as const).map((i) =>
cropRoi(gray, rois.stat(cy, i)),
);
const parsed = yield* all(
crops.map((crop) => parseNumberSteps(crop, statDigits, {}, speculative)),
);
for (const crop of crops) crop.delete();
return parsed;
}

View File

@@ -5,8 +5,9 @@
* enough since it only splits near-tied main icons (Splash- vs Sploosh-o-matic)
* whose kit silhouettes are far apart (stamp vs crab, bomb vs beakon).
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import { getCV, type Mat } from "../../cv";
import type { FrameData } from "../../image";
import type { MatchSteps } from "../../match-steps";
import { WEAPON_KITS } from "./kits";
import type { WeaponMatch } from "./weapons";
@@ -18,8 +19,8 @@ const SPECIAL_INK_THRESHOLD = 48;
export interface SpecialTemplate {
id: string;
/** binary silhouette + ink pixel count at each templateSizes entry */
sizes: { mat: Mat; ink: number }[];
/** binary silhouette (dimensions mirrored off embind) + ink pixel count at each templateSizes entry */
sizes: { mat: Mat; rows: number; cols: number; ink: number }[];
}
export interface SpecialMatch {
@@ -81,7 +82,7 @@ export function prepareSpecialTemplates(
resized.delete();
let ink = 0;
for (const v of mat.data) if (v > 0) ink++;
return { mat, ink };
return { mat, rows: mat.rows, cols: mat.cols, ink };
});
silhouette.delete();
return { id, sizes };
@@ -89,10 +90,10 @@ export function prepareSpecialTemplates(
}
/** searchRgb: RGB crop of the icon ROI (view is fine); binarized on max(r,g,b) so any tint reads as shape. */
export function matchSpecial(
export function* matchSpecialSteps(
searchRgb: Mat,
templates: SpecialTemplate[],
): SpecialMatch {
): MatchSteps<SpecialMatch> {
const cv = getCV();
// binarized copy of the search region (pixel access needs a copy)
@@ -112,14 +113,20 @@ export function matchSpecial(
}
cont.delete();
const result = new cv.Mat();
const sizesOf = (template: SpecialTemplate) =>
template.sizes.filter((size) => size.rows <= rows && size.cols <= cols);
const [scoreOf] = yield [
{
image: binary,
templates: templates.flatMap((t) => sizesOf(t).map((s) => s.mat)),
},
];
const ranked: { id: string; score: number }[] = [];
let index = 0;
for (const template of templates) {
let score = -1;
for (const { mat, ink } of template.sizes) {
if (mat.rows > binary.rows || mat.cols > binary.cols) continue;
cv.matchTemplate(binary, mat, result, cv.TM_CCOEFF_NORMED);
const { maxVal } = minMaxLoc(result);
for (const { ink } of sizesOf(template)) {
const maxVal = scoreOf!(index++);
const r =
Math.min(ink, searchInk) / Math.max(Math.max(ink, searchInk), 1);
const adjusted = maxVal * (0.75 + 0.25 * r);
@@ -127,7 +134,6 @@ export function matchSpecial(
}
ranked.push({ id: template.id, score });
}
result.delete();
binary.delete();
ranked.sort((a, b) => b.score - a.score);
return {

View File

@@ -4,8 +4,9 @@
* template can't win on a lucky sub-window of a bigger icon. Large sets run
* coarse-to-fine (quarter-res ranking, shortlist re-matched at full res).
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import { getCV, type Mat } from "../../cv";
import type { FrameData } from "../../image";
import type { MatchSteps } from "../../match-steps";
/** Icon heights to try: live rows ~44-56px, replay browser ~60-64px (skipped inside the live 56px ROI). */
const WEAPON_TEMPLATE_SIZES = [40, 44, 48, 52, 56, 60, 64] as const;
@@ -34,11 +35,14 @@ const COARSE_SCALE = 0.25;
/** How many coarse-ranked ids survive into the full-resolution pass. */
const COARSE_SHORTLIST = 16;
/** `rows`/`cols` mirror the mat's dimensions, kept off the embind accessors in the matching loops. */
export interface TemplateSize {
mat: Mat;
rows: number;
cols: number;
ink: number;
/** the same template at COARSE_SCALE, for the coarse ranking pass */
coarse: { mat: Mat; ink: number };
coarse: { mat: Mat; rows: number; cols: number; ink: number };
}
export interface WeaponTemplate {
@@ -115,8 +119,15 @@ export function buildTemplateSizes(
);
return {
mat,
rows: mat.rows,
cols: mat.cols,
ink: countInkRgb(mat, inkThreshold),
coarse: { mat: coarseMat, ink: countInkRgb(coarseMat, inkThreshold) },
coarse: {
mat: coarseMat,
rows: coarseMat.rows,
cols: coarseMat.cols,
ink: countInkRgb(coarseMat, inkThreshold),
},
};
});
}
@@ -218,11 +229,11 @@ function compositeOnBackground(rgba: Mat, background: number): Mat {
* COARSE_SHORTLIST ids; scoped ids drag their unscoped twin along for the
* tie-break. Null when no coarse template fits.
*/
function coarseShortlist(
function* coarseShortlist(
searchRgb: Mat,
templates: WeaponTemplate[],
inkThreshold: number,
): Set<string> | null {
): MatchSteps<Set<string> | null> {
const cv = getCV();
const region = new cv.Mat();
cv.resize(
@@ -235,21 +246,31 @@ function coarseShortlist(
);
const searchInk = countInkRgb(region, inkThreshold);
const result = new cv.Mat();
const scored: { id: string; score: number }[] = [];
const searchRows = searchRgb.rows;
const searchCols = searchRgb.cols;
const regionRows = region.rows;
const regionCols = region.cols;
// gate on the *full-res* dims so a size competes here iff it competes in the full pass
const sizesOf = (template: WeaponTemplate) =>
template.sizes.filter(
({ rows, cols, coarse }) =>
rows <= searchRows &&
cols <= searchCols &&
coarse.rows <= regionRows &&
coarse.cols <= regionCols,
);
const [scoreOf] = yield [
{
image: region,
templates: templates.flatMap((t) => sizesOf(t).map((s) => s.coarse.mat)),
},
];
const scored: { id: string; score: number }[] = [];
let index = 0;
for (const template of templates) {
let score = -1;
for (const { mat, coarse } of template.sizes) {
// gate on the *full-res* dims so a size competes here iff it competes in the full pass
if (mat.rows > searchRows || mat.cols > searchCols) continue;
if (coarse.mat.rows > regionRows || coarse.mat.cols > regionCols)
continue;
cv.matchTemplate(region, coarse.mat, result, cv.TM_CCOEFF_NORMED);
const { maxVal } = minMaxLoc(result);
for (const { coarse } of sizesOf(template)) {
const maxVal = scoreOf!(index++);
const r =
Math.min(coarse.ink, searchInk) /
Math.max(Math.max(coarse.ink, searchInk), 1);
@@ -258,7 +279,6 @@ function coarseShortlist(
}
if (score > -1) scored.push({ id: template.id, score });
}
result.delete();
region.delete();
if (scored.length === 0) return null;
@@ -274,13 +294,14 @@ function coarseShortlist(
/**
* searchRgb: RGB crop of the weapon ROI (view is fine). Raise inkThreshold on
* screens with lighter pills (replay browser ~61 vs live ~12) or everything
* counts as ink and the coverage penalty collapses.
* counts as ink and the coverage penalty collapses. Runs as match steps: a
* coarse shortlist pass, then one full-res pass.
*/
export function matchWeapon(
export function* matchWeaponSteps(
searchRgb: Mat,
templates: WeaponTemplate[],
options: { inkThreshold?: number; topN?: number } = {},
): WeaponMatch {
): MatchSteps<WeaponMatch> {
const cv = getCV();
const inkThreshold = options.inkThreshold ?? INK_THRESHOLD;
const topN = options.topN ?? 3;
@@ -294,20 +315,28 @@ export function matchWeapon(
// below ~2x the shortlist size the coarse pass costs more calls than it saves
let pool = templates;
if (templates.length > COARSE_SHORTLIST * 2) {
const ids = coarseShortlist(searchRgb, templates, inkThreshold);
const ids = yield* coarseShortlist(searchRgb, templates, inkThreshold);
if (ids) pool = templates.filter((t) => ids.has(t.id));
}
const result = new cv.Mat();
const best = new Map<string, number>();
const searchRows = searchRgb.rows;
const searchCols = searchRgb.cols;
const sizesOf = (template: WeaponTemplate) =>
template.sizes.filter(
({ rows, cols }) => rows <= searchRows && cols <= searchCols,
);
const [scoreOf] = yield [
{
image: searchRgb,
templates: pool.flatMap((t) => sizesOf(t).map((s) => s.mat)),
},
];
let index = 0;
for (const template of pool) {
let score = -1;
for (const { mat, ink } of template.sizes) {
if (mat.rows > searchRows || mat.cols > searchCols) continue;
cv.matchTemplate(searchRgb, mat, result, cv.TM_CCOEFF_NORMED);
const { maxVal } = minMaxLoc(result);
for (const { ink } of sizesOf(template)) {
const maxVal = scoreOf!(index++);
const r =
Math.min(ink, searchInk) / Math.max(Math.max(ink, searchInk), 1);
const adjusted = maxVal * (0.75 + 0.25 * r);
@@ -315,7 +344,6 @@ export function matchWeapon(
}
best.set(template.id, score);
}
result.delete();
const ranked = [...best.entries()]
.map(([id, score]) => ({ id, score }))
.sort((a, b) => b.score - a.score);

View File

@@ -23,6 +23,9 @@ export interface ScanTelemetry {
/** video seconds covered by keyframe-hop skimming (chunk scan) */
skimVideoS: number;
wallMs: number;
/** scans (workers) that matched on WebGPU, and their summed wait for GPU results */
gpuScans: number;
gpuWaitMs: number;
detectors: Record<string, DetectorTelemetry>;
}
@@ -34,6 +37,8 @@ export function createScanTelemetry(): ScanTelemetry {
activeVideoS: 0,
skimVideoS: 0,
wallMs: 0,
gpuScans: 0,
gpuWaitMs: 0,
detectors: {},
};
}
@@ -68,6 +73,8 @@ export function mergeScanTelemetry(
out.activeVideoS += part.activeVideoS;
out.skimVideoS += part.skimVideoS;
out.wallMs = Math.max(out.wallMs, part.wallMs);
out.gpuScans += part.gpuScans;
out.gpuWaitMs += part.gpuWaitMs;
for (const [id, d] of Object.entries(part.detectors)) {
const bucket = detectorTelemetry(out, id);
bucket.checks += d.checks;

View File

@@ -1,4 +1,5 @@
import type { Mat } from "../cv";
import type { MatchSteps } from "../match-steps";
export interface DetectedEvent<TData = unknown> {
type: string;
@@ -70,4 +71,14 @@ export interface Detector<TData = unknown> {
attachFrame?: boolean;
gate(frame: Mat): GateResult;
parse(frame: Mat, t: number, gate?: GateResult): DetectedEvent<TData>[];
/**
* parse() as match steps (match-steps.ts) for a batching driver; `parse`
* must equal runSync of it. `speculative` prefetches candidate sets.
*/
parseSteps(
frame: Mat,
t: number,
gate: GateResult | undefined,
speculative: boolean,
): MatchSteps<DetectedEvent<TData>[]>;
}

View File

@@ -13,6 +13,7 @@
*/
import { getCV, type Mat } from "./cv";
import type { FrameData } from "./image";
import { all, type MatchSteps } from "./match-steps";
interface AtlasGlyphMeta {
char: string;
@@ -38,12 +39,15 @@ interface Glyph {
char: string;
/** grayscale white-on-black, tight box */
mat: Mat;
/** `mat`'s dimensions, kept off the embind accessors in the matching loops */
rows: number;
cols: number;
/** count of pixels above the binarization threshold */
ink: number;
/** exact fixture crop vs font-rendered approximation */
source: "fixture" | "font";
/** lazily-built PRESCREEN_SCALE thumbnail for the eligibility prescreen */
small?: Mat;
small?: { mat: Mat; rows: number; cols: number };
}
export interface GlyphSet {
@@ -75,10 +79,17 @@ export function loadGlyphSet(atlas: FrameData, meta: AtlasMeta): GlyphSet {
let ink = 0;
for (const v of mat.data) if (v > TEMPLATE_BIN_THRESHOLD) ink++;
// untagged glyphs predate hybrid atlases and were all fixture crops
return { char: g.char, mat, ink, source: g.source ?? "fixture" };
return {
char: g.char,
mat,
rows: mat.rows,
cols: mat.cols,
ink,
source: g.source ?? "fixture",
};
});
gray.delete();
const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b);
const widths = glyphs.map((g) => g.cols).sort((a, b) => a - b);
const medianWidth = widths[Math.floor(widths.length / 2)] ?? 8;
return { glyphs, height: meta.height, medianWidth };
}
@@ -91,7 +102,14 @@ export function scaleGlyphSet(set: GlyphSet, factor: number): GlyphSet {
cv.resize(g.mat, mat, new cv.Size(0, 0), factor, factor, cv.INTER_CUBIC);
let ink = 0;
for (const v of mat.data) if (v > TEMPLATE_BIN_THRESHOLD) ink++;
return { char: g.char, mat, ink, source: g.source };
return {
char: g.char,
mat,
rows: mat.rows,
cols: mat.cols,
ink,
source: g.source,
};
});
return {
glyphs,
@@ -325,7 +343,14 @@ const PRESCREEN_SCALE = 0.5;
const PRESCREEN_MARGIN = 0.3;
const PRESCREEN_MAX_KEEP = 1024;
function classifySegment(
type RankedCandidate = {
char: string;
score: number;
ncc: number;
source: "fixture" | "font";
};
function* classifySegment(
masked: Mat,
seg: SegmentInfo,
set: GlyphSet,
@@ -337,7 +362,9 @@ function classifySegment(
*/
scoreFloor = Number.NEGATIVE_INFINITY,
maxCandidates = DEFAULT_MAX_CANDIDATES,
): { char: string; score: number; ncc: number; source: "fixture" | "font" }[] {
/** identity of `masked`'s pixels, so a batching driver can reuse scores */
maskedKey?: string,
): MatchSteps<RankedCandidate[]> {
const cv = getCV();
const segWidth = seg.x1 - seg.x0;
const pad = 5;
@@ -356,6 +383,10 @@ function classifySegment(
const y1 = Math.min(maskedRows, seg.y1 + vSlack);
const regionRows = y1 - y0;
const region = masked.roi(new cv.Rect(x0, y0, regionCols, regionRows));
const regionKey =
maskedKey === undefined
? undefined
: `${maskedKey}|${x0},${y0},${regionCols},${regionRows}`;
// Both penalty factors depend only on glyph and segment, and NCC <= 1, so
// their product bounds a glyph's score before matching. Matching in
@@ -363,9 +394,8 @@ function classifySegment(
// come within FIXTURE_TIEBREAK of the best.
const eligible: EligibleGlyph[] = [];
for (const glyph of set.glyphs) {
const t = glyph.mat;
const tRows = t.rows;
const tCols = t.cols;
const tRows = glyph.rows;
const tCols = glyph.cols;
if (tRows > regionRows || tCols > regionCols) continue;
const wRatio = tCols / Math.max(segWidth, 1);
if (wRatio < 0.4 || wRatio > 2.5) continue;
@@ -391,7 +421,6 @@ function classifySegment(
}
eligible.sort((a, b) => b.bound - a.bound);
const result = new cv.Mat();
const candidates: {
char: string;
score: number;
@@ -411,7 +440,7 @@ function classifySegment(
// every template whose loose bound exceeds it
const contenders =
eligible.length >= PRESCREEN_MIN_ELIGIBLE
? prescreen(
? yield* prescreen(
region,
eligible,
{
@@ -421,32 +450,45 @@ function classifySegment(
minOverlap,
},
probeMode ? scoreFloor : null,
regionKey,
)
: eligible;
// overlap(sx) is concave in sx, so the valid placements form one
// contiguous rx interval per template; empty ones are never matched
const windows = contenders.map(({ tCols }) =>
placementWindow(
regionCols - tCols + 1,
(rx) =>
Math.min(x0 + rx + tCols, seg.x1) - Math.max(x0 + rx, seg.x0) >=
minOverlap,
),
);
const requestIndex: number[] = [];
const templates: Mat[] = [];
const requestWindows: (readonly [number, number])[] = [];
for (const [i, window] of windows.entries()) {
requestIndex.push(window ? templates.length : -1);
if (!window) continue;
templates.push(contenders[i]!.glyph.mat);
requestWindows.push(window);
}
const [scoreOf] =
templates.length > 0
? yield [
{
image: region,
templates,
windows: requestWindows,
key: regionKey,
},
]
: [() => Number.NEGATIVE_INFINITY];
let bestScore = scoreFloor;
for (const { glyph, tRows, tCols, r, hr, bound } of contenders) {
for (const [i, { glyph, r, hr, bound }] of contenders.entries()) {
if (bound < bestScore - FIXTURE_TIEBREAK) break;
if (probeMode && (bound <= scoreFloor || bestScore > scoreFloor)) break;
cv.matchTemplate(region, glyph.mat, result, cv.TM_CCOEFF_NORMED);
const rCols = regionCols - tCols + 1;
const rRows = regionRows - tRows + 1;
// overlap(sx) is concave in sx, so the valid placements form one
// contiguous rx interval — find its edges, then scan row-major
const overlapAt = (rx: number) =>
Math.min(x0 + rx + tCols, seg.x1) - Math.max(x0 + rx, seg.x0);
let lo = 0;
while (lo < rCols && overlapAt(lo) < minOverlap) lo++;
let hi = rCols - 1;
while (hi >= lo && overlapAt(hi) < minOverlap) hi--;
if (hi < lo) continue;
let maxVal = Number.NEGATIVE_INFINITY;
const scores = result.data32F;
for (let ry = 0, rowBase = 0; ry < rRows; ry++, rowBase += rCols) {
for (let rx = lo; rx <= hi; rx++) {
const v = scores[rowBase + rx]!;
if (v > maxVal) maxVal = v;
}
}
if (requestIndex[i] === -1) continue;
const maxVal = scoreOf!(requestIndex[i]!);
const score = maxVal * (0.7 + 0.3 * r) * (0.85 + 0.15 * hr);
if (Number.isFinite(score)) {
if (score > bestScore) bestScore = score;
@@ -459,7 +501,6 @@ function classifySegment(
});
}
}
result.delete();
region.delete();
candidates.sort((a, b) => b.score - a.score);
const top = candidates[0];
@@ -497,13 +538,14 @@ interface EligibleGlyph {
* without the same min-overlap restriction a template scoring on the neighbor
* inside the pad inflates the front-runner and prunes the true glyph.
*/
function prescreen(
function* prescreen(
region: Mat,
eligible: EligibleGlyph[],
geometry: { x0: number; segX0: number; segX1: number; minOverlap: number },
/** probe mode: prune against this floor instead of the front-runner */
probeFloor: number | null = null,
): EligibleGlyph[] {
probeFloor: number | null,
regionKey: string | undefined,
): MatchSteps<EligibleGlyph[]> {
const cv = getCV();
const smallRegion = new cv.Mat();
cv.resize(
@@ -514,53 +556,61 @@ function prescreen(
0,
cv.INTER_AREA,
);
const smallRows = smallRegion.rows;
const smallCols = smallRegion.cols;
const x0 = geometry.x0 * PRESCREEN_SCALE;
const segX0 = geometry.segX0 * PRESCREEN_SCALE;
const segX1 = geometry.segX1 * PRESCREEN_SCALE;
// the slack pixel keeps quantized low-res placements from cutting a
// boundary placement the full-res window allows
const minOverlap = geometry.minOverlap * PRESCREEN_SCALE - 1;
const result = new cv.Mat();
// entries the low-res pass cannot estimate (degenerate template or no valid
// placement after scaling) are force-kept but stay out of the front-runner
// max, or their untightened bound (≈1) prunes every estimated glyph
const kept: EligibleGlyph[] = [];
const scored: { entry: EligibleGlyph; est: number }[] = [];
const estimable: {
entry: EligibleGlyph;
small: { mat: Mat; rows: number; cols: number };
window: readonly [number, number];
}[] = [];
for (const entry of eligible) {
const small = smallGlyph(entry.glyph);
if (
small.rows < 2 ||
small.cols < 2 ||
small.rows > smallRegion.rows ||
small.cols > smallRegion.cols
small.rows > smallRows ||
small.cols > smallCols
) {
kept.push(entry);
continue;
}
cv.matchTemplate(smallRegion, small, result, cv.TM_CCOEFF_NORMED);
const rCols = smallRegion.cols - small.cols + 1;
const rRows = smallRegion.rows - small.rows + 1;
const overlapAt = (rx: number) =>
Math.min(x0 + rx + small.cols, segX1) - Math.max(x0 + rx, segX0);
let lo = 0;
while (lo < rCols && overlapAt(lo) < minOverlap) lo++;
let hi = rCols - 1;
while (hi >= lo && overlapAt(hi) < minOverlap) hi--;
if (hi < lo) {
const window = placementWindow(
smallCols - small.cols + 1,
(rx) =>
Math.min(x0 + rx + small.cols, segX1) - Math.max(x0 + rx, segX0) >=
minOverlap,
);
if (!window) {
kept.push(entry);
continue;
}
let maxVal = Number.NEGATIVE_INFINITY;
const scores = result.data32F;
for (let ry = 0, rowBase = 0; ry < rRows; ry++, rowBase += rCols) {
for (let rx = lo; rx <= hi; rx++) {
const v = scores[rowBase + rx]!;
if (v > maxVal) maxVal = v;
}
}
scored.push({ entry, est: maxVal * entry.bound });
estimable.push({ entry, small, window });
}
result.delete();
const [scoreOf] =
estimable.length > 0
? yield [
{
image: smallRegion,
templates: estimable.map((e) => e.small.mat),
windows: estimable.map((e) => e.window),
key: regionKey === undefined ? undefined : `${regionKey}|small`,
},
]
: [() => Number.NEGATIVE_INFINITY];
const scored = estimable.map(({ entry }, i) => ({
entry,
est: scoreOf!(i) * entry.bound,
}));
smallRegion.delete();
scored.sort((a, b) => b.est - a.est);
if (scored.length > 0) {
@@ -580,19 +630,19 @@ function prescreen(
return kept;
}
function smallGlyph(glyph: Glyph): Mat {
function smallGlyph(glyph: Glyph): { mat: Mat; rows: number; cols: number } {
if (!glyph.small) {
const cv = getCV();
const small = new cv.Mat();
cv.resize(
glyph.mat,
small,
scaledSize(glyph.mat.cols, glyph.mat.rows),
scaledSize(glyph.cols, glyph.rows),
0,
0,
cv.INTER_AREA,
);
glyph.small = small;
glyph.small = { mat: small, rows: small.rows, cols: small.cols };
}
return glyph.small;
}
@@ -605,9 +655,21 @@ function scaledSize(cols: number, rows: number) {
);
}
/** The contiguous [lo, hi] run of result columns where `valid` holds; null when none does. */
function placementWindow(
cols: number,
valid: (rx: number) => boolean,
): readonly [number, number] | null {
let lo = 0;
while (lo < cols && !valid(lo)) lo++;
let hi = cols - 1;
while (hi >= lo && !valid(hi)) hi--;
return hi < lo ? null : [lo, hi];
}
interface ClassifiedSegment {
seg: SegmentInfo;
ranked: ReturnType<typeof classifySegment>;
ranked: RankedCandidate[];
}
/**
@@ -631,25 +693,20 @@ const MERGE_WEAK_FRAGMENT = 0.65;
const MERGE_STRONG_READ = 0.8;
const MERGE_WEAK_SLACK = 0.03;
function mergeSplitGlyphs(
function* mergeSplitGlyphs(
items: ClassifiedSegment[],
binary: Mat,
masked: Mat,
set: GlyphSet,
maxCandidates: number,
): void {
ctx: RecutContext,
): MatchSteps<void> {
const { masked, set, maxCandidates, maskedKey } = ctx;
const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO));
const maxCharWidth = Math.round(set.medianWidth * 1.5);
for (let i = 0; i + 1 < items.length; ) {
const mergeCandidate = (i: number) => {
const a = items[i]!;
const b = items[i + 1]!;
const gap = b.seg.x0 - a.seg.x1;
const width = b.seg.x1 - a.seg.x0;
if (gap > maxGap || width > maxCharWidth) {
i++;
continue;
}
const seg = measureSegment(binary, { x0: a.seg.x0, x1: b.seg.x1 });
if (gap > maxGap || width > maxCharWidth) return null;
const seg = ctx.measure({ x0: a.seg.x0, x1: b.seg.x1 });
const aScore = a.ranked[0]?.score ?? 0;
const bScore = b.ranked[0]?.score ?? 0;
const fragmentBest = Math.max(aScore, bScore);
@@ -660,20 +717,51 @@ function mergeSplitGlyphs(
Math.max(MERGE_STRONG_READ, fragmentBest - MERGE_WEAK_SLACK),
);
}
return { seg, floor };
};
if (ctx.speculative) {
// batching driver: every pair's probe and full read in one lockstep, so
// the sequential pass below mostly hits the driver's score cache
const pairs = items
.slice(0, -1)
.map((_, i) => mergeCandidate(i))
.filter((pair) => pair !== null);
yield* all(
pairs.flatMap(({ seg, floor }) => [
classifySegment(masked, seg, set, floor, undefined, maskedKey),
classifySegment(masked, seg, set, undefined, maxCandidates, maskedKey),
]),
);
}
for (let i = 0; i + 1 < items.length; ) {
const candidate = mergeCandidate(i);
if (!candidate) {
i++;
continue;
}
const { seg, floor } = candidate;
// Probe with the floor first: most neighbor pairs are genuine letter pairs
// whose merge can't win, so the bound-sorted matching stops almost at once.
// Probe scores are exact, so "nothing beats the floor" is definitive.
const probe = classifySegment(masked, seg, set, floor);
const probe = yield* classifySegment(
masked,
seg,
set,
floor,
undefined,
maskedKey,
);
let merged = false;
if (probe.some((c) => c.score > floor)) {
// full run (rare): the winning merge's ranked list must also carry
// the sub-floor runner-up candidates downstream consumers see
const ranked = classifySegment(
const ranked = yield* classifySegment(
masked,
seg,
set,
undefined,
maxCandidates,
maskedKey,
);
if ((ranked[0]?.score ?? 0) > floor) {
// stay at i: the merged segment may absorb yet another stroke
@@ -745,10 +833,14 @@ function deepestDipCuts(
interface RecutContext {
profile: number[];
binary: Mat;
/** measureSegment on the binarized crop, memoized: the prefetch and the sequential passes measure the same spans */
measure: (seg: Segment) => SegmentInfo;
masked: Mat;
set: GlyphSet;
maxCandidates: number;
maskedKey: string | undefined;
/** prefetch whole candidate sets in lockstep (batching drivers only: on the sync path it is wasted work) */
speculative: boolean;
}
/**
@@ -756,38 +848,85 @@ interface RecutContext {
* `minScore` (raised to each adopted cut's weaker score); cuts inside `skip` are
* the original segmentation and are not retried.
*/
function bestRecut(
function recutHalves(
ctx: RecutContext,
span: Segment,
cuts: number[],
skip: Segment | null,
) {
return cuts
.filter((cut) => !(skip && cut >= skip.x0 && cut <= skip.x1))
.map((cut) => ({
left: ctx.measure({ x0: span.x0, x1: cut }),
right: ctx.measure({ x0: cut, x1: span.x1 }),
}));
}
/** Batching drivers only: every probe and full read `bestRecut` may ask for at `minScore`, in one lockstep. */
function prefetchRecuts(
ctx: RecutContext,
recuts: { halves: ReturnType<typeof recutHalves>; minScore: number }[],
): MatchSteps<unknown> {
const { masked, set, maxCandidates, maskedKey } = ctx;
return all(
recuts.flatMap(({ halves, minScore }) =>
halves.flatMap(({ left, right }) =>
[left, right].flatMap((seg) => [
classifySegment(masked, seg, set, minScore, undefined, maskedKey),
classifySegment(
masked,
seg,
set,
undefined,
maxCandidates,
maskedKey,
),
]),
),
),
);
}
function* bestRecut(
ctx: RecutContext,
span: Segment,
cuts: number[],
skip: Segment | null,
minScore: number,
): [ClassifiedSegment, ClassifiedSegment] | null {
const { binary, masked, set, maxCandidates } = ctx;
): MatchSteps<[ClassifiedSegment, ClassifiedSegment] | null> {
const { masked, set, maxCandidates, maskedKey } = ctx;
let floor = minScore;
let best: [ClassifiedSegment, ClassifiedSegment] | null = null;
for (const cut of cuts) {
if (skip && cut >= skip.x0 && cut <= skip.x1) continue;
const left = measureSegment(binary, { x0: span.x0, x1: cut });
const right = measureSegment(binary, { x0: cut, x1: span.x1 });
for (const { left, right } of recutHalves(ctx, span, cuts, skip)) {
// probe with the floor first (see mergeSplitGlyphs): most candidate
// cuts can't beat it and the probes early-stop almost immediately
const canWin = (seg: SegmentInfo) =>
classifySegment(masked, seg, set, floor).some((c) => c.score > floor);
if (!canWin(left) || !canWin(right)) continue;
const leftRanked = classifySegment(
const canWin = function* (seg: SegmentInfo): MatchSteps<boolean> {
const probe = yield* classifySegment(
masked,
seg,
set,
floor,
undefined,
maskedKey,
);
return probe.some((c) => c.score > floor);
};
if (!(yield* canWin(left)) || !(yield* canWin(right))) continue;
const leftRanked = yield* classifySegment(
masked,
left,
set,
undefined,
maxCandidates,
maskedKey,
);
const rightRanked = classifySegment(
const rightRanked = yield* classifySegment(
masked,
right,
set,
undefined,
maxCandidates,
maskedKey,
);
const weaker = Math.min(
leftRanked[0]?.score ?? 0,
@@ -803,30 +942,48 @@ function bestRecut(
return best;
}
function recutMiscutPairs(items: ClassifiedSegment[], ctx: RecutContext): void {
function* recutMiscutPairs(
items: ClassifiedSegment[],
ctx: RecutContext,
): MatchSteps<void> {
const maxGap = Math.max(
3,
Math.round(ctx.set.medianWidth * MERGE_MAX_GAP_RATIO),
);
for (let i = 0; i + 1 < items.length; i++) {
const recut = (i: number) => {
const a = items[i]!;
const b = items[i + 1]!;
const aScore = a.ranked[0]?.score ?? 0;
const bScore = b.ranked[0]?.score ?? 0;
if (aScore >= RECUT_MAX_SCORE || bScore >= RECUT_MAX_SCORE) continue;
if (b.seg.x0 - a.seg.x1 > maxGap) continue;
if (aScore >= RECUT_MAX_SCORE || bScore >= RECUT_MAX_SCORE) return null;
if (b.seg.x0 - a.seg.x1 > maxGap) return null;
const floor = Math.max(
RECUT_MIN_SCORE,
Math.max(aScore, bScore) + RECUT_MARGIN,
);
const span = { x0: a.seg.x0, x1: b.seg.x1 };
const best = bestRecut(
const cuts = dipCuts(ctx.profile, span.x0, span.x1);
const skip = { x0: a.seg.x1, x1: b.seg.x0 };
return { span, cuts, skip, floor };
};
if (ctx.speculative) {
yield* prefetchRecuts(
ctx,
span,
dipCuts(ctx.profile, span.x0, span.x1),
{ x0: a.seg.x1, x1: b.seg.x0 },
floor,
items
.slice(0, -1)
.map((_, i) => recut(i))
.filter((r) => r !== null)
.map(({ span, cuts, skip, floor }) => ({
halves: recutHalves(ctx, span, cuts, skip),
minScore: floor,
})),
);
}
for (let i = 0; i + 1 < items.length; i++) {
const candidate = recut(i);
if (!candidate) continue;
const { span, cuts, skip, floor } = candidate;
const best = yield* bestRecut(ctx, span, cuts, skip, floor);
if (best) items.splice(i, 2, ...best);
}
}
@@ -842,13 +999,15 @@ function recutMiscutPairs(items: ClassifiedSegment[], ctx: RecutContext): void {
const FUSED_MIN_WIDTH_RATIO = 1.2;
const FUSED_DIPS_TRIED = 3;
function splitFusedGlyphs(items: ClassifiedSegment[], ctx: RecutContext): void {
function* splitFusedGlyphs(
items: ClassifiedSegment[],
ctx: RecutContext,
): MatchSteps<void> {
const minWidth = ctx.set.medianWidth * FUSED_MIN_WIDTH_RATIO;
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
const fused = (item: ClassifiedSegment) => {
const score = item.ranked[0]?.score ?? 0;
if (score >= RECUT_MAX_SCORE || item.seg.x1 - item.seg.x0 < minWidth)
continue;
return null;
const floor = Math.max(RECUT_MIN_SCORE, score + RECUT_MARGIN);
const cuts = deepestDipCuts(
ctx.profile,
@@ -856,7 +1015,30 @@ function splitFusedGlyphs(items: ClassifiedSegment[], ctx: RecutContext): void {
item.seg.x1,
FUSED_DIPS_TRIED,
);
const best = bestRecut(ctx, item.seg, cuts, null, floor);
return { floor, cuts };
};
if (ctx.speculative) {
yield* prefetchRecuts(
ctx,
items.flatMap((item) => {
const f = fused(item);
return f
? [
{
halves: recutHalves(ctx, item.seg, f.cuts, null),
minScore: f.floor,
},
]
: [];
}),
);
}
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
const candidate = fused(item);
if (!candidate) continue;
const { floor, cuts } = candidate;
const best = yield* bestRecut(ctx, item.seg, cuts, null, floor);
if (best) {
items.splice(i, 1, ...best);
i++;
@@ -864,12 +1046,19 @@ function splitFusedGlyphs(items: ClassifiedSegment[], ctx: RecutContext): void {
}
}
/** Recognizes white-on-dark text in a grayscale crop tight to one text line. */
export function recognizeText(
let maskedKeySeq = 0;
/**
* Recognizes white-on-dark text in a grayscale crop tight to one text line, as
* match steps; `speculative` prefetches whole candidate sets in lockstep, which
* only pays off under a batching driver.
*/
export function* recognizeTextSteps(
gray: Mat,
set: GlyphSet,
options: RecognizeOptions = {},
): RecognizedText {
speculative = false,
): MatchSteps<RecognizedText> {
const cv = getCV();
const {
binThreshold = 150,
@@ -892,19 +1081,43 @@ export function recognizeText(
gray.copyTo(masked, mask);
mask.delete();
const measured = new Map<number, SegmentInfo>();
const measure = (seg: Segment) => {
const spanKey = seg.x0 * 65536 + seg.x1;
let info = measured.get(spanKey);
if (!info) {
info = measureSegment(binary, seg);
measured.set(spanKey, info);
}
return info;
};
const profile = columnProfile(binary);
const segments = segmentColumns(profile, minColumnPixels)
.flatMap((s) => splitWideSegment(profile, s, set.medianWidth))
.map((s) => measureSegment(binary, s));
.map((s) => measure(s));
const items: ClassifiedSegment[] = segments.map((seg) => ({
const maskedKey = speculative ? `m${maskedKeySeq++}` : undefined;
const rankedSegments = yield* all(
segments.map((seg) =>
classifySegment(masked, seg, set, undefined, maxCandidates, maskedKey),
),
);
const items: ClassifiedSegment[] = segments.map((seg, i) => ({
seg,
ranked: classifySegment(masked, seg, set, undefined, maxCandidates),
ranked: rankedSegments[i]!,
}));
mergeSplitGlyphs(items, binary, masked, set, maxCandidates);
const ctx: RecutContext = { profile, binary, masked, set, maxCandidates };
recutMiscutPairs(items, ctx);
splitFusedGlyphs(items, ctx);
const ctx: RecutContext = {
profile,
measure,
masked,
set,
maxCandidates,
maskedKey,
speculative,
};
yield* mergeSplitGlyphs(items, ctx);
yield* recutMiscutPairs(items, ctx);
yield* splitFusedGlyphs(items, ctx);
const chars: RecognizedChar[] = [];
let text = "";

View File

@@ -56,6 +56,58 @@ export function normalizeFrame(src: Mat): Mat {
return dst;
}
let conversions: { frame: Mat; gray?: Mat; rgb?: Mat; hsv?: Mat } | null = null;
function conversionsOf(frame: Mat) {
if (conversions?.frame !== frame) {
conversions?.gray?.delete();
conversions?.rgb?.delete();
conversions?.hsv?.delete();
conversions = { frame };
}
return conversions;
}
/**
* Grayscale of a canonical frame, shared by every gate and parse that reads
* the frame: the first caller converts, the rest reuse the mat until a
* different frame is converted. Read-only; never delete it. Pass only the
* frame the detectors receive, never a derived mat (that would release the
* frame's conversions while a parse still reads them).
*/
export function frameGray(frame: Mat): Mat {
const cached = conversionsOf(frame);
if (!cached.gray) {
const cv = getCV();
cached.gray = new cv.Mat();
cv.cvtColor(frame, cached.gray, cv.COLOR_RGBA2GRAY);
}
return cached.gray;
}
/** RGB of a canonical frame, shared like frameGray. */
export function frameRgb(frame: Mat): Mat {
const cached = conversionsOf(frame);
if (!cached.rgb) {
const cv = getCV();
cached.rgb = new cv.Mat();
cv.cvtColor(frame, cached.rgb, cv.COLOR_RGBA2RGB);
}
return cached.rgb;
}
/** HSV (from frameRgb) of a canonical frame, shared like frameGray. */
export function frameHsv(frame: Mat): Mat {
const rgb = frameRgb(frame);
const cached = conversionsOf(frame);
if (!cached.hsv) {
const cv = getCV();
cached.hsv = new cv.Mat();
cv.cvtColor(rgb, cached.hsv, cv.COLOR_RGB2HSV);
}
return cached.hsv;
}
/**
* Crops a rect out of a mat as a view: fine as *input* to OpenCV calls but
* NEVER read `.data` off it — this opencv.js build mishandles `.data` and

View File

@@ -0,0 +1,341 @@
/**
* Template matching as resumable steps. Recognizers are generators that
* yield every TM_CCOEFF_NORMED match their next decision needs and resume
* with the scores, so one sequential algorithm runs on two drivers: `runSync`
* answers each request lazily on the calling thread, while a batching driver
* (worker/gpu-matcher.ts) answers the union of every pending request from many
* generators with one GPU dispatch. `all` steps generators in lockstep so
* independent reads share round trips.
*
* Both drivers compute the score exactly: integer cross, window and square
* sums, one f64 normalization with OpenCV's guards (`normalizeNcc`), rounded
* to f32 like OpenCV's result mat. A score is therefore bit-identical on
* either driver (OpenCV's own matchTemplate runs a float DFT whose result
* wanders by up to ~3e-4, enough to flip a near-tie between them).
*/
import { simdCrossSums } from "./cross-sums";
import { getCV, type Mat } from "./cv";
export interface MatchRequest {
/** search image; must stay alive until the generator resumes past its scores */
image: Mat;
templates: readonly Mat[];
/**
* per template: the result columns [lo, hi] whose max counts (every row);
* omitted = the whole result map. An empty window (hi < lo) is never asked.
*/
windows?: readonly (readonly [number, number])[];
/**
* content identity of `image` (same key = same pixels) so a batching driver
* can reuse scores across steps; omitted = never cached
*/
key?: string;
}
/** Max score per template index; the sync driver matches on first access. */
export type MatchScores = (templateIndex: number) => number;
export type MatchSteps<T> = Generator<MatchRequest[], T, MatchScores[]>;
/**
* Runs `steps` to completion on the calling thread, scoring exactly (see the
* module header); `step` is where a batching driver that gave up mid-run
* hands over its pending step.
*/
export function runSync<T>(
steps: MatchSteps<T>,
step: IteratorResult<MatchRequest[], T> = steps.next(),
): T {
let current = step;
while (!current.done) {
const scorers = current.value.map(exactScorer);
current = steps.next(scorers.map((scorer) => scorer.scores));
for (const scorer of scorers) scorer.release();
}
return current.value;
}
/**
* OpenCV's TM_CCOEFF_NORMED over exact integer sums: `num` = N·ΣTI − Σ_c
* ΣI_c·ΣT_c, `windowVar` = N·ΣI² − Σ_c (ΣI_c)², `templVar` the template's
* alike; its guards (flat template → 1, flat window → 0, |r| ≥ 1 clamped or
* voided) and its f32 result.
*/
export function normalizeNcc(
num: number,
windowVar: number,
templVar: number,
): number {
if (templVar === 0) return 1;
if (windowVar <= 0) return 0;
const r = num / (Math.sqrt(windowVar) * Math.sqrt(templVar));
const a = Math.abs(r);
if (a < 1) return Math.fround(r);
if (a < 1.125) return r > 0 ? 1 : -1;
return 0;
}
type StepResults<T extends readonly MatchSteps<unknown>[]> = {
-readonly [K in keyof T]: T[K] extends MatchSteps<infer R> ? R : never;
};
/** Steps several generators in lockstep: each yield is the union of their pending requests. */
export function all<T extends readonly MatchSteps<unknown>[]>(
steps: readonly [...T],
): MatchSteps<StepResults<T>>;
export function all<T>(steps: readonly MatchSteps<T>[]): MatchSteps<T[]>;
export function* all<T>(steps: readonly MatchSteps<T>[]): MatchSteps<T[]> {
const results = new Array<T>(steps.length);
let active = steps.map((gen, index) => ({ gen, index, step: gen.next() }));
for (;;) {
active = active.filter(({ index, step }) => {
if (step.done) results[index] = step.value;
return !step.done;
});
if (active.length === 0) return results;
const scores = yield active.flatMap(
({ step }) => step.value as MatchRequest[],
);
let offset = 0;
for (const entry of active) {
const count = (entry.step.value as MatchRequest[]).length;
entry.step = entry.gen.next(scores.slice(offset, offset + count));
offset += count;
}
}
}
/** A step sequence that asks for nothing and returns `value`: an `all` slot whose read is skipped or already known. */
// biome-ignore lint/correctness/useYield: completing without a request is the point
export function* done<T>(value: T): MatchSteps<T> {
return value;
}
/**
* Above this many multiply-adds (placements × template samples) a template's
* cross sums come from one f64 `filter2D` (a DFT) instead of the direct SIMD
* loops: the measured crossover.
*/
const DIRECT_MAX_WORK = 1_500_000;
interface Pixels {
rows: number;
cols: number;
ch: number;
data: Uint8Array;
}
interface TemplatePixels extends Pixels {
n: number;
/** per channel ΣT */
sum: number[];
/** Σ_c (N·ΣT_c² − (ΣT_c)²) */
varInt: number;
}
interface ImagePixels extends Pixels {
/** per-channel integral image, (rows + 1) × (cols + 1) × ch */
sum: Float64Array;
/** integral image of Σ_c I_c², (rows + 1) × (cols + 1) */
squares: Float64Array;
/** the f64 plane filter2D reads, made on first need and freed with the scorer */
plane: Mat | null;
}
/** Templates are long-lived (atlases, icon sets): their pixels and sums are read once. */
const templatePixels = new WeakMap<Mat, TemplatePixels>();
/**
* Exact scores of one request, each computed on first access; released (and
* guarded against late reads) once the generator resumed past the step.
*/
function exactScorer(request: MatchRequest): {
scores: MatchScores;
release: () => void;
} {
let image: ImagePixels | null = null;
let released = false;
return {
scores: (index) => {
if (released) throw new Error("match scores read after their step");
image ??= imagePixels(request.image);
return exactWindowMax(
image,
templateOf(request.templates[index]!),
request.windows?.[index],
);
},
release: () => {
released = true;
image?.plane?.delete();
},
};
}
/** Max exact score over the placements in `window` (every row). */
function exactWindowMax(
image: ImagePixels,
template: TemplatePixels,
window: readonly [number, number] | undefined,
): number {
if (template.varInt === 0) return 1;
const { rows, cols, ch, sum, squares } = image;
const [lo, hi] = window ?? [0, cols - template.cols];
const placementRows = rows - template.rows + 1;
const work = placementRows * (hi - lo + 1) * template.n * ch;
const filtered =
work > DIRECT_MAX_WORK ? filteredCrossSums(image, template) : null;
// read in place: nothing below allocates on the WASM heap, so the view stays valid
const cross = filtered?.data64F as Float64Array | undefined;
const crossStride = cols * ch;
const direct = filtered
? null
: (simdCrossSums()?.compute(image, template, lo, hi) ?? null);
const width = hi - lo + 1;
const sumStride = (cols + 1) * ch;
const squareStride = cols + 1;
let best = Number.NEGATIVE_INFINITY;
for (let y = 0; y < placementRows; y++) {
for (let x = lo; x <= hi; x++) {
const P = cross
? Math.round(cross[y * crossStride + x * ch]!)
: direct
? direct[y * width + x - lo]!
: directCrossSum(image, template, x, y);
const x1 = x + template.cols;
const y1 = y + template.rows;
let num = template.n * P;
let windowVar =
template.n *
(squares[y1 * squareStride + x1]! -
squares[y1 * squareStride + x]! -
squares[y * squareStride + x1]! +
squares[y * squareStride + x]!);
for (let c = 0; c < ch; c++) {
const S =
sum[y1 * sumStride + x1 * ch + c]! -
sum[y1 * sumStride + x * ch + c]! -
sum[y * sumStride + x1 * ch + c]! +
sum[y * sumStride + x * ch + c]!;
num -= S * template.sum[c]!;
windowVar -= S * S;
}
const score = normalizeNcc(num, windowVar, template.varInt);
if (score > best) best = score;
}
}
filtered?.delete();
return best;
}
/** ΣT·I over every channel at placement (x, y); rows are contiguous interleaved samples. */
function directCrossSum(
image: ImagePixels,
template: TemplatePixels,
x: number,
y: number,
): number {
const rowLength = template.cols * image.ch;
const imageData = image.data;
const templateData = template.data;
let P = 0;
for (let ty = 0; ty < template.rows; ty++) {
const ib = ((y + ty) * image.cols + x) * image.ch;
const tb = ty * rowLength;
for (let k = 0; k < rowLength; k++) {
P += imageData[ib + k]! * templateData[tb + k]!;
}
}
return P;
}
/**
* Cross sums of every placement at once: the interleaved channels read as one
* plane `ch` times wider, so a single f64 `filter2D` correlates all of them
* (column x·ch of the output is placement x). Exact after rounding: the
* largest sum (< 2^32) leaves the f64 DFT's error far under 0.5.
*/
function filteredCrossSums(image: ImagePixels, template: TemplatePixels): Mat {
const cv = getCV();
image.plane ??= planeOf(image);
const kernel = planeOf(template);
const out = new cv.Mat();
cv.filter2D(
image.plane,
out,
cv.CV_64F,
kernel,
new cv.Point(0, 0),
0,
cv.BORDER_CONSTANT,
);
kernel.delete();
return out;
}
/** A single-channel f64 plane of interleaved samples. */
function planeOf(pixels: Pixels): Mat {
const cv = getCV();
const plane = new cv.Mat(pixels.rows, pixels.cols * pixels.ch, cv.CV_64F);
plane.data64F.set(pixels.data);
return plane;
}
function readPixels(mat: Mat): Pixels {
const copy = new (getCV().Mat)();
mat.copyTo(copy);
const pixels = {
rows: copy.rows,
cols: copy.cols,
ch: copy.channels(),
data: new Uint8Array(copy.data),
};
copy.delete();
return pixels;
}
function imagePixels(mat: Mat): ImagePixels {
const pixels = readPixels(mat);
const { rows, cols, ch, data } = pixels;
const sum = new Float64Array((rows + 1) * (cols + 1) * ch);
const squares = new Float64Array((rows + 1) * (cols + 1));
const sumStride = (cols + 1) * ch;
for (let y = 0; y < rows; y++) {
let squareRow = 0;
const rowSums = new Array<number>(ch).fill(0);
for (let x = 0; x < cols; x++) {
for (let c = 0; c < ch; c++) {
const v = data[(y * cols + x) * ch + c]!;
rowSums[c]! += v;
squareRow += v * v;
sum[(y + 1) * sumStride + (x + 1) * ch + c] =
sum[y * sumStride + (x + 1) * ch + c]! + rowSums[c]!;
}
squares[(y + 1) * (cols + 1) + x + 1] =
squares[y * (cols + 1) + x + 1]! + squareRow;
}
}
return { ...pixels, sum, squares, plane: null };
}
function templateOf(mat: Mat): TemplatePixels {
const known = templatePixels.get(mat);
if (known) return known;
const pixels = readPixels(mat);
const { ch, data } = pixels;
const n = pixels.rows * pixels.cols;
const sum = new Array<number>(ch).fill(0);
const sq = new Array<number>(ch).fill(0);
for (let i = 0; i < n; i++) {
for (let c = 0; c < ch; c++) {
const v = data[i * ch + c]!;
sum[c]! += v;
sq[c]! += v * v;
}
}
let varInt = 0;
for (let c = 0; c < ch; c++) varInt += n * sq[c]! - sum[c]! * sum[c]!;
const template = { ...pixels, n, sum, varInt };
templatePixels.set(mat, template);
return template;
}

View File

@@ -0,0 +1,30 @@
/**
* WebGPU for the Node scripts: Dawn from the `webgpu` npm package, which is
* deliberately not a repo dependency (a native binary nothing ships with) —
* install it anywhere (`npm i webgpu` in a scratch dir) and point
* WEBGPU_NODE at its package dir. Load OpenCV first: in a plain Node process
* the two crashed together, under vite-node they coexist.
*/
import { createRequire } from "node:module";
let instance: GPU | null = null;
/** Dawn's `navigator.gpu` equivalent; throws when WEBGPU_NODE is unset. */
export function nodeGpu(): GPU {
if (instance) return instance;
const dir = process.env.WEBGPU_NODE;
if (!dir) {
throw new Error(
"set WEBGPU_NODE to an installed `webgpu` package dir (npm i webgpu)",
);
}
const { create, globals } = createRequire(import.meta.url)(dir) as {
create: (flags: string[]) => GPU;
globals: Record<string, unknown>;
};
Object.assign(globalThis, globals);
// a module-level reference: Dawn tears the adapter down (and Node
// segfaults mid-run) once the instance is garbage-collected
instance = create([]);
return instance;
}

View File

@@ -16,6 +16,7 @@ import {
VideoSampleSink,
} from "mediabunny";
import { loadOpenCV } from "../core/cv";
import { runDetectorPass } from "../core/detectors/frame-pass";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import {
createAllDetectors,
@@ -24,12 +25,13 @@ import {
import { DetectorScheduler } from "../core/detectors/scheduler";
import {
createScanTelemetry,
detectorTelemetry,
type ScanTelemetry,
} from "../core/detectors/telemetry";
import type { Detector } from "../core/detectors/types";
import { normalizeFrame, toMat } from "../core/image";
import { TimelineBuilder } from "../core/timeline/index";
import { createGpuFrameScaler, type GpuFrameScaler } from "./gpu-frame-scaler";
import { createGpuMatcher, type GpuMatcher } from "./gpu-matcher";
import type {
AnalyzeRequest,
InitRequest,
@@ -47,6 +49,8 @@ const PREVIEW_WIDTH = 480;
const PREVIEW_HEIGHT = 270;
let detectors: Detector<unknown>[] = [];
let gpuMatcher: GpuMatcher | null = null;
let gpuScaler: GpuFrameScaler | null = null;
let scheduler: DetectorScheduler | null = null;
/** null unless the init message asked for telemetry */
let telemetry: ScanTelemetry | null = null;
@@ -71,9 +75,22 @@ async function init({
assetsBaseUrl,
suppressSteadyFrames = true,
collectTelemetry: collect = false,
webgpu = false,
}: InitRequest): Promise<void> {
try {
await loadOpenCV();
if (webgpu && navigator.gpu) {
gpuMatcher = await createGpuMatcher(navigator.gpu).catch((error) => {
// biome-ignore lint/suspicious/noConsole: a missing GPU silently costs speed, so say why
console.warn("scanner: WebGPU unavailable, matching on the CPU", error);
return null;
});
if (gpuMatcher) {
gpuScaler = await createGpuFrameScaler(gpuMatcher.device).catch(
() => null,
);
}
}
const resources = await fetchScoreboardResources(assetsBaseUrl);
detectors = createAllDetectors(resources);
scheduler = new DetectorScheduler(detectors, {
@@ -115,7 +132,10 @@ async function analyzeFrame(
});
let frame: ReturnType<typeof normalizeFrame>;
try {
frame = normalizeFrame(src);
frame =
gpuScaler && gpuRunner()
? await gpuScaler.normalize(src)
: normalizeFrame(src);
} finally {
src.delete();
}
@@ -130,31 +150,16 @@ async function analyzeFrame(
};
try {
for (const detector of detectors) {
if (!due.includes(detector.id)) continue;
const counters = telemetry
? detectorTelemetry(telemetry, detector.id)
: null;
const gateStart = counters ? performance.now() : 0;
const gate = detector.gate(frame);
if (counters) {
counters.checks++;
counters.gateMs += performance.now() - gateStart;
}
scheduler!.recordGate(detector.id, t, gate.pass, gate.signature);
if (counters && gate.pass) counters.gatePasses++;
const runParse = gate.pass && scheduler!.shouldParse(detector.id, t);
if (counters && gate.pass && !runParse) counters.suppressedParses++;
let events: ReturnType<typeof detector.parse> = [];
if (runParse) {
const parseStart = counters ? performance.now() : 0;
events = detector.parse(frame, t, gate);
if (counters) {
counters.parses++;
counters.parseMs += performance.now() - parseStart;
}
scheduler!.recordParse(detector.id, t, events);
}
const outcomes = await runDetectorPass({
frame,
t,
detectors,
due,
scheduler: scheduler!,
telemetry,
runSteps: gpuRunner(),
});
for (const { detector, gate, events } of outcomes) {
let listed = false;
for (const event of events) {
const { action } = shadowTimeline.push(event);
@@ -203,6 +208,7 @@ async function scanChunk({
telemetry = freshTelemetry();
shadowTimeline = new TimelineBuilder();
const wallStart = performance.now();
const gpuWaitStart = gpuMatcher?.stats.gpuWaitMs ?? 0;
let lastProgressAt = 0;
let lastPreviewAt = 0;
let cursor = tStart;
@@ -304,7 +310,11 @@ async function scanChunk({
}
}
if (telemetry) telemetry.wallMs = performance.now() - wallStart;
if (telemetry) {
telemetry.wallMs = performance.now() - wallStart;
telemetry.gpuScans = gpuMatcher ? 1 : 0;
telemetry.gpuWaitMs = (gpuMatcher?.stats.gpuWaitMs ?? 0) - gpuWaitStart;
}
post({ kind: "chunkDone", chunkIndex, telemetry });
} catch (error) {
post({
@@ -316,6 +326,20 @@ async function scanChunk({
}
}
/** The GPU matcher's runner while its device lives; once lost, parses run on the CPU. */
function gpuRunner() {
if (!gpuMatcher) return undefined;
if (!gpuMatcher.lost) return gpuMatcher.run;
// biome-ignore lint/suspicious/noConsole: a lost device silently costs speed, so say so once
console.warn(
"scanner: WebGPU device lost, continuing on the CPU",
gpuMatcher.lostReason,
);
gpuMatcher = null;
gpuScaler = null;
return undefined;
}
function freshTelemetry(): ScanTelemetry | null {
return collectTelemetry ? createScanTelemetry() : null;
}

View File

@@ -66,6 +66,8 @@ export class AnalyzerClient {
collectTelemetry?: boolean;
/** max frames buffered while a frame is in flight (0 = drop them) */
frameQueueLimit?: number;
/** match templates on WebGPU when the browser has an adapter (settings `webgpu`) */
webgpu?: boolean;
} = {},
) {
this.#frameQueueLimit = options.frameQueueLimit ?? 0;
@@ -120,6 +122,7 @@ export class AnalyzerClient {
assetsBaseUrl: Config.staticAssetsUrl,
suppressSteadyFrames: options.suppressSteadyFrames ?? true,
collectTelemetry: options.collectTelemetry ?? false,
webgpu: options.webgpu ?? false,
});
}

View File

@@ -0,0 +1,246 @@
/**
* normalizeFrame (core/image.ts) with its INTER_CUBIC upscale on the GPU —
* the costliest per-frame step for sub-1080p sources (~13-25 ms of WASM per
* 720p frame). The kernel reproduces OpenCV's 8-bit cubic resize exactly: the
* same coefficient tables (float math and lrint rounding, computed here in f32
* steps), the horizontal pass as integer sums, the vertical pass as the
* fixed-point combine `(Σ + 2^21) >> 22` with saturation, clamped borders.
* Integer arithmetic makes it identical on every GPU; frames it does not cover
* (exact 1080p copies, INTER_AREA downscales) run the CPU path unchanged.
*/
import {
CANONICAL_HEIGHT,
CANONICAL_WIDTH,
detectContentBox,
} from "../core/canonical";
import { getCV, type Mat } from "../core/cv";
import { normalizeFrame } from "../core/image";
const WORKGROUP_SIZE = 64;
const MAX_DISPATCH_X = 65535;
/** INTER_RESIZE_COEF_SCALE */
const COEF_SCALE = 2048;
const BUFFER_MAP_READ = 0x0001;
const BUFFER_COPY_SRC = 0x0004;
const BUFFER_COPY_DST = 0x0008;
const BUFFER_UNIFORM = 0x0040;
const BUFFER_STORAGE = 0x0080;
const MAP_MODE_READ = 0x0001;
const SHADER = /* wgsl */ `
struct Params { srcStride: u32, x0: u32, y0: u32, w: u32, h: u32, dw: u32, dh: u32, _pad: u32 };
@group(0) @binding(0) var<storage, read> src: array<u32>;
@group(0) @binding(1) var<storage, read> tables: array<i32>;
@group(0) @binding(2) var<storage, read_write> dst: array<u32>;
@group(0) @binding(3) var<uniform> params: Params;
fn texel(x: i32, y: i32) -> vec4<i32> {
let cx = u32(clamp(x, 0, i32(params.w) - 1));
let cy = u32(clamp(y, 0, i32(params.h) - 1));
let p = src[(params.y0 + cy) * params.srcStride + params.x0 + cx];
return vec4<i32>(i32(p & 0xffu), i32((p >> 8u) & 0xffu), i32((p >> 16u) & 0xffu), i32(p >> 24u));
}
@compute @workgroup_size(${WORKGROUP_SIZE})
fn main(@builtin(global_invocation_id) gid: vec3<u32>, @builtin(num_workgroups) nwg: vec3<u32>) {
let i = gid.x + gid.y * nwg.x * ${WORKGROUP_SIZE}u;
if (i >= params.dw * params.dh) { return; }
let dx = i % params.dw; let dy = i / params.dw;
// tables: per column (sx, 4 coefficients), then per row (sy, 4 coefficients)
let xb = dx * 5u; let yb = (params.dw + dy) * 5u;
let sx = tables[xb]; let sy = tables[yb];
var acc = vec4<i32>(0);
for (var k = 0; k < 4; k++) {
var h = vec4<i32>(0);
for (var j = 0; j < 4; j++) {
h += texel(sx - 1 + j, sy - 1 + k) * tables[xb + 1u + u32(j)];
}
acc += h * tables[yb + 1u + u32(k)];
}
let v = vec4<u32>(clamp((acc + vec4<i32>(1 << 21)) >> vec4<u32>(22u), vec4<i32>(0), vec4<i32>(255)));
dst[i] = v.x | (v.y << 8u) | (v.z << 16u) | (v.w << 24u);
}
`;
export interface GpuFrameScaler {
/** normalizeFrame, with sub-canonical pictures upscaled on the GPU; a failed dispatch falls back to the CPU. */
normalize(src: Mat): Promise<Mat>;
}
export async function createGpuFrameScaler(
device: GPUDevice,
): Promise<GpuFrameScaler> {
device.pushErrorScope("validation");
const pipeline = device.createComputePipeline({
layout: "auto",
compute: {
module: device.createShaderModule({ code: SHADER }),
entryPoint: "main",
},
});
const pipelineError = await device.popErrorScope();
if (pipelineError)
throw new Error(`scaler pipeline: ${pipelineError.message}`);
const paramsBuffer = device.createBuffer({
size: 32,
usage: BUFFER_UNIFORM | BUFFER_COPY_DST,
});
const tablesByShape = new Map<string, Int32Array>();
const dstBytes = CANONICAL_WIDTH * CANONICAL_HEIGHT * 4;
const dstBuffer = device.createBuffer({
size: dstBytes,
usage: BUFFER_STORAGE | BUFFER_COPY_SRC,
});
const readBuffer = device.createBuffer({
size: dstBytes,
usage: BUFFER_MAP_READ | BUFFER_COPY_DST,
});
let srcBuffer: GPUBuffer | null = null;
let tablesBuffer: GPUBuffer | null = null;
let tablesKey = "";
let failed = false;
async function normalize(src: Mat): Promise<Mat> {
if (failed) return normalizeFrame(src);
const box = detectContentBox(src.cols, src.rows, src.data as Uint8Array);
const x0 = box?.x ?? 0;
const y0 = box?.y ?? 0;
const w = box?.w ?? src.cols;
const h = box?.h ?? src.rows;
// exact-size copies and INTER_AREA downscales stay on the CPU
if (
(w === CANONICAL_WIDTH && h === CANONICAL_HEIGHT) ||
w > CANONICAL_WIDTH
) {
return normalizeFrame(src);
}
try {
return await upscale(src, x0, y0, w, h);
} catch {
failed = true;
return normalizeFrame(src);
}
}
async function upscale(
src: Mat,
x0: number,
y0: number,
w: number,
h: number,
): Promise<Mat> {
const key = `${w}x${h}`;
if (tablesKey !== key) {
let tables = tablesByShape.get(key);
if (!tables) {
tables = new Int32Array([
...cubicTable(w, CANONICAL_WIDTH),
...cubicTable(h, CANONICAL_HEIGHT),
]);
tablesByShape.set(key, tables);
}
tablesBuffer?.destroy();
tablesBuffer = device.createBuffer({
size: tables.byteLength,
usage: BUFFER_STORAGE | BUFFER_COPY_DST,
});
device.queue.writeBuffer(tablesBuffer, 0, tables);
tablesKey = key;
}
const srcData = src.data as Uint8Array;
if (!srcBuffer || srcBuffer.size < srcData.byteLength) {
srcBuffer?.destroy();
srcBuffer = device.createBuffer({
size: srcData.byteLength,
usage: BUFFER_STORAGE | BUFFER_COPY_DST,
});
}
device.queue.writeBuffer(srcBuffer, 0, srcData);
device.queue.writeBuffer(
paramsBuffer,
0,
new Uint32Array([
src.cols,
x0,
y0,
w,
h,
CANONICAL_WIDTH,
CANONICAL_HEIGHT,
0,
]),
);
// an invalid submit would still map the read buffer, stale: check it
device.pushErrorScope("validation");
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(
0,
device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [srcBuffer, tablesBuffer!, dstBuffer, paramsBuffer].map(
(buffer, binding) => ({ binding, resource: { buffer } }),
),
}),
);
const groups = Math.ceil(
(CANONICAL_WIDTH * CANONICAL_HEIGHT) / WORKGROUP_SIZE,
);
pass.dispatchWorkgroups(
Math.min(groups, MAX_DISPATCH_X),
Math.ceil(groups / MAX_DISPATCH_X),
);
pass.end();
encoder.copyBufferToBuffer(dstBuffer, 0, readBuffer, 0, dstBytes);
device.queue.submit([encoder.finish()]);
const submitError = await device.popErrorScope();
if (submitError) throw new Error(`scaler: ${submitError.message}`);
await readBuffer.mapAsync(MAP_MODE_READ);
const cv = getCV();
const dst = new cv.Mat(CANONICAL_HEIGHT, CANONICAL_WIDTH, cv.CV_8UC4);
dst.data.set(new Uint8Array(readBuffer.getMappedRange()));
readBuffer.unmap();
return dst;
}
return { normalize };
}
/**
* OpenCV's cubic resize table for one axis: per destination index the source
* index `floor(fx)` and four fixed-point weights, with OpenCV's f32 math.
*/
function cubicTable(srcSize: number, dstSize: number): number[] {
const f = Math.fround;
const scale = 1 / (dstSize / srcSize);
const table: number[] = [];
for (let d = 0; d < dstSize; d++) {
let fx = f((d + 0.5) * scale - 0.5);
const sx = Math.floor(fx);
fx = f(fx - sx);
const A = f(-0.75);
const x1 = f(fx + 1);
const c0 = f(
f(f(f(f(f(A * x1) - f(5 * A)) * x1) + f(8 * A)) * x1) - f(4 * A),
);
const c1 = f(f(f(f(f(f(A + 2) * fx) - f(A + 3)) * fx) * fx) + 1);
const omx = f(1 - fx);
const c2 = f(f(f(f(f(f(A + 2) * omx) - f(A + 3)) * omx) * omx) + 1);
const c3 = f(f(f(1 - c0) - c1) - c2);
table.push(
sx,
...[c0, c1, c2, c3].map((c) =>
Math.max(-32768, Math.min(32767, roundHalfEven(f(c * COEF_SCALE)))),
),
);
}
return table;
}
/** lrint in the default rounding mode, as OpenCV's cvRound compiles under clang */
function roundHalfEven(v: number): number {
const r = Math.round(v);
return Math.abs(v % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r;
}

View File

@@ -0,0 +1,908 @@
/**
* WebGPU batching driver for match steps (core/match-steps.ts). Every pending
* request of a step becomes jobs (one image against one template, max over a
* placement window) answered by one submit of three passes:
*
* 1. `sat`: per image, integral images of each channel and of the summed
* squares (u32, wrapping: a window's sum is exact whenever it fits in 32
* bits, which MAX_TEMPLATE_SAMPLES guarantees).
* 2. `score`: one thread per BLOCK_ROWS vertically adjacent placements of a
* job accumulates their cross sums ΣT·I with packed u8 dot products (each
* image row read once), takes the window sums from the integral images,
* and folds f32 estimates of the scores into the job's max.
* 3. `select`: placements within EPS of that max recompute their exact
* integer sums (64-bit, emulated) and return them; a flat window
* (variance 0, score exactly 0) only raises a flag.
*
* The CPU finishes the candidates with `normalizeNcc`: the exact score,
* identical on every GPU and to the CPU driver's (`runSync`), which scores
* exactly too. scripts/scanner/gpu-replay.ts checks both against a JS
* reference.
*/
import { getCV, type Mat } from "../core/cv";
import {
type MatchRequest,
type MatchSteps,
normalizeNcc,
runSync,
} from "../core/match-steps";
/** Candidate placements returned per job; more near-ties fall back to the CPU. */
const K = 8;
/** Candidate margin under the f32 max; the estimate's error is ~1e-6. */
const EPS = 1e-4;
const WORKGROUP_SIZE = 64;
/** placements one score thread covers, stacked vertically so each image row is read once */
const BLOCK_ROWS = 4;
const JOB_U32 = 16;
const IMAGE_U32 = 8;
/** per job: count | ZERO_FLAG, then K × (num lo, num hi | sign, variance lo, variance hi) */
const OUT_U32 = 1 + 4 * K;
const ZERO_FLAG = 0x80000000;
const MAX_DISPATCH_X = 65535;
/** Samples per template (rows × cols × channels) whose u8 products still sum below 2^32. */
const MAX_TEMPLATE_SAMPLES = Math.floor(0xffffffff / (255 * 255));
/** Widest request image the numeric score-cache keys cover (canonical frames are 1920 wide). */
const MAX_IMAGE_COLS = 4096;
const WINDOW_KEYS = MAX_IMAGE_COLS * MAX_IMAGE_COLS;
/** Words after each packed image: the funnel-shifted reads run one word past a row (those bytes only meet zero template padding). */
const IMAGE_PAD_WORDS = 2;
/** GPUBufferUsage / GPUMapMode flags (spec values; the globals are missing from the TS DOM lib) */
const BUFFER_MAP_READ = 0x0001;
const BUFFER_COPY_SRC = 0x0004;
const BUFFER_COPY_DST = 0x0008;
const BUFFER_UNIFORM = 0x0040;
const BUFFER_STORAGE = 0x0080;
const BUFFER_QUERY_RESOLVE = 0x0200;
const MAP_MODE_READ = 0x0001;
const shader = (packedDot: boolean) => /* wgsl */ `
${packedDot ? "requires packed_4x8_integer_dot_product;" : ""}
struct Params {
total: u32, jobCount: u32, startsOff: u32, imagesOff: u32,
imageCount: u32, blocks: u32, blockStartsOff: u32,
};
@group(0) @binding(0) var<storage, read> img: array<u32>;
@group(0) @binding(1) var<storage, read> tpl: array<u32>;
@group(0) @binding(2) var<storage, read_write> sat: array<u32>;
@group(0) @binding(3) var<storage, read> info: array<u32>;
@group(0) @binding(4) var<storage, read_write> scratch: array<u32>;
@group(0) @binding(5) var<storage, read_write> outs: array<atomic<u32>>;
@group(0) @binding(6) var<uniform> params: Params;
fn dot4(a: u32, b: u32) -> u32 {
${
packedDot
? "return dot4U8Packed(a, b);"
: `return (a & 0xffu) * (b & 0xffu) + ((a >> 8u) & 0xffu) * ((b >> 8u) & 0xffu)
+ ((a >> 16u) & 0xffu) * ((b >> 16u) & 0xffu) + (a >> 24u) * (b >> 24u);`
}
}
fn mul64(a: u32, b: u32) -> vec2<u32> {
let a0 = a & 0xffffu; let a1 = a >> 16u;
let b0 = b & 0xffffu; let b1 = b >> 16u;
let p00 = a0 * b0; let p01 = a0 * b1; let p10 = a1 * b0; let p11 = a1 * b1;
let mid = (p00 >> 16u) + (p01 & 0xffffu) + (p10 & 0xffffu);
return vec2<u32>((p00 & 0xffffu) | (mid << 16u), p11 + (p01 >> 16u) + (p10 >> 16u) + (mid >> 16u));
}
fn add64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
let lo = a.x + b.x;
return vec2<u32>(lo, a.y + b.y + select(0u, 1u, lo < a.x));
}
fn sub64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
return vec2<u32>(a.x - b.x, a.y - b.y - select(0u, 1u, a.x < b.x));
}
fn ge64(a: vec2<u32>, b: vec2<u32>) -> bool { return a.y > b.y || (a.y == b.y && a.x >= b.x); }
fn toF(a: vec2<u32>) -> f32 { return f32(a.y) * 4294967296.0 + f32(a.x); }
fn diffF(a: vec2<u32>, b: vec2<u32>) -> f32 {
if (ge64(a, b)) { return toF(sub64(a, b)); }
return -toF(sub64(b, a));
}
fn orderedBits(v: f32) -> u32 {
let b = bitcast<u32>(v);
return select(b | 0x80000000u, ~b, (b & 0x80000000u) != 0u);
}
fn fromOrdered(b: u32) -> f32 {
return bitcast<f32>(select(~b, b & 0x7fffffffu, (b & 0x80000000u) != 0u));
}
struct Job {
imgOff: u32, rows: u32, cols: u32, ch: u32, satOff: u32,
tplOff: u32, tRows: u32, tCols: u32, tRowWords: u32,
lo: u32, w: u32, n: u32, tvarF: f32, tSum: vec3<u32>,
};
fn job(j: u32) -> Job {
let b = j * ${JOB_U32}u;
return Job(info[b], info[b + 1u], info[b + 2u], info[b + 3u], info[b + 4u],
info[b + 5u], info[b + 6u], info[b + 7u], info[b + 8u],
info[b + 9u], info[b + 10u], info[b + 11u], bitcast<f32>(info[b + 12u]),
vec3<u32>(info[b + 13u], info[b + 14u], info[b + 15u]));
}
/** the job owning index g of a per-job prefix array at off: the last j with prefix[j] <= g */
fn jobIn(off: u32, g: u32) -> u32 {
var lo = 0u; var hi = params.jobCount;
while (lo + 1u < hi) {
let mid = (lo + hi) >> 1u;
if (info[off + mid] <= g) { lo = mid; } else { hi = mid; }
}
return lo;
}
fn satAt(jb: Job, x: u32, y: u32, c: u32) -> u32 {
return sat[jb.satOff + (y * (jb.cols + 1u) + x) * (jb.ch + 1u) + c];
}
fn windowSum(jb: Job, x0: u32, y0: u32, c: u32) -> u32 {
let x1 = x0 + jb.tCols; let y1 = y0 + jb.tRows;
return satAt(jb, x1, y1, c) - satAt(jb, x0, y1, c) - satAt(jb, x1, y0, c) + satAt(jb, x0, y0, c);
}
struct Sums { numA: vec2<u32>, numB: vec2<u32>, varA: vec2<u32>, varB: vec2<u32> };
fn sums(jb: Job, rx: u32, ry: u32, P: u32) -> Sums {
let Q = windowSum(jb, rx, ry, jb.ch);
var numB = vec2<u32>(0u); var varB = vec2<u32>(0u);
for (var c = 0u; c < jb.ch; c++) {
let S = windowSum(jb, rx, ry, c);
numB = add64(numB, mul64(S, jb.tSum[c]));
varB = add64(varB, mul64(S, S));
}
return Sums(mul64(jb.n, P), numB, mul64(jb.n, Q), varB);
}
fn estimate(jb: Job, s: Sums) -> f32 {
let wvar = diffF(s.varA, s.varB);
if (wvar <= 0.0) { return 0.0; }
return clamp(diffF(s.numA, s.numB) / (sqrt(wvar) * sqrt(jb.tvarF)), -1.0, 1.0);
}
/**
* Cross sums of the ${BLOCK_ROWS} vertically adjacent placements (rx, ry0 + d),
* d < n: every image row is read once and meets each template row it overlaps.
*/
fn crossSums(jb: Job, rx: u32, ry0: u32, n: u32) -> vec4<u32> {
var P0 = 0u; var P1 = 0u; var P2 = 0u; var P3 = 0u;
let rows = jb.tRows + n - 1u;
for (var y = 0u; y < rows; y++) {
let B = ((ry0 + y) * jb.cols + rx) * jb.ch;
var wi = jb.imgOff + (B >> 2u);
let r = (B & 3u) * 8u;
var cur = img[wi];
let use0 = y < jb.tRows;
let use1 = n > 1u && y >= 1u && y - 1u < jb.tRows;
let use2 = n > 2u && y >= 2u && y - 2u < jb.tRows;
let use3 = n > 3u && y >= 3u && y - 3u < jb.tRows;
let t0 = jb.tplOff + y * jb.tRowWords;
let t1 = t0 - jb.tRowWords; let t2 = t1 - jb.tRowWords; let t3 = t2 - jb.tRowWords;
for (var k = 0u; k < jb.tRowWords; k++) {
wi++;
let nxt = img[wi];
let w = select((cur >> r) | (nxt << (32u - r)), cur, r == 0u);
cur = nxt;
if (use0) { P0 += dot4(w, tpl[t0 + k]); }
if (use1) { P1 += dot4(w, tpl[t1 + k]); }
if (use2) { P2 += dot4(w, tpl[t2 + k]); }
if (use3) { P3 += dot4(w, tpl[t3 + k]); }
}
}
return vec4<u32>(P0, P1, P2, P3);
}
fn globalId(gid: vec3<u32>, nwg: vec3<u32>) -> u32 {
return gid.x + gid.y * nwg.x * ${WORKGROUP_SIZE}u;
}
@compute @workgroup_size(${WORKGROUP_SIZE})
fn satMain(@builtin(workgroup_id) wg: vec3<u32>, @builtin(num_workgroups) nwg: vec3<u32>,
@builtin(local_invocation_index) lid: u32) {
let i = wg.x + wg.y * nwg.x;
if (i >= params.imageCount) { return; }
let b = params.imagesOff + i * ${IMAGE_U32}u;
let off = info[b]; let rows = info[b + 1u]; let cols = info[b + 2u]; let ch = info[b + 3u];
let satOff = info[b + 4u];
let stride = ch + 1u;
// row prefixes, row 0 and column 0 zero
for (var x = lid; x <= cols; x += ${WORKGROUP_SIZE}u) {
for (var c = 0u; c <= ch; c++) { sat[satOff + x * stride + c] = 0u; }
}
for (var y = lid; y < rows; y += ${WORKGROUP_SIZE}u) {
let rowBase = satOff + (y + 1u) * (cols + 1u) * stride;
var acc = vec4<u32>(0u);
for (var c = 0u; c <= ch; c++) { sat[rowBase + c] = 0u; }
for (var x = 0u; x < cols; x++) {
var q = 0u;
for (var c = 0u; c < ch; c++) {
let B = (y * cols + x) * ch + c;
let v = (img[off + (B >> 2u)] >> ((B & 3u) * 8u)) & 0xffu;
acc[c] += v;
q += v * v;
}
acc[3] += q;
let o = rowBase + (x + 1u) * stride;
for (var c = 0u; c < ch; c++) { sat[o + c] = acc[c]; }
sat[o + ch] = acc[3];
}
}
storageBarrier();
workgroupBarrier();
// column prefixes
for (var x = lid + 1u; x <= cols; x += ${WORKGROUP_SIZE}u) {
for (var y = 2u; y <= rows; y++) {
let o = satOff + (y * (cols + 1u) + x) * stride;
let p = o - (cols + 1u) * stride;
for (var c = 0u; c <= ch; c++) { sat[o + c] += sat[p + c]; }
}
}
}
@compute @workgroup_size(${WORKGROUP_SIZE})
fn scoreMain(@builtin(global_invocation_id) gid: vec3<u32>, @builtin(num_workgroups) nwg: vec3<u32>) {
let g = globalId(gid, nwg);
if (g >= params.blocks) { return; }
let j = jobIn(params.blockStartsOff, g);
let jb = job(j);
let b = g - info[params.blockStartsOff + j];
let rx = jb.lo + b % jb.w;
let ry0 = (b / jb.w) * ${BLOCK_ROWS}u;
let n = min(${BLOCK_ROWS}u, jb.rows - jb.tRows + 1u - ry0);
let P = crossSums(jb, rx, ry0, n);
let base = info[params.startsOff + j] + rx - jb.lo;
var best = 0u;
for (var d = 0u; d < n; d++) {
scratch[base + (ry0 + d) * jb.w] = P[d];
best = max(best, orderedBits(estimate(jb, sums(jb, rx, ry0 + d, P[d]))));
}
atomicMax(&outs[j], best);
}
@compute @workgroup_size(${WORKGROUP_SIZE})
fn selectMain(@builtin(global_invocation_id) gid: vec3<u32>, @builtin(num_workgroups) nwg: vec3<u32>) {
let g = globalId(gid, nwg);
if (g >= params.total) { return; }
let j = jobIn(params.startsOff, g);
let jb = job(j);
let p = g - info[params.startsOff + j];
let rx = jb.lo + p % jb.w; let ry = p / jb.w;
let s = sums(jb, rx, ry, scratch[g]);
if (estimate(jb, s) < fromOrdered(atomicLoad(&outs[j])) - ${EPS}) { return; }
let base = params.jobCount + j * ${OUT_U32}u;
let wv = sub64(s.varA, s.varB);
if (wv.x == 0u && wv.y == 0u) {
atomicOr(&outs[base], ${ZERO_FLAG}u);
return;
}
let i = atomicAdd(&outs[base], 1u) & ${~ZERO_FLAG >>> 0}u;
if (i >= ${K}u) { return; }
let neg = !ge64(s.numA, s.numB);
let num = select(sub64(s.numA, s.numB), sub64(s.numB, s.numA), neg);
let o = base + 1u + i * 4u;
atomicStore(&outs[o], num.x);
atomicStore(&outs[o + 1u], num.y | select(0u, 0x80000000u, neg));
atomicStore(&outs[o + 2u], wv.x);
atomicStore(&outs[o + 3u], wv.y);
}
`;
interface U8Image {
rows: number;
cols: number;
ch: number;
data: Uint8Array;
}
interface TemplateEntry {
/** sequential, for numeric score-cache keys */
id: number;
image: U8Image;
/** offset into the GPU template buffer, in words; rows padded to whole words */
offset: number;
rowWords: number;
n: number;
sum: number[];
/** Σ_c (N·ΣT_c² − (ΣT_c)²) */
varInt: number;
}
interface Job {
image: U8Image;
template: TemplateEntry;
lo: number;
hi: number;
/** score arrays (and indices into them) waiting on this job, plus its cache slot */
targets: number[][];
targetIndices: number[];
cache: Map<number, number> | null;
cacheKey: number;
}
function resolveJob(job: Job, score: number) {
for (const [t, target] of job.targets.entries()) {
target[job.targetIndices[t]!] = score;
}
job.cache?.set(job.cacheKey, score);
}
export interface GpuMatcherStats {
steps: number;
dispatches: number;
jobs: number;
placements: number;
cacheHits: number;
/** jobs finished on the CPU: more than K near-tied placements */
cpuFallbacks: number;
/** jobs the kernel cannot take (channel layout, template size), matched on the CPU */
unsupported: number;
/** submit to readback, summed */
gpuWaitMs: number;
/** kernel time from timestamp queries (only when created with `timestamps`) */
kernelMs: number;
}
export interface GpuMatcher {
/**
* Runs `steps` to completion, answering each step's requests with at most
* one submit. A device lost mid-run hands the pending step to the CPU
* (runSync), so the generators run exactly once either way.
*/
run<T>(steps: MatchSteps<T>): Promise<T>;
/** the matcher's device, shared with the frame scaler */
readonly device: GPUDevice;
/** the device is gone; callers switch to the CPU path */
readonly lost: boolean;
readonly lostReason: string | null;
stats: GpuMatcherStats;
destroy(): void;
}
export async function createGpuMatcher(
gpu: GPU,
options: { timestamps?: boolean } = {},
): Promise<GpuMatcher> {
const adapter = await gpu.requestAdapter({
powerPreference: "high-performance",
});
if (!adapter) throw new Error("no WebGPU adapter");
const timestamps =
options.timestamps === true && adapter.features.has("timestamp-query");
const device = await adapter.requestDevice({
requiredFeatures: timestamps ? ["timestamp-query"] : [],
requiredLimits: {
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
maxBufferSize: adapter.limits.maxBufferSize,
},
});
device.pushErrorScope("validation");
const module = device.createShaderModule({
code: shader(
gpu.wgslLanguageFeatures.has("packed_4x8_integer_dot_product"),
),
});
const pipeline = (entryPoint: string) =>
device.createComputePipeline({
layout: "auto",
compute: { module, entryPoint },
});
const satPipeline = pipeline("satMain");
const scorePipeline = pipeline("scoreMain");
const selectPipeline = pipeline("selectMain");
const pipelineError = await device.popErrorScope();
if (pipelineError) {
device.destroy();
throw new Error(`matcher pipelines: ${pipelineError.message}`);
}
const stats: GpuMatcherStats = {
steps: 0,
dispatches: 0,
jobs: 0,
placements: 0,
cacheHits: 0,
cpuFallbacks: 0,
unsupported: 0,
gpuWaitMs: 0,
kernelMs: 0,
};
// templates are long-lived (glyph atlases, weapon icons): packed once, on
// first use, into one growable buffer that only uploads what is new
const templates = new Map<Mat, TemplateEntry>();
let templateMirror = new Uint32Array(1 << 20);
let templateBytes = new Uint8Array(templateMirror.buffer);
let templateLength = 0;
let templateUploaded = 0;
let templateBuffer: GPUBuffer | null = null;
const templateOf = (mat: Mat): TemplateEntry => {
const known = templates.get(mat);
if (known) return known;
const image = readPixels(mat);
const rowBytes = image.cols * image.ch;
const rowWords = Math.ceil(rowBytes / 4);
const words = image.rows * rowWords;
if (templateLength + words > templateMirror.length) {
const grown = new Uint32Array(
2 ** Math.ceil(Math.log2(templateLength + words)),
);
grown.set(templateMirror.subarray(0, templateLength));
templateMirror = grown;
templateBytes = new Uint8Array(grown.buffer);
}
for (let y = 0; y < image.rows; y++) {
templateBytes.set(
image.data.subarray(y * rowBytes, (y + 1) * rowBytes),
(templateLength + y * rowWords) * 4,
);
}
const entry = {
id: templates.size,
image,
offset: templateLength,
rowWords,
...templateStats(image),
};
templateLength += words;
templates.set(mat, entry);
return entry;
};
const syncTemplates = () => {
if (!templateBuffer || templateBuffer.size < templateLength * 4) {
templateBuffer?.destroy();
templateBuffer = device.createBuffer({
size: Math.max(256, templateMirror.byteLength),
usage: BUFFER_STORAGE | BUFFER_COPY_DST,
});
templateUploaded = 0;
}
if (templateUploaded < templateLength) {
device.queue.writeBuffer(
templateBuffer,
templateUploaded * 4,
templateMirror,
templateUploaded,
templateLength - templateUploaded,
);
templateUploaded = templateLength;
}
return templateBuffer;
};
const pool = new Map<string, GPUBuffer>();
const pooled = (name: string, bytes: number, usage: number) => {
const have = pool.get(name);
if (have && have.size >= bytes) return have;
have?.destroy();
const buffer = device.createBuffer({
size: Math.max(256, 2 ** Math.ceil(Math.log2(bytes))),
usage,
});
pool.set(name, buffer);
return buffer;
};
const paramsBuffer = device.createBuffer({
size: 32,
usage: BUFFER_UNIFORM | BUFFER_COPY_DST,
});
const querySet = timestamps
? device.createQuerySet({ type: "timestamp", count: 2 })
: null;
const queryBuffer = timestamps
? device.createBuffer({
size: 16,
usage: BUFFER_QUERY_RESOLVE | BUFFER_COPY_SRC,
})
: null;
let imageWords = new Uint32Array(1 << 16);
let meta = new Uint32Array(1 << 16);
async function dispatch(jobs: Job[]): Promise<void> {
stats.dispatches++;
stats.jobs += jobs.length;
// images, each word-aligned and padded, with their integral-image slots
const imageIndex = new Map<U8Image, number>();
const images: { image: U8Image; offset: number; satOffset: number }[] = [];
let wordLength = 0;
let satLength = 0;
for (const { image } of jobs) {
if (imageIndex.has(image)) continue;
imageIndex.set(image, images.length);
images.push({ image, offset: wordLength, satOffset: satLength });
wordLength += Math.ceil(image.data.length / 4) + IMAGE_PAD_WORDS;
satLength += (image.rows + 1) * (image.cols + 1) * (image.ch + 1);
}
// stale padding is harmless: it only ever meets zero template padding
if (imageWords.length < wordLength) {
imageWords = new Uint32Array(2 ** Math.ceil(Math.log2(wordLength)));
}
const imageBytes = new Uint8Array(imageWords.buffer);
for (const { image, offset } of images)
imageBytes.set(image.data, offset * 4);
// meta: jobs, placement prefix, images
const startsOffset = jobs.length * JOB_U32;
const blockStartsOffset = startsOffset + jobs.length + 1;
const imagesOffset = blockStartsOffset + jobs.length + 1;
const metaLength = imagesOffset + images.length * IMAGE_U32;
if (meta.length < metaLength) {
meta = new Uint32Array(2 ** Math.ceil(Math.log2(metaLength)));
}
const metaFloats = new Float32Array(meta.buffer);
let total = 0;
let blocks = 0;
for (const [i, { image, template, lo, hi }] of jobs.entries()) {
const { offset, satOffset } = images[imageIndex.get(image)!]!;
const b = i * JOB_U32;
meta[b] = offset;
meta[b + 1] = image.rows;
meta[b + 2] = image.cols;
meta[b + 3] = image.ch;
meta[b + 4] = satOffset;
meta[b + 5] = template.offset;
meta[b + 6] = template.image.rows;
meta[b + 7] = template.image.cols;
meta[b + 8] = template.rowWords;
meta[b + 9] = lo;
meta[b + 10] = hi - lo + 1;
meta[b + 11] = template.n;
metaFloats[b + 12] = template.varInt;
meta[b + 13] = template.sum[0] ?? 0;
meta[b + 14] = template.sum[1] ?? 0;
meta[b + 15] = template.sum[2] ?? 0;
const rows = image.rows - template.image.rows + 1;
meta[startsOffset + i] = total;
total += rows * (hi - lo + 1);
meta[blockStartsOffset + i] = blocks;
blocks += Math.ceil(rows / BLOCK_ROWS) * (hi - lo + 1);
}
meta[startsOffset + jobs.length] = total;
meta[blockStartsOffset + jobs.length] = blocks;
for (const [i, { image, offset, satOffset }] of images.entries()) {
const b = imagesOffset + i * IMAGE_U32;
meta[b] = offset;
meta[b + 1] = image.rows;
meta[b + 2] = image.cols;
meta[b + 3] = image.ch;
meta[b + 4] = satOffset;
}
stats.placements += total;
const tplBuffer = syncTemplates();
const imgBuffer = pooled(
"img",
wordLength * 4,
BUFFER_STORAGE | BUFFER_COPY_DST,
);
const satBuffer = pooled("sat", satLength * 4, BUFFER_STORAGE);
const metaBuffer = pooled(
"meta",
metaLength * 4,
BUFFER_STORAGE | BUFFER_COPY_DST,
);
const scratchBuffer = pooled("scratch", total * 4, BUFFER_STORAGE);
const outLength = jobs.length * (1 + OUT_U32);
const outBytes = outLength * 4;
const outBuffer = pooled(
"out",
outBytes,
BUFFER_STORAGE | BUFFER_COPY_SRC | BUFFER_COPY_DST,
);
const readBuffer = pooled(
"read",
outBytes,
BUFFER_MAP_READ | BUFFER_COPY_DST,
);
device.queue.writeBuffer(imgBuffer, 0, imageWords, 0, wordLength);
device.queue.writeBuffer(metaBuffer, 0, meta, 0, metaLength);
device.queue.writeBuffer(
paramsBuffer,
0,
new Uint32Array([
total,
jobs.length,
startsOffset,
imagesOffset,
images.length,
blocks,
blockStartsOffset,
0,
]),
);
const encoder = device.createCommandEncoder();
encoder.clearBuffer(outBuffer, 0, outBytes);
const pass = encoder.beginComputePass(
querySet
? {
timestampWrites: {
querySet,
beginningOfPassWriteIndex: 0,
endOfPassWriteIndex: 1,
},
}
: {},
);
pass.setPipeline(satPipeline);
pass.setBindGroup(0, bindAll(satPipeline, [0, 2, 3, 6]));
pass.dispatchWorkgroups(...grid(images.length));
pass.setPipeline(scorePipeline);
pass.setBindGroup(0, bindAll(scorePipeline, [0, 1, 2, 3, 4, 5, 6]));
pass.dispatchWorkgroups(...grid(Math.ceil(blocks / WORKGROUP_SIZE)));
pass.setPipeline(selectPipeline);
pass.setBindGroup(0, bindAll(selectPipeline, [2, 3, 4, 5, 6]));
pass.dispatchWorkgroups(...grid(Math.ceil(total / WORKGROUP_SIZE)));
pass.end();
if (querySet && queryBuffer) {
encoder.resolveQuerySet(querySet, 0, 2, queryBuffer, 0);
encoder.copyBufferToBuffer(
queryBuffer,
0,
pooled("query", 16, BUFFER_MAP_READ | BUFFER_COPY_DST),
0,
16,
);
}
encoder.copyBufferToBuffer(
outBuffer,
jobs.length * 4,
readBuffer,
0,
jobs.length * OUT_U32 * 4,
);
device.queue.submit([encoder.finish()]);
const waitStart = performance.now();
const readBytes = jobs.length * OUT_U32 * 4;
await readBuffer.mapAsync(MAP_MODE_READ, 0, readBytes);
stats.gpuWaitMs += performance.now() - waitStart;
const out = new Uint32Array(
readBuffer.getMappedRange(0, readBytes).slice(0),
);
readBuffer.unmap();
if (querySet) {
const read = pool.get("query")!;
await read.mapAsync(MAP_MODE_READ, 0, 16);
const [begin, end] = new BigUint64Array(read.getMappedRange(0, 16));
stats.kernelMs += Number(end! - begin!) / 1e6;
read.unmap();
}
for (const [i, job] of jobs.entries()) {
const b = i * OUT_U32;
const info = out[b]!;
const count = (info & ~ZERO_FLAG) >>> 0;
if (count > K || (count === 0 && !(info & ZERO_FLAG))) {
stats.cpuFallbacks++;
resolveJob(job, exactMax(job.image, job.template, job.lo, job.hi));
continue;
}
let best = info & ZERO_FLAG ? 0 : Number.NEGATIVE_INFINITY;
for (let c = 0; c < count; c++) {
const o = b + 1 + c * 4;
const hi = out[o + 1]!;
const magnitude = (hi & 0x7fffffff) * 2 ** 32 + out[o]!;
const score = normalizeNcc(
hi & 0x80000000 ? -magnitude : magnitude,
out[o + 3]! * 2 ** 32 + out[o + 2]!,
job.template.varInt,
);
if (score > best) best = score;
}
resolveJob(job, best);
}
function bindAll(pipe: GPUComputePipeline, bindings: number[]) {
const byBinding: Record<number, GPUBuffer> = {
0: imgBuffer,
1: tplBuffer,
2: satBuffer,
3: metaBuffer,
4: scratchBuffer,
5: outBuffer,
6: paramsBuffer,
};
return device.createBindGroup({
layout: pipe.getBindGroupLayout(0),
entries: bindings.map((binding) => ({
binding,
resource: { buffer: byBinding[binding]! },
})),
});
}
}
let lost = false;
let lostReason: string | null = null;
void device.lost.then((info) => {
lostReason ??= info.message || info.reason;
lost = true;
});
async function run<T>(steps: MatchSteps<T>): Promise<T> {
const cache = new Map<string, Map<number, number>>();
const keyedImages = new Map<string, U8Image>();
let step = steps.next();
while (!step.done) {
if (lost) return runSync(steps, step);
stats.steps++;
const jobs: Job[] = [];
const inflight = new Map<string, Map<number, Job>>();
const answers = step.value.map((request) =>
answer(request, cache, inflight, keyedImages, jobs),
);
if (jobs.length > 0) {
try {
await dispatch(jobs);
} catch (error) {
// a lost device fails the readback, sometimes before `device.lost` settles
lostReason ??= String(error);
lost = true;
return runSync(steps, step);
}
}
step = steps.next(answers.map((scores) => (i: number) => scores[i]!));
}
return step.value;
}
function answer(
request: MatchRequest,
cache: Map<string, Map<number, number>>,
inflight: Map<string, Map<number, Job>>,
keyedImages: Map<string, U8Image>,
jobs: Job[],
): number[] {
const scores = new Array<number>(request.templates.length);
const { key, windows } = request;
const cached = key === undefined ? null : entriesOf(cache, key);
const pending = key === undefined ? null : entriesOf(inflight, key);
let image: U8Image | null = null;
for (const [i, mat] of request.templates.entries()) {
const template = templateOf(mat);
// a score is the max over its window, so the window is part of its identity
const window = windows?.[i];
const scoreKey =
template.id * WINDOW_KEYS +
(window ? window[0] * MAX_IMAGE_COLS + window[1] : WINDOW_KEYS - 1);
const hit = cached?.get(scoreKey);
if (hit !== undefined) {
stats.cacheHits++;
scores[i] = hit;
continue;
}
const waiting = pending?.get(scoreKey);
if (waiting) {
stats.cacheHits++;
waiting.targets.push(scores);
waiting.targetIndices.push(i);
continue;
}
image ??=
key === undefined
? readPixels(request.image)
: (keyedImages.get(key) ??
keyedImages.set(key, readPixels(request.image)).get(key)!);
const [lo, hi] = window ?? [0, image.cols - template.image.cols];
const job: Job = {
image,
template,
lo,
hi,
targets: [scores],
targetIndices: [i],
cache: cached,
cacheKey: scoreKey,
};
if (template.varInt === 0) {
resolveJob(job, 1);
continue;
}
if (
(image.ch !== 1 && image.ch !== 3) ||
image.ch !== template.image.ch ||
template.n * template.image.ch > MAX_TEMPLATE_SAMPLES ||
image.cols > MAX_IMAGE_COLS
) {
stats.unsupported++;
resolveJob(job, exactMax(image, template, lo, hi));
continue;
}
pending?.set(scoreKey, job);
jobs.push(job);
}
return scores;
}
return {
run,
device,
get lost() {
return lost;
},
get lostReason() {
return lostReason;
},
stats,
destroy: () => device.destroy(),
};
}
function entriesOf<V>(cache: Map<string, Map<number, V>>, key: string) {
let entries = cache.get(key);
if (!entries) {
entries = new Map();
cache.set(key, entries);
}
return entries;
}
/** Workgroup grid for `count` workgroups, split over y past the per-dimension limit. */
function grid(count: number): [number, number] {
return [
Math.max(1, Math.min(count, MAX_DISPATCH_X)),
Math.max(1, Math.ceil(count / MAX_DISPATCH_X)),
];
}
function readPixels(mat: Mat): U8Image {
const copy = new (getCV().Mat)();
mat.copyTo(copy);
const image = {
rows: copy.rows,
cols: copy.cols,
ch: copy.channels(),
data: new Uint8Array(copy.data),
};
copy.delete();
return image;
}
function templateStats(t: U8Image) {
const n = t.rows * t.cols;
const sum = new Array<number>(t.ch).fill(0);
const sq = new Array<number>(t.ch).fill(0);
for (let i = 0; i < n; i++) {
for (let c = 0; c < t.ch; c++) {
const v = t.data[i * t.ch + c]!;
sum[c]! += v;
sq[c]! += v * v;
}
}
let varInt = 0;
for (let c = 0; c < t.ch; c++) varInt += n * sq[c]! - sum[c]! * sum[c]!;
return { n, sum, varInt };
}
function exactMax(
image: U8Image,
template: TemplateEntry,
lo: number,
hi: number,
): number {
const t = template.image;
const { ch } = image;
let best = Number.NEGATIVE_INFINITY;
for (let y = 0; y + t.rows <= image.rows; y++) {
for (let x = lo; x <= hi; x++) {
let num = 0;
let windowVar = 0;
for (let c = 0; c < ch; c++) {
let S = 0;
let Q = 0;
let P = 0;
for (let ty = 0; ty < t.rows; ty++) {
const ib = ((y + ty) * image.cols + x) * ch + c;
const tb = ty * t.cols * ch + c;
for (let tx = 0; tx < t.cols; tx++) {
const iv = image.data[ib + tx * ch]!;
S += iv;
Q += iv * iv;
P += iv * t.data[tb + tx * ch]!;
}
}
num += template.n * P - S * template.sum[c]!;
windowVar += template.n * Q - S * S;
}
const score = normalizeNcc(num, windowVar, template.varInt);
if (score > best) best = score;
}
}
return best;
}

View File

@@ -13,6 +13,8 @@ export interface InitRequest {
suppressSteadyFrames?: boolean;
/** accumulate scan telemetry counters and time the detectors; default false (VoD telemetry panel opts in) */
collectTelemetry?: boolean;
/** match templates (and upscale sub-1080p frames) on WebGPU when an adapter exists; default false */
webgpu?: boolean;
}
export interface AnalyzeRequest {

View File

@@ -34,6 +34,8 @@
"scanner:report": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/report.ts",
"scanner:scan-vod": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/scan-vod.ts",
"scanner:status-audit": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/status-audit.ts",
"scanner:gpu-parity": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/gpu-parity.ts",
"scanner:gpu-replay": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/gpu-replay.ts",
"scanner:fixtures": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/run-fixtures.ts",
"scanner:replay": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/replay-frames.ts",
"scanner:bootstrap-atlas": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/bootstrap-atlas-from-fixture.ts",

View File

@@ -0,0 +1,142 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* GPU parity over every fixture frame: each detector gates the frame, then
* parses it twice — synchronously on the CPU (runSync) and through the WebGPU
* matcher (separate detector instances, so memo state never crosses). Both
* drivers score exactly, so the two event lists must be byte-identical, raw
* scores included. Also checks the GPU frame upscale
* (worker/gpu-frame-scaler.ts) pixel-for-pixel against normalizeFrame on each
* frame. WebGPU comes from Dawn (node/webgpu.ts: WEBGPU_NODE).
*
* Usage: pnpm scanner:gpu-parity [--verbose]
*/
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
import { createAllDetectors } from "../../app/features/scanner/core/detectors/registry";
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
import { FIXTURES_DIR } from "../../app/features/scanner/node/fixtures";
import { readImage } from "../../app/features/scanner/node/image-io";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
import { nodeGpu } from "../../app/features/scanner/node/webgpu";
import { createGpuFrameScaler } from "../../app/features/scanner/worker/gpu-frame-scaler";
import { createGpuMatcher } from "../../app/features/scanner/worker/gpu-matcher";
/** Fields that carry raw match scores; everything else is a decision. */
const SCORE_FIELDS = new Set([
"confidence",
"score",
"ncc",
"teamColor",
"debug",
]);
const verbose = process.argv.includes("--verbose");
await loadOpenCV();
const resources = await loadScoreboardResources();
const matcher = await createGpuMatcher(nodeGpu());
const scaler = await createGpuFrameScaler(matcher.device);
let rows = 0;
let identicalRows = 0;
let decisionRows = 0;
let pixelMismatchFrames = 0;
const failures: string[] = [];
let cpuMs = 0;
let gpuMs = 0;
for (const group of fixtureGroups()) {
// fresh instances per fixture folder, like the suites: memos and layout
// latches never carry from one detector's cases into another's
const cpuDetectors = createAllDetectors(resources);
const gpuDetectors = createAllDetectors(resources);
for (const path of group) {
const src = toMat(await readImage(path));
const frame = normalizeFrame(src);
const scaled = await scaler.normalize(src);
if (!samePixels(frame, scaled)) {
pixelMismatchFrames++;
failures.push(`${path}: GPU upscale differs from normalizeFrame`);
}
scaled.delete();
src.delete();
for (const [i, cpuDetector] of cpuDetectors.entries()) {
const gpuDetector = gpuDetectors[i]!;
const gate = cpuDetector.gate(frame);
gpuDetector.gate(frame);
rows++;
if (!gate.pass) {
identicalRows++;
decisionRows++;
continue;
}
let start = performance.now();
const cpuEvents = cpuDetector.parse(frame, 0, gate);
cpuMs += performance.now() - start;
start = performance.now();
const gpuEvents = await matcher.run(
gpuDetector.parseSteps(frame, 0, gate, true),
);
gpuMs += performance.now() - start;
if (decisions(cpuEvents) === decisions(gpuEvents)) decisionRows++;
if (JSON.stringify(cpuEvents) === JSON.stringify(gpuEvents)) {
identicalRows++;
} else {
failures.push(`${path} ${cpuDetector.id}: events differ`);
if (verbose) {
console.log("CPU", JSON.stringify(cpuEvents));
console.log("GPU", JSON.stringify(gpuEvents));
}
}
}
frame.delete();
}
}
for (const failure of failures) console.log(failure);
console.log(
`${rows} detector × frame rows: ${identicalRows} byte-identical, ${decisionRows} with identical decisions; ${pixelMismatchFrames} frames with upscale mismatches`,
);
console.log(
`parse time: CPU ${(cpuMs / 1000).toFixed(1)} s, GPU ${(gpuMs / 1000).toFixed(1)} s · ${JSON.stringify(matcher.stats)}`,
);
matcher.destroy();
process.exit(failures.length === 0 ? 0 : 1);
/** Fixture frames grouped by detector folder, in a stable order. */
function fixtureGroups(): string[][] {
return readdirSync(FIXTURES_DIR)
.filter((dir) => statSync(join(FIXTURES_DIR, dir)).isDirectory())
.sort()
.map((dir) =>
readdirSync(join(FIXTURES_DIR, dir))
.sort()
.flatMap((fixture) =>
["frame.png", "frame.jpg", "frame.jpeg"]
.map((name) => join(FIXTURES_DIR, dir, fixture, name))
.filter((path) => {
try {
return statSync(path).isFile();
} catch {
return false;
}
})
.slice(0, 1),
),
);
}
function decisions(events: unknown): string {
return JSON.stringify(events, (key, value) =>
SCORE_FIELDS.has(key) ? undefined : value,
);
}
function samePixels(a: Mat, b: Mat): boolean {
const x = a.data as Uint8Array;
const y = b.data as Uint8Array;
if (x.length !== y.length) return false;
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
return true;
}

View File

@@ -0,0 +1,268 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Replay benchmark for the GPU matcher: replays a recorded match-request
* corpus (scan-vod --record) through worker/gpu-matcher.ts, times it, and
* checks every score of a sample of runs bit-for-bit against an exact JS
* reference (integer sums, one f64 normalization with OpenCV's guards, f32
* result). Any kernel change must keep 0 mismatches. WebGPU comes from Dawn
* (node/webgpu.ts: WEBGPU_NODE).
*
* Usage: pnpm scanner:gpu-replay <corpus-dir> [--check-every N] [--threads T] [--cpu]
* --check-every: exact-check every N-th run (default 1 = all; the reference is slow)
* --cpu: also run the CPU driver (runSync) on the same corpus: timed, and every
* score compared with the GPU's
*/
import { cpus } from "node:os";
import { Worker } from "node:worker_threads";
import {
getCV,
loadOpenCV,
type Mat,
} from "../../app/features/scanner/core/cv";
import {
type MatchRequest,
type MatchSteps,
runSync,
} from "../../app/features/scanner/core/match-steps";
import { nodeGpu } from "../../app/features/scanner/node/webgpu";
import { createGpuMatcher } from "../../app/features/scanner/worker/gpu-matcher";
import { type CorpusRequest, loadMatchCorpus } from "./match-corpus";
const EXACT_WORKER = /* js */ `
const { parentPort, workerData } = require("node:worker_threads");
const { images } = workerData;
const stats = new Map();
function templateStats(id) {
let s = stats.get(id);
if (s) return s;
const t = images[id];
const n = t.rows * t.cols;
const sum = new Array(t.ch).fill(0);
const sq = new Array(t.ch).fill(0);
for (let i = 0; i < n; i++) for (let c = 0; c < t.ch; c++) {
const v = t.data[i * t.ch + c];
sum[c] += v;
sq[c] += v * v;
}
let varInt = 0;
for (let c = 0; c < t.ch; c++) varInt += n * sq[c] - sum[c] * sum[c];
s = { n, sum, varInt };
stats.set(id, s);
return s;
}
function normalize(num, wvar, tvar) {
if (tvar === 0) return 1;
if (wvar <= 0) return 0;
const r = num / (Math.sqrt(wvar) * Math.sqrt(tvar));
const a = Math.abs(r);
if (a < 1) return Math.fround(r);
if (a < 1.125) return r > 0 ? 1 : -1;
return 0;
}
function windowMax(img, t, s, lo, hi) {
const ch = img.ch;
const rowLen = t.cols * ch;
let best = -Infinity;
for (let y = 0; y + t.rows <= img.rows; y++) {
for (let x = lo; x <= hi; x++) {
let num = 0;
let wvar = 0;
for (let c = 0; c < ch; c++) {
let S = 0, Q = 0, P = 0;
for (let ty = 0; ty < t.rows; ty++) {
const ib = ((y + ty) * img.cols + x) * ch + c;
const tb = ty * rowLen + c;
for (let k = 0; k < rowLen; k += ch) {
const iv = img.data[ib + k];
S += iv;
Q += iv * iv;
P += iv * t.data[tb + k];
}
}
num += s.n * P - S * s.sum[c];
wvar += s.n * Q - S * S;
}
const v = normalize(num, wvar, s.varInt);
if (v > best) best = v;
}
}
return best;
}
parentPort.on("message", ({ run, steps }) => {
const out = [];
for (const step of steps) for (const r of step) {
const img = images[r.image];
for (const [k, id] of r.templates.entries()) {
const t = images[id];
const [lo, hi] = r.windows?.[k] ?? [0, img.cols - t.cols];
out.push(windowMax(img, t, templateStats(id), lo, hi));
}
}
parentPort.postMessage({ run, scores: Float32Array.from(out) });
});
`;
const options = parseArgs(process.argv.slice(2));
if (!options) {
console.error(
"usage: pnpm scanner:gpu-replay <corpus-dir> [--check-every N] [--threads T] [--cpu]",
);
process.exit(1);
}
await loadOpenCV();
const cv = getCV();
const corpus = loadMatchCorpus(options.dir);
const mats = new Map<number, Mat>();
const matOf = (id: number): Mat => {
let mat = mats.get(id);
if (!mat) {
const image = corpus.images[id]!;
mat = new cv.Mat(
image.rows,
image.cols,
image.ch === 1 ? cv.CV_8UC1 : cv.CV_8UC3,
);
mat.data.set(image.data);
mats.set(id, mat);
}
return mat;
};
const matcher = await createGpuMatcher(nodeGpu(), { timestamps: true });
// warm-up (pipeline compilation, template upload), then a clean timed pass
await matcher.run(replay(0, []));
for (const key of Object.keys(
matcher.stats,
) as (keyof typeof matcher.stats)[]) {
matcher.stats[key] = 0;
}
const gpuScores: number[][] = [];
const gpuStart = performance.now();
for (let run = 0; run < corpus.runs.length; run++) {
const scores: number[] = [];
await matcher.run(replay(run, scores));
gpuScores.push(scores);
}
const gpuMs = performance.now() - gpuStart;
const requests = corpus.runs.reduce(
(n, r) => n + r.steps.reduce((m, s) => m + s.length, 0),
0,
);
console.log(
`GPU: ${gpuMs.toFixed(0)} ms for ${corpus.runs.length} runs (${requests} requests)`,
);
console.log(JSON.stringify(matcher.stats));
if (options.cpu) {
// the CPU driver scores exactly too: every score must match the GPU's bit for bit
const cpuStart = performance.now();
let cpuMismatches = 0;
for (let run = 0; run < corpus.runs.length; run++) {
const scores: number[] = [];
runSync(replay(run, scores));
for (const [i, score] of scores.entries()) {
if (!Object.is(score, gpuScores[run]![i])) cpuMismatches++;
}
}
console.log(
`CPU (runSync): ${(performance.now() - cpuStart).toFixed(0)} ms, ${cpuMismatches} scores differing from the GPU's`,
);
}
const checked = corpus.runs
.map((_, run) => run)
.filter((run) => run % options.checkEvery === 0);
const exact = await exactScores(checked);
let compared = 0;
let mismatches = 0;
for (const [run, reference] of exact) {
const scores = gpuScores[run]!;
for (let i = 0; i < reference.length; i++) {
compared++;
if (!Object.is(Math.fround(scores[i]!), reference[i]!)) {
mismatches++;
if (mismatches <= 5)
console.log(
`mismatch run ${run} #${i}: GPU ${scores[i]} exact ${reference[i]}`,
);
}
}
}
console.log(
`exact check: ${checked.length}/${corpus.runs.length} runs, ${compared} scores, ${mismatches} mismatches`,
);
matcher.destroy();
process.exit(mismatches === 0 ? 0 : 1);
function* replay(run: number, out: number[]): MatchSteps<void> {
for (const step of corpus.runs[run]!.steps) {
const batch: MatchRequest[] = step.map((r) => ({
image: matOf(r.image),
templates: r.templates.map(matOf),
windows: r.windows,
key: r.key,
}));
const scores = yield batch;
for (const [i, r] of step.entries()) {
for (let k = 0; k < r.templates.length; k++) out.push(scores[i]!(k));
}
}
}
async function exactScores(runs: number[]): Promise<Map<number, Float32Array>> {
const results = new Map<number, Float32Array>();
const queue = [...runs];
const workers = Array.from(
{ length: Math.min(options!.threads, runs.length) },
() =>
new Worker(EXACT_WORKER, {
eval: true,
workerData: { images: corpus.images },
}),
);
await Promise.all(
workers.map(
(worker) =>
new Promise<void>((resolve, reject) => {
const next = () => {
const run = queue.shift();
if (run === undefined) {
void worker.terminate();
resolve();
return;
}
worker.postMessage({
run,
steps: corpus.runs[run]!.steps as CorpusRequest[][],
});
};
worker.on("message", ({ run, scores }) => {
results.set(run, scores);
next();
});
worker.on("error", reject);
next();
}),
),
);
return results;
}
function parseArgs(argv: string[]) {
let dir: string | undefined;
let checkEvery = 1;
let threads = Math.max(1, cpus().length - 2);
let cpu = false;
// biome-ignore lint/style/useForOf: the index advances inside the loop to consume flag values
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--check-every") checkEvery = Number(argv[++i]);
else if (arg === "--threads") threads = Number(argv[++i]);
else if (arg === "--cpu") cpu = true;
else if (!arg.startsWith("--") && dir === undefined) dir = arg;
else return null;
}
if (!dir || !(checkEvery >= 1) || !(threads >= 1)) return null;
return { dir, checkEvery, threads, cpu };
}

View File

@@ -0,0 +1,137 @@
/**
* Match-request corpora for the GPU matcher's replay benchmark: every run's
* steps (requests with image bytes, template bytes, windows, keys), recorded
* from a real scan (scan-vod --record) and replayed by gpu-replay.ts. On disk:
* <dir>/index.json (runs, blob table) + <dir>/data.bin (pixels, deduped by
* content).
*/
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { getCV, type Mat } from "../../app/features/scanner/core/cv";
import type { StepsRunner } from "../../app/features/scanner/core/detectors/frame-pass";
import type {
MatchScores,
MatchSteps,
} from "../../app/features/scanner/core/match-steps";
export interface CorpusImage {
rows: number;
cols: number;
ch: number;
data: Uint8Array;
}
export interface CorpusRequest {
image: number;
templates: number[];
windows?: [number, number][];
key?: string;
}
export interface MatchCorpus {
images: CorpusImage[];
runs: { steps: CorpusRequest[][] }[];
}
interface BlobMeta {
rows: number;
cols: number;
ch: number;
off: number;
}
/** Wraps `inner` so every run it answers is recorded; `save` writes the corpus to `dir`. */
export function recordingRunner(inner: StepsRunner, dir: string) {
const blobs: BlobMeta[] = [];
const chunks: Uint8Array[] = [];
let dataLength = 0;
const byHash = new Map<string, number>();
const templateIds = new Map<Mat, number>();
const runs: MatchCorpus["runs"] = [];
const blobOf = (mat: Mat): number => {
const copy = new (getCV().Mat)();
mat.copyTo(copy);
const data = new Uint8Array(copy.data);
const meta = { rows: copy.rows, cols: copy.cols, ch: copy.channels() };
copy.delete();
const hash = createHash("sha1")
.update(`${meta.rows}x${meta.cols}x${meta.ch}`)
.update(data)
.digest("hex");
const known = byHash.get(hash);
if (known !== undefined) return known;
blobs.push({ ...meta, off: dataLength });
chunks.push(data);
dataLength += data.length;
byHash.set(hash, blobs.length - 1);
return blobs.length - 1;
};
const templateOf = (mat: Mat) => {
let id = templateIds.get(mat);
if (id === undefined) {
id = blobOf(mat);
templateIds.set(mat, id);
}
return id;
};
function* recorded<T>(steps: MatchSteps<T>): MatchSteps<T> {
const run: MatchCorpus["runs"][number] = { steps: [] };
runs.push(run);
let scores: MatchScores[] | undefined;
for (;;) {
const step = scores === undefined ? steps.next() : steps.next(scores);
if (step.done) return step.value;
run.steps.push(
step.value.map((request) => ({
image: blobOf(request.image),
templates: request.templates.map(templateOf),
windows: request.windows?.map(([lo, hi]) => [lo, hi]),
key: request.key,
})),
);
scores = yield step.value;
}
}
return {
run: (<T>(steps: MatchSteps<T>) => inner(recorded(steps))) as StepsRunner,
save(): string {
mkdirSync(dir, { recursive: true });
const data = new Uint8Array(dataLength);
let off = 0;
for (const chunk of chunks) {
data.set(chunk, off);
off += chunk.length;
}
writeFileSync(`${dir}/data.bin`, data);
writeFileSync(`${dir}/index.json`, JSON.stringify({ blobs, runs }));
const requests = runs.reduce(
(n, r) => n + r.steps.reduce((m, s) => m + s.length, 0),
0,
);
return `recorded ${runs.length} runs, ${requests} requests, ${blobs.length} images (${(dataLength / 1e6).toFixed(1)} MB) to ${dir}`;
},
};
}
export function loadMatchCorpus(dir: string): MatchCorpus {
const index = JSON.parse(readFileSync(`${dir}/index.json`, "utf8")) as {
blobs: BlobMeta[];
runs: MatchCorpus["runs"];
};
// shared, so worker threads receive the pixels without a copy each
const file = readFileSync(`${dir}/data.bin`);
const data = new Uint8Array(new SharedArrayBuffer(file.length));
data.set(file);
return {
images: index.blobs.map(({ rows, cols, ch, off }) => ({
rows,
cols,
ch,
data: data.subarray(off, off + rows * cols * ch),
})),
runs: index.runs,
};
}

View File

@@ -8,8 +8,13 @@
*
* Requires ffmpeg (and ffprobe for the progress percentage) on PATH.
*
* Usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]
* Usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry] [--gpu] [--record dir]
* --telemetry prints the VoD tab's ?telemetry=true scan counters after the run.
* --gpu matches templates on WebGPU (worker/gpu-matcher.ts) like the browser's
* GPU path; needs Dawn (node/webgpu.ts: WEBGPU_NODE). The CSV must stay
* byte-identical to a CPU scan's.
* --record writes every template-match request as a replay corpus for
* `pnpm scanner:gpu-replay` (match-corpus.ts).
*/
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
@@ -19,19 +24,21 @@ import {
eventsToCsv,
} from "../../app/features/scanner/core/csv/events";
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import { runDetectorPass } from "../../app/features/scanner/core/detectors/frame-pass";
import { MAP_START_EVENT_TYPE } from "../../app/features/scanner/core/detectors/map-start/index";
import {
createAllDetectors,
SCOREBOARD_EVENT_TYPES,
} from "../../app/features/scanner/core/detectors/registry";
import { DetectorScheduler } from "../../app/features/scanner/core/detectors/scheduler";
import {
createScanTelemetry,
detectorTelemetry,
} from "../../app/features/scanner/core/detectors/telemetry";
import { createScanTelemetry } from "../../app/features/scanner/core/detectors/telemetry";
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
import { runSync } from "../../app/features/scanner/core/match-steps";
import { TimelineBuilder } from "../../app/features/scanner/core/timeline/index";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
import { nodeGpu } from "../../app/features/scanner/node/webgpu";
import { createGpuMatcher } from "../../app/features/scanner/worker/gpu-matcher";
import { recordingRunner } from "./match-corpus";
const FRAME_WIDTH = 1920;
const FRAME_HEIGHT = 1080;
@@ -43,13 +50,30 @@ const PROGRESS_INTERVAL_SECONDS = 60;
const options = parseArgs(process.argv.slice(2));
if (!options) {
console.error(
"usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]",
"usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry] [--gpu] [--record dir]",
);
process.exit(1);
}
const { videoPath, fps, start, duration, outPath, collectTelemetry } = options;
const {
videoPath,
fps,
start,
duration,
outPath,
collectTelemetry,
gpu,
recordDir,
} = options;
await loadOpenCV();
const matcher = gpu ? await createGpuMatcher(nodeGpu()) : null;
const recorder = recordDir
? recordingRunner(
matcher?.run ?? (async (steps) => runSync(steps)),
recordDir,
)
: null;
const runSteps = recorder?.run ?? matcher?.run;
const detectors = createAllDetectors(await loadScoreboardResources());
const scheduler = new DetectorScheduler(detectors, {
matchOpeningTypes: [MAP_START_EVENT_TYPE],
@@ -58,6 +82,8 @@ const scheduler = new DetectorScheduler(detectors, {
scheduler.reset(start);
const timeline = new TimelineBuilder();
const telemetry = collectTelemetry ? createScanTelemetry() : null;
/** per detector: the whole frame pass's latency on each frame it parsed */
const passLatencies = new Map<string, number[]>();
const totalSeconds = await probeDurationSeconds(videoPath);
const scanEnd =
@@ -104,7 +130,7 @@ for await (const chunk of ffmpeg.stdout) {
offset += take;
if (frameFill < FRAME_BYTES) continue;
frameFill = 0;
processFrame(start + frameIndex / fps);
await processFrame(start + frameIndex / fps);
frameIndex++;
}
}
@@ -127,6 +153,8 @@ function parseArgs(argv: string[]): {
duration: number | undefined;
outPath: string;
collectTelemetry: boolean;
gpu: boolean;
recordDir: string | undefined;
} | null {
let parsedVideoPath: string | undefined;
let parsedFps = DEFAULT_FPS;
@@ -134,6 +162,8 @@ function parseArgs(argv: string[]): {
let parsedDuration: number | undefined;
let parsedOutPath: string | undefined;
let parsedCollectTelemetry = false;
let parsedGpu = false;
let parsedRecordDir: string | undefined;
// biome-ignore lint/style/useForOf: the index advances inside the loop to consume flag values
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
@@ -142,6 +172,8 @@ function parseArgs(argv: string[]): {
else if (arg === "--duration") parsedDuration = Number(argv[++i]);
else if (arg === "--out") parsedOutPath = argv[++i];
else if (arg === "--telemetry") parsedCollectTelemetry = true;
else if (arg === "--gpu") parsedGpu = true;
else if (arg === "--record") parsedRecordDir = argv[++i];
else if (!arg.startsWith("--") && parsedVideoPath === undefined)
parsedVideoPath = arg;
else return null;
@@ -164,6 +196,8 @@ function parseArgs(argv: string[]): {
parsedOutPath ??
`${basename(parsedVideoPath).replace(/\.[^.]+$/, "")}-events.csv`,
collectTelemetry: parsedCollectTelemetry,
gpu: parsedGpu,
recordDir: parsedRecordDir,
};
}
@@ -194,7 +228,7 @@ function probeDurationSeconds(path: string): Promise<number | null> {
});
}
function processFrame(t: number): void {
async function processFrame(t: number): Promise<void> {
if (t >= nextProgressT) {
const percent =
scanEnd === null
@@ -226,31 +260,26 @@ function processFrame(t: number): void {
});
const frame = normalizeFrame(src);
src.delete();
for (const detector of detectors) {
if (!due.includes(detector.id)) continue;
const counters = telemetry
? detectorTelemetry(telemetry, detector.id)
: null;
const gateStart = counters ? performance.now() : 0;
const gate = detector.gate(frame);
if (counters) {
counters.checks++;
counters.gateMs += performance.now() - gateStart;
const passStart = performance.now();
const outcomes = await runDetectorPass({
frame,
t,
detectors,
due,
scheduler,
telemetry,
runSteps,
});
if (telemetry) {
const ms = performance.now() - passStart;
for (const { detector, parsed } of outcomes) {
if (!parsed) continue;
const list = passLatencies.get(detector.id) ?? [];
list.push(ms);
passLatencies.set(detector.id, list);
}
scheduler.recordGate(detector.id, t, gate.pass, gate.signature);
if (!gate.pass) continue;
if (counters) counters.gatePasses++;
if (!scheduler.shouldParse(detector.id, t)) {
if (counters) counters.suppressedParses++;
continue;
}
const parseStart = counters ? performance.now() : 0;
const events = detector.parse(frame, t, gate);
if (counters) {
counters.parses++;
counters.parseMs += performance.now() - parseStart;
}
scheduler.recordParse(detector.id, t, events);
}
for (const { events } of outcomes) {
for (const event of events) {
const action = timeline.push(event);
if (action.action === "added" || action.action === "replaced") {
@@ -277,6 +306,8 @@ function printSummary(): void {
console.error(`timeline events: ${timeline.events.length} (${countText})`);
console.error(`wrote ${outPath}`);
if (telemetry) printTelemetry();
if (matcher) console.error(`GPU matcher: ${JSON.stringify(matcher.stats)}`);
if (recorder) console.error(recorder.save());
console.error(`next: pnpm scanner:status-audit ${outPath}`);
}
@@ -318,4 +349,17 @@ function printTelemetry(): void {
.join(" ");
console.error(line(header));
for (const row of rows) console.error(line(row));
// how long a frame waits for its results when the detector parses it: the
// whole pass (every gate and parse of the frame, GPU round trips included)
for (const [id, list] of [...passLatencies].sort(([a], [b]) =>
a.localeCompare(b),
)) {
const sorted = [...list].sort((a, b) => a - b);
const mean = sorted.reduce((sum, ms) => sum + ms, 0) / sorted.length;
const p95 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]!;
console.error(
`frame pass with a ${id} parse: mean ${mean.toFixed(1)} ms · p95 ${p95.toFixed(1)} ms · max ${sorted[sorted.length - 1]!.toFixed(1)} ms (${sorted.length} frames)`,
);
}
}