diff --git a/app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois.ts b/app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois.ts index 6aa24916f..69c66f62e 100644 --- a/app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois.ts +++ b/app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois.ts @@ -30,9 +30,12 @@ export function specialIconRoi(cy: number, dx: number): Roi { return { x: 1104 + dx, y: cy - 32, w: 38, h: 33 }; } -/** Name text (descenders reach cy+18); long names run into the paint column, trim at its leftmost digit. */ +/** + * Name text (dakuten marks reach cy-19, descenders cy+18); long names run into the paint column, + * trim at its leftmost digit. + */ export function nameRoi(cy: number, dx: number): Roi { - return { x: 620 + dx, y: cy - 16, w: 226, h: 37 }; + return { x: 620 + dx, y: cy - 21, w: 226, h: 42 }; } /** diff --git a/app/features/scanner/core/detectors/scoreboard/names.ts b/app/features/scanner/core/detectors/scoreboard/names.ts index 1a2823bbf..81873a7f0 100644 --- a/app/features/scanner/core/detectors/scoreboard/names.ts +++ b/app/features/scanner/core/detectors/scoreboard/names.ts @@ -22,6 +22,9 @@ export interface ParsedName { */ const BAR_CHARS = new Set(["I", "l", "|", "1"]); +/** recognizeText's own default, shared with the mark probe so both see the same ink */ +const DEFAULT_BIN_THRESHOLD = 150; + function normalizeBars(name: string): string { const chars = [...name]; // context is the nearest NON-BAR char within the word ("ll" in "Chill" must @@ -209,6 +212,120 @@ function resolveBhByBowlFloor( return { ...raw, text, chars }; } +/** + * A (han)dakuten is two short ticks (or a ring) floating above the base kana's + * upper right. Capture blur thins those ticks, so the ink-coverage penalty lets + * the plain twin ('か') edge out the voiced glyph ('が') whose extra template ink + * the thinned segment no longer explains — even when the voiced template + * correlates better (quick-log ジ over シ on raw NCC, and lost the tie). When a + * plain kana wins a near-tie over a voiced twin, the segment's own row profile + * decides: a 2+ row blob confined to the segment's right half, a blank row under + * it and a base at least half the height beneath is the floating mark. Only that + * direction is re-decided: the game draws some marks touching the base stroke + * (quick-log ば, ギ) and the templates already read those right, so a missing gap + * must not demote a voiced read. A plain kana's own detached top tick (う) starts + * far left of the mark's column band and fails the left-edge floor. + */ +const VOICED_TWINS: Record = {}; +for (const [plain, voiced] of [ + [ + "かきくけこさしすせそたちつてとはひふへほう", + "がぎぐげござじずぜぞだぢづでどばびぶべぼゔ", + ], + ["はひふへほ", "ぱぴぷぺぽ"], + [ + "カキクケコサシスセソタチツテトハヒフヘホウ", + "ガギグゲゴザジズゼゾダヂヅデドバビブベボヴ", + ], + ["ハヒフヘホ", "パピプペポ"], +] as const) { + for (const [i, base] of [...plain].entries()) { + VOICED_TWINS[base] = [...(VOICED_TWINS[base] ?? []), [...voiced][i]!]; + } +} +const VOICED_SCORE_MARGIN = 0.1; +/** ink pixels a row may hold and still count as the gap under the mark (capture noise) */ +const VOICED_GAP_MAX_INK = 1; +const VOICED_MARK_MAX_HEIGHT_FRACTION = 0.4; +const VOICED_BASE_MIN_HEIGHT_FRACTION = 0.5; +const VOICED_MARK_MIN_LEFT_FRACTION = 0.35; +const VOICED_MARK_MIN_RIGHT_FRACTION = 0.75; + +function resolveVoicedByMark( + raw: RecognizedText, + grayView: Mat, + binThreshold: number, +): RecognizedText { + const voicedRunnerUp = (c: RecognizedChar) => { + const twins = VOICED_TWINS[c.char]; + if (!twins) return undefined; + return c.candidates?.find( + (k) => twins.includes(k.char) && c.score - k.score <= VOICED_SCORE_MARGIN, + ); + }; + if (!raw.chars.some(voicedRunnerUp)) return raw; + + const gray = new (getCV().Mat)(); + grayView.copyTo(gray); + const { cols, data } = gray; + const chars = raw.chars.map((c) => { + const twin = voicedRunnerUp(c); + if (!twin || !hasFloatingMark(data, cols, c, binThreshold)) return c; + return { ...c, char: twin.char, score: twin.score }; + }); + gray.delete(); + let ci = 0; + const text = [...raw.text] + .map((ch) => (ch === " " ? ch : chars[ci++]!.char)) + .join(""); + return { ...raw, text, chars }; +} + +function hasFloatingMark( + data: Uint8Array, + cols: number, + c: RecognizedChar, + binThreshold: number, +): boolean { + const w = c.x1 - c.x0; + const h = c.y1 - c.y0; + let markRows = 0; + let markX0 = Number.POSITIVE_INFINITY; + let markX1 = -1; + let y = c.y0; + for (; y < c.y1; y++) { + let ink = 0; + let lo = -1; + let hi = -1; + for (let x = c.x0; x < c.x1; x++) { + if (data[y * cols + x]! > binThreshold) { + ink++; + if (lo < 0) lo = x; + hi = x; + } + } + if (ink <= VOICED_GAP_MAX_INK) break; + markRows++; + markX0 = Math.min(markX0, lo); + markX1 = Math.max(markX1, hi); + } + if (markRows < 2 || markRows > VOICED_MARK_MAX_HEIGHT_FRACTION * h) + return false; + if (y >= c.y1) return false; + for (; y < c.y1; y++) { + let ink = 0; + for (let x = c.x0; x < c.x1; x++) { + if (data[y * cols + x]! > binThreshold) ink++; + } + if (ink > VOICED_GAP_MAX_INK) break; + } + if (c.y1 - y < VOICED_BASE_MIN_HEIGHT_FRACTION * h) return false; + return ( + (markX0 - c.x0) / w >= VOICED_MARK_MIN_LEFT_FRACTION && + (markX1 + 1 - c.x0) / w >= VOICED_MARK_MIN_RIGHT_FRACTION + ); +} + /** * 'P' and 'p' tight-crop to the same shape, but the segment keeps the position * the templates lose: 'p' hangs below the baseline (median ink bottom of the @@ -283,9 +400,10 @@ export function parseName( plainTieMargin?: number; } = {}, ): ParsedName { + const binThreshold = options.binThreshold ?? DEFAULT_BIN_THRESHOLD; const recognized = recognizeText(gray, glyphs, { spaceGap: options.spaceGap ?? 7, - binThreshold: options.binThreshold, + binThreshold, minCharScore: 0.35, }); const raw = @@ -297,7 +415,11 @@ export function parseName( normalizeOhs( normalizeBars( resolveCaseByDescent( - resolveBhByBowlFloor(fixRaisedDots(raw), gray), + resolveVoicedByMark( + resolveBhByBowlFloor(fixRaisedDots(raw), gray), + gray, + binThreshold, + ), ).trim(), ), ), diff --git a/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json b/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json index 86dbab134..0592cb84e 100644 --- a/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json @@ -105,9 +105,6 @@ "stageLabel": "Undertow Spillway" }, "options": { - "skipFields": [ - "teammates.0.name" - ], - "notes": "Tower Control frame with three special-ready camo surfaces: the left card (Splatana Stamper — dark art whose padded-square icon renders bigger than the 54px weapon box, unmatchable before the art-cropped templates) and enemy rows 0 (Inkbrush Nouveau) and 2 (Big Swig Roller). Self card is a Custom Blaster on a warm translucent background (kit tiles: Point Sensor + Triple Splashdown). teammates.0.name skipped: the up card reads こむきこをこねたユウ — the ぎ dakuten is below atlas resolution at 29px. Control values are the deterministic estimator output." + "notes": "Tower Control frame with three special-ready camo surfaces: the left card (Splatana Stamper — dark art whose padded-square icon renders bigger than the 54px weapon box, unmatchable before the art-cropped templates) and enemy rows 0 (Inkbrush Nouveau) and 2 (Big Swig Roller). Self card is a Custom Blaster on a warm translucent background (kit tiles: Point Sensor + Triple Splashdown). teammates.0.name (こむぎこをこねたユウ) once read こむきこをこねたユウ — the thinned ぎ dakuten lost the near-tie to き until parseName's floating-mark probe. Control values are the deterministic estimator output." } } diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json index 867e99ad8..95748b88f 100644 --- a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json @@ -106,9 +106,6 @@ "stageLabel": "Manta Maria" }, "options": { - "skipFields": [ - "enemies.0.name" - ], - "notes": "8-player spectator map screen from the AREA CUP stream (Round 1), pre-match — teams at spawn, so the control estimate sits near zero (spawn markers only). Formerly negative/map-screen-2; relabeled when the spectator variant became gated off the X jump-button disc. enemies.0.name skipped: ウルボー reads ウルホー — the ボ dakuten is below atlas resolution at this size (same limit as snix-special-ready's ぎ). teammates.2.name ーロたべる is the pixel reading; if the real name uses kanji (一口たべる) it is unrecoverable — 一/ー and 口/ロ are pixel-identical and the atlas carries no kanji." + "notes": "8-player spectator map screen from the AREA CUP stream (Round 1), pre-match — teams at spawn, so the control estimate sits near zero (spawn markers only). Formerly negative/map-screen-2; relabeled when the spectator variant became gated off the X jump-button disc. enemies.0.name (ウルボー) once read ウルホー — the thinned ボ dakuten lost the near-tie to ホ until parseName's floating-mark probe (same as snix-special-ready's ぎ). teammates.2.name ーロたべる is the pixel reading; if the real name uses kanji (一口たべる) it is unrecoverable — 一/ー and 口/ロ are pixel-identical and the atlas carries no kanji." } } diff --git a/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/anarchy-series-clam-blitz-wahoo-world/expected.json b/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/anarchy-series-clam-blitz-wahoo-world/expected.json index 7fb6e2648..e7a9b98dc 100644 --- a/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/anarchy-series-clam-blitz-wahoo-world/expected.json +++ b/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/anarchy-series-clam-blitz-wahoo-world/expected.json @@ -79,10 +79,6 @@ "stageLabel": "Wahoo World" }, "options": { - "skipFields": [ - "players.0.name", - "players.5.name" - ], - "notes": "1080p capture of the lobby's quick battle log (X menu overlay, list on the left), a knockout with the POV player on the winning side. The card is drawn in slight perspective, rectified by the detector before parsing. players.0.name (エンジェルサック) reads エンシェルサック and players.5.name (がんばるりゅた) reads かんばるりゅた — the dakuten marks are lost at this size. players.6.name's question marks may be fullwidth on screen; the read is ASCII. Weapons row order: .52 Gal, Range Blaster, .52 Gal, Dapple Dualies Nouveau, Sploosh-o-matic, Slosher, Wellstring V, Luna Blaster Neo." + "notes": "1080p capture of the lobby's quick battle log (X menu overlay, list on the left), a knockout with the POV player on the winning side. The card is drawn in slight perspective, rectified by the detector before parsing. players.0.name (エンジェルサック) and players.5.name (がんばるりゅた) once read エンシェルサック / かんばるりゅた: at this size the thinned dakuten ticks let the plain twin win the ink penalty; parseName's floating-mark probe now settles those near-ties. players.6.name's question marks may be fullwidth on screen; the read is ASCII. Weapons row order: .52 Gal, Range Blaster, .52 Gal, Dapple Dualies Nouveau, Sploosh-o-matic, Slosher, Wellstring V, Luna Blaster Neo." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1411/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1411/expected.json index bb6f95059..12f66b3e5 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1411/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1411/expected.json @@ -79,11 +79,6 @@ "stageLabel": "Brinewater Springs" }, "options": { - "skipFields": [ - "players.1.name", - "players.4.name", - "players.7.name" - ], - "notes": "Same stream as the 1404 case. The old export carried the anonymized-view nicknames; names here are the on-screen ones. Skipped names read without dakuten (めがねがし>めかねかし, むぎ>むき, コジャケサラダ>コジャケサラタ) - names atlas lacks templates for those glyphs. Code hand-verified (F>E misread)." + "notes": "Same stream as the 1404 case. The old export carried the anonymized-view nicknames; names here are the on-screen ones. めがねがし, むぎ and コジャケサラダ once read without dakuten (めかねかし, むき, コジャケサラタ): the name ROI clipped the marks off the top, so the voiced templates could not be placed. Code hand-verified (F>E misread)." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1416/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1416/expected.json index 36e4ae59b..45b4c4277 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1416/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1416/expected.json @@ -79,10 +79,6 @@ "stageLabel": "Brinewater Springs" }, "options": { - "skipFields": [ - "players.6.name", - "players.7.name" - ], - "notes": "Heavily compressed stream capture. Code hand-verified from the zoomed frame; a prior parse misread U>D, N>H, F>E, 5>6. Skipped names read without dakuten (see the 1411 case)." + "notes": "Heavily compressed stream capture. Code hand-verified from the zoomed frame; a prior parse misread U>D, N>H, F>E, 5>6. めがねがし and むぎ once read without dakuten (see the 1411 case)." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard/german-private-battle-clam-blitz/expected.json b/app/features/scanner/tests/fixtures/scoreboard/german-private-battle-clam-blitz/expected.json index 2aa63ea22..8b6ab5eff 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/german-private-battle-clam-blitz/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/german-private-battle-clam-blitz/expected.json @@ -79,9 +79,9 @@ }, "options": { "skipFields": [ - "players.0.name", "players.3.name", "players.6.name" - ] + ], + "notes": "players.3.name (Ù Ballóòn) reads u Balloon — the accents are lost at this size; players.6.name (リッター4K) reads リツター4K — small ッ and ツ differ only in size." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard/robot/expected.json b/app/features/scanner/tests/fixtures/scoreboard/robot/expected.json index 98c6a9589..f53c373a8 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/robot/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/robot/expected.json @@ -34,7 +34,7 @@ "s": 3 }, { - "name": "てんさく", + "name": "でんさく", "weaponId": 3000, "paint": 818, "ka": 5, @@ -75,5 +75,8 @@ } ], "stageLabel": "Mincemeat Metalworks" + }, + "options": { + "notes": "Two players named でんさく (rows 1 and 3): row 3 was once labeled てんさく off a read that lost the dakuten; the frame shows the mark on both." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard/splash-sploosh/expected.json b/app/features/scanner/tests/fixtures/scoreboard/splash-sploosh/expected.json index 3960bd54e..cd4575410 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/splash-sploosh/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/splash-sploosh/expected.json @@ -1,10 +1,5 @@ { "event": "Scoreboard", - "options": { - "skipFields": [ - "players.2.name" - ] - }, "data": { "lobby": "PRIVATE", "mode": "SZ",