mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-13 06:36:49 -05:00
Fix battle logs missed
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user