From 77d27dcf19ad309b9ecac319266da1505de8ae2b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:29:30 +0300 Subject: [PATCH] Fix battle logs missed --- app/features/scanner/README.md | 5 +- .../core/detectors/battle-log/index.ts | 24 +++++++- .../scanner/core/detectors/scheduler.ts | 60 +++++++++++++++++-- .../core/detectors/scoreboard-replay/index.ts | 22 ++++++- app/features/scanner/core/detectors/types.ts | 7 +++ app/features/scanner/core/image.ts | 24 ++++++++ app/features/scanner/tests/scheduler.test.ts | 51 +++++++++++++++- .../scanner/worker/analyzer.worker.ts | 2 +- scripts/scanner/replay-frames.ts | 2 +- 9 files changed, 182 insertions(+), 15 deletions(-) diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 1d625fbf2..2840d8098 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -110,7 +110,10 @@ sequenceDiagram sits just under each detector's measured clean-read confidence floor (fixture suite + confirmed scan events); death adds `rearmCooldownS` (safe because it fits inside the Death timeline - merge window). `checkIntervalS` (objective: 1s) + merge window). Battle-log and replay gates also return a content + `signature` (grid-cell means over the header/name/code ROIs) — browsing + distinct entries never drops those gates, so the scheduler instead resets + the streak when the signature moves, one parse per distinct battle. `checkIntervalS` (objective: 1s) still hard-caps both phases and exempts from suppression; a detector can also declare `attachFrame: false` to keep continuously-firing events from storing a frame PNG each. In the UI a match's objective reads render as diff --git a/app/features/scanner/core/detectors/battle-log/index.ts b/app/features/scanner/core/detectors/battle-log/index.ts index d97a1c8bd..c97e30d46 100644 --- a/app/features/scanner/core/detectors/battle-log/index.ts +++ b/app/features/scanner/core/detectors/battle-log/index.ts @@ -17,6 +17,7 @@ import { maxBrightness, maxChannel, meanBrightness, + roiSignature, } from "../../image"; import { RESULT_TAG_ENTRIES } from "../../localized"; import { closestBy } from "../../text"; @@ -166,7 +167,6 @@ export function createBattleLogDetector( for (const roi of GATE_COLOR_PROBES) { if (probeSaturation(frame, roi) >= GATE_COLOR_MIN_SATURATION) colorOk++; } - gray.delete(); const rowCount = PANEL_DYS.length * ROW_CENTERS.length; const score = @@ -175,7 +175,23 @@ export function createBattleLogDetector( colorOk / GATE_COLOR_PROBES.length) / 3; const pass = darkOk >= 7 && suffixOk >= 7 && colorOk === 3; - return { pass, score }; + // browsing flips between entries never drop this gate, so it + // fingerprints the content that always differs between two battles + // (recording timestamp + stage tag) plus the name column — the + // scheduler re-arms suppression when the fingerprint moves + const signature = pass ? contentSignature(gray) : undefined; + gray.delete(); + return { pass, score, signature }; + } + + function contentSignature(gray: Mat): number[] { + const signature = roiSignature(gray, HEADER_TOP_BAND, 32, 2); + for (const dy of PANEL_DYS) { + for (const base of ROW_CENTERS) { + signature.push(...roiSignature(gray, nameRoi(base + dy), 8, 1)); + } + } + return signature; } function parsePanel(gray: Mat, rgb: Mat, dy: number): PanelParse { @@ -351,7 +367,9 @@ export function createBattleLogDetector( } // no rearm cooldown — distinct battles browsed in quick succession are - // told apart by content (same as the replay browser) + // told apart by content: the gate signature re-arms scheduler suppression + // and the timeline merges via sameScoreboardMatch (same as the replay + // browser) return { id: "battle-log", sufficientConfidence: 0.8, diff --git a/app/features/scanner/core/detectors/scheduler.ts b/app/features/scanner/core/detectors/scheduler.ts index 2e5e48894..56a734067 100644 --- a/app/features/scanner/core/detectors/scheduler.ts +++ b/app/features/scanner/core/detectors/scheduler.ts @@ -27,7 +27,12 @@ * then never re-arm). A parse reaching the detector's * `sufficientConfidence` suppresses immediately: the timeline keeps the * best read per merge window, so once a read is that good further parses - * cannot change the outcome. A + * cannot change the outcome. A gate that reports a content `signature` + * ends the streak the moment the signature moves past + * `signatureTolerance`: browsing distinct battle-log/replay entries never + * drops those gates, so without the signature one sufficient read would + * suppress every subsequent entry for as long as the screen family stays + * up. A * detector with `rearmCooldownS` (death: the overlay's animated * background makes its gate flicker) additionally skips parses for that * long after a sufficient read, across gate drops; safe only where the @@ -75,6 +80,13 @@ export interface SchedulerOptions { stagnantAfterS: number; /** minimum confidence gain that counts as an improvement */ minImprovement: number; + /** + * a gate signature cell moving more than this since the streak's last + * parse means the screen's content changed and the streak resets + * (measured on battle-log browsing: static-screen noise ≤2 per cell, + * entry flips ≥57) + */ + signatureTolerance: number; /** seconds without any gate pass before the scan counts as calm */ quietAfterS: number; /** a match opened this long ago without closing is assumed abandoned */ @@ -94,6 +106,7 @@ const DEFAULT_SCHEDULER_OPTIONS: SchedulerOptions = { // the sampling cadence stagnantAfterS: 3, minImprovement: 0.001, + signatureTolerance: 12, quietAfterS: 15, matchOpenMaxS: 8 * 60, matchOpeningTypes: [], @@ -113,6 +126,8 @@ interface StreakState { stagnant: number; lastImprovementT: number; suppressed: boolean; + /** gate signature of the streak's last parsed frame */ + signature: readonly number[] | undefined; } interface DetectorState { @@ -120,6 +135,8 @@ interface DetectorState { lastCheckT: number | undefined; gatePassing: boolean; streak: StreakState | null; + /** signature reported by the latest passing gate */ + lastGateSignature: readonly number[] | undefined; /** parses skipped until this t after a sufficient read (rearmCooldownS) */ parseHoldUntilT: number; } @@ -187,16 +204,33 @@ export class DetectorScheduler { } /** Report a gate outcome for a detector this scheduler marked due. */ - recordGate(id: string, t: number, pass: boolean): void { + recordGate( + id: string, + t: number, + pass: boolean, + signature?: readonly number[], + ): void { const state = this.#states.get(id); if (!state) return; state.lastCheckT = t; state.gatePassing = pass; - if (pass) { - this.#lastActivityT = Math.max(this.#lastActivityT, t); - } else { + if (!pass) { + state.streak = null; + return; + } + this.#lastActivityT = Math.max(this.#lastActivityT, t); + if ( + signature && + state.streak?.signature && + signaturesDiffer( + signature, + state.streak.signature, + this.#options.signatureTolerance, + ) + ) { state.streak = null; } + state.lastGateSignature = signature; } /** Whether the (passed) gate should be followed by a parse at `t`. */ @@ -234,6 +268,7 @@ export class DetectorScheduler { stagnant: 0, lastImprovementT: t, suppressed: true, + signature: state.lastGateSignature, }; if (rearmCooldownS !== undefined) { state.parseHoldUntilT = t + rearmCooldownS; @@ -246,10 +281,12 @@ export class DetectorScheduler { stagnant: 0, lastImprovementT: t, suppressed: false, + signature: state.lastGateSignature, }; return; } const streak = state.streak; + streak.signature = state.lastGateSignature; if (confidence > streak.best + this.#options.minImprovement) { streak.best = confidence; streak.stagnant = 0; @@ -316,6 +353,19 @@ function freshState(info: SchedulingInfo): DetectorState { lastCheckT: undefined, gatePassing: false, streak: null, + lastGateSignature: undefined, parseHoldUntilT: Number.NEGATIVE_INFINITY, }; } + +function signaturesDiffer( + a: readonly number[], + b: readonly number[], + tolerance: number, +): boolean { + if (a.length !== b.length) return true; + for (let i = 0; i < a.length; i++) { + if (Math.abs(a[i]! - b[i]!) > tolerance) return true; + } + return false; +} diff --git a/app/features/scanner/core/detectors/scoreboard-replay/index.ts b/app/features/scanner/core/detectors/scoreboard-replay/index.ts index 30ffee355..4f28b453f 100644 --- a/app/features/scanner/core/detectors/scoreboard-replay/index.ts +++ b/app/features/scanner/core/detectors/scoreboard-replay/index.ts @@ -17,6 +17,7 @@ import { maxChannel, meanBrightness, type Roi, + roiSignature, } from "../../image"; import { RESULT_TAG_ENTRIES } from "../../localized"; import { closestBy } from "../../text"; @@ -50,6 +51,7 @@ import { gateFlatProbe, HEADER_LINE_HEIGHT, HEADER_TIMESTAMP_HEIGHT, + HEADER_TOP_BAND, MATCH_SCORE_DIGIT_HEIGHT, MATCH_SCORE_ROIS, NAME_TEXT_HEIGHT, @@ -185,7 +187,6 @@ export function createScoreboardReplayDetector( if (meanBrightness(frame, roi) < GATE_GAP_MAX_MEAN) gapOk++; } const codeFraction = greenFraction(frame, REPLAY_CODE_ROI); - gray.delete(); const rowCount = PANEL_XS.length * ROW_CENTERS.length; const score = @@ -199,7 +200,24 @@ export function createScoreboardReplayDetector( suffixOk >= 7 && gapOk === 2 && codeFraction >= GATE_CODE_MIN_FRACTION; - return { pass, score }; + // browsing flips between replays never drop this gate, so it + // fingerprints the content that always differs between two battles + // (recording timestamp band + replay code) plus the name columns — + // the scheduler re-arms suppression when the fingerprint moves + const signature = pass ? contentSignature(gray) : undefined; + gray.delete(); + return { pass, score, signature }; + } + + function contentSignature(gray: Mat): number[] { + const signature = roiSignature(gray, HEADER_TOP_BAND, 32, 2); + signature.push(...roiSignature(gray, REPLAY_CODE_ROI, 32, 1)); + for (const dx of PANEL_XS) { + for (const cy of ROW_CENTERS) { + signature.push(...roiSignature(gray, nameRoi(cy, dx), 8, 1)); + } + } + return signature; } function parsePanel(gray: Mat, rgb: Mat, dx: number): PanelParse { diff --git a/app/features/scanner/core/detectors/types.ts b/app/features/scanner/core/detectors/types.ts index 09ba41fa9..eb271476b 100644 --- a/app/features/scanner/core/detectors/types.ts +++ b/app/features/scanner/core/detectors/types.ts @@ -21,6 +21,13 @@ export interface GateResult { * instead of re-running the gate probes */ variant?: string; + /** + * coarse content fingerprint of the recognized screen (grid-cell means + * over content ROIs, see image.ts roiSignature) — the scheduler re-arms a + * suppressed streak when it moves, for screens whose distinct real + * occurrences keep the gate passing (browsing battle-log / replay entries) + */ + signature?: number[]; } /** diff --git a/app/features/scanner/core/image.ts b/app/features/scanner/core/image.ts index 073b23f08..d49d61a95 100644 --- a/app/features/scanner/core/image.ts +++ b/app/features/scanner/core/image.ts @@ -128,6 +128,30 @@ export function minChannel(mat: Mat, roi?: Roi): Mat { return channelExtreme(mat, roi, "min"); } +/** + * Coarse content fingerprint of a grayscale ROI: the mean brightness of each + * cell in a cols x rows grid over the region. Cheap enough for gates. + * Consecutive frames of one static screen move a cell by ≤~2 while different + * text/content moves cells by tens (measured on battle-log browsing footage) + * — the scheduler compares fingerprints to re-arm suppression when a passing + * gate's screen flips to a new real occurrence (GateResult.signature). + */ +export function roiSignature( + gray: Mat, + roi: Roi, + cols: number, + rows: number, +): number[] { + const cv = getCV(); + const view = cropRoi(gray, roi); + const small = new cv.Mat(); + cv.resize(view, small, new cv.Size(cols, rows), 0, 0, cv.INTER_AREA); + view.delete(); + const cells = Array.from(small.data as Uint8Array, Number); + small.delete(); + return cells; +} + /** |Laplacian| response of a grayscale mat; caller owns the result. */ export function laplacianAbs(gray: Mat): Mat { const cv = getCV(); diff --git a/app/features/scanner/tests/scheduler.test.ts b/app/features/scanner/tests/scheduler.test.ts index 4b9827aac..ec9044738 100644 --- a/app/features/scanner/tests/scheduler.test.ts +++ b/app/features/scanner/tests/scheduler.test.ts @@ -34,10 +34,15 @@ function make(detector: Partial, options = {}) { function feed( s: DetectorScheduler, t: number, - options: { pass: boolean; confidence?: number; type?: string }, + options: { + pass: boolean; + confidence?: number; + type?: string; + signature?: number[]; + }, ): "skipped" | "gated" | "parsed" { if (!s.dueDetectors(t).includes("d")) return "skipped"; - s.recordGate("d", t, options.pass); + s.recordGate("d", t, options.pass, options.signature); if (!options.pass || !s.shouldParse("d", t)) return "gated"; s.recordParse( "d", @@ -144,6 +149,48 @@ test("a sufficient read suppresses immediately", () => { assert.equal(feed(s, 0.3, { pass: true, confidence: 0.7 }), "parsed"); }); +test("a changed gate signature re-arms a suppressed streak", () => { + const s = make({ sufficientConfidence: 0.8 }); + const entryA = [20, 200]; + const entryB = [200, 20]; + assert.equal( + feed(s, 0, { pass: true, confidence: 0.9, signature: entryA }), + "parsed", + ); + assert.equal( + feed(s, 0.1, { pass: true, confidence: 0.9, signature: entryA }), + "gated", + ); + // codec noise within tolerance is still the same screen + assert.equal( + feed(s, 0.2, { pass: true, confidence: 0.9, signature: [22, 198] }), + "gated", + ); + // the next browsed entry keeps the gate passing but moves the content + assert.equal( + feed(s, 0.3, { pass: true, confidence: 0.9, signature: entryB }), + "parsed", + ); + assert.equal( + feed(s, 0.4, { pass: true, confidence: 0.9, signature: entryB }), + "gated", + ); +}); + +test("a signature change mid-streak starts a fresh best", () => { + const s = make({}); + feed(s, 0.0, { pass: true, confidence: 0.9, signature: [0] }); + feed(s, 0.1, { pass: true, confidence: 0.9, signature: [0] }); + // the next entry reads worse than the old best — with the old streak's + // best carried over these would count stagnant and suppress at t=0.4 + feed(s, 0.2, { pass: true, confidence: 0.7, signature: [255] }); + feed(s, 0.3, { pass: true, confidence: 0.7, signature: [255] }); + assert.equal( + feed(s, 0.4, { pass: true, confidence: 0.7, signature: [255] }), + "parsed", + ); +}); + test("rearm cooldown skips parses across gate flicker", () => { const s = make({ sufficientConfidence: 0.95, rearmCooldownS: 4 }); assert.equal(feed(s, 1.0, { pass: true, confidence: 0.96 }), "parsed"); diff --git a/app/features/scanner/worker/analyzer.worker.ts b/app/features/scanner/worker/analyzer.worker.ts index 1c6ee2aa3..28fa79660 100644 --- a/app/features/scanner/worker/analyzer.worker.ts +++ b/app/features/scanner/worker/analyzer.worker.ts @@ -135,7 +135,7 @@ async function analyzeFrame( const gateStart = performance.now(); const gate = detector.gate(frame); counters.gateMs += performance.now() - gateStart; - scheduler!.recordGate(detector.id, t, gate.pass); + scheduler!.recordGate(detector.id, t, gate.pass, gate.signature); if (gate.pass) counters.gatePasses++; const runParse = gate.pass && scheduler!.shouldParse(detector.id, t); if (gate.pass && !runParse) counters.suppressedParses++; diff --git a/scripts/scanner/replay-frames.ts b/scripts/scanner/replay-frames.ts index f4784b951..db0338cc4 100644 --- a/scripts/scanner/replay-frames.ts +++ b/scripts/scanner/replay-frames.ts @@ -55,7 +55,7 @@ for (const [i, file] of files.entries()) { for (const detector of detectors) { if (!due.includes(detector.id)) continue; const gate = detector.gate(frame); - scheduler.recordGate(detector.id, t, gate.pass); + scheduler.recordGate(detector.id, t, gate.pass, gate.signature); if (!gate.pass) { if (process.env.LOG_GATES?.includes(detector.id)) { console.log(