mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 14:20:46 -05:00
Further improve name recognition
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"s": 4
|
||||
},
|
||||
{
|
||||
"name": "lnvisifloats",
|
||||
"name": "Invisifloats",
|
||||
"weaponId": 40,
|
||||
"paint": 1233,
|
||||
"ka": 20,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"s": 1
|
||||
},
|
||||
{
|
||||
"name": "∴Columhina",
|
||||
"name": "∴Columbina",
|
||||
"weaponId": 3020,
|
||||
"paint": 529,
|
||||
"ka": 3,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user