Proper score

This commit is contained in:
Kalle
2026-08-05 17:18:41 +03:00
parent d1cf38fabe
commit aefe99a204
47 changed files with 582 additions and 230 deletions

View File

@@ -33,18 +33,12 @@ function testMatch(partial: Partial<ScannerMatch> = {}): ScannerMatch {
lobby: "PRIVATE",
mode: "SZ",
stage: 0,
matchScores: null,
matchScores: [100, 52],
replayCode: null,
cast: false,
teams: [
{
score: 100,
players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)),
},
{
score: 52,
players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)),
},
{ players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) },
{ players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) },
],
winner: 0,
pov: null,
@@ -103,10 +97,8 @@ describe("isSameMatch", () => {
});
const b = testMatch({
replayCode: "RABC-DEFG-HIJK-LMNO",
teams: [
{ score: null, players: [] },
{ score: null, players: [] },
],
matchScores: null,
teams: [{ players: [] }, { players: [] }],
winner: null,
});
expect(Matches.isSameMatch(a, b)).toBe(true);
@@ -128,10 +120,8 @@ describe("isSameMatch", () => {
const a = testMatch({ playedAt: 1_700_000_000_000 });
const b = testMatch({
playedAt: 1_700_000_000_000 + 5 * 60 * 1000,
teams: [
{ score: null, players: [] },
{ score: null, players: [] },
],
matchScores: null,
teams: [{ players: [] }, { players: [] }],
winner: null,
});
expect(Matches.isSameMatch(a, b)).toBe(true);
@@ -175,15 +165,10 @@ describe("isSameMatch", () => {
const minimap = testMatch({
winner: null,
lobby: null,
matchScores: null,
teams: [
{
score: null,
players: WEAPONS.slice(0, 4).map((w) => player(null, w)),
},
{
score: null,
players: WEAPONS.slice(4).map((w) => player(null, w)),
},
{ players: WEAPONS.slice(0, 4).map((w) => player(null, w)) },
{ players: WEAPONS.slice(4).map((w) => player(null, w)) },
],
});
expect(Matches.isSameMatch(testMatch(), minimap)).toBe(true);
@@ -191,15 +176,14 @@ describe("isSameMatch", () => {
it("unrelated matches are not the same", () => {
const other = testMatch({
matchScores: [88, 12],
teams: [
{
score: 88,
players: ["a", "b", "c", "d"].map((n, i) =>
player(n, (100 + 10 * i) as MainWeaponId),
),
},
{
score: 12,
players: ["e", "f", "g", "h"].map((n, i) =>
player(n, (200 + 10 * i) as MainWeaponId),
),
@@ -237,12 +221,12 @@ describe("mergeMatches", () => {
it("aligns a side-swapped incoming match before merging", () => {
const existing = testMatch({ winner: null, matchScores: null });
const incoming = sideSwapped(
testMatch({ matchScores: [3, 1], playedAt: 1_700_000_000_000 }),
testMatch({ matchScores: [84, 71], playedAt: 1_700_000_000_000 }),
);
const { merged } = Matches.mergeMatches(existing, incoming);
expect(merged.winner).toBe(0);
expect(merged.matchScores).toEqual([3, 1]);
expect(merged.matchScores).toEqual([84, 71]);
expect(merged.teams[0].players.map((p) => p.name)).toEqual(
NAMES.slice(0, 4),
);
@@ -269,10 +253,8 @@ describe("mergeMatches", () => {
it("fills empty teams from the incoming match", () => {
const existing = testMatch({
winner: null,
teams: [
{ score: null, players: [] },
{ score: null, players: [] },
],
matchScores: null,
teams: [{ players: [] }, { players: [] }],
replayCode: "RABC-DEFG-HIJK-LMNO",
});
const incoming = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO" });
@@ -283,6 +265,6 @@ describe("mergeMatches", () => {
expect(merged.teams[0].players.map((p) => p.name)).toEqual(
NAMES.slice(0, 4),
);
expect(merged.teams[0].score).toBe(100);
expect(merged.matchScores).toEqual([100, 52]);
});
});

View File

@@ -144,7 +144,6 @@ export function normalizeInGameName(name: string): string {
function canonicalTeam(team: ScannerMatchTeam): ScannerMatchTeam {
return {
score: team.score,
players: team.players.map(canonicalPlayer),
};
}
@@ -319,7 +318,7 @@ function mergeTeam(
players.push(entry.player);
}
return { score: existing.score ?? incoming.score, players };
return { players };
}
function mergePlayer(

View File

@@ -73,13 +73,10 @@ function testMatch({
lobby,
mode,
stage,
matchScores: null,
matchScores: [100, 52],
replayCode: null,
cast: false,
teams: [
{ score: 100, players: players.slice(0, 4) },
{ score: 52, players: players.slice(4) },
],
teams: [{ players: players.slice(0, 4) }, { players: players.slice(4) }],
winner: 0,
pov:
povIndex === null
@@ -94,6 +91,10 @@ function swapSides(match: ScannerMatch): ScannerMatch {
...match,
teams: [match.teams[1], match.teams[0]],
winner: match.winner === null ? null : match.winner === 0 ? 1 : 0,
matchScores:
match.matchScores === null
? null
: [match.matchScores[1], match.matchScores[0]],
pov:
match.pov === null
? null

View File

@@ -71,6 +71,7 @@ export interface IngestedScoreboardPlayer {
}
export interface IngestedScoreboardData {
/** game scores [winner, loser] (0-100; a knockout's winner is 100) */
scores: [number | null, number | null];
/** in scoreboard order: rows 0-3 winning team, rows 4-7 losing team */
players: IngestedScoreboardPlayer[];
@@ -198,6 +199,7 @@ interface WinnerFirstView {
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** game scores [winner, loser] from the match's "Score:" banner */
scores: [number | null, number | null];
players: WinnerFirstPlayer[];
povIndex: number | null;
@@ -233,7 +235,10 @@ function winnerFirstView(
lobby: match.lobby,
mode: match.mode,
stage: match.stage,
scores: [winners.score, losers.score],
scores: [
match.matchScores?.[match.winner] ?? null,
match.matchScores?.[match.winner === 0 ? 1 : 0] ?? null,
],
players: [...winners.players, ...losers.players].map((player) => ({
...player,
name: player.name ?? "",

View File

@@ -61,9 +61,6 @@ export function MatchCard({
modeLabel(match.mode),
ingestable ? null : lobbyLabel(match.lobby),
match.startsAt !== null ? timeRangeLabel(match) : null,
match.matchScores
? `set ${match.matchScores[0] ?? "?"}${match.matchScores[1] ?? "?"}`
: null,
match.replayCode,
match.cast ? "cast" : null,
]
@@ -150,20 +147,19 @@ function timeRangeLabel(match: ScannerMatch): string {
}
function Score({ match }: { match: ScannerMatch }) {
const [alpha, bravo] = match.teams;
if (alpha.score === null && bravo.score === null) return null;
if (match.matchScores === null) return null;
const [alpha, bravo] = match.matchScores;
// a 100 only happens on a knockout — show it the way players say it
const label = (score: number | null) =>
score === 100 ? "KO" : (score ?? "?");
// scoreboard-sourced matches list the winners first
const winnerKnown = match.winner !== null;
return (
<div className="match-score">
<span className={winnerKnown ? "win" : undefined}>
{alpha.score ?? "?"}
</span>
<span className={winnerKnown ? "win" : undefined}>{label(alpha)}</span>
<span className="sep"> </span>
<span className={winnerKnown ? "lose" : undefined}>
{bravo.score ?? "?"}
</span>
<span className={winnerKnown ? "lose" : undefined}>{label(bravo)}</span>
</div>
);
}

View File

@@ -55,9 +55,8 @@ function PlayerRows({
}
function teamHeading(label: string, data: CardData, side: 0 | 1): string {
const score = `${label}${data.scores[side] ?? "?"}p`;
const match = data.matchScores?.[side];
return match != null ? `${score} · score ${match}` : score;
const score = data.matchScores[side];
return score !== null ? `${label}${score}` : label;
}
export function ScoreboardCard(props: {

View File

@@ -160,9 +160,10 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
rect(minimap.enemyCrossRoi(cy), "#e879f9");
}
for (const roi of [
minimap.GATE_CLOSE_BRIGHT,
...minimap.GATE_CLOSE_X_BRIGHT,
minimap.GATE_SPAWN_BRIGHT,
...minimap.GATE_CLOSE_DARK_PROBES,
...minimap.GATE_CLOSE_X_DARK,
...minimap.GATE_SPAWN_DARK_PROBES,
]) {
rect(roi, "#facc15");
@@ -197,6 +198,7 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
rect(sb.gateDarkProbe(cy), "#facc15");
}
for (const roi of sb.TEAM_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of sb.MATCH_SCORE_ROIS) rect(roi, "#fb923c");
for (const roi of sb.GATE_PANEL_PROBES) rect(roi, "#facc15");
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
@@ -426,7 +428,9 @@ export function ScreenshotPage() {
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}% ·
scores{" "}
{JSON.stringify((result.events[0].data as CardData).scores)}
{JSON.stringify(
(result.events[0].data as CardData).matchScores,
)}
{" · "}
{(() => {
const data = result.events[0].data as CardData;

View File

@@ -204,8 +204,8 @@ function eventCells(event: CsvEvent): Cell[] {
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
d.scores[0],
d.scores[1],
d.matchScores[0],
d.matchScores[1],
d.povIndex === null ? "" : d.players[d.povIndex]?.name,
"",
"",

View File

@@ -26,9 +26,7 @@ import { mainWeaponLabel, stageLabel, weaponLabel } from "./labels";
/** Scoreboard data with the replay extras present when the event has them. */
export type CardData = ScoreboardData &
Partial<
Pick<ScoreboardReplayData, "timestamp" | "replayCode" | "matchScores">
>;
Partial<Pick<ScoreboardReplayData, "timestamp" | "replayCode">>;
/** Any detector's event payload that can prefill a fixture. */
export type FixtureData =
@@ -160,10 +158,7 @@ function buildExpectedJson(
}),
...(card.timestamp != null && { timestamp: card.timestamp }),
...(card.replayCode != null && { replayCode: card.replayCode }),
scores: card.scores,
...(card.matchScores !== undefined && {
matchScores: card.matchScores,
}),
matchScores: card.matchScores,
players: card.players.map((p) => ({
name: p.name,
weaponId: p.weaponId,

View File

@@ -59,8 +59,9 @@ import {
enemySubTileRoi,
enemyWeaponRoi,
GATE_BRIGHT_MIN_MAX,
GATE_CLOSE_BRIGHT,
GATE_CLOSE_DARK_PROBES,
GATE_CLOSE_X_BRIGHT,
GATE_CLOSE_X_DARK,
GATE_DARK_MAX_MEAN,
GATE_SPAWN_BRIGHT,
GATE_SPAWN_DARK_PROBES,
@@ -224,8 +225,12 @@ export function createMinimapDetector(
function overlayGate(gray: Mat): GateResult {
return probeGate(
gray,
[...GATE_CLOSE_DARK_PROBES, ...GATE_SPAWN_DARK_PROBES],
[GATE_CLOSE_BRIGHT, GATE_SPAWN_BRIGHT],
[
...GATE_CLOSE_DARK_PROBES,
...GATE_CLOSE_X_DARK,
...GATE_SPAWN_DARK_PROBES,
],
[...GATE_CLOSE_X_BRIGHT, GATE_SPAWN_BRIGHT],
);
}

View File

@@ -200,8 +200,29 @@ export const SPECIAL_READY_WEAPON_MIN_SCORE = 0.42;
*/
export const PRESENCE_MIN_LAPLACIAN = 8;
/** Gate probes (overlay variant): close-button disc + Spawn Point pill shapes. */
export const GATE_CLOSE_BRIGHT: Roi = { x: 90, y: 90, w: 16, h: 16 };
/**
* Gate probes (overlay variant): close-button disc + Spawn Point pill
* shapes. The close button is a white ✕ glyph on a dark disc (center
* (110,92), ±4px across fixtures), traced like the spectator X: bright
* probes on the crossing point and the four stroke arms, dark probes in
* the cardinal gaps between them. A mere bright blob at the same spot —
* the results screen's splat counter puts white digits exactly there —
* lights the gaps or misses the arms and fails.
*/
export const GATE_CLOSE_X_BRIGHT: readonly Roi[] = [
{ x: 104, y: 88, w: 12, h: 10 },
{ x: 88, y: 72, w: 8, h: 10 },
{ x: 124, y: 72, w: 8, h: 10 },
{ x: 88, y: 108, w: 8, h: 10 },
{ x: 124, y: 108, w: 8, h: 10 },
];
export const GATE_CLOSE_X_DARK: readonly Roi[] = [
{ x: 104, y: 62, w: 12, h: 8 },
{ x: 104, y: 116, w: 12, h: 8 },
{ x: 76, y: 88, w: 8, h: 10 },
{ x: 136, y: 88, w: 8, h: 10 },
];
/** Dark ring/background just outside the close-button disc. */
export const GATE_CLOSE_DARK_PROBES: readonly Roi[] = [
{ x: 88, y: 50, w: 20, h: 14 },
{ x: 88, y: 132, w: 20, h: 14 },

View File

@@ -5,9 +5,9 @@
*
* Layout differs from the live scoreboard: the two team panels sit side by
* side and the replay owner's team may be on either side, so the
* VICTORY/DEFEAT panel tags are read to keep `players`/`scores` ordered
* winners-first like the live event. Field parsing reuses the scoreboard
* helpers with glyph sets rescaled to this screen's text sizes.
* VICTORY/DEFEAT panel tags are read to keep `players`/`matchScores`
* ordered winners-first like the live event. Field parsing reuses the
* scoreboard helpers with glyph sets rescaled to this screen's text sizes.
*/
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
@@ -20,6 +20,11 @@ import {
} from "../../image";
import { RESULT_TAG_ENTRIES } from "../../localized";
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 {
ScoreboardData,
@@ -71,11 +76,6 @@ export interface ScoreboardReplayData extends ScoreboardData {
timestamp: string | null;
/** "XXXX-XXXX-XXXX-XXXX" */
replayCode: string | null;
/**
* the colored "Score:" banner values, [winner, loser] like `scores`;
* a knockout's winner reports 100 (the burst hides the real banner)
*/
matchScores: [number | null, number | null];
}
export const SCOREBOARD_REPLAY_EVENT_TYPE = "ScoreboardReplay";
@@ -90,25 +90,6 @@ const REPLAY_INK_THRESHOLD = 90;
*/
const BANNER_BIN_THRESHOLD = 190;
/**
* On a knockout the winner's "Score:" banner is replaced by the localized
* "KNOCKOUT!" burst, whose letters overlap the score ROI and weakly match
* digit templates (an O reads as a ~0.42 zero; real digits score 0.9+).
* Reads below this floor are discarded rather than trusted as a score — the
* knockout that put the burst there is recovered from the team count below.
*/
const MATCH_SCORE_MIN_CONF = 0.6;
/** The count a knockout wins at — the burst hides it, so it is never read. */
const KO_MATCH_SCORE = 100;
/**
* The team box prints the count times five ("440 p" alongside a 88 banner),
* so a knockout's full 100 count shows as 500 — a total only a knockout
* reaches, which is what separates a burst-covered banner from an unread one.
*/
const FULL_COUNT_TEAM_SCORE = KO_MATCH_SCORE * 5;
/** Canonical results the localized VICTORY/DEFEAT panel tags snap to. */
type PanelResult = "VICTORY" | "DEFEAT";
const RESULT_MIN_SCORE = 0.6;
@@ -268,6 +249,9 @@ export function createScoreboardReplayDetector(
rows.push(row.debug);
}
// The panel's point total is read only to recognize a knockout below
// (the count times five: only a knockout's full count reaches 500);
// it is never emitted as a score.
let teamScore: ParsedNumber | null = null;
if (teamDigits) {
const crop = cropRoi(gray, teamScoreRoi(dx));
@@ -284,7 +268,10 @@ export function createScoreboardReplayDetector(
matchScore = parseNumber(crop, matchScoreDigits, {
binThreshold: BANNER_BIN_THRESHOLD,
});
if (matchScore.confidence < MATCH_SCORE_MIN_CONF) {
if (
matchScore.confidence < MATCH_SCORE_MIN_CONF ||
(matchScore.value !== null && matchScore.value > KO_MATCH_SCORE)
) {
matchScore = { ...matchScore, value: null };
}
crop.delete();
@@ -402,10 +389,6 @@ export function createScoreboardReplayDetector(
stage: header?.stage ?? null,
timestamp: header?.timestamp ?? null,
replayCode: code?.code ?? null,
scores: [
winner.teamScore?.value ?? null,
loser.teamScore?.value ?? null,
],
matchScores: [
winner.matchScore?.value ?? null,
loser.matchScore?.value ?? null,

View File

@@ -0,0 +1,214 @@
/**
* "Score:" banner parsing for the results screens. Each side of the colored
* banner shows one team's game score (0-100) as white BlitzBold digits after
* a localized label — some languages render no label at all, so the digits'
* x position is not fixed. A knockout replaces the winning side's value with
* the localized KNOCKOUT! burst, whose letters only weakly match digit
* templates (real digits score 0.9+); the knockout itself is recognized from
* the winner's team total instead (the box prints the count times five, and
* only a knockout's full 100 count reaches 500). The score value bounces as
* it lands, so a frame may catch the digits settled or mid-pop — callers
* parse at both sizes and this module keeps the more confident read.
*/
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
type RecognizedText,
recognizeText,
} from "../../glyphs";
import { copyRoi, type Roi } from "../../image";
/** The count a knockout wins at — the burst hides it, so it is never read. */
export const KO_MATCH_SCORE = 100;
/**
* The team box prints the count times five ("440 p" alongside a 88 banner),
* so a knockout's full 100 count shows as 500 — a total only a knockout
* reaches, which is what separates a burst-covered banner from an unread one.
*/
export const FULL_COUNT_TEAM_SCORE = KO_MATCH_SCORE * 5;
/**
* Replay-screen reads below this floor are discarded rather than trusted as
* a score — burst/label letters overlapping a score ROI match digit
* templates at ~0.4 there.
*/
export const MATCH_SCORE_MIN_CONF = 0.6;
/**
* Char floor for the live banner's trailing-digit run. KNOCKOUT! letters
* have matched digit templates at up to 0.62 (the ko-hagglefish fixture's
* "07"), while genuine banner digits score 0.79+ across every fixture —
* including 720p upscales.
*/
const DIGIT_MIN_CONF = 0.75;
/** White banner digits on saturated team color (yellow ink grays at ~170). */
const BANNER_SCORE_BIN_THRESHOLD = 205;
/**
* Digits of one number nearly touch; anything further apart than this
* fraction of a digit width is the label (or an unreadable glyph) ending
* the run.
*/
const DIGIT_GAP_MAX_RATIO = 0.55;
/**
* A score digit spans the set's full height; the label's lowercase letters
* top out ~0.75 of it, so they cannot pass as digits even when their shapes
* correlate.
*/
const DIGIT_MIN_HEIGHT_RATIO = 0.82;
/**
* The banner's bright wave-crest highlight can dip into the score line as a
* wide ~12px-tall streak whose columns merge into the digits' segments and
* ruin their ink extents. Every real digit is at least ~26px tall, so ink
* components shorter than this are wiped before recognition.
*/
const MIN_COMPONENT_HEIGHT = 20;
export interface BannerScoreRead {
/** the side's score; null when unread (knockout burst, blur, label-only) */
value: number | null;
/** min glyph score across the accepted digits (0 when none) */
confidence: number;
/** best raw reading, for debugging */
reading: string;
}
const EMPTY_READ: BannerScoreRead = { value: null, confidence: 0, reading: "" };
/**
* Reads one banner side's score from `roi`: recognizes with each digit set
* (one per on-screen text size) and keeps the best read. The score is the
* trailing run of full-height, confidently-matched digits — everything the
* localized label or the KNOCKOUT! burst leaves in the ROI fails at least
* one of those tests.
*/
export function parseBannerScore(
gray: Mat,
roi: Roi,
sets: readonly GlyphSet[],
): BannerScoreRead {
const crop = copyRoi(gray, roi);
clearShortBlobs(crop);
let best = EMPTY_READ;
for (const set of sets) {
const raw = recognizeText(crop, set, {
binThreshold: BANNER_SCORE_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const read = trailingScore(raw, set);
if (
(read.value !== null) === (best.value !== null)
? read.confidence > best.confidence
: read.value !== null
) {
best = read;
}
}
crop.delete();
return best;
}
/**
* Winner-first score pair from the two banner sides. A confirmed knockout
* (winner team total = 500) dominates: the winner reports the full count no
* matter what was read off the burst-covered side, and the loser is the
* more confident read (genuine digits score well clear of burst letters
* that survive the floor). Without a knockout, ranked scores never tie, so
* when both sides read the higher value is the winner's; one unreadable
* side cannot be attributed to a team, so nothing is reported.
*/
export function resolveMatchScores({
left,
right,
knockout,
}: {
left: BannerScoreRead;
right: BannerScoreRead;
knockout: boolean;
}): [number | null, number | null] {
if (knockout) {
const loser =
left.value !== null && right.value !== null
? left.confidence >= right.confidence
? left
: right
: left.value !== null
? left
: right;
return [KO_MATCH_SCORE, loser.value];
}
if (left.value !== null && right.value !== null) {
return left.value >= right.value
? [left.value, right.value]
: [right.value, left.value];
}
return [null, null];
}
/** Zero out ink components shorter than any digit (see MIN_COMPONENT_HEIGHT). */
function clearShortBlobs(band: Mat): void {
const cv = getCV();
const bin = new cv.Mat();
cv.threshold(band, bin, BANNER_SCORE_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
const labels = new cv.Mat();
const stats = new cv.Mat();
const centroids = new cv.Mat();
const count = cv.connectedComponentsWithStats(
bin,
labels,
stats,
centroids,
8,
);
bin.delete();
centroids.delete();
const s = stats.data32S;
const short = new Uint8Array(count);
for (let i = 1; i < count; i++) {
short[i] = s[i * 5 + cv.CC_STAT_HEIGHT]! < MIN_COMPONENT_HEIGHT ? 1 : 0;
}
stats.delete();
const lab = labels.data32S;
const out = band.data;
for (let i = 0; i < out.length; i++) {
if (short[lab[i]!]!) out[i] = 0;
}
labels.delete();
}
function trailingScore(raw: RecognizedText, set: GlyphSet): BannerScoreRead {
const maxGap = Math.max(4, Math.round(set.medianWidth * DIGIT_GAP_MAX_RATIO));
const isScoreDigit = (c: RecognizedChar) =>
c.score >= DIGIT_MIN_CONF &&
c.y1 - c.y0 >= set.height * DIGIT_MIN_HEIGHT_RATIO;
const run: RecognizedChar[] = [];
let i = raw.chars.length - 1;
for (; i >= 0; i--) {
const c = raw.chars[i]!;
if (!isScoreDigit(c)) break;
if (run.length > 0 && run[0]!.x0 - c.x1 > maxGap) break;
run.unshift(c);
}
if (run.length === 0) return { ...EMPTY_READ, reading: raw.text };
// A further digit left of the run means an unreadable glyph split the
// number (turf war percentages read "48", ".", "7") — the tail is not
// the score.
if (i >= 0 && isScoreDigit(raw.chars[i]!)) {
return { ...EMPTY_READ, reading: raw.text };
}
const value = Number.parseInt(run.map((c) => c.char).join(""), 10);
if (value > KO_MATCH_SCORE) return { ...EMPTY_READ, reading: raw.text };
return {
value,
confidence: Math.min(...run.map((c) => c.score)),
reading: raw.text,
};
}

View File

@@ -12,6 +12,11 @@ import { getCV, type Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import { cropRoi, maxBrightness, meanBrightness } from "../../image";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
FULL_COUNT_TEAM_SCORE,
parseBannerScore,
resolveMatchScores,
} from "./banner";
import { parseNumber } from "./digits";
import { type ParsedHeader, parseHeader } from "./header";
import { findPovIndex } from "./pov";
@@ -21,6 +26,8 @@ import {
GATE_PANEL_PROBES,
GATE_TEXT_MIN_MAX,
gateDarkProbe,
MATCH_SCORE_DIGIT_HEIGHTS,
MATCH_SCORE_ROIS,
nameRoi,
PAINT_DIGIT_HEIGHT,
paintRoi,
@@ -53,8 +60,11 @@ export interface ScoreboardData {
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** [winning team total, losing team total] as shown ("500 p") */
scores: [number | null, number | null];
/**
* the "Score:" banner game scores, [winner, loser]; a knockout's winner
* reports 100 (the burst hides the real banner)
*/
matchScores: [number | null, number | null];
/** 8 players: rows 0-3 winning team, rows 4-7 losing team */
players: ScoreboardPlayer[];
/**
@@ -178,6 +188,11 @@ export function createScoreboardDetector(
TEAM_DIGIT_HEIGHT / PAINT_DIGIT_HEIGHT,
)
: null);
const matchScoreSets = teamDigits
? MATCH_SCORE_DIGIT_HEIGHTS.map((height) =>
scaleGlyphSet(teamDigits, height / teamDigits.height),
)
: [];
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
@@ -248,19 +263,32 @@ export function createScoreboardDetector(
confidences.push(header.confidence);
}
const scores: [number | null, number | null] = [null, null];
const teamScoreConf: number[] = [];
// The winner's team total is read only to recognize a knockout: the
// box prints the count times five, and only a knockout's full 100
// count reaches 500 (the banner value it would confirm is hidden
// under the KNOCKOUT! burst).
let knockout = false;
let winnerTotalConf = 0;
if (teamDigits) {
for (const i of [0, 1] as const) {
// Team totals sit on the team-colored box (light swirl pattern),
// so binarize more aggressively than on the black pills.
const crop = cropRoi(gray, TEAM_SCORE_ROIS[i]);
const parsed = parseNumber(crop, teamDigits, { binThreshold: 175 });
crop.delete();
scores[i] = parsed.value;
teamScoreConf.push(parsed.confidence);
confidences.push(parsed.confidence);
}
// The total sits on the team-colored box (light swirl pattern),
// so binarize more aggressively than on the 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;
}
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);
matchScores = resolveMatchScores({ left, right, knockout });
confidences.push(left.confidence, right.confidence);
bannerDebug = { left, right, knockout, winnerTotalConf };
}
gray.delete();
@@ -280,11 +308,15 @@ export function createScoreboardDetector(
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
scores,
matchScores,
players,
povIndex,
},
debug: { rows: rowDebug, teamScoreConf, header: header?.debug },
debug: {
rows: rowDebug,
matchScore: bannerDebug,
header: header?.debug,
},
},
];
}

View File

@@ -69,12 +69,32 @@ export function povArrowRoi(cy: number): Roi {
return { x: 930, y: cy - 32, w: 58, h: 56 };
}
/** Team score totals ("500 p"), larger digits, right-aligned ending at x=1658. */
/**
* Team point totals ("500 p"), larger digits, right-aligned ending at
* x=1658. Only read to recognize a knockout (winner total 500) — the
* totals are the count times five, not the match score.
*/
export const TEAM_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 1530, y: 330, w: 132, h: 44 },
{ x: 1530, y: 684, w: 132, h: 44 },
];
/**
* The two sides of the colored "Score:" banner above the team boxes. The
* left side's text is left-aligned from x~946 (digits directly there when
* the language renders no label), the right side's right-aligned ending at
* x~1691 — or x~1712 mid-pop, the digits bounce between ~28px and ~39px as
* the value lands (MATCH_SCORE_DIGIT_HEIGHTS). Boxes cover both sizes plus
* the label overlap the trailing-digit parse tolerates.
*/
export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 935, y: 231, w: 230, h: 50 },
{ x: 1600, y: 231, w: 125, h: 50 },
];
/** Banner score digit sizes: settled, and the landing bounce's peak. */
export const MATCH_SCORE_DIGIT_HEIGHTS = [28, 39] as const;
/**
* Gate probe: the strip between the paint "p" suffix (ends 1424) and the
* first stat "x" (starts 1477) is always empty pill background (near-black).

View File

@@ -237,7 +237,9 @@ function toBuiltMatch<E extends DetectedEvent>(
lobby: board?.lobby ?? null,
mode: board?.mode ?? start?.mode ?? null,
stage: board?.stage ?? start?.stage ?? leadingStage(open.stageVotes),
matchScores: replay?.matchScores ?? null,
matchScores: board?.matchScores.some((score) => score !== null)
? board.matchScores
: null,
replayCode: replay?.replayCode ?? null,
cast: open.minimaps.some((event) => (event.data as MinimapData).spectator),
teams: board
@@ -303,8 +305,8 @@ function teamsFromScoreboard(
};
});
return [
{ score: board.scores[0], players: players.slice(0, PLAYERS_PER_TEAM) },
{ score: board.scores[1], players: players.slice(PLAYERS_PER_TEAM) },
{ players: players.slice(0, PLAYERS_PER_TEAM) },
{ players: players.slice(PLAYERS_PER_TEAM) },
];
}
@@ -328,8 +330,8 @@ function teamsFromMinimaps(
});
return [
{ score: null, players: withAbilities.slice(0, alpha.length) },
{ score: null, players: withAbilities.slice(alpha.length) },
{ players: withAbilities.slice(0, alpha.length) },
{ players: withAbilities.slice(alpha.length) },
];
}

View File

@@ -26,8 +26,6 @@ export interface ScannerMatchPlayer {
}
export interface ScannerMatchTeam {
/** the team's game score; null when no results screen was seen */
score: number | null;
/** up to 4 players; slots the scan never saw are absent */
players: ScannerMatchPlayer[];
}
@@ -45,7 +43,11 @@ export interface ScannerMatch {
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** set score from the replay screen, in `teams` order */
/**
* the "Score:" banner game scores (0-100) in `teams` order, from a
* results/replay screen; a knockout's winner reports 100. Null when no
* such screen was seen.
*/
matchScores: [number | null, number | null] | null;
replayCode: string | null;
/** spectator/casted footage (the 8-player spectator map screen was seen) */

View File

@@ -71,9 +71,8 @@ interface ExpectedScoreboard {
timestamp?: string;
/** ScoreboardReplay only */
replayCode?: string;
scores?: [number, number];
/** ScoreboardReplay only: the "Score:" banner values */
matchScores?: [number, number];
/** the "Score:" banner game scores, [winner, loser]; KO winner = 100 */
matchScores?: [number | null, number | null];
players?: ExpectedPlayer[];
/** index of the yellow POV-arrow row in `players`; null = no arrow */
povIndex?: number | null;

View File

@@ -48,7 +48,6 @@ const scannerMatchPlayerSchema = z.object({
});
const scannerMatchTeamSchema = z.object({
score: z.number().nullable(),
players: z.array(scannerMatchPlayerSchema).max(4),
});

View File

@@ -0,0 +1,68 @@
/**
* resolveMatchScores is pure — the parse side of banner.ts is covered by
* the scoreboard fixture suite, this pins the winner/loser resolution.
*/
import assert from "node:assert/strict";
import {
type BannerScoreRead,
resolveMatchScores,
} from "../core/detectors/scoreboard/banner";
import test from "./node-test-compat";
function read(value: number | null, confidence = 0.9): BannerScoreRead {
return { value, confidence, reading: value === null ? "" : String(value) };
}
test("both sides read: higher value is the winner's, either way around", () => {
assert.deepEqual(
resolveMatchScores({ left: read(71), right: read(88), knockout: false }),
[88, 71],
);
assert.deepEqual(
resolveMatchScores({ left: read(88), right: read(71), knockout: false }),
[88, 71],
);
});
test("a knockout reports the full count over anything read off the burst", () => {
// burst letters surviving the floor read as a junk low-confidence value
assert.deepEqual(
resolveMatchScores({
left: read(0, 0.88),
right: read(7, 0.62),
knockout: true,
}),
[100, 0],
);
});
test("a knockout's loser is the side that read", () => {
assert.deepEqual(
resolveMatchScores({ left: read(null), right: read(0), knockout: true }),
[100, 0],
);
assert.deepEqual(
resolveMatchScores({ left: read(0), right: read(null), knockout: true }),
[100, 0],
);
assert.deepEqual(
resolveMatchScores({ left: read(null), right: read(null), knockout: true }),
[100, null],
);
});
test("one unreadable side without a knockout reports nothing", () => {
assert.deepEqual(
resolveMatchScores({ left: read(71), right: read(null), knockout: false }),
[null, null],
);
assert.deepEqual(
resolveMatchScores({
left: read(null),
right: read(null),
knockout: false,
}),
[null, null],
);
});

View File

@@ -6,10 +6,6 @@
"stage": 6,
"timestamp": "6/7/2026 19:04",
"replayCode": "RWYQ-4X37-M1EL-EGGQ",
"scores": [
500,
0
],
"matchScores": [
100,
0

View File

@@ -6,10 +6,6 @@
"stage": 17,
"timestamp": "3/7/2026 21:22",
"replayCode": "R0M1-UCVK-F5AK-VUL2",
"scores": [
500,
0
],
"matchScores": [
100,
0

View File

@@ -5,10 +5,6 @@
"mode": "SZ",
"stage": 2,
"replayCode": "RVRM-XXEL-0573-Q45U",
"scores": [
500,
0
],
"matchScores": [
100,
0

View File

@@ -6,10 +6,6 @@
"stage": 18,
"timestamp": "9/7/2026 18:13",
"replayCode": "R1V4-PAHW-GGM2-PD9S",
"scores": [
500,
0
],
"matchScores": [
100,
0

View File

@@ -6,10 +6,6 @@
"stage": 2,
"timestamp": "3/7/2026 22:28",
"replayCode": "R6KE-D064-3CXD-XVKL",
"scores": [
440,
385
],
"matchScores": [
88,
77

View File

@@ -5,10 +5,6 @@
"mode": "SZ",
"stage": 22,
"timestamp": "9/7/2026 18:17",
"scores": [
500,
0
],
"matchScores": [
100,
0

View File

@@ -6,10 +6,6 @@
"stage": 13,
"timestamp": "9/7/2026 14:04",
"replayCode": "R80B-00DL-WF4X-V3CA",
"scores": [
500,
0
],
"matchScores": [
100,
0

View File

@@ -4,10 +4,6 @@
"lobby": "X",
"mode": "RM",
"replayCode": "R17L-PFKK-C1Q2-143L",
"scores": [
265,
135
],
"matchScores": [
53,
27

View File

@@ -6,10 +6,6 @@
"stage": 13,
"timestamp": "9/7/2026 14:16",
"replayCode": "RUH3-3NEF-F5FY-PAJL",
"scores": [
500,
0
],
"matchScores": [
100,
null

View File

@@ -6,10 +6,6 @@
"stage": 13,
"timestamp": "9/7/2026 14:22",
"replayCode": "RGX0-QWEM-E3VS-AVG6",
"scores": [
500,
0
],
"matchScores": [
100,
0

View File

@@ -6,10 +6,6 @@
"stage": 24,
"timestamp": "9/7/2026 14:34",
"replayCode": "RUCT-5HNH-XWDC-J51U",
"scores": [
160,
135
],
"matchScores": [
32,
7

View File

@@ -4,9 +4,9 @@
"lobby": "PRIVATE",
"mode": "CB",
"stage": 0,
"scores": [
480,
370
"matchScores": [
96,
74
],
"povIndex": 0,
"players": [

View File

@@ -0,0 +1,86 @@
{
"event": "Scoreboard",
"options": {
"skipFields": [
"players.3.name"
],
"notes": "KNOCKOUT! burst on the RIGHT banner side (loser's Score: 0 on the left) - regression guard for the burst reading as a 7-0 score. players.3 truth is AHOOUUUUUU but reads AHOOLUUUUU (U misread as L). players.6 name uses dotless i (as parsed) - verify against source if it ever fails."
},
"data": {
"lobby": "PRIVATE",
"mode": "SZ",
"stage": 2,
"stageLabel": "Hagglefish Market",
"matchScores": [
100,
0
],
"povIndex": 7,
"players": [
{
"name": "nwrm",
"weaponId": 50,
"paint": 381,
"ka": 8,
"d": 2,
"s": 0
},
{
"name": "slord",
"weaponId": 20,
"paint": 613,
"ka": 4,
"d": 1,
"s": 2
},
{
"name": "Olise",
"weaponId": 2070,
"paint": 456,
"ka": 3,
"d": 1,
"s": 2
},
{
"name": "AHOOUUUUUU",
"weaponId": 1015,
"paint": 287,
"ka": 1,
"d": 2,
"s": 1
},
{
"name": "★Bønk★•PE•",
"weaponId": 1001,
"paint": 327,
"ka": 3,
"d": 3,
"s": 1
},
{
"name": "Enamel",
"weaponId": 2070,
"paint": 408,
"ka": 1,
"d": 1,
"s": 2
},
{
"name": "Rιppιng_H",
"weaponId": 8001,
"paint": 270,
"ka": 3,
"d": 3,
"s": 1
},
{
"name": "Sendou",
"weaponId": 10,
"paint": 354,
"ka": 0,
"d": 3,
"s": 1
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

View File

@@ -4,9 +4,9 @@
"lobby": "PRIVATE",
"mode": "SZ",
"stage": 3,
"scores": [
425,
410
"matchScores": [
85,
82
],
"povIndex": 3,
"players": [

View File

@@ -4,8 +4,8 @@
"lobby": "PRIVATE",
"mode": "SZ",
"stage": 6,
"scores": [
500,
"matchScores": [
100,
0
],
"povIndex": 3,

View File

@@ -4,8 +4,8 @@
"lobby": "PRIVATE",
"mode": "SZ",
"stage": 17,
"scores": [
500,
"matchScores": [
100,
0
],
"povIndex": 2,

View File

@@ -4,8 +4,8 @@
"lobby": "PRIVATE",
"mode": "SZ",
"stage": 4,
"scores": [
500,
"matchScores": [
100,
0
],
"players": [

View File

@@ -4,8 +4,8 @@
"lobby": "SERIES",
"mode": "SZ",
"stage": 8,
"scores": [
500,
"matchScores": [
100,
0
],
"players": [

View File

@@ -9,8 +9,8 @@
"lobby": "PRIVATE",
"mode": "SZ",
"stage": 13,
"scores": [
500,
"matchScores": [
100,
0
],
"povIndex": 1,

View File

@@ -4,8 +4,8 @@
"lobby": "X",
"mode": "SZ",
"stage": 0,
"scores": [
500,
"matchScores": [
100,
0
],
"povIndex": 0,

View File

@@ -4,8 +4,8 @@
"lobby": "X",
"mode": "SZ",
"stage": 0,
"scores": [
500,
"matchScores": [
100,
0
],
"povIndex": 0,

View File

@@ -58,7 +58,7 @@ function scoreboard(
lobby,
mode,
stage,
scores: [100, 47],
matchScores: [100, 47],
players: weapons.map((weaponId, i) => ({
name: NAMES[i] ?? `p${i}`,
weaponId,
@@ -84,7 +84,7 @@ function replayScoreboard(
...base,
timestamp,
replayCode,
matchScores: [3, 1],
matchScores: [88, 71],
};
return { type: "ScoreboardReplay", t, confidence: 0.9, data };
}
@@ -162,10 +162,7 @@ test("scoreboard fields land on the match", () => {
assert.equal(match.stage, 0);
assert.equal(match.winner, 0);
assert.deepEqual(match.pov, { team: 0, index: 0 });
assert.deepEqual(
match.teams.map((team) => team.score),
[100, 47],
);
assert.deepEqual(match.matchScores, [100, 47]);
assert.deepEqual(
match.teams.map((team) => team.players.map((p) => p.name)),
[
@@ -175,7 +172,6 @@ test("scoreboard fields land on the match", () => {
);
assert.deepEqual(weapons(match), ALL);
assert.equal(match.cast, false);
assert.equal(match.matchScores, null);
assert.equal(match.replayCode, null);
});
@@ -340,7 +336,7 @@ test("a replay scoreboard supplies replay code, set score and recording time", (
const built = buildScannerMatches([event]);
const match = built[0]!.match;
assert.equal(match.replayCode, "RABC-DEFG-HIJK-LMNO");
assert.deepEqual(match.matchScores, [3, 1]);
assert.deepEqual(match.matchScores, [88, 71]);
assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime());
});
@@ -357,10 +353,7 @@ test("a minimap-only match has no playedAt and no winner", () => {
assert.equal(match.playedAt, null);
assert.equal(match.winner, null);
assert.equal(match.pov, null);
assert.deepEqual(
match.teams.map((team) => team.score),
[null, null],
);
assert.equal(match.matchScores, null);
});
test("a spectator map's minimaps become one cast match: weapons + stage from the minimap, mode unread", () => {

View File

@@ -49,8 +49,15 @@ for (const fixture of fixtures) {
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
const expected = fixture.expected.data ?? {};
await t.test("scores", { skip: skip(fixture, "scores") }, () => {
assert.deepEqual(event.data.scores, expected.scores);
await t.test("matchScores", { skip: skip(fixture, "matchScores") }, () => {
const dbg = event.debug?.matchScore as
| { left?: { reading?: string }; right?: { reading?: string } }
| undefined;
assert.deepEqual(
event.data.matchScores,
expected.matchScores,
`matchScores mismatch (readings: "${dbg?.left?.reading}" / "${dbg?.right?.reading}")`,
);
});
await t.test(

View File

@@ -67,16 +67,9 @@ export async function runScoreboardReplaySuite(
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
const expected = fixture.expected.data ?? {};
await t.test("scores", { skip: skip(fixture, "scores") }, () => {
assert.deepEqual(event.data.scores, expected.scores);
});
await t.test(
"matchScores",
{
skip:
expected.matchScores === undefined || skip(fixture, "matchScores"),
},
{ skip: skip(fixture, "matchScores") },
() => {
assert.deepEqual(event.data.matchScores, expected.matchScores);
},

View File

@@ -103,7 +103,6 @@ for (const config of configs) {
header: { ok: 0, total: 0 } as Tally,
timestamp: { ok: 0, total: 0 } as Tally,
replayCode: { ok: 0, total: 0 } as Tally,
scores: { ok: 0, total: 0 } as Tally,
matchScores: { ok: 0, total: 0 } as Tally,
weapons: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
@@ -144,11 +143,6 @@ for (const config of configs) {
tally.replayCode.total++;
if (event.data.replayCode === expected.replayCode) tally.replayCode.ok++;
}
if (expected.scores) {
tally.scores.total++;
if (JSON.stringify(event.data.scores) === JSON.stringify(expected.scores))
tally.scores.ok++;
}
if (expected.matchScores) {
tally.matchScores.total++;
if (
@@ -200,10 +194,7 @@ for (const config of configs) {
console.info(`timestamp ${pct(tally.timestamp)}`);
console.info(`replayCode ${pct(tally.replayCode)}`);
}
console.info(`scores ${pct(tally.scores)}`);
if (config.event === "ScoreboardReplay") {
console.info(`matchScores ${pct(tally.matchScores)}`);
}
console.info(`matchScores ${pct(tally.matchScores)}`);
console.info(`weapons ${pct(tally.weapons)}`);
console.info(`names ${pct(tally.names)}`);
console.info(`paint ${pct(tally.paint)}`);

View File

@@ -35,7 +35,7 @@ for (const fixture of fixtures) {
console.info(`gate: pass=${gate.pass} score=${gate.score.toFixed(3)}`);
for (const event of events) {
console.info(`event confidence=${event.confidence.toFixed(3)}`);
console.info(`scores: ${JSON.stringify(event.data.scores)}`);
console.info(`matchScores: ${JSON.stringify(event.data.matchScores)}`);
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
event.data.players.forEach((p, i) => {
const dbg = rows[i];