Further improve name recognition

This commit is contained in:
Kalle
2026-09-20 17:38:56 +03:00
parent 3b86bc330c
commit 4df8daada1
17 changed files with 407 additions and 149 deletions

View File

@@ -234,7 +234,9 @@ export function spectatorCardLayout(
): Omit<CardLayout, "self"> {
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: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@
"s": 4
},
{
"name": "lnvisifloats",
"name": "Invisifloats",
"weaponId": 40,
"paint": 1233,
"ka": 20,

View File

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

View File

@@ -35,7 +35,7 @@
"s": 1
},
{
"name": "∴Columhina",
"name": "∴Columbina",
"weaponId": 3020,
"paint": 529,
"ka": 3,

View File

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

View File

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

View File

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

View File

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