mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-26 21:27:48 -05:00
Fix bad name recognition
This commit is contained in:
@@ -166,27 +166,38 @@ function retext(text: string, chars: RecognizedChar[]): string {
|
||||
|
||||
/**
|
||||
* '.', '・', '·' tight-crop to near-identical blobs. A dot floating well above
|
||||
* the baseline cannot be '.', so it rereads as the best middle-dot candidate;
|
||||
* the reverse does not hold (BlitzMain draws '・' ON the baseline in some names,
|
||||
* scoreboard/robot row 5), so baseline dots keep the template ranking.
|
||||
* the baseline cannot be '.', so it rereads as the best middle-dot candidate.
|
||||
* BlitzMain draws '・' ON the baseline in some names (scoreboard/robot row 5,
|
||||
* next to symbols), so a baseline dot only rereads as '.' between two Latin
|
||||
* letters or digits ("R.O.B.O.T", "Lv.13"), where no fixture attests a '・'.
|
||||
*/
|
||||
const DOT_CHARS = new Set([".", "・", "·"]);
|
||||
const DOT_BASELINE_SLACK_PX = 3;
|
||||
const LATIN_OR_DIGIT = /^[\p{Script=Latin}\d]$/u;
|
||||
|
||||
function fixRaisedDots(raw: RecognizedText): RecognizedText {
|
||||
if (!raw.chars.some((c) => c.char === ".")) return raw;
|
||||
function fixDotsByPosition(raw: RecognizedText): RecognizedText {
|
||||
if (!raw.chars.some((c) => DOT_CHARS.has(c.char))) return raw;
|
||||
const anchors = raw.chars
|
||||
.filter((c) => !DOT_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 (c.char !== "." || baseline - c.y1 <= DOT_BASELINE_SLACK_PX) return c;
|
||||
const alt = c.candidates?.find(
|
||||
(k) => DOT_CHARS.has(k.char) && k.char !== ".",
|
||||
);
|
||||
return { ...c, char: alt?.char ?? "・" };
|
||||
const chars = raw.chars.map((c, i) => {
|
||||
if (!DOT_CHARS.has(c.char)) return c;
|
||||
const raised = baseline - c.y1 > DOT_BASELINE_SLACK_PX;
|
||||
if (raised && c.char === ".") {
|
||||
const alt = c.candidates?.find(
|
||||
(k) => DOT_CHARS.has(k.char) && k.char !== ".",
|
||||
);
|
||||
return { ...c, char: alt?.char ?? "・" };
|
||||
}
|
||||
const betweenLatin =
|
||||
LATIN_OR_DIGIT.test(raw.chars[i - 1]?.char ?? "") &&
|
||||
LATIN_OR_DIGIT.test(raw.chars[i + 1]?.char ?? "");
|
||||
if (raised || c.char === "." || !betweenLatin) return c;
|
||||
const period = c.candidates?.find((k) => k.char === ".");
|
||||
return period ? { ...c, char: ".", score: period.score } : c;
|
||||
});
|
||||
return { ...raw, text: retext(raw.text, chars), chars };
|
||||
}
|
||||
@@ -258,6 +269,198 @@ function resolveBhByBowlFloor(
|
||||
return { ...raw, text: retext(raw.text, chars), chars };
|
||||
}
|
||||
|
||||
/**
|
||||
* 'D' and 'O' differ only in the left corners, which soft text blurs until the
|
||||
* ink penalty decides (the quick battle log's "DUDE" read "OUDE"). The edge
|
||||
* profile keeps them: an O's top and bottom solid rows sit inset from its
|
||||
* mid-height edge on both sides alike, a D's only on the right. Measured
|
||||
* across fixtures at 15-29px, a D's right inset exceeds its left by 0.15+ of
|
||||
* the glyph height, an O's by under 0.08. Only an O read over a near-tied D
|
||||
* is re-decided: a D read already stands.
|
||||
*/
|
||||
const ROUND_CHARS = new Set(["O", "0"]);
|
||||
const DO_SCORE_MARGIN = 0.05;
|
||||
const D_MIN_CORNER_ASYMMETRY = 0.15;
|
||||
/** solid rows reach this share of the glyph's brightest pixel */
|
||||
const SOLID_ROW_FRACTION = 0.8;
|
||||
const EDGE_LEVEL = 128;
|
||||
|
||||
function resolveDoByCorners(
|
||||
raw: RecognizedText,
|
||||
grayView: Mat,
|
||||
): RecognizedText {
|
||||
const contested = (c: RecognizedChar) =>
|
||||
ROUND_CHARS.has(c.char) &&
|
||||
(c.candidates?.some(
|
||||
(k) => k.char === "D" && c.score - k.score <= DO_SCORE_MARGIN,
|
||||
) ??
|
||||
false);
|
||||
if (!raw.chars.some(contested)) return raw;
|
||||
|
||||
const gray = new (getCV().Mat)();
|
||||
grayView.copyTo(gray);
|
||||
const { cols, data } = gray;
|
||||
const chars = raw.chars.map((c) => {
|
||||
if (!contested(c)) return c;
|
||||
const asymmetry = cornerAsymmetry(data, cols, c);
|
||||
if (!(asymmetry >= D_MIN_CORNER_ASYMMETRY * (c.y1 - c.y0))) return c;
|
||||
const d = c.candidates!.find((k) => k.char === "D")!;
|
||||
return { ...c, char: "D", score: d.score };
|
||||
});
|
||||
gray.delete();
|
||||
return { ...raw, text: retext(raw.text, chars), chars };
|
||||
}
|
||||
|
||||
/**
|
||||
* How much further the segment's top and bottom solid rows are inset from the
|
||||
* mid-height edge on the right than on the left, in px (NaN when unmeasurable).
|
||||
*/
|
||||
function cornerAsymmetry(
|
||||
data: Uint8Array,
|
||||
cols: number,
|
||||
c: RecognizedChar,
|
||||
): number {
|
||||
const at = (x: number, y: number) =>
|
||||
x >= c.x0 && x < c.x1 ? data[y * cols + x]! : 0;
|
||||
const crossing = (x: number, y: number, step: -1 | 1) => {
|
||||
const v = at(x, y);
|
||||
const outside = at(x - step, y);
|
||||
return x - step + (step * (EDGE_LEVEL - outside)) / (v - outside);
|
||||
};
|
||||
const leftEdge = (y: number) => {
|
||||
for (let x = c.x0; x < c.x1; x++) {
|
||||
if (at(x, y) >= EDGE_LEVEL) return crossing(x, y, 1);
|
||||
}
|
||||
return Number.NaN;
|
||||
};
|
||||
const rightEdge = (y: number) => {
|
||||
for (let x = c.x1 - 1; x >= c.x0; x--) {
|
||||
if (at(x, y) >= EDGE_LEVEL) return crossing(x, y, -1);
|
||||
}
|
||||
return Number.NaN;
|
||||
};
|
||||
const rowMax = (y: number) => {
|
||||
let max = 0;
|
||||
for (let x = c.x0; x < c.x1; x++) max = Math.max(max, at(x, y));
|
||||
return max;
|
||||
};
|
||||
|
||||
const h = c.y1 - c.y0;
|
||||
let glyphMax = 0;
|
||||
for (let y = c.y0; y < c.y1; y++) glyphMax = Math.max(glyphMax, rowMax(y));
|
||||
let top = c.y0;
|
||||
while (top < c.y1 - 1 && rowMax(top) < SOLID_ROW_FRACTION * glyphMax) top++;
|
||||
let bottom = c.y1 - 1;
|
||||
while (bottom > top && rowMax(bottom) < SOLID_ROW_FRACTION * glyphMax)
|
||||
bottom--;
|
||||
const midRows: number[] = [];
|
||||
for (
|
||||
let y = c.y0 + Math.round(h * 0.3);
|
||||
y <= c.y1 - 1 - Math.round(h * 0.3);
|
||||
y++
|
||||
) {
|
||||
midRows.push(y);
|
||||
}
|
||||
if (midRows.length === 0) return Number.NaN;
|
||||
const median = (values: number[]) =>
|
||||
values.sort((a, b) => a - b)[Math.floor(values.length / 2)]!;
|
||||
const leftMid = median(midRows.map(leftEdge));
|
||||
const rightMid = median(midRows.map(rightEdge));
|
||||
const leftInset = leftEdge(top) + leftEdge(bottom) - 2 * leftMid;
|
||||
const rightInset = 2 * rightMid - rightEdge(top) - rightEdge(bottom);
|
||||
return rightInset - leftInset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft text also blurs the letters that differ only in where their stem meets
|
||||
* the baseline, so the ink penalty settles near-ties: a 'T' read 'r' (quick log
|
||||
* "R.O.B.O.T"), a 'Y' read 'u' (720p "BDAYBOY"). The bottom rows keep the stem:
|
||||
* a T's sits centered where an r's hugs the left (ink centroid 0.44+ of the
|
||||
* width vs 0.42 and under across fixtures), and a Y ends in a lone stem where
|
||||
* a u's bowl spans the glyph (bottom ink width 0.40 and under vs 0.57+). Only
|
||||
* the lowercase read of a near-tie is re-decided.
|
||||
*/
|
||||
const STEM_TWINS: {
|
||||
read: string;
|
||||
twin: string;
|
||||
isTwin: (bottom: { centroid: number; width: number }) => boolean;
|
||||
}[] = [
|
||||
{ read: "r", twin: "T", isTwin: (bottom) => bottom.centroid >= 0.43 },
|
||||
{ read: "u", twin: "Y", isTwin: (bottom) => bottom.width < 0.5 },
|
||||
];
|
||||
const STEM_SCORE_MARGIN = 0.05;
|
||||
const STEM_BOTTOM_FRACTION = 0.3;
|
||||
|
||||
function resolveStemTwins(
|
||||
raw: RecognizedText,
|
||||
grayView: Mat,
|
||||
binThreshold: number,
|
||||
): RecognizedText {
|
||||
const contested = (c: RecognizedChar) => {
|
||||
const rule = STEM_TWINS.find((r) => r.read === c.char);
|
||||
const twin = c.candidates?.find(
|
||||
(k) => k.char === rule?.twin && c.score - k.score <= STEM_SCORE_MARGIN,
|
||||
);
|
||||
return rule && twin ? { rule, twin } : undefined;
|
||||
};
|
||||
if (!raw.chars.some(contested)) return raw;
|
||||
|
||||
const gray = new (getCV().Mat)();
|
||||
grayView.copyTo(gray);
|
||||
const { cols, data } = gray;
|
||||
const chars = raw.chars.map((c) => {
|
||||
const match = contested(c);
|
||||
if (!match) return c;
|
||||
const bottom = bottomInk(data, cols, c, binThreshold);
|
||||
if (!bottom || !match.rule.isTwin(bottom)) return c;
|
||||
return { ...c, char: match.twin.char, score: match.twin.score };
|
||||
});
|
||||
gray.delete();
|
||||
return { ...raw, text: retext(raw.text, chars), chars };
|
||||
}
|
||||
|
||||
/**
|
||||
* The segment's bottom rows: brightness-weighted ink centroid and mean ink
|
||||
* extent per row, both as fractions of the segment width.
|
||||
*/
|
||||
function bottomInk(
|
||||
data: Uint8Array,
|
||||
cols: number,
|
||||
c: RecognizedChar,
|
||||
binThreshold: number,
|
||||
): { centroid: number; width: number } | null {
|
||||
const w = c.x1 - c.x0;
|
||||
const h = c.y1 - c.y0;
|
||||
let weight = 0;
|
||||
let weightedX = 0;
|
||||
let extents = 0;
|
||||
let rows = 0;
|
||||
for (
|
||||
let y = c.y1 - Math.max(2, Math.round(h * STEM_BOTTOM_FRACTION));
|
||||
y < c.y1;
|
||||
y++
|
||||
) {
|
||||
let lo = -1;
|
||||
let hi = -1;
|
||||
for (let x = c.x0; x < c.x1; x++) {
|
||||
const v = data[y * cols + x]!;
|
||||
if (v <= binThreshold) continue;
|
||||
weight += v;
|
||||
weightedX += v * (x + 0.5);
|
||||
if (lo < 0) lo = x;
|
||||
hi = x;
|
||||
}
|
||||
if (lo < 0) continue;
|
||||
extents += hi - lo + 1;
|
||||
rows++;
|
||||
}
|
||||
if (rows === 0) return null;
|
||||
return {
|
||||
centroid: (weightedX / weight - c.x0) / w,
|
||||
width: extents / rows / w,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A (han)dakuten is two short ticks (or a ring) floating above the base kana's
|
||||
* upper right. Capture blur thins those ticks, so the ink-coverage penalty lets
|
||||
@@ -270,7 +473,9 @@ function resolveBhByBowlFloor(
|
||||
* direction is re-decided: the game draws some marks touching the base stroke
|
||||
* (quick-log ば, ギ) and the templates already read those right, so a missing gap
|
||||
* must not demote a voiced read. A plain kana's own detached top tick (う) starts
|
||||
* far left of the mark's column band and fails the left-edge floor.
|
||||
* far left of the mark's column band and fails the left-edge floor. Blur can
|
||||
* bridge the gap with a single antialiased row (quick-log プ read フ), so the
|
||||
* probe retries on the strokes' cores, above that antialiasing.
|
||||
*/
|
||||
const VOICED_TWINS: Record<string, string[]> = {};
|
||||
for (const [plain, voiced] of [
|
||||
@@ -293,6 +498,7 @@ const VOICED_SCORE_MARGIN = 0.1;
|
||||
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 MARK_CORE_THRESHOLD_LIFT = 50;
|
||||
const VOICED_MARK_SHAPE: MarkShape = {
|
||||
minLeft: 0.35,
|
||||
minRight: 0.75,
|
||||
@@ -321,7 +527,16 @@ function resolveVoicedByMark(
|
||||
const twin = voicedRunnerUp(c);
|
||||
if (
|
||||
!twin ||
|
||||
!hasFloatingMark(data, cols, c, binThreshold, VOICED_MARK_SHAPE)
|
||||
!(
|
||||
hasFloatingMark(data, cols, c, binThreshold, VOICED_MARK_SHAPE) ||
|
||||
hasFloatingMark(
|
||||
data,
|
||||
cols,
|
||||
c,
|
||||
binThreshold + MARK_CORE_THRESHOLD_LIFT,
|
||||
VOICED_MARK_SHAPE,
|
||||
)
|
||||
)
|
||||
)
|
||||
return c;
|
||||
return { ...c, char: twin.char, score: twin.score };
|
||||
@@ -551,7 +766,14 @@ export function* parseNameSteps(
|
||||
resolveAccentByMark(
|
||||
resolveVoicedByMark(
|
||||
resolveBhByBowlFloor(
|
||||
fixRaisedDots(resolveUnderscoreByBaseline(raw)),
|
||||
resolveStemTwins(
|
||||
resolveDoByCorners(
|
||||
fixDotsByPosition(resolveUnderscoreByBaseline(raw)),
|
||||
gray,
|
||||
),
|
||||
gray,
|
||||
binThreshold,
|
||||
),
|
||||
gray,
|
||||
),
|
||||
gray,
|
||||
|
||||
@@ -362,6 +362,14 @@ const FIXTURE_TIEBREAK = 0.02;
|
||||
const FIXTURE_TIEBREAK_MAX_INK_GAP = 0.1;
|
||||
|
||||
const DEFAULT_MAX_CANDIDATES = 5;
|
||||
/**
|
||||
* A dot's template carries a pad row above and below its few ink rows, so on a
|
||||
* soft capture (quick log "R.O.B.O.T": 4x3 dots) it outgrows the height ratio
|
||||
* and only '_' was left to read them. Blobs no wider than they are tall (plus
|
||||
* that pad) accept templates up to the pad taller; bars stay height-checked,
|
||||
* which is what tells '_' from '-'.
|
||||
*/
|
||||
const DOT_TEMPLATE_PAD_ROWS = 2;
|
||||
|
||||
/**
|
||||
* A CJK-charset segment leaves thousands of templates eligible with barely
|
||||
@@ -442,7 +450,10 @@ function* classifySegment(
|
||||
const hRatio = tRows / Math.max(seg.height, 1);
|
||||
// a template sliding freely in a taller region can score high on a fragment
|
||||
// of the segment (an 'l' bar inside a 'c'), so reject height mismatches
|
||||
if (hRatio < 0.5 || hRatio > 1.3) continue;
|
||||
const padded =
|
||||
segWidth <= seg.height + DOT_TEMPLATE_PAD_ROWS &&
|
||||
tRows - seg.height <= DOT_TEMPLATE_PAD_ROWS;
|
||||
if (hRatio < 0.5 || (hRatio > 1.3 && !padded)) continue;
|
||||
// ink-coverage penalty: templates should explain the segment's ink
|
||||
const r =
|
||||
Math.min(glyph.ink, seg.ink) / Math.max(Math.max(glyph.ink, seg.ink), 1);
|
||||
@@ -784,6 +795,14 @@ const MERGE_MARGIN = 0.02;
|
||||
const MERGE_WEAK_FRAGMENT = 0.65;
|
||||
const MERGE_STRONG_READ = 0.8;
|
||||
const MERGE_WEAK_SLACK = 0.03;
|
||||
/**
|
||||
* Marks drawn at the cap line: a fragment read as one while its ink reaches the
|
||||
* baseline is a stroke of a split glyph, however well the stroke matches (ル's
|
||||
* left stroke reads ′ at 0.78 beside its right stroke as ι at 0.87, ル at 0.83),
|
||||
* so its pair only needs the merge to be a strong read.
|
||||
*/
|
||||
const RAISED_MARKS = new Set([..."′″‘’‛“”'\"`´ªº°¹²³^˜¨¯"]);
|
||||
const RAISED_MARK_BASELINE_SLACK_PX = 2;
|
||||
|
||||
function* mergeSplitGlyphs(
|
||||
items: ClassifiedSegment[],
|
||||
@@ -792,6 +811,11 @@ function* mergeSplitGlyphs(
|
||||
const { set, maxCandidates } = ctx;
|
||||
const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO));
|
||||
const maxCharWidth = Math.round(set.medianWidth * 1.5);
|
||||
const bottoms = items.map((item) => item.seg.y1).sort((a, b) => a - b);
|
||||
const baseline = bottoms[Math.floor(bottoms.length / 2)] ?? 0;
|
||||
const strayMark = ({ seg, ranked }: ClassifiedSegment) =>
|
||||
RAISED_MARKS.has(ranked[0]?.char ?? "") &&
|
||||
baseline - seg.y1 <= RAISED_MARK_BASELINE_SLACK_PX;
|
||||
const mergeCandidate = (i: number) => {
|
||||
const a = items[i]!;
|
||||
const b = items[i + 1]!;
|
||||
@@ -803,7 +827,9 @@ function* mergeSplitGlyphs(
|
||||
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) {
|
||||
if (strayMark(a) || strayMark(b)) {
|
||||
floor = Math.min(floor, MERGE_STRONG_READ);
|
||||
} else if (Math.min(aScore, bScore) < MERGE_WEAK_FRAGMENT) {
|
||||
floor = Math.min(
|
||||
floor,
|
||||
Math.max(MERGE_STRONG_READ, fragmentBest - MERGE_WEAK_SLACK),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"event": "QuickScoreboardBattleLog",
|
||||
"data": {
|
||||
"lobby": "SERIES",
|
||||
"mode": "SZ",
|
||||
"stage": 14,
|
||||
"timestamp": "23/9/2026 22:03",
|
||||
"matchScores": [
|
||||
100,
|
||||
0
|
||||
],
|
||||
"povIndex": 3,
|
||||
"players": [
|
||||
{
|
||||
"name": "Gyu",
|
||||
"paint": 713,
|
||||
"ka": 9,
|
||||
"d": 0,
|
||||
"s": 3,
|
||||
"weaponId": 4015
|
||||
},
|
||||
{
|
||||
"name": "DUDE",
|
||||
"paint": 469,
|
||||
"ka": 8,
|
||||
"d": 1,
|
||||
"s": 1,
|
||||
"weaponId": 6010
|
||||
},
|
||||
{
|
||||
"name": "Muきき",
|
||||
"paint": 366,
|
||||
"ka": 7,
|
||||
"d": 1,
|
||||
"s": 1,
|
||||
"weaponId": 1111
|
||||
},
|
||||
{
|
||||
"name": "Sendou",
|
||||
"paint": 343,
|
||||
"ka": 5,
|
||||
"d": 3,
|
||||
"s": 1,
|
||||
"weaponId": 220
|
||||
},
|
||||
{
|
||||
"name": "ゆさ.",
|
||||
"paint": 580,
|
||||
"ka": 2,
|
||||
"d": 5,
|
||||
"s": 2,
|
||||
"weaponId": 30
|
||||
},
|
||||
{
|
||||
"name": "ねむねむクロロフィル",
|
||||
"paint": 547,
|
||||
"ka": 2,
|
||||
"d": 5,
|
||||
"s": 2,
|
||||
"weaponId": 1020
|
||||
},
|
||||
{
|
||||
"name": "ぽ",
|
||||
"paint": 692,
|
||||
"ka": 1,
|
||||
"d": 3,
|
||||
"s": 3,
|
||||
"weaponId": 1030
|
||||
},
|
||||
{
|
||||
"name": "エキヒヒリヨβノ、",
|
||||
"paint": 202,
|
||||
"ka": 0,
|
||||
"d": 7,
|
||||
"s": 0,
|
||||
"weaponId": 201
|
||||
}
|
||||
],
|
||||
"stageLabel": "Manta Maria"
|
||||
},
|
||||
"options": {
|
||||
"notes": "1080p capture of the lobby's quick battle log, a knockout with the POV player on the winning side. players.1.name (DUDE) once read OUDE: the soft first D lost a near-tie to O on the ink penalty, now settled by parseName's D/O corner probe. players.5.name read ねむねむクロロフィ′ι: ル's split strokes read ′ and ι, and a raised mark reaching the baseline now lets the merge through. players.7.name read エキヒヒリヨµノ、: β (a descending Greek beta) was not in the names charset. Weapons row order: Order Splatling Replica, Tenta Brella, Octobrush Nouveau, Range Blaster, Aerospray MG, Dynamo Roller, Flingza Roller, Luna Blaster Neo."
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -78,10 +78,6 @@
|
||||
"stageLabel": "Bluefin Depot"
|
||||
},
|
||||
"options": {
|
||||
"skipFields": [
|
||||
"players.1.name",
|
||||
"players.2.name"
|
||||
],
|
||||
"notes": "1080p VoD frame (InTheZone #52, t=11491.5) of the lobby's quick battle log, opened 16s after this game's results screen. players.4.stats and players.7.stats (deaths x07) once read 1: the rectified card's digits are softer than the atlas crops, so at the default binarization the 7's antialiased bar thins out and the ink penalty hands the segment to 1. The misread broke the match builder's history-screen fingerprint and the revisit became a duplicate match card. players.1.name reads R_O_B_Or and players.2.name ι′ちごシロッフ (small-size name misreads, unrelated). timestamp omitted: the screen shows 10:43PM on a 12h clock and the parse drops the PM."
|
||||
"notes": "1080p VoD frame (InTheZone #52, t=11491.5) of the lobby's quick battle log, opened 16s after this game's results screen. players.4.stats and players.7.stats (deaths x07) once read 1: the rectified card's digits are softer than the atlas crops, so at the default binarization the 7's antialiased bar thins out and the ink penalty hands the segment to 1. The misread broke the match builder's history-screen fingerprint and the revisit became a duplicate match card. players.1.name once read R_O_B_Or: the 4x3 dots were too short for the dot templates (only '_' qualified) and the soft T lost to r on the ink penalty. players.2.name once read ι′ちごシロッフ: い's split strokes read ι + ′ (a raised mark on the baseline no longer blocks the merge), and プ's ring touches the bar through one antialiased row, so the mark probe now retries on the stroke cores. timestamp omitted: the screen shows 10:43PM on a 12h clock and the parse drops the PM."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +79,6 @@
|
||||
"stageLabel": "MakoMart"
|
||||
},
|
||||
"options": {
|
||||
"skipFields": [
|
||||
"players.0.name"
|
||||
],
|
||||
"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."
|
||||
"notes": "720p stream capture with horizontal banding. players.0.name (BDAYBOY) once read BOAuBOY: the banding blurs D into O and Y into u, near-ties now settled by parseName's D/O corner probe and the Y/u bottom-stem probe. 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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ function nameCharset(): string[] {
|
||||
* few narrow glyphs shift an atlas's median width enough to change
|
||||
* wide-segment splitting, so it stays out of the death-tag charset until attested.
|
||||
*/
|
||||
const NAME_GREEK = "ια"; // ι: "Rιppιng_H", α: "◇Dαrz™" (special-symbols fixture)
|
||||
const NAME_GREEK = "ιαβ"; // ι: "Rιppιng_H", α: "◇Dαrz™" (special-symbols fixture), β: "エキヒヒリヨβノ、" (quick log manta-maria)
|
||||
|
||||
/**
|
||||
* The rest of the in-game name editor's symbol pickers (sendou.ink's
|
||||
|
||||
Reference in New Issue
Block a user