Fix battle logs missed

This commit is contained in:
Kalle
2026-08-07 11:29:30 +03:00
parent 41f7dfeeed
commit 77d27dcf19
9 changed files with 182 additions and 15 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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[];
}
/**

View File

@@ -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();

View File

@@ -34,10 +34,15 @@ function make(detector: Partial<SchedulingInfo>, 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");

View File

@@ -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++;

View File

@@ -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(