mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 14:20:46 -05:00
Improve scanner replay code case
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/private-battle-makomart/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/scoreboard-battle-log-replay/private-battle-makomart/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
Reference in New Issue
Block a user