Fix scanner misreading lowercase g/p in names
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-10-03 22:30:43 +03:00
parent bb9b76736f
commit 26c2b610a8
19 changed files with 339 additions and 27 deletions

View File

@@ -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]!;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
),
),

View File

@@ -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: [

View File

@@ -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 };

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -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."
}
}

View File

@@ -0,0 +1 @@
../../death/kani-oguricap-lowercase-g/frame.webp

View File

@@ -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."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -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
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -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