mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-26 12:34:13 -05:00
WebGPU
This commit is contained in:
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