diff --git a/app/features/scanner/core/detectors/minimap/rois.ts b/app/features/scanner/core/detectors/minimap/rois.ts index 8ee942655..7a050aca6 100644 --- a/app/features/scanner/core/detectors/minimap/rois.ts +++ b/app/features/scanner/core/detectors/minimap/rois.ts @@ -234,7 +234,9 @@ export function spectatorCardLayout( ): Omit { const dy = SPECTATOR_ROW_PITCH * row; return { - name: { x: 198 + dx, y: 306 + dy, w: 310, h: 44 }, + // superscripts and accents reach 2px above the old top (x² read x・); the + // card's team stripe ends 4px above this one + name: { x: 198 + dx, y: 303 + dy, w: 310, h: 47 }, weapon: { x: 196 + dx, y: 350 + dy, w: 66, h: 54 }, subTile: { x: 264 + dx, y: 354 + dy, w: 38, h: 42 }, badges: [ diff --git a/app/features/scanner/core/detectors/scoreboard/names.ts b/app/features/scanner/core/detectors/scoreboard/names.ts index 81873a7f0..9faa63549 100644 --- a/app/features/scanner/core/detectors/scoreboard/names.ts +++ b/app/features/scanner/core/detectors/scoreboard/names.ts @@ -16,14 +16,18 @@ export interface ParsedName { /** * BlitzMain renders 'I', 'l', '|', '1' as near-identical bars, so context - * decides: next to lowercase 'l' ("Olise"), digit '1' ("Jrod_14"), uppercase - * 'I' ("SHIP"), off an underscore '1' ("gori_1"), else 'l'. Misses "McIntosh", - * but so would a human reading the pixels. + * decides: opening a word before a consonant 'I' ("Invisifloats", no word + * starts "ln"/"lv"), otherwise next to lowercase 'l' ("Olise", "lucas"), digit + * '1' ("Jrod_14"), uppercase 'I' ("SHIP"), off an underscore '1' ("gori_1"), + * else 'l'. Misses "McIntosh", but so would a human reading the pixels. */ const BAR_CHARS = new Set(["I", "l", "|", "1"]); +const LOWER_CONSONANT = /^[b-df-hj-np-tv-xz]$/; -/** recognizeText's own default, shared with the mark probe so both see the same ink */ +/** recognizeText's own default, shared with the mark probes so both see the same ink */ const DEFAULT_BIN_THRESHOLD = 150; +/** deep enough for an accented variant to survive a stack of bare-letter fixture crops */ +const NAME_MAX_CANDIDATES = 12; function normalizeBars(name: string): string { const chars = [...name]; @@ -47,6 +51,9 @@ function normalizeBars(name: string): string { if (!BAR_CHARS.has(c)) return c; const left = neighbor(i, -1); const right = neighbor(i, 1); + const wordInitial = + i === 0 || chars[i - 1] === " " || chars[i - 1] === "_"; + if (wordInitial && LOWER_CONSONANT.test(chars[i + 1] ?? "")) return "I"; if (isLower(left) || isLower(right)) return "l"; if (isDigit(left) || isDigit(right)) return "1"; if (isUpper(left) || isUpper(right)) return "I"; @@ -59,8 +66,10 @@ function normalizeBars(name: string): string { /** * Kana 'ー' and hyphen '-' are homoglyphs under capture blur ("ドラグ-ン"): * a kana neighbor reads 'ー', a Latin/digit neighbor '-', else the raw pick stands. + * Kana punctuation ('・') is not a kana neighbor: "x²-8" must keep its hyphen. */ const LONG_BAR_CHARS = new Set(["-", "ー"]); +const KANA_LETTER = /[ぁ-ゖァ-ヺ]/u; function normalizeLongBars(name: string): string { const chars = [...name]; @@ -74,7 +83,7 @@ function normalizeLongBars(name: string): string { }; const test = (re: RegExp) => (c: string | undefined) => c !== undefined && re.test(c); - const isKana = test(/[ぁ-ヾ]/u); + const isKana = test(KANA_LETTER); const isLatinOrDigit = test(/[a-zA-Z0-9]/); return chars .map((c, i) => { @@ -123,6 +132,37 @@ function normalizeOhs(name: string): string { .join(""); } +/** + * A long bar is '_' or a dash by height alone, which the templates weigh + * lightly: ">_<" read ">ー<". A bar whose bottom reaches the line's baseline + * (median ink bottom of the other glyphs) is an underscore; dashes float at + * mid height. + */ +const BASELINE_BAR_CHARS = new Set(["-", "ー", "¯", "_"]); +const UNDERSCORE_BASELINE_SLACK_PX = 2; + +function resolveUnderscoreByBaseline(raw: RecognizedText): RecognizedText { + if (!raw.chars.some((c) => LONG_BAR_CHARS.has(c.char))) return raw; + const anchors = raw.chars + .filter((c) => !BASELINE_BAR_CHARS.has(c.char)) + .map((c) => c.y1) + .sort((a, b) => a - b); + if (anchors.length === 0) return raw; + const baseline = anchors[Math.floor(anchors.length / 2)]!; + const chars = raw.chars.map((c) => { + if (!LONG_BAR_CHARS.has(c.char)) return c; + if (baseline - c.y1 > UNDERSCORE_BASELINE_SLACK_PX) return c; + return { ...c, char: "_" }; + }); + return { ...raw, text: retext(raw.text, chars), chars }; +} + +/** Rebuild the text from re-decided chars, keeping the spaces where they were. */ +function retext(text: string, chars: RecognizedChar[]): string { + let ci = 0; + return [...text].map((ch) => (ch === " " ? ch : chars[ci++]!.char)).join(""); +} + /** * '.', '・', '·' tight-crop to near-identical blobs. A dot floating well above * the baseline cannot be '.', so it rereads as the best middle-dot candidate; @@ -147,25 +187,21 @@ function fixRaisedDots(raw: RecognizedText): RecognizedText { ); return { ...c, char: alt?.char ?? "・" }; }); - let ci = 0; - const text = [...raw.text] - .map((ch) => (ch === " " ? ch : chars[ci++]!.char)) - .join(""); - return { ...raw, text, chars }; + return { ...raw, text: retext(raw.text, chars), chars }; } /** * On soft captures 'b' vs 'h' comes down to the bowl floor, which correlation * weighs too lightly (font 'h' beats fixture 'b' by ~0.003 on a true 'b'). The - * bottom band between the stems is solid in a 'b', empty in an 'h' (0.90 vs - * 0.15). Only near-ties are re-decided, and only above ~20px ink height: blur - * closes a true 'h' below that (15px 'h' measured 0.67). + * bottom band between the stems is bright in a 'b' and dark in an 'h', but blur + * lifts it in both, so it is read against the band just below the arch, which + * blur lifts the same way: measured across fixtures at 15-50px, a 'b' floor + * is 90+ gray levels brighter than its mid band, an 'h' floor never brighter. + * Only near-ties are re-decided. */ 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; +const BH_BOWL_MIN_CONTRAST = 40; function resolveBhByBowlFloor( raw: RecognizedText, @@ -173,7 +209,7 @@ function resolveBhByBowlFloor( ): RecognizedText { const contested = (c: RecognizedChar) => { const twin = BH_TWINS[c.char]; - if (!twin || c.y1 - c.y0 < BH_MIN_INK_HEIGHT_PX) return false; + if (!twin) return false; return ( c.candidates?.some( (k) => k.char === twin && c.score - k.score <= BH_SCORE_MARGIN, @@ -186,30 +222,39 @@ function resolveBhByBowlFloor( const gray = new (getCV().Mat)(); grayView.copyTo(gray); const { cols, data } = gray; + const bandMean = (x0: number, x1: number, y0: number, y1: number) => { + let sum = 0; + let total = 0; + for (let y = y0; y < y1; y++) { + for (let x = x0; x < x1; x++) { + sum += data[y * cols + x]!; + total++; + } + } + return total > 0 ? sum / total : 0; + }; 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" }; + const floor = bandMean( + cx0, + cx1, + c.y1 - Math.max(2, Math.round(h * 0.18)), + c.y1, + ); + const mid = bandMean( + cx0, + cx1, + c.y0 + Math.round(h * 0.45), + c.y0 + Math.round(h * 0.6), + ); + return { ...c, char: floor - mid >= BH_BOWL_MIN_CONTRAST ? "b" : "h" }; }); gray.delete(); - let ci = 0; - const text = [...raw.text] - .map((ch) => (ch === " " ? ch : chars[ci++]!.char)) - .join(""); - return { ...raw, text, chars }; + return { ...raw, text: retext(raw.text, chars), chars }; } /** @@ -244,12 +289,15 @@ for (const [plain, voiced] of [ } } 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; +const MARK_MAX_HEIGHT_FRACTION = 0.4; +const BASE_MIN_HEIGHT_FRACTION = 0.5; +/** the (han)dakuten: upper-right corner; a blank gap row tolerates one noise pixel */ +const VOICED_MARK_SHAPE: MarkShape = { + minLeft: 0.35, + minRight: 0.75, + maxWidth: 1, + gapMaxInkFraction: 0, +}; function resolveVoicedByMark( raw: RecognizedText, @@ -270,25 +318,44 @@ function resolveVoicedByMark( const { cols, data } = gray; const chars = raw.chars.map((c) => { const twin = voicedRunnerUp(c); - if (!twin || !hasFloatingMark(data, cols, c, binThreshold)) return c; + if ( + !twin || + !hasFloatingMark(data, cols, c, binThreshold, VOICED_MARK_SHAPE) + ) + 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 }; + return { ...raw, text: retext(raw.text, chars), chars }; } +/** Where a detached mark may sit over its base glyph, as fractions of the segment width. */ +interface MarkShape { + /** the mark's left edge is at least this far in */ + minLeft: number; + /** the mark's right edge reaches at least this far */ + minRight: number; + /** the mark spans at most this much of the width */ + maxWidth: number; + /** ink a row may hold and still be the gap under the mark, beyond one noise pixel */ + gapMaxInkFraction: number; +} + +/** + * Detached blob on top of the segment: 2+ ink rows (after any sparse leading + * rows, the antialiased top of the mark), a sparse row under it and a base at + * least half the height beneath, with the blob's columns inside `shape`. + */ function hasFloatingMark( data: Uint8Array, cols: number, c: RecognizedChar, binThreshold: number, + shape: MarkShape, ): boolean { const w = c.x1 - c.x0; const h = c.y1 - c.y0; + const gapMaxInk = Math.max(1, shape.gapMaxInkFraction * w); let markRows = 0; let markX0 = Number.POSITIVE_INFINITY; let markX1 = -1; @@ -304,28 +371,91 @@ function hasFloatingMark( hi = x; } } - if (ink <= VOICED_GAP_MAX_INK) break; + if (ink <= gapMaxInk) { + if (markRows === 0) continue; + 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 (markRows < 2 || markRows > 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 (ink > gapMaxInk) break; } - if (c.y1 - y < VOICED_BASE_MIN_HEIGHT_FRACTION * h) return false; + if (c.y1 - y < 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 + (markX0 - c.x0) / w >= shape.minLeft && + (markX1 + 1 - c.x0) / w >= shape.minRight && + (markX1 + 1 - markX0) / w <= shape.maxWidth ); } +/** + * Latin accents at name size are thin, so the exact fixture crop of the bare + * letter outranks the font-rendered accented glyph (fixture 'u' over 'Ù' by + * 0.001; 'ó' trails a stack of 'o' crops). A bare Latin letter whose segment + * carries a detached blob above the letter body is re-decided to its best + * accented candidate. 'i' and 'j' carry their own detached dot and are exempt; + * the margin is wide because the mark itself is the evidence. + */ +const ACCENT_SCORE_MARGIN = 0.15; +const ACCENT_EXEMPT = new Set(["i", "j"]); +/** + * Anywhere over the letter but narrower than it, so the bar of a 'T' or the arms + * of a 'Y' over their stem never pass; blur leaves a couple of pixels in the + * gap row under an 'ó' accent, which stems (a quarter width and more) exceed. + */ +const ACCENT_MARK_SHAPE: MarkShape = { + minLeft: 0, + minRight: 0, + maxWidth: 0.8, + gapMaxInkFraction: 0.25, +}; + +function resolveAccentByMark( + raw: RecognizedText, + grayView: Mat, + binThreshold: number, +): RecognizedText { + const accentedRunnerUp = (c: RecognizedChar) => { + if (!/^\p{Script=Latin}$/u.test(c.char) || ACCENT_EXEMPT.has(c.char)) + return undefined; + if (c.char !== stripMarks(c.char)) return undefined; + return c.candidates?.find( + (k) => + k.char !== stripMarks(k.char) && + stripMarks(k.char).toLowerCase() === c.char.toLowerCase() && + c.score - k.score <= ACCENT_SCORE_MARGIN, + ); + }; + if (!raw.chars.some(accentedRunnerUp)) return raw; + + const gray = new (getCV().Mat)(); + grayView.copyTo(gray); + const { cols, data } = gray; + const chars = raw.chars.map((c) => { + const accented = accentedRunnerUp(c); + if ( + !accented || + !hasFloatingMark(data, cols, c, binThreshold, ACCENT_MARK_SHAPE) + ) + return c; + return { ...c, char: accented.char, score: accented.score }; + }); + gray.delete(); + return { ...raw, text: retext(raw.text, chars), chars }; +} + +function stripMarks(s: string): string { + return s.normalize("NFD").replace(/[̀-ͯ]/g, ""); +} + /** * '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 @@ -383,11 +513,7 @@ function preferPlainTies(raw: RecognizedText, margin: number): RecognizedText { ); return pick ? { ...c, char: pick.char, score: pick.score } : c; }); - let ci = 0; - const text = [...raw.text] - .map((ch) => (ch === " " ? ch : chars[ci++]!.char)) - .join(""); - return { ...raw, text, chars }; + return { ...raw, text: retext(raw.text, chars), chars }; } export function parseName( @@ -405,25 +531,34 @@ export function parseName( spaceGap: options.spaceGap ?? 7, binThreshold, minCharScore: 0.35, + maxCandidates: NAME_MAX_CANDIDATES, }); const raw = options.plainTieMargin === undefined ? recognized : preferPlainTies(recognized, options.plainTieMargin); - return { - name: normalizeLongBars( - normalizeOhs( - normalizeBars( - resolveCaseByDescent( + const name = normalizeLongBars( + normalizeOhs( + normalizeBars( + resolveCaseByDescent( + resolveAccentByMark( resolveVoicedByMark( - resolveBhByBowlFloor(fixRaisedDots(raw), gray), + resolveBhByBowlFloor( + fixRaisedDots(resolveUnderscoreByBaseline(raw)), + gray, + ), gray, binThreshold, ), - ).trim(), - ), + gray, + binThreshold, + ), + ).trim(), ), ), + ); + return { + name, confidence: raw.confidence, raw, }; diff --git a/app/features/scanner/core/glyphs.ts b/app/features/scanner/core/glyphs.ts index b53b52ec6..3fdd625fd 100644 --- a/app/features/scanner/core/glyphs.ts +++ b/app/features/scanner/core/glyphs.ts @@ -165,6 +165,8 @@ export interface RecognizeOptions { spaceGap?: number; /** discard chars scoring below this */ minCharScore?: number; + /** runner-up candidates kept per segment */ + maxCandidates?: number; } interface Segment { @@ -294,6 +296,14 @@ function measureSegment(binary: Mat, seg: Segment): SegmentInfo { * crop of a different char ('h' vs 'b') can displace a correct font match. */ const FIXTURE_TIEBREAK = 0.02; +/** + * ...and the fixture must explain about as much of the segment's ink as the + * font glyph: an 'L' crop correlates 0.91 with the left stem and floor of a 'U' + * while covering 0.63 of its ink against the font 'U's 0.93. + */ +const FIXTURE_TIEBREAK_MAX_INK_GAP = 0.1; + +const DEFAULT_MAX_CANDIDATES = 5; /** * A CJK-charset segment leaves thousands of templates eligible with barely @@ -326,6 +336,7 @@ function classifySegment( * sub-floor candidates are omitted, so pass it only for probes. */ scoreFloor = Number.NEGATIVE_INFINITY, + maxCandidates = DEFAULT_MAX_CANDIDATES, ): { char: string; score: number; ncc: number; source: "fixture" | "font" }[] { const cv = getCV(); const segWidth = seg.x1 - seg.x0; @@ -386,6 +397,7 @@ function classifySegment( score: number; ncc: number; source: "fixture" | "font"; + inkCoverage: number; }[] = []; // Only placements covering most of the segment: the region is padded, so a // free-sliding template can otherwise match the *neighboring* glyph inside @@ -443,6 +455,7 @@ function classifySegment( score, ncc: maxVal, source: glyph.source, + inkCoverage: r, }); } } @@ -458,14 +471,15 @@ function classifySegment( // ...only when the fixture's raw correlation is also competitive: a fixture // crop of a *different* char landing near the top via the ink penalty must // not displace a well-matching font glyph - top.ncc - c.ncc < FIXTURE_TIEBREAK, + top.ncc - c.ncc < FIXTURE_TIEBREAK && + top.inkCoverage - c.inkCoverage < FIXTURE_TIEBREAK_MAX_INK_GAP, ); if (fixture && fixture !== top) { candidates.splice(candidates.indexOf(fixture), 1); candidates.unshift(fixture); } } - return candidates.slice(0, 5); + return candidates.slice(0, maxCandidates); } interface EligibleGlyph { @@ -607,12 +621,22 @@ interface ClassifiedSegment { */ const MERGE_MAX_GAP_RATIO = 0.45; const MERGE_MARGIN = 0.02; +/** + * A fragment no template explains (ル's left stroke reads ィ at 0.61) is itself + * evidence of a split glyph: the merge then only has to be a strong read that + * keeps up with the better fragment, which a fixture crop of a lookalike can + * outscore on its own stroke ('L' at 0.87 on ル's right stroke, ル at 0.86). + */ +const MERGE_WEAK_FRAGMENT = 0.65; +const MERGE_STRONG_READ = 0.8; +const MERGE_WEAK_SLACK = 0.03; function mergeSplitGlyphs( items: ClassifiedSegment[], binary: Mat, masked: Mat, set: GlyphSet, + maxCandidates: number, ): void { const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO)); const maxCharWidth = Math.round(set.medianWidth * 1.5); @@ -626,11 +650,16 @@ function mergeSplitGlyphs( continue; } const seg = measureSegment(binary, { x0: a.seg.x0, x1: b.seg.x1 }); - const fragmentBest = Math.max( - a.ranked[0]?.score ?? 0, - b.ranked[0]?.score ?? 0, - ); - const floor = fragmentBest + MERGE_MARGIN; + const aScore = a.ranked[0]?.score ?? 0; + const bScore = b.ranked[0]?.score ?? 0; + const fragmentBest = Math.max(aScore, bScore); + let floor = fragmentBest + MERGE_MARGIN; + if (Math.min(aScore, bScore) < MERGE_WEAK_FRAGMENT) { + floor = Math.min( + floor, + Math.max(MERGE_STRONG_READ, fragmentBest - MERGE_WEAK_SLACK), + ); + } // Probe with the floor first: most neighbor pairs are genuine letter pairs // whose merge can't win, so the bound-sorted matching stops almost at once. // Probe scores are exact, so "nothing beats the floor" is definitive. @@ -639,7 +668,13 @@ function mergeSplitGlyphs( if (probe.some((c) => c.score > floor)) { // full run (rare): the winning merge's ranked list must also carry // the sub-floor runner-up candidates downstream consumers see - const ranked = classifySegment(masked, seg, set); + const ranked = classifySegment( + masked, + seg, + set, + undefined, + maxCandidates, + ); if ((ranked[0]?.score ?? 0) > floor) { // stay at i: the merged segment may absorb yet another stroke items.splice(i, 2, { seg, ranked }); @@ -677,14 +712,102 @@ function dipCuts(profile: number[], x0: number, x1: number): number[] { return [...cuts]; } -function recutMiscutPairs( - items: ClassifiedSegment[], +/** + * The `count` lowest dips (ties broken by depth relative to the peaks either + * side), with each dip's far edge like dipCuts. Raw depth first: the junction + * of two fused glyphs is the sparsest column even when one of them is a low + * bar ("g_") whose own columns hold barely more ink. + */ +function deepestDipCuts( profile: number[], - binary: Mat, - masked: Mat, - set: GlyphSet, -): void { - const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO)); + x0: number, + x1: number, + count: number, +): number[] { + const dips: { x: number; value: number; ratio: number }[] = []; + for (let x = x0 + 3; x <= x1 - 3; x++) { + const v = profile[x]!; + if (v > profile[x - 1]! || v > profile[x + 1]!) continue; + const peak = Math.min( + Math.max(...profile.slice(x0, x)), + Math.max(...profile.slice(x + 1, x1)), + ); + dips.push({ x, value: v, ratio: v / Math.max(1, peak) }); + } + dips.sort((a, b) => a.value - b.value || a.ratio - b.ratio); + const cuts = new Set(); + for (const { x } of dips.slice(0, count)) { + cuts.add(x); + if (x + 1 <= x1 - 3) cuts.add(x + 1); + } + return [...cuts]; +} + +interface RecutContext { + profile: number[]; + binary: Mat; + masked: Mat; + set: GlyphSet; + maxCandidates: number; +} + +/** + * Best two-way cut of `span` among `cuts` whose weaker half classifies above + * `minScore` (raised to each adopted cut's weaker score); cuts inside `skip` are + * the original segmentation and are not retried. + */ +function bestRecut( + ctx: RecutContext, + span: Segment, + cuts: number[], + skip: Segment | null, + minScore: number, +): [ClassifiedSegment, ClassifiedSegment] | null { + const { binary, masked, set, maxCandidates } = ctx; + let floor = minScore; + let best: [ClassifiedSegment, ClassifiedSegment] | null = null; + for (const cut of cuts) { + if (skip && cut >= skip.x0 && cut <= skip.x1) continue; + const left = measureSegment(binary, { x0: span.x0, x1: cut }); + const right = measureSegment(binary, { x0: cut, x1: span.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, + undefined, + maxCandidates, + ); + const rightRanked = classifySegment( + masked, + right, + set, + undefined, + maxCandidates, + ); + 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 }, + ]; + } + return best; +} + +function recutMiscutPairs(items: ClassifiedSegment[], ctx: RecutContext): void { + const maxGap = Math.max( + 3, + Math.round(ctx.set.medianWidth * MERGE_MAX_GAP_RATIO), + ); for (let i = 0; i + 1 < items.length; i++) { const a = items[i]!; const b = items[i + 1]!; @@ -692,38 +815,55 @@ function recutMiscutPairs( 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( + const 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 }, - ]; - } + const span = { x0: a.seg.x0, x1: b.seg.x1 }; + const best = bestRecut( + ctx, + span, + dipCuts(ctx.profile, span.x0, span.x1), + { x0: a.seg.x1, x1: b.seg.x0 }, + floor, + ); if (best) items.splice(i, 2, ...best); } } +/** + * Blur can also fuse two glyphs into one segment narrower than the wide-segment + * split's floor ("oj" reading as a lone パ, "fi" as ↑), which then classifies + * poorly as a whole. A low-scoring segment wider than a typical glyph is re-cut + * the same way, adopted under the same floor and margin. Unreadable text (a + * splash tag in a decorative face) is full of such segments, so only the three + * lowest dips are tried: the junction of two fused glyphs is always one. + */ +const FUSED_MIN_WIDTH_RATIO = 1.2; +const FUSED_DIPS_TRIED = 3; + +function splitFusedGlyphs(items: ClassifiedSegment[], ctx: RecutContext): void { + const minWidth = ctx.set.medianWidth * FUSED_MIN_WIDTH_RATIO; + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + const score = item.ranked[0]?.score ?? 0; + if (score >= RECUT_MAX_SCORE || item.seg.x1 - item.seg.x0 < minWidth) + continue; + const floor = Math.max(RECUT_MIN_SCORE, score + RECUT_MARGIN); + const cuts = deepestDipCuts( + ctx.profile, + item.seg.x0, + item.seg.x1, + FUSED_DIPS_TRIED, + ); + const best = bestRecut(ctx, item.seg, cuts, null, floor); + if (best) { + items.splice(i, 1, ...best); + i++; + } + } +} + /** Recognizes white-on-dark text in a grayscale crop tight to one text line. */ export function recognizeText( gray: Mat, @@ -736,6 +876,7 @@ export function recognizeText( minColumnPixels = 1, spaceGap = Math.max(5, Math.round(set.medianWidth * 0.55)), minCharScore = 0.4, + maxCandidates = DEFAULT_MAX_CANDIDATES, } = options; const binary = new cv.Mat(); @@ -758,10 +899,12 @@ export function recognizeText( const items: ClassifiedSegment[] = segments.map((seg) => ({ seg, - ranked: classifySegment(masked, seg, set), + ranked: classifySegment(masked, seg, set, undefined, maxCandidates), })); - mergeSplitGlyphs(items, binary, masked, set); - recutMiscutPairs(items, profile, binary, masked, set); + mergeSplitGlyphs(items, binary, masked, set, maxCandidates); + const ctx: RecutContext = { profile, binary, masked, set, maxCandidates }; + recutMiscutPairs(items, ctx); + splitFusedGlyphs(items, ctx); const chars: RecognizedChar[] = []; let text = ""; diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json b/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json index ee61bb763..ddacc1938 100644 --- a/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json @@ -5,7 +5,6 @@ "names": ["Burstie", "leafi !!"] }, "options": { - "skipFields": ["names.1"], - "notes": "Frame shared with objective/splat-zones-cast-lime-control-blur-left (SWS26 broadcast): the spectator HUD draws the specced player's kill feed. Bottom row Burstie (the dot of its i reads as a grave accent, re-decided by the plain-tie rule), above it 'leafi !!' — the row shows three marks, the last being the message's own. Stream blur merges 'fi' into one segment (reads ↑), hence the skip." + "notes": "Frame shared with objective/splat-zones-cast-lime-control-blur-left (SWS26 broadcast): the spectator HUD draws the specced player's kill feed. Bottom row Burstie (the dot of its i reads as a grave accent, re-decided by the plain-tie rule), above it 'leafi !!' — the row shows three marks, the last being the message's own. Stream blur fuses 'fi' into one segment that read ↑ until the recognizer started re-cutting wide low-scoring segments." } } diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json index a0414284c..5879b7f43 100644 --- a/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json @@ -106,9 +106,6 @@ ] }, "options": { - "skipFields": [ - "enemies.2.name" - ], - "notes": "8-player spectator map screen, Robo ROM-en, captured as a 1920x1080 screenshot with the game drawn ~97% size inside a black frame (16px top/bottom, 29px left/right) — the case that added black-bar cropping to frame normalization: untouched, every ROI lands ~30px off and no gate fires. Also the first frame with the button glyphs mirrored: A/B/Y/X face buttons down the left column, D-pad down the right (every earlier spectator fixture has the reverse), so the spectator gate probes the X jump-button disc in either column. The card grid is unchanged and the left column is reported as teammates like the other spectator fixtures; whether the mirrored buttons say anything about which team is alpha is unverified. Labels transcribed from the visible cards. enemies.2.name skipped: >_< reads >ー< — the underscore is matched as a long dash at this name height." + "notes": "8-player spectator map screen, Robo ROM-en, captured as a 1920x1080 screenshot with the game drawn ~97% size inside a black frame (16px top/bottom, 29px left/right) — the case that added black-bar cropping to frame normalization: untouched, every ROI lands ~30px off and no gate fires. Also the first frame with the button glyphs mirrored: A/B/Y/X face buttons down the left column, D-pad down the right (every earlier spectator fixture has the reverse), so the spectator gate probes the X jump-button disc in either column. The card grid is unchanged and the left column is reported as teammates like the other spectator fixtures; whether the mirrored buttons say anything about which team is alpha is unverified. Labels transcribed from the visible cards. enemies.2.name (>_<) once read >ー< — the underscore matched a long dash at this name height until parseName started reading a bar sitting on the baseline as an underscore." } } diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json index 67cdd9528..2082a4b1b 100644 --- a/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json @@ -106,9 +106,6 @@ "stageLabel": "Crableg Capital" }, "options": { - "skipFields": [ - "teammates.3.name" - ], - "notes": "8-player spectator map screen from a different stream layout (PXG score bug top-left) — attests the card grid and the X jump-button disc land on the same ROIs across layouts. Latin names anchor the 29-30px glyph-height calibration. teammates.3.name skipped: the superscript two in f(x)= x²-8 is not a charset glyph (reads ´ or ·)." + "notes": "8-player spectator map screen from a different stream layout (PXG score bug top-left) — attests the card grid and the X jump-button disc land on the same ROIs across layouts. Latin names anchor the 29-30px glyph-height calibration. teammates.3.name (f(x)= x²-8) once read x・ー8: the superscript two sat above the name ROI, whose top now reaches to within 4px of the card's team stripe, and the leftover dot counted as a kana neighbor for the hyphen." } } diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json index 7aac22e5c..793ab43a8 100644 --- a/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json @@ -106,9 +106,6 @@ "stageLabel": "Eeltail Alley" }, "options": { - "skipFields": [ - "teammates.0.name" - ], - "notes": "8-player spectator map screen from the SWS26 cast, same game as the objective/ splat-zones-cast-* fixtures: green team left column, purple right — the cross-detector anchor pair for objective team-color tracking. teammates.0.name skipped: King Inate reads King lnate — capital I and lowercase l are pixel-identical in BlitzMain at this size (the POV nameplate in the sibling frame confirms Inate)." + "notes": "8-player spectator map screen from the SWS26 cast, same game as the objective/ splat-zones-cast-* fixtures: green team left column, purple right — the cross-detector anchor pair for objective team-color tracking. teammates.0.name (King Inate) once read King lnate — capital I and lowercase l are pixel-identical in BlitzMain, so the bar rule reads a word-initial bar before a consonant as I (the POV nameplate in the sibling frame confirms Inate)." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/low-res-2/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/low-res-2/expected.json index 768fd361e..e7d659f47 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/low-res-2/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/low-res-2/expected.json @@ -28,7 +28,7 @@ "s": 6 }, { - "name": "Sen★u", + "name": "Sendou", "weaponId": 10, "paint": 1534, "ka": 13, @@ -36,7 +36,7 @@ "s": 6 }, { - "name": "Rιppιn←H", + "name": "Rιppιng_H", "weaponId": 8001, "paint": 1248, "ka": 10, diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/low-res/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/low-res/expected.json index 094b0e3c8..efa09057d 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/low-res/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/low-res/expected.json @@ -59,7 +59,7 @@ "s": 2 }, { - "name": "Rιppιn←H", + "name": "Rιppιng_H", "weaponId": 8001, "paint": 270, "ka": 3, @@ -67,7 +67,7 @@ "s": 1 }, { - "name": "Sen★u", + "name": "Sendou", "weaponId": 10, "paint": 354, "ka": 0, diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1422/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1422/expected.json index 6065877e9..2d32bc7d8 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1422/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1422/expected.json @@ -79,9 +79,6 @@ "stageLabel": "Brinewater Springs" }, "options": { - "skipFields": [ - "players.5.name" - ], - "notes": "Same stream as the 1404 case; parses correctly today - regression guard. The old export carried anonymized-view nicknames; names here are the on-screen ones ('S' and '0' are real one-char names). players.5 truth is ブラスターアレルギー but reads with アレィLギ garbled mid-name." + "notes": "Same stream as the 1404 case; parses correctly today - regression guard. The old export carried anonymized-view nicknames; names here are the on-screen ones ('S' and '0' are real one-char names). players.5 (ブラスターアレルギー) once read アレィLギ mid-name: ル's two strokes segment apart and a fixture 'L' crop outscored the merged ル on the right stroke alone, until a fragment no template explains lowered the merge floor." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-rainmaker-lemuria-hub/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-rainmaker-lemuria-hub/expected.json index 20aa68272..c30fb43fe 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-rainmaker-lemuria-hub/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-rainmaker-lemuria-hub/expected.json @@ -20,7 +20,7 @@ "s": 4 }, { - "name": "lnvisifloats", + "name": "Invisifloats", "weaponId": 40, "paint": 1233, "ka": 20, diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-makomart/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-makomart/expected.json index 84220f518..8fbcecd8c 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-makomart/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-makomart/expected.json @@ -80,9 +80,8 @@ }, "options": { "skipFields": [ - "players.0.name", - "players.1.name" + "players.0.name" ], - "notes": "720p stream capture with horizontal banding. players.0.name (BDAYBOY) reads BOAuBOY and players.1.name (∴Columbina) reads ∴Columhina — the banding cuts glyph cores at this size. Weapons row order: N-ZAP '85, Sloshing Machine, .52 Gal, Custom Blaster, Tri-Slosher Nouveau, Stickerz Splatana Stamper, Snipewriter 5B, Carbon Roller ANG-L." + "notes": "720p stream capture with horizontal banding. players.0.name (BDAYBOY) reads BOAuBOY — the banding rounds the D's stem corners and lifts the u/Y tie the wrong way at this size; no measured corner or edge feature separates a blurred D from an O here. players.1.name (∴Columbina) once read ∴Columhina: the bowl-floor probe skipped glyphs under 20px, and now reads the floor against the band below the arch instead. Weapons row order: N-ZAP '85, Sloshing Machine, .52 Gal, Custom Blaster, Tri-Slosher Nouveau, Stickerz Splatana Stamper, Snipewriter 5B, Carbon Roller ANG-L." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-robo-rom-en/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-robo-rom-en/expected.json index d1ce633d5..1e7fca695 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-robo-rom-en/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-robo-rom-en/expected.json @@ -35,7 +35,7 @@ "s": 1 }, { - "name": "∴Columhina", + "name": "∴Columbina", "weaponId": 3020, "paint": 529, "ka": 3, diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-wahoo-world/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-wahoo-world/expected.json index e26f953f2..46686671d 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-wahoo-world/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log/private-battle-splat-zones-wahoo-world/expected.json @@ -80,9 +80,8 @@ }, "options": { "skipFields": [ - "players.1.name", - "players.3.name" + "players.1.name" ], - "notes": "1080p stream capture with horizontal banding. Live misread: the header stage read came back 'Wahnn WnrId l' (banding cuts the o bowls) and failed to snap to Wahoo World before bar-lookalike folding covered 'I'. players.1.name (p) reads P and players.3.name (goji) reads gパi under the same banding." + "notes": "1080p stream capture with horizontal banding. Live misread: the header stage read came back 'Wahnn WnrId l' (banding cuts the o bowls) and failed to snap to Wahoo World before bar-lookalike folding covered 'I'. players.3.name (goji) once read gパi: banding fused 'oj' into one 15px segment below the wide-segment split floor, now re-cut as a low-scoring wide segment. players.1.name skipped: the lone glyph reads P. Its ink spans the cap line to the baseline exactly like the P of Pony two rows down (a p would hang 4px below the baseline like the paint suffix's), so the pixels say P — worth re-checking the label against the source." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard-battle-log/x-battle-clam-blitz-lemuria/expected.json b/app/features/scanner/tests/fixtures/scoreboard-battle-log/x-battle-clam-blitz-lemuria/expected.json index 6fdfbe2a0..e4246a5f5 100644 --- a/app/features/scanner/tests/fixtures/scoreboard-battle-log/x-battle-clam-blitz-lemuria/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard-battle-log/x-battle-clam-blitz-lemuria/expected.json @@ -79,9 +79,6 @@ "stageLabel": "Lemuria Hub" }, "options": { - "skipFields": [ - "players.4.name" - ], - "notes": "players.4.name (Invisifloats) reads lnvisifloats — I and l are pixel-identical bars in this face. Weapons row order: N-ZAP '85, Sploosh-o-matic, E-liter 4K, Rapid Blaster Pro Deco, N-ZAP '85, Rapid Blaster Pro WNT-R, E-liter 4K, Splat Roller (Big Bubbler special confirms the near-tied Flingza off)." + "notes": "players.4.name (Invisifloats) once read lnvisifloats — I and l are pixel-identical bars in this face; a word-initial bar before a consonant now reads I. Weapons row order: N-ZAP '85, Sploosh-o-matic, E-liter 4K, Rapid Blaster Pro Deco, N-ZAP '85, Rapid Blaster Pro WNT-R, E-liter 4K, Splat Roller (Big Bubbler special confirms the near-tied Flingza off)." } } 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 8b6ab5eff..896d73302 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,8 @@ }, "options": { "skipFields": [ - "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." + "notes": "players.3.name (Ù Ballóòn) once read u Balloon — the thin accents lost to the bare-letter fixture crops until parseName's floating-mark probe covered Latin accents (the Ù's grave is clipped by the name ROI top and still passes). players.6.name skipped: リッター4K reads リツター4K. The second glyph measures 14px tall against 15px for the リ and タ beside it and correlates far better with ツ (0.90 vs 0.76); a small ッ elsewhere (minimap/spectator-pxg バッラスト) measures ~0.72 of its neighbors, so the pixels here look like a full-size ツ — worth re-checking the label against the source." } } diff --git a/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/expected.json b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/expected.json index a6de364a8..ede900f01 100644 --- a/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/expected.json +++ b/app/features/scanner/tests/fixtures/scoreboard/private-battle-splat-zones-ko-hagglefish/expected.json @@ -1,10 +1,7 @@ { "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." + "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 (AHOOUUUUUU) once read AHOOLUUUUU: a fixture 'L' crop correlated 0.91 with the first U's stem and floor and took the fixture tie-break while explaining only 63% of its ink, which the tie-break now requires. players.6 name uses dotless i (as parsed) - verify against source if it ever fails." }, "data": { "lobby": "PRIVATE",