Improve scanner replay code case

This commit is contained in:
Kalle
2026-08-11 19:20:44 +03:00
parent af2504633f
commit 9a7a1bdd65
5 changed files with 233 additions and 6 deletions

View File

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

View File

@@ -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<string, string> = { 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,

View File

@@ -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<number>();
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 = "";

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB