diff --git a/app/features/scanner/core/detectors/death/index.ts b/app/features/scanner/core/detectors/death/index.ts index 9cac63766..d5a701b05 100644 --- a/app/features/scanner/core/detectors/death/index.ts +++ b/app/features/scanner/core/detectors/death/index.ts @@ -57,6 +57,9 @@ import { gateAbilityProbe, JA_CONST_LINE_ROI, JA_WEAPON_LINE_ROI, + REPLAY_HINT_CHIP_MIN_CHANNEL, + REPLAY_HINT_CHIP_PROBES, + REPLAY_HINT_L_CHIP, SPLAT_LINE1_ROI, SPLAT_TEXT_BIN_THRESHOLD, TAG_NAME_INNER, @@ -260,6 +263,12 @@ export function createDeathDetector( /** Crop the tilted tag, rotate it level, and return the name band crop. */ function levelTagInner(rgb: Mat): Mat { const outer = copyRoi(rgb, TAG_NAME_OUTER); + const inner = levelOuter(outer, cv.INTER_LINEAR, cv.BORDER_REPLICATE); + outer.delete(); + return inner; + } + + function levelOuter(outer: Mat, interpolation: number, border: number): Mat { const center = new cv.Point(outer.cols / 2, outer.rows / 2); const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1); const rotated = new cv.Mat(); @@ -268,17 +277,54 @@ export function createDeathDetector( rotated, m, new cv.Size(outer.cols, outer.rows), - cv.INTER_LINEAR, - cv.BORDER_REPLICATE, + interpolation, + border, new cv.Scalar(), ); m.delete(); - outer.delete(); const inner = copyRoi(rotated, TAG_NAME_INNER); rotated.delete(); return inner; } + function showsReplayHints(rgb: Mat): boolean { + return REPLAY_HINT_CHIP_PROBES.every((roi) => { + const probe = copyRoi(rgb, roi); + const d = probe.data; + let min = 255; + for (let i = 0; i < probe.rows * probe.cols; i++) { + min = Math.min(min, d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!); + } + probe.delete(); + return min >= REPLAY_HINT_CHIP_MIN_CHANNEL; + }); + } + + let replayChipMask: Mat | null = null; + /** REPLAY_HINT_L_CHIP in the leveled name band's coordinates. */ + function replayChipMaskInner(): Mat { + if (!replayChipMask) { + const outer = new cv.Mat( + TAG_NAME_OUTER.h, + TAG_NAME_OUTER.w, + cv.CV_8UC1, + new cv.Scalar(0), + ); + const x = REPLAY_HINT_L_CHIP.x - TAG_NAME_OUTER.x; + const y = REPLAY_HINT_L_CHIP.y - TAG_NAME_OUTER.y; + cv.rectangle( + outer, + new cv.Point(x, y), + new cv.Point(x + REPLAY_HINT_L_CHIP.w, y + REPLAY_HINT_L_CHIP.h), + new cv.Scalar(255), + -1, + ); + replayChipMask = levelOuter(outer, cv.INTER_NEAREST, cv.BORDER_CONSTANT); + outer.delete(); + } + return replayChipMask; + } + const tagMemo: { signature: Uint8Array; read: TagNameRead }[] = []; function tagSignature(inner: Mat): Uint8Array { @@ -485,6 +531,7 @@ export function createDeathDetector( inner: Mat, backgrounds: readonly [number, number, number][], spaceGap: number, + chipMask: Mat | null, speculative: boolean, ): MatchSteps<{ parsed: ParsedName; @@ -492,6 +539,7 @@ export function createDeathDetector( textColor: [number, number, number] | null; }> { const band = distanceBand(inner, backgrounds, false); + if (chipMask) band.setTo(new cv.Scalar(0), chipMask); cv.normalize(band, band, 0, 255, cv.NORM_MINMAX); clearBorderBlobs(band, TAG_NAME_BIN_THRESHOLD); @@ -504,6 +552,7 @@ export function createDeathDetector( if (inkCount >= TAG_NAME_REFINE_MIN_INK) { textColor = medianColor(inner, (i) => ink[i]! > TAG_NAME_BIN_THRESHOLD); refined = distanceBand(inner, [textColor], true); + if (chipMask) refined.setTo(new cv.Scalar(0), chipMask); clearBorderBlobs(refined, TAG_NAME_REFINE_BIN_THRESHOLD); } const [bandParse, refinedParse] = yield* all([ @@ -542,6 +591,7 @@ export function createDeathDetector( ): MatchSteps<{ read: TagNameRead; memoHit: boolean }> { const spaceGap = Math.max(7, Math.round(tagNameGlyphs!.medianWidth * 0.55)); const inner = levelTagInner(rgb); + const chipMask = showsReplayHints(rgb) ? replayChipMaskInner() : null; const signature = tagSignature(inner); let read = tagMemoLookup(signature); const memoHit = read !== null; @@ -580,7 +630,13 @@ export function createDeathDetector( }; const reads = yield* all( candidates.map((backgrounds) => - readWithBackground(inner, backgrounds, spaceGap, speculative), + readWithBackground( + inner, + backgrounds, + spaceGap, + chipMask, + speculative, + ), ), ); let best = reads[0]!; diff --git a/app/features/scanner/core/detectors/death/rois.ts b/app/features/scanner/core/detectors/death/rois.ts index 164b706d1..e2e0608d6 100644 --- a/app/features/scanner/core/detectors/death/rois.ts +++ b/app/features/scanner/core/detectors/death/rois.ts @@ -77,9 +77,25 @@ export const TAG_TILT_DEG = 3.0; export const TAG_NAME_OUTER: Roi = { x: 1130, y: 770, w: 650, h: 140 }; /** * Top edge must clear kana dakuten (y=34 clipped them, misreading こ for ご) yet stay below the title - * line (ends y=17). + * line (ends y=17); the bottom keeps a 'p' tail at the long name's sagging right end off the border. */ -export const TAG_NAME_INNER: Roi = { x: 20, y: 21, w: 610, h: 87 }; +export const TAG_NAME_INNER: Roi = { x: 20, y: 21, w: 610, h: 91 }; + +/** + * Replay playback draws button hint chips (R View Graph, L Pause, ...) over the + * tag's right end; the L chip reads as a trailing glyph ("カニ Jokera ¬"). + * Probes: chip faces either side of the R and L letters, white (min channel + * 255) on replays, under 200 on every other fixture. + */ +export const REPLAY_HINT_CHIP_PROBES: readonly Roi[] = [ + { x: 1697, y: 752, w: 10, h: 12 }, + { x: 1730, y: 752, w: 10, h: 12 }, + { x: 1708, y: 809, w: 8, h: 12 }, + { x: 1733, y: 809, w: 10, h: 12 }, +]; +export const REPLAY_HINT_CHIP_MIN_CHANNEL = 220; +/** The L chip with its shadow, blanked out of the tag band when the hints show. */ +export const REPLAY_HINT_L_CHIP: Roi = { x: 1684, y: 800, w: 74, h: 36 }; /** Tight cap height of the tag name text (atlas nominal height). */ export const TAG_NAME_TEXT_HEIGHT = 46; diff --git a/app/features/scanner/core/detectors/kill/rois.ts b/app/features/scanner/core/detectors/kill/rois.ts index 3a2453bbb..13a020998 100644 --- a/app/features/scanner/core/detectors/kill/rois.ts +++ b/app/features/scanner/core/detectors/kill/rois.ts @@ -15,8 +15,8 @@ export const MAX_ROWS = 4; /** Text caps sit at y1004 on the bottom row and y938 on the one above. */ const ROW_PITCH = 66; -/** Bottom row's text band: from the squid icon's right edge to the pill's plain right end, with drift headroom. */ -const TEXT_ROI_BOTTOM: Roi = { x: 796, y: 997, w: 412, h: 38 }; +/** Bottom row's text band: from the squid icon's right edge to the pill's plain right end, with drift headroom; descenders reach y1035 ('g' lost its tail at h 38 and read 'A'). */ +const TEXT_ROI_BOTTOM: Roi = { x: 796, y: 997, w: 412, h: 41 }; /** Tight cap height of the row text (atlas nominal height). */ export const KILL_TEXT_HEIGHT = 24; diff --git a/app/features/scanner/core/detectors/minimap/index.ts b/app/features/scanner/core/detectors/minimap/index.ts index 8671ba40f..e419204c2 100644 --- a/app/features/scanner/core/detectors/minimap/index.ts +++ b/app/features/scanner/core/detectors/minimap/index.ts @@ -71,6 +71,7 @@ import { MINIMAP_ABILITY_INK_THRESHOLD, MINIMAP_WEAPON_INK_THRESHOLD, NAME_BIN_THRESHOLD, + NAME_PLAIN_TIE_MARGIN, NAME_TEXT_HEIGHT, PRESENCE_MIN_LAPLACIAN, SPECIAL_READY_INK_THRESHOLD, @@ -425,7 +426,10 @@ export function createMinimapDetector( parseNameSteps( band, set, - { binThreshold: NAME_BIN_THRESHOLD }, + { + binThreshold: NAME_BIN_THRESHOLD, + plainTieMargin: NAME_PLAIN_TIE_MARGIN, + }, speculative, ), ), diff --git a/app/features/scanner/core/detectors/minimap/rois.ts b/app/features/scanner/core/detectors/minimap/rois.ts index c33a3b063..89872dc83 100644 --- a/app/features/scanner/core/detectors/minimap/rois.ts +++ b/app/features/scanner/core/detectors/minimap/rois.ts @@ -106,6 +106,8 @@ export const MINIMAP_ABILITY_INK_THRESHOLD = 90; /** BlitzMain caps measure 28-29px on every card (self included). */ export const NAME_TEXT_HEIGHT = 29; export const NAME_BIN_THRESHOLD = 170; +/** A fullwidth bracket reads level with its ASCII twin ("[K]yo!" 0.015 apart). */ +export const NAME_PLAIN_TIE_MARGIN = 0.02; /** Cross-out probe: fraction of saturated+bright HSV pixels. Struck 0.26-0.38, clean <=0.01. */ export const CROSS_SATURATION_MIN = 110; @@ -244,8 +246,8 @@ export function spectatorCardLayout( const dy = SPECTATOR_ROW_PITCH * row; return { // 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 }, + // card's team stripe ends 4px above this one; a 'g' tail reaches 50px down + name: { x: 198 + dx, y: 303 + dy, w: 310, h: 51 }, 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 ff78c0c09..40aded403 100644 --- a/app/features/scanner/core/detectors/scoreboard/names.ts +++ b/app/features/scanner/core/detectors/scoreboard/names.ts @@ -135,15 +135,16 @@ function normalizeOhs(name: string): string { /** * 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. + * lightly: ">_<" read ">ー<", "Jrod_14" read "Jrod¯14". A bar whose bottom + * reaches the line's baseline (median ink bottom of the other glyphs) is an + * underscore; dashes float at mid height, a macron at the cap line. */ const BASELINE_BAR_CHARS = new Set(["-", "ー", "¯", "_"]); +const UNDERSCORE_LOOKALIKES = 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; + if (!raw.chars.some((c) => UNDERSCORE_LOOKALIKES.has(c.char))) return raw; const anchors = raw.chars .filter((c) => !BASELINE_BAR_CHARS.has(c.char)) .map((c) => c.y1) @@ -151,7 +152,7 @@ function resolveUnderscoreByBaseline(raw: RecognizedText): RecognizedText { 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 (!UNDERSCORE_LOOKALIKES.has(c.char)) return c; if (baseline - c.y1 > UNDERSCORE_BASELINE_SLACK_PX) return c; return { ...c, char: "_" }; }); @@ -275,12 +276,14 @@ function resolveBhByBowlFloor( * profile keeps them: an O's top and bottom solid rows sit inset from its * mid-height edge on both sides alike, a D's only on the right. Measured * across fixtures at 15-29px, a D's right inset exceeds its left by 0.15+ of - * the glyph height, an O's by under 0.08. Only an O read over a near-tied D - * is re-decided: a D read already stands. + * the glyph height, an O's by under 0.08. A D read over a near-tied O only + * turns O when its corners are near level: a ~15px D can sit at 0.05 ("sDpp"), + * the 48px splash tag O of "OguriCap" read D at 0.00. */ const ROUND_CHARS = new Set(["O", "0"]); const DO_SCORE_MARGIN = 0.05; const D_MIN_CORNER_ASYMMETRY = 0.15; +const O_MAX_CORNER_ASYMMETRY = 0.025; /** solid rows reach this share of the glyph's brightest pixel */ const SOLID_ROW_FRACTION = 0.8; const EDGE_LEVEL = 128; @@ -289,12 +292,14 @@ function resolveDoByCorners( raw: RecognizedText, grayView: Mat, ): RecognizedText { + const twinOf = (c: RecognizedChar) => + c.candidates?.find( + (k) => + (ROUND_CHARS.has(c.char) ? k.char === "D" : ROUND_CHARS.has(k.char)) && + c.score - k.score <= DO_SCORE_MARGIN, + ); const contested = (c: RecognizedChar) => - ROUND_CHARS.has(c.char) && - (c.candidates?.some( - (k) => k.char === "D" && c.score - k.score <= DO_SCORE_MARGIN, - ) ?? - false); + (ROUND_CHARS.has(c.char) || c.char === "D") && twinOf(c) !== undefined; if (!raw.chars.some(contested)) return raw; const gray = new (getCV().Mat)(); @@ -302,10 +307,14 @@ function resolveDoByCorners( const { cols, data } = gray; const chars = raw.chars.map((c) => { if (!contested(c)) return c; - const asymmetry = cornerAsymmetry(data, cols, c); - if (!(asymmetry >= D_MIN_CORNER_ASYMMETRY * (c.y1 - c.y0))) return c; - const d = c.candidates!.find((k) => k.char === "D")!; - return { ...c, char: "D", score: d.score }; + const asymmetry = cornerAsymmetry(data, cols, c) / (c.y1 - c.y0); + const isD = + c.char === "D" + ? !(asymmetry < O_MAX_CORNER_ASYMMETRY) + : asymmetry >= D_MIN_CORNER_ASYMMETRY; + if (isD === (c.char === "D")) return c; + const twin = twinOf(c)!; + return { ...c, char: twin.char, score: twin.score }; }); gray.delete(); return { ...raw, text: retext(raw.text, chars), chars }; diff --git a/app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/expected.json b/app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/expected.json new file mode 100644 index 000000000..8c6430f90 --- /dev/null +++ b/app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/expected.json @@ -0,0 +1,29 @@ +{ + "event": "Death", + "data": { + "weaponLabel": "Cometz Octobrush", + "weaponId": 1112, + "weaponType": "MAIN", + "abilities": [ + [ + "CB", + "SCU", + "SPU", + "SPU" + ], + [ + "NS", + "SCU", + "SS", + "SSU" + ], + [ + "SJ", + "QSJ", + "RES", + "QSJ" + ] + ], + "name": "カニ Jokera" + } +} diff --git a/app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/frame.webp b/app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/frame.webp new file mode 100644 index 000000000..19d88a843 Binary files /dev/null and b/app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/frame.webp differ diff --git a/app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/expected.json b/app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/expected.json new file mode 100644 index 000000000..b4292ae78 --- /dev/null +++ b/app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/expected.json @@ -0,0 +1,29 @@ +{ + "event": "Death", + "data": { + "weaponLabel": "Splattershot Jr.", + "weaponId": 10, + "weaponType": "MAIN", + "abilities": [ + [ + "ISS", + "QSJ", + "RES", + "QR" + ], + [ + "ISS", + "ISS", + "ISS", + "QR" + ], + [ + "SJ", + "SPU", + "BRU", + "ISS" + ] + ], + "name": "カニOguriCap" + } +} diff --git a/app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/frame.webp b/app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/frame.webp new file mode 100644 index 000000000..c7e68a06c Binary files /dev/null and b/app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/frame.webp differ diff --git a/app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/expected.json b/app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/expected.json new file mode 100644 index 000000000..2f9092149 --- /dev/null +++ b/app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/expected.json @@ -0,0 +1,29 @@ +{ + "event": "Death", + "data": { + "weaponLabel": "N-ZAP '85", + "weaponId": 60, + "weaponType": "MAIN", + "abilities": [ + [ + "RSU", + "RSU", + "RSU", + "RSU" + ], + [ + "SCU", + "SPU", + "SPU", + "SPU" + ], + [ + "SJ", + "RES", + "QSJ", + "QSJ" + ] + ], + "name": "カニ strings" + } +} diff --git a/app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/frame.webp b/app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/frame.webp new file mode 100644 index 000000000..3450f68c7 Binary files /dev/null and b/app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/frame.webp differ diff --git a/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/expected.json b/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/expected.json new file mode 100644 index 000000000..33aab2d58 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 256, + "names": ["カニOguriCap"] + }, + "options": { + "notes": "Frame shared with death/kani-oguricap-lowercase-g (1080p POV, Rainmaker): a trade — the death overlay is up while the feed shows the splat. Lowercase g in the name." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/frame.webp b/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/frame.webp new file mode 120000 index 000000000..d70b5f8ca --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/frame.webp @@ -0,0 +1 @@ +../../death/kani-oguricap-lowercase-g/frame.webp \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/expected.json b/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/expected.json new file mode 100644 index 000000000..817e2708b --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 258, + "names": ["カニOguriCap"] + }, + "options": { + "notes": "1080p POV, Rainmaker (timer 4:18). Lowercase g in the name, read as \"A\"; the same row a second earlier read correctly." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/frame.webp b/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/frame.webp new file mode 100644 index 000000000..d7f0a7139 Binary files /dev/null and b/app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/frame.webp differ diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/expected.json new file mode 100644 index 000000000..0a4237aa6 --- /dev/null +++ b/app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/expected.json @@ -0,0 +1,112 @@ +{ + "event": "Minimap", + "data": { + "stage": 16, + "stageLabel": "Humpback Pump Track", + "spectator": true, + "teammates": [ + { + "self": false, + "name": "Deffy»95", + "weaponLabel": "Carbon Roller Deco", + "weaponId": 1001, + "abilities": [ + "QSJ", + "NS", + "SJ" + ], + "dead": false, + "specialReady": false + }, + { + "self": false, + "name": "Stance☆»95", + "weaponLabel": ".96 Gal", + "weaponId": 80, + "abilities": [ + "SSU", + "SSU", + "IRU" + ], + "dead": false, + "specialReady": false + }, + { + "self": false, + "name": "Kei»95", + "weaponLabel": "Heavy Edit Splatling", + "weaponId": 4050, + "abilities": [ + "LDE", + "RSU", + "SCU" + ], + "dead": false, + "specialReady": false + }, + { + "self": false, + "name": "S&wich»95", + "weaponLabel": "Splatana Wiper", + "weaponId": 8010, + "abilities": [ + "CB", + "QR", + "SJ" + ], + "dead": false, + "specialReady": false + } + ], + "enemies": [ + { + "name": "カニ strings", + "weaponLabel": "N-ZAP '85", + "weaponId": 60, + "abilities": [ + "RSU", + "SCU", + "SJ" + ], + "dead": false, + "specialReady": false + }, + { + "name": "カニ glum", + "weaponLabel": ".52 Gal", + "weaponId": 50, + "abilities": [ + "SSU", + "SSU", + "SJ" + ], + "dead": false, + "specialReady": false + }, + { + "name": "カニ Jokera", + "weaponLabel": "Cometz Octobrush", + "weaponId": 1112, + "abilities": [ + "CB", + "NS", + "SJ" + ], + "dead": false, + "specialReady": false + }, + { + "name": "カニOguriCap", + "weaponLabel": "Splattershot Jr.", + "weaponId": 10, + "abilities": [ + "ISS", + "ISS", + "SJ" + ], + "dead": false, + "specialReady": false + } + ] + } +} diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/frame.webp b/app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/frame.webp new file mode 100644 index 000000000..da7102b90 Binary files /dev/null and b/app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/frame.webp differ diff --git a/changelog/2026-10-03-scanner-name-reads.md b/changelog/2026-10-03-scanner-name-reads.md new file mode 100644 index 000000000..d52dfbe86 --- /dev/null +++ b/changelog/2026-10-03-scanner-name-reads.md @@ -0,0 +1,5 @@ +--- +navItem: scanner +type: bug +--- +Scanner reads player names with a lowercase g or p correctly, and no longer adds a stray symbol to names when watching a replay