diff --git a/app/features/scanner/core/detectors/scoreboard-battle-log-replay/code.ts b/app/features/scanner/core/detectors/scoreboard-battle-log-replay/code.ts index 86c9e1055..5213eb60c 100644 --- a/app/features/scanner/core/detectors/scoreboard-battle-log-replay/code.ts +++ b/app/features/scanner/core/detectors/scoreboard-battle-log-replay/code.ts @@ -121,10 +121,12 @@ export function parseReplayCode(rgb: Mat, glyphs: GlyphSet): ParsedReplayCode { green.delete(); let text = resolveQsByDescent(resolved).toUpperCase(); - // Dashes are thin and can drop out of segmentation; a clean 16-char - // alphanumeric read is unambiguous, so re-insert them. - if (/^[0-9A-Z]{16}$/.test(text)) { - text = text.replace(/(.{4})(?=.)/g, "$1-"); + // Dashes are thin and can drop out of segmentation (any subset of the + // three); the 16 alphanumerics are unambiguous on their own, so re-derive + // the grouping from them alone. + const compact = text.replaceAll("-", ""); + if (/^[0-9A-Z]{16}$/.test(compact)) { + text = compact.replace(/(.{4})(?=.)/g, "$1-"); } return { code: CODE_RE.test(text) ? text : null, diff --git a/app/features/scanner/core/detectors/scoreboard/names.ts b/app/features/scanner/core/detectors/scoreboard/names.ts index e1d37fa01..3a9f31868 100644 --- a/app/features/scanner/core/detectors/scoreboard/names.ts +++ b/app/features/scanner/core/detectors/scoreboard/names.ts @@ -1,7 +1,7 @@ /** * Player name recognition: glyph matching over the name ROI. */ -import type { Mat } from "../../cv"; +import { getCV, type Mat } from "../../cv"; import { type GlyphSet, type RecognizedChar, @@ -171,6 +171,68 @@ function fixRaisedDots(raw: RecognizedText): RecognizedText { return { ...raw, text, chars }; } +/** + * On soft captures BlitzMain's 'b' and 'h' come down to whether the bowl + * closes at the bottom, and template correlation weighs that stroke too + * lightly to be trusted (a font 'h' beats a fixture 'b' by ~0.003 on a true + * 'b'). The segment's own pixels decide: the bottom band between the stems + * is solid ink in a 'b' (the bowl floor) and empty in an 'h' (open legs) — + * measured 0.90 vs 0.15 across fixtures. Only near-tied twin reads are + * re-decided; confident reads keep the template pick. Small text is exempt: + * below ~20px of ink height the blur closes a true 'h' too (a 15px 'h' + * measured 0.67), so the pixels only outrank the templates at row scale. + */ +const BH_TWINS: Record = { b: "h", h: "b" }; +const BH_SCORE_MARGIN = 0.08; +const BH_INK_THRESHOLD = 150; +const BH_BOWL_MIN_FRACTION = 0.5; +const BH_MIN_INK_HEIGHT_PX = 20; + +function resolveBhByBowlFloor( + raw: RecognizedText, + grayView: Mat, +): RecognizedText { + const contested = (c: RecognizedChar) => { + const twin = BH_TWINS[c.char]; + if (!twin || c.y1 - c.y0 < BH_MIN_INK_HEIGHT_PX) return false; + return ( + c.candidates?.some( + (k) => k.char === twin && c.score - k.score <= BH_SCORE_MARGIN, + ) ?? false + ); + }; + if (!raw.chars.some(contested)) return raw; + + // the crop is an ROI view, so copy before pixel access + const gray = new (getCV().Mat)(); + grayView.copyTo(gray); + const { cols, data } = gray; + const chars = raw.chars.map((c) => { + if (!contested(c)) return c; + const w = c.x1 - c.x0; + const h = c.y1 - c.y0; + const cx0 = c.x0 + Math.round(w * 0.3); + const cx1 = c.x1 - Math.round(w * 0.3); + const by0 = c.y1 - Math.max(2, Math.round(h * 0.18)); + let ink = 0; + let total = 0; + for (let y = by0; y < c.y1; y++) { + for (let x = cx0; x < cx1; x++) { + total++; + if (data[y * cols + x]! > BH_INK_THRESHOLD) ink++; + } + } + const bowlClosed = total > 0 && ink / total >= BH_BOWL_MIN_FRACTION; + return { ...c, char: bowlClosed ? "b" : "h" }; + }); + gray.delete(); + let ci = 0; + const text = [...raw.text] + .map((ch) => (ch === " " ? ch : chars[ci++]!.char)) + .join(""); + return { ...raw, text, chars }; +} + /** * BlitzMain's 'P' and 'p' tight-crop to the same stem-and-bowl shape, so the * template scores between them are noise — but unlike the bars and ohs the @@ -220,7 +282,11 @@ export function parseName( return { name: normalizeLongBars( normalizeOhs( - normalizeBars(resolveCaseByDescent(fixRaisedDots(raw)).trim()), + normalizeBars( + resolveCaseByDescent( + resolveBhByBowlFloor(fixRaisedDots(raw), gray), + ).trim(), + ), ), ), confidence: raw.confidence, diff --git a/app/features/scanner/core/glyphs.ts b/app/features/scanner/core/glyphs.ts index 9f37482ef..9de5993ac 100644 --- a/app/features/scanner/core/glyphs.ts +++ b/app/features/scanner/core/glyphs.ts @@ -511,6 +511,83 @@ function mergeSplitGlyphs( } } +/** + * A wide-segment split can land inside a glyph instead of at the true + * boundary: capture blur can leave the gap between two glyphs shallower in + * the column profile than the hollows of their bowls, so the deepest-dip cut + * severs a bowl edge ("do" reading as "′k"). Both halves of such a miscut + * classify poorly, so adjacent low-scoring segments are re-cut: each profile + * dip across their joint span is tried as the boundary and the cut whose + * weaker half classifies best wins. Template scores carry real noise at the + * blur levels where miscuts happen (a wrong "l|U" outscored a true "d|u" by + * 0.07), so a re-cut is adopted only when it is both a decent read in + * absolute terms and decisively better than the original pair — otherwise + * the read stays as segmented. + */ +const RECUT_MAX_SCORE = 0.75; +const RECUT_MARGIN = 0.08; +const RECUT_MIN_SCORE = 0.7; + +function dipCuts(profile: number[], x0: number, x1: number): number[] { + const cuts = new Set(); + for (let x = x0 + 3; x <= x1 - 3; x++) { + const v = profile[x]!; + if (v <= profile[x - 1]! && v <= profile[x + 1]!) { + cuts.add(x); + // ink ownership over the dip is ambiguous; try its far edge too + if (x + 1 <= x1 - 3) cuts.add(x + 1); + } + } + return [...cuts]; +} + +function recutMiscutPairs( + items: ClassifiedSegment[], + profile: number[], + binary: Mat, + masked: Mat, + set: GlyphSet, +): void { + const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO)); + for (let i = 0; i + 1 < items.length; i++) { + const a = items[i]!; + const b = items[i + 1]!; + const aScore = a.ranked[0]?.score ?? 0; + const bScore = b.ranked[0]?.score ?? 0; + if (aScore >= RECUT_MAX_SCORE || bScore >= RECUT_MAX_SCORE) continue; + if (b.seg.x0 - a.seg.x1 > maxGap) continue; + let floor = Math.max( + RECUT_MIN_SCORE, + Math.max(aScore, bScore) + RECUT_MARGIN, + ); + let best: [ClassifiedSegment, ClassifiedSegment] | null = null; + for (const cut of dipCuts(profile, a.seg.x0, b.seg.x1)) { + // a cut inside the existing gap reproduces the original pair + if (cut >= a.seg.x1 && cut <= b.seg.x0) continue; + const left = measureSegment(binary, { x0: a.seg.x0, x1: cut }); + const right = measureSegment(binary, { x0: cut, x1: b.seg.x1 }); + // probe with the floor first (see mergeSplitGlyphs): most candidate + // cuts can't beat it and the probes early-stop almost immediately + const canWin = (seg: SegmentInfo) => + classifySegment(masked, seg, set, floor).some((c) => c.score > floor); + if (!canWin(left) || !canWin(right)) continue; + const leftRanked = classifySegment(masked, left, set); + const rightRanked = classifySegment(masked, right, set); + const weaker = Math.min( + leftRanked[0]?.score ?? 0, + rightRanked[0]?.score ?? 0, + ); + if (weaker <= floor) continue; + floor = weaker; + best = [ + { seg: left, ranked: leftRanked }, + { seg: right, ranked: rightRanked }, + ]; + } + if (best) items.splice(i, 2, ...best); + } +} + /** * Recognize white-on-dark text in a grayscale crop. * The crop must be tight vertically (one text line). @@ -551,6 +628,7 @@ export function recognizeText( ranked: classifySegment(masked, seg, set), })); mergeSplitGlyphs(items, binary, masked, set); + recutMiscutPairs(items, profile, binary, masked, set); const chars: RecognizedChar[] = []; let text = ""; diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/private-battle-makomart/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/private-battle-makomart/expected.json new file mode 100644 index 000000000..7d6f893e6 --- /dev/null +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/private-battle-makomart/expected.json @@ -0,0 +1,81 @@ +{ + "event": "ScoreboardBattleLogReplay", + "data": { + "lobby": "PRIVATE", + "mode": "SZ", + "stage": 10, + "stageLabel": "MakoMart", + "timestamp": "8/8/2026 9:44", + "replayCode": "R3KH-BTXL-PMW0-C1U6", + "matchScores": [ + 100, + 0 + ], + "players": [ + { + "name": "bran", + "weaponId": 211, + "paint": 321, + "ka": 11, + "d": 3, + "s": 1 + }, + { + "name": "queue", + "weaponId": 2070, + "paint": 572, + "ka": 6, + "d": 1, + "s": 2 + }, + { + "name": "Shadowind", + "weaponId": 6011, + "paint": 474, + "ka": 6, + "d": 2, + "s": 1 + }, + { + "name": "Jared", + "weaponId": 5010, + "paint": 533, + "ka": 4, + "d": 3, + "s": 1 + }, + { + "name": "soro", + "weaponId": 2070, + "paint": 669, + "ka": 4, + "d": 2, + "s": 3 + }, + { + "name": "★Bønk★•PE•", + "weaponId": 1001, + "paint": 401, + "ka": 2, + "d": 3, + "s": 2 + }, + { + "name": "yaeba", + "weaponId": 5001, + "paint": 412, + "ka": 5, + "d": 5, + "s": 1 + }, + { + "name": "kera", + "weaponId": 50, + "paint": 462, + "ka": 2, + "d": 6, + "s": 2 + } + ] + } +} diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/private-battle-makomart/frame.png b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/private-battle-makomart/frame.png new file mode 100644 index 000000000..e57e338aa Binary files /dev/null and b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/private-battle-makomart/frame.png differ