mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 14:20:46 -05:00
WebGPU
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
46
app/features/scanner/core/cross-sums.c
Normal file
46
app/features/scanner/core/cross-sums.c
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
app/features/scanner/core/cross-sums.ts
Normal file
88
app/features/scanner/core/cross-sums.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
117
app/features/scanner/core/detectors/frame-pass.ts
Normal file
117
app/features/scanner/core/detectors/frame-pass.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>[]>;
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
|
||||
341
app/features/scanner/core/match-steps.ts
Normal file
341
app/features/scanner/core/match-steps.ts
Normal 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;
|
||||
}
|
||||
30
app/features/scanner/node/webgpu.ts
Normal file
30
app/features/scanner/node/webgpu.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
246
app/features/scanner/worker/gpu-frame-scaler.ts
Normal file
246
app/features/scanner/worker/gpu-frame-scaler.ts
Normal 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;
|
||||
}
|
||||
908
app/features/scanner/worker/gpu-matcher.ts
Normal file
908
app/features/scanner/worker/gpu-matcher.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
142
scripts/scanner/gpu-parity.ts
Normal file
142
scripts/scanner/gpu-parity.ts
Normal 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;
|
||||
}
|
||||
268
scripts/scanner/gpu-replay.ts
Normal file
268
scripts/scanner/gpu-replay.ts
Normal 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 };
|
||||
}
|
||||
137
scripts/scanner/match-corpus.ts
Normal file
137
scripts/scanner/match-corpus.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user