mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
764 lines
22 KiB
TypeScript
764 lines
22 KiB
TypeScript
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||
/**
|
||
* Bootstraps glyph atlases from labeled reference fixtures: slices templates
|
||
* straight out of the frames, since the same scoreboard captured through
|
||
* different pipelines (OBS virtual camera 720p, game capture 1080p) yields
|
||
* subtly different pixels — every source contributes its own crop per char and
|
||
* recognition takes the best-scoring one. build-glyph-atlas.ts then fills the
|
||
* rest of the charset from the fonts, preserving these fixture-tagged glyphs.
|
||
*
|
||
* Usage: pnpm scanner:bootstrap-atlas
|
||
* Writes SCANNER_ASSETS_DIR/glyphs/*.{png,json}
|
||
*/
|
||
import { mkdirSync, writeFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
|
||
import {
|
||
TAG_NAME_INNER,
|
||
TAG_NAME_OUTER,
|
||
TAG_NAME_TEXT_HEIGHT,
|
||
TAG_TILT_DEG,
|
||
} from "../../app/features/scanner/core/detectors/death/rois";
|
||
import {
|
||
nameRoi,
|
||
paintRoi,
|
||
ROW_CENTERS,
|
||
statRoi,
|
||
TEAM_SCORE_ROIS,
|
||
} from "../../app/features/scanner/core/detectors/scoreboard/rois";
|
||
import {
|
||
CODE_TEXT_HEIGHT,
|
||
REPLAY_CODE_ROI,
|
||
} from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois";
|
||
import type { AtlasMeta } from "../../app/features/scanner/core/glyphs";
|
||
import {
|
||
normalizeFrame,
|
||
type Roi,
|
||
toMat,
|
||
} from "../../app/features/scanner/core/image";
|
||
import { SCANNER_ASSETS_DIR } from "../../app/features/scanner/node/assets-dir";
|
||
import { FIXTURES_DIR } from "../../app/features/scanner/node/fixtures";
|
||
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
|
||
|
||
const OUT_DIR = join(SCANNER_ASSETS_DIR, "glyphs");
|
||
const BIN_THRESHOLD = 150;
|
||
|
||
/** A labeled header tag region (positions are fixture-specific: tags size to their text). */
|
||
interface HeaderSpec {
|
||
roi: Roi;
|
||
label: string;
|
||
splitHints?: number[];
|
||
threshold?: number;
|
||
}
|
||
|
||
interface Source {
|
||
frame: string;
|
||
/** row labels, top to bottom; "" skips a row (e.g. unverifiable glyphs) */
|
||
names: string[];
|
||
paints: string[];
|
||
/** per row: [ka, deaths, specials], zero-padded 2 digits as rendered */
|
||
stats: string[][];
|
||
teamScores: string[];
|
||
/**
|
||
* absolute x positions where merged segments must be cut, keyed by row —
|
||
* e.g. "Te" in Teddy renders as one segment, boundary shifting a few px per source
|
||
*/
|
||
nameSplitHints: Record<number, number[]>;
|
||
/** omit to skip header harvest for this source */
|
||
header?: { lobby: HeaderSpec; mode: HeaderSpec; stage: HeaderSpec };
|
||
}
|
||
|
||
/** Ground-truth labels for the reference match (captured through two pipelines). */
|
||
const REFERENCE = {
|
||
names: [
|
||
"Pinhole",
|
||
"Sunshield",
|
||
"Headphones",
|
||
"Now or Never Seven",
|
||
"Charms",
|
||
"Teddy",
|
||
"Circle",
|
||
"Fleece",
|
||
],
|
||
paints: ["842", "1217", "1768", "693", "1422", "980", "1204", "1053"],
|
||
stats: [
|
||
["12", "07", "02"],
|
||
["07", "06", "03"],
|
||
["04", "04", "05"],
|
||
["04", "04", "02"],
|
||
["09", "06", "05"],
|
||
["07", "07", "05"],
|
||
["08", "06", "03"],
|
||
["05", "02", "04"],
|
||
],
|
||
/**
|
||
* Team totals render outlined on the colored team box, so they get their own
|
||
* atlas. This fixture only contributes '5' and '0'; harvest keeps the first
|
||
* instance per char, so rerunning with more fixtures is additive.
|
||
*/
|
||
teamScores: ["500", "0"],
|
||
header: {
|
||
lobby: { roi: { x: 838, y: 44, w: 82, h: 26 }, label: "X Battle" },
|
||
// mode is bold, stage is regular — harvested separately so shared letters
|
||
// (e.g. both have an 'S') keep one template per face; bold text bridges
|
||
// at threshold 150, 170 separates all but "pl"
|
||
mode: {
|
||
roi: { x: 832, y: 88, w: 216, h: 40 },
|
||
label: "Splat Zones",
|
||
splitHints: [877],
|
||
threshold: 170,
|
||
},
|
||
stage: { roi: { x: 1078, y: 88, w: 160, h: 40 }, label: "Scorch Gorge" },
|
||
},
|
||
};
|
||
|
||
/** Labeled source frames; every source contributes its own crop per character. */
|
||
const SOURCES: Source[] = [
|
||
{
|
||
frame: "scoreboard/xbattle-splat-zones-ko/frame.jpg",
|
||
...REFERENCE,
|
||
nameSplitHints: { 5: [1137] },
|
||
},
|
||
{
|
||
frame: "scoreboard/xbattle-splat-zones-ko-capture/frame.png",
|
||
...REFERENCE,
|
||
nameSplitHints: { 5: [1135] },
|
||
},
|
||
{
|
||
frame: "scoreboard/private-battle-splat-zones-ko-kera/frame.png",
|
||
names: [
|
||
"Mongering",
|
||
"y0s",
|
||
"fuzzy",
|
||
"kera",
|
||
"sigma",
|
||
"Reefslider",
|
||
"Cucumber",
|
||
"tomato",
|
||
],
|
||
paints: ["503", "766", "624", "323", "500", "503", "427", "414"],
|
||
stats: [
|
||
["11", "01", "02"],
|
||
["08", "00", "03"],
|
||
["05", "02", "02"],
|
||
["05", "03", "00"],
|
||
["04", "03", "02"],
|
||
["03", "06", "01"],
|
||
["02", "05", "02"],
|
||
["01", "06", "01"],
|
||
],
|
||
teamScores: ["500", "0"],
|
||
nameSplitHints: {},
|
||
},
|
||
{
|
||
// Renders names slightly smaller than the reference: its 'T' (9x15) loses
|
||
// to the taller reference crops, and its baseline dots are homoglyphs the
|
||
// font templates can't split ('.' 4px, '・' 5px; exact crops separate them
|
||
// via the ink-coverage penalty). Only the dot/T rows are labeled.
|
||
frame: "scoreboard/robot/frame.png",
|
||
names: ["R.O.B.O.T", "", "", "", "", "Rαι×ι..・", "", ""],
|
||
paints: ["", "", "", "", "", "", "", ""],
|
||
stats: [
|
||
["", "", ""],
|
||
["", "", ""],
|
||
["", "", ""],
|
||
["", "", ""],
|
||
["", "", ""],
|
||
["", "", ""],
|
||
["", "", ""],
|
||
["", "", ""],
|
||
],
|
||
teamScores: ["", ""],
|
||
nameSplitHints: {},
|
||
},
|
||
{
|
||
frame: "scoreboard/splash-sploosh/frame.png",
|
||
// rows 2 and 7 hold kana/symbol glyphs not yet verified char-by-char
|
||
names: [
|
||
"Bocchi",
|
||
"have faith",
|
||
"",
|
||
"Florescent",
|
||
"Elis",
|
||
"Jrod_14",
|
||
"GOLD SHIP",
|
||
"",
|
||
],
|
||
paints: ["1422", "1161", "993", "1046", "1662", "1105", "799", "1155"],
|
||
stats: [
|
||
["15", "10", "06"],
|
||
["13", "06", "04"],
|
||
["06", "02", "05"],
|
||
["13", "06", "05"],
|
||
["15", "02", "06"],
|
||
["10", "10", "03"],
|
||
["02", "02", "04"],
|
||
["08", "12", "06"],
|
||
],
|
||
teamScores: ["500", "0"],
|
||
nameSplitHints: {},
|
||
},
|
||
];
|
||
|
||
/** Team totals sit on the light team-color pattern; binarize higher. */
|
||
const TEAM_BIN_THRESHOLD = 175;
|
||
|
||
const cv = await loadOpenCV();
|
||
|
||
/** Normalized grayscale of the source frame currently being harvested. */
|
||
let gray: Mat;
|
||
|
||
async function loadGray(framePath: string): Promise<Mat> {
|
||
const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath)));
|
||
const frame = normalizeFrame(srcMat);
|
||
srcMat.delete();
|
||
const out = new cv.Mat();
|
||
cv.cvtColor(frame, out, cv.COLOR_RGBA2GRAY);
|
||
frame.delete();
|
||
return out;
|
||
}
|
||
|
||
/** Green channel instead of luminance — the replay code is green-on-dark. */
|
||
async function loadGreen(framePath: string): Promise<Mat> {
|
||
const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath)));
|
||
const frame = normalizeFrame(srcMat);
|
||
srcMat.delete();
|
||
const channels = new cv.MatVector();
|
||
cv.split(frame, channels);
|
||
frame.delete();
|
||
const g = channels.get(1);
|
||
const out = new cv.Mat();
|
||
g.copyTo(out);
|
||
g.delete();
|
||
channels.delete();
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Death splash-tag name band, prepared the way death/index.ts reads it: crop
|
||
* the tilted tag, rotate it level, crop the name band, then map each pixel to
|
||
* its max-channel distance from the median banner color, normalized to 0-255.
|
||
*/
|
||
async function loadTagBand(framePath: string): Promise<Mat> {
|
||
const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath)));
|
||
const frame = normalizeFrame(srcMat);
|
||
srcMat.delete();
|
||
const rgb = new cv.Mat();
|
||
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
|
||
frame.delete();
|
||
|
||
const outerView = rgb.roi(
|
||
new cv.Rect(
|
||
TAG_NAME_OUTER.x,
|
||
TAG_NAME_OUTER.y,
|
||
TAG_NAME_OUTER.w,
|
||
TAG_NAME_OUTER.h,
|
||
),
|
||
);
|
||
const outer = new cv.Mat();
|
||
outerView.copyTo(outer);
|
||
outerView.delete();
|
||
rgb.delete();
|
||
const center = new cv.Point(outer.cols / 2, outer.rows / 2);
|
||
const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1);
|
||
const rotated = new cv.Mat();
|
||
cv.warpAffine(
|
||
outer,
|
||
rotated,
|
||
m,
|
||
new cv.Size(outer.cols, outer.rows),
|
||
cv.INTER_LINEAR,
|
||
cv.BORDER_REPLICATE,
|
||
new cv.Scalar(),
|
||
);
|
||
m.delete();
|
||
outer.delete();
|
||
const innerView = rotated.roi(
|
||
new cv.Rect(
|
||
TAG_NAME_INNER.x,
|
||
TAG_NAME_INNER.y,
|
||
TAG_NAME_INNER.w,
|
||
TAG_NAME_INNER.h,
|
||
),
|
||
);
|
||
const inner = new cv.Mat();
|
||
innerView.copyTo(inner);
|
||
innerView.delete();
|
||
rotated.delete();
|
||
|
||
const n = inner.rows * inner.cols;
|
||
const px = inner.data;
|
||
const background: number[] = [];
|
||
for (let c = 0; c < 3; c++) {
|
||
const hist = new Array<number>(256).fill(0);
|
||
for (let i = 0; i < n; i++) hist[px[i * 3 + c]!]!++;
|
||
let acc = 0;
|
||
let v = 0;
|
||
for (; v < 255; v++) {
|
||
acc += hist[v]!;
|
||
if (acc >= n / 2) break;
|
||
}
|
||
background.push(v);
|
||
}
|
||
const band = new cv.Mat(inner.rows, inner.cols, cv.CV_8UC1);
|
||
const out = band.data;
|
||
for (let i = 0; i < n; i++) {
|
||
out[i] = Math.max(
|
||
Math.abs(px[i * 3]! - background[0]!),
|
||
Math.abs(px[i * 3 + 1]! - background[1]!),
|
||
Math.abs(px[i * 3 + 2]! - background[2]!),
|
||
);
|
||
}
|
||
inner.delete();
|
||
cv.normalize(band, band, 0, 255, cv.NORM_MINMAX);
|
||
clearBorderBlobs(band, TAG_BIN_THRESHOLD);
|
||
return band;
|
||
}
|
||
|
||
/**
|
||
* Zeroes ink components touching the band border, as the detector does before
|
||
* parsing (clearBorderBlobs in death/index.ts): banner art and title-line
|
||
* slivers at the edges would join the column runs or push a tight box past it.
|
||
*/
|
||
function clearBorderBlobs(band: Mat, threshold: number): void {
|
||
const bin = new cv.Mat();
|
||
cv.threshold(band, bin, threshold, 255, cv.THRESH_BINARY);
|
||
const labels = new cv.Mat();
|
||
const stats = new cv.Mat();
|
||
const centroids = new cv.Mat();
|
||
const count = cv.connectedComponentsWithStats(
|
||
bin,
|
||
labels,
|
||
stats,
|
||
centroids,
|
||
8,
|
||
);
|
||
bin.delete();
|
||
centroids.delete();
|
||
const s = stats.data32S;
|
||
const touchesBorder = new Uint8Array(count);
|
||
for (let i = 1; i < count; i++) {
|
||
const left = s[i * 5 + cv.CC_STAT_LEFT]!;
|
||
const top = s[i * 5 + cv.CC_STAT_TOP]!;
|
||
const right = left + s[i * 5 + cv.CC_STAT_WIDTH]!;
|
||
const bottom = top + s[i * 5 + cv.CC_STAT_HEIGHT]!;
|
||
touchesBorder[i] =
|
||
left === 0 || top === 0 || right === band.cols || bottom === band.rows
|
||
? 1
|
||
: 0;
|
||
}
|
||
stats.delete();
|
||
const lab = labels.data32S;
|
||
const out = band.data;
|
||
for (let i = 0; i < out.length; i++) {
|
||
if (touchesBorder[lab[i]!]!) out[i] = 0;
|
||
}
|
||
labels.delete();
|
||
}
|
||
|
||
function columnRuns(
|
||
roi: Roi,
|
||
splitHints: number[] = [],
|
||
threshold = BIN_THRESHOLD,
|
||
mergeHints: number[] = [],
|
||
): { x0: number; x1: number }[] {
|
||
let runs: { x0: number; x1: number }[] = [];
|
||
let start = -1;
|
||
for (let x = roi.x; x < roi.x + roi.w; x++) {
|
||
let count = 0;
|
||
for (let y = roi.y; y < roi.y + roi.h; y++) {
|
||
if (gray.ucharPtr(y, x)[0]! > threshold) count++;
|
||
}
|
||
const on = count >= 1;
|
||
if (on && start < 0) start = x;
|
||
if (!on && start >= 0) {
|
||
if (x - start >= 2) runs.push({ x0: start, x1: x });
|
||
start = -1;
|
||
}
|
||
}
|
||
if (start >= 0) runs.push({ x0: start, x1: roi.x + roi.w });
|
||
// Merge hints bridge runs belonging to one glyph (multi-stroke kana like パ
|
||
// segment as two strokes): a hint x inside the gap joins the two runs.
|
||
for (const hint of mergeHints) {
|
||
const i = runs.findIndex(
|
||
(r, idx) =>
|
||
idx + 1 < runs.length && r.x1 <= hint && runs[idx + 1]!.x0 >= hint,
|
||
);
|
||
if (i >= 0) {
|
||
runs = [
|
||
...runs.slice(0, i),
|
||
{ x0: runs[i]!.x0, x1: runs[i + 1]!.x1 },
|
||
...runs.slice(i + 2),
|
||
];
|
||
}
|
||
}
|
||
return runs.flatMap((run) => {
|
||
const cuts = splitHints
|
||
.filter((h) => h > run.x0 + 1 && h < run.x1 - 1)
|
||
.sort((a, b) => a - b);
|
||
if (cuts.length === 0) return [run];
|
||
const parts: { x0: number; x1: number }[] = [];
|
||
let x0 = run.x0;
|
||
for (const cut of cuts) {
|
||
parts.push({ x0, x1: cut });
|
||
x0 = cut;
|
||
}
|
||
parts.push({ x0, x1: run.x1 });
|
||
return parts;
|
||
});
|
||
}
|
||
|
||
function cropGlyph(
|
||
run: { x0: number; x1: number },
|
||
roi: Roi,
|
||
threshold = BIN_THRESHOLD,
|
||
): Mat {
|
||
// tight vertical bounds within the run
|
||
let yMin = roi.y + roi.h;
|
||
let yMax = -1;
|
||
for (let y = roi.y; y < roi.y + roi.h; y++) {
|
||
for (let x = run.x0; x < run.x1; x++) {
|
||
if (gray.ucharPtr(y, x)[0]! > threshold) {
|
||
if (y < yMin) yMin = y;
|
||
if (y > yMax) yMax = y;
|
||
}
|
||
}
|
||
}
|
||
const pad = 1;
|
||
const rect = new cv.Rect(
|
||
run.x0 - pad,
|
||
yMin - pad,
|
||
run.x1 - run.x0 + 2 * pad,
|
||
yMax - yMin + 1 + 2 * pad,
|
||
);
|
||
const view = gray.roi(rect);
|
||
const out = new cv.Mat();
|
||
view.copyTo(out);
|
||
view.delete();
|
||
// Apply the same background masking recognizeText uses at match time, so
|
||
// templates harvested off colored backgrounds stay comparable.
|
||
const binary = new cv.Mat();
|
||
cv.threshold(out, binary, threshold, 255, cv.THRESH_BINARY);
|
||
const mask = new cv.Mat();
|
||
const kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3));
|
||
cv.dilate(binary, mask, kernel, new cv.Point(-1, -1), 2);
|
||
kernel.delete();
|
||
binary.delete();
|
||
const masked = new cv.Mat(out.rows, out.cols, cv.CV_8UC1, new cv.Scalar(0));
|
||
out.copyTo(masked, mask);
|
||
out.delete();
|
||
mask.delete();
|
||
return masked;
|
||
}
|
||
|
||
/**
|
||
* Slices a labeled text ROI into per-char crops. A Map collector keeps the
|
||
* first instance per char (digits render identically everywhere); an array
|
||
* collector keeps every instance (letters land on different subpixel phases).
|
||
*/
|
||
function harvest(
|
||
roi: Roi,
|
||
label: string,
|
||
collected: Map<string, Mat> | [string, Mat][],
|
||
splitHints: number[] = [],
|
||
threshold = BIN_THRESHOLD,
|
||
mergeHints: number[] = [],
|
||
): void {
|
||
if (label === "") return;
|
||
const chars = [...label.replace(/ /g, "")];
|
||
const runs = columnRuns(roi, splitHints, threshold, mergeHints);
|
||
if (runs.length !== chars.length) {
|
||
console.warn(
|
||
`segment mismatch for "${label}": ${runs.length} segments vs ${chars.length} chars — skipped`,
|
||
);
|
||
return;
|
||
}
|
||
runs.forEach((run, i) => {
|
||
const ch = chars[i]!;
|
||
if (Array.isArray(collected)) {
|
||
collected.push([ch, cropGlyph(run, roi, threshold)]);
|
||
} else if (!collected.has(ch)) {
|
||
collected.set(ch, cropGlyph(run, roi, threshold));
|
||
}
|
||
});
|
||
}
|
||
|
||
function writeAtlas(
|
||
name: string,
|
||
height: number,
|
||
collected: Map<string, Mat> | [string, Mat][],
|
||
): void {
|
||
const entries =
|
||
collected instanceof Map ? [...collected.entries()] : collected;
|
||
const glyphs = entries.sort(([a], [b]) => a.localeCompare(b));
|
||
if (glyphs.length === 0) {
|
||
console.warn(`${name}: nothing harvested, atlas not written`);
|
||
return;
|
||
}
|
||
const spacing = 2;
|
||
const totalW =
|
||
glyphs.reduce((acc, [, m]) => acc + m.cols, 0) +
|
||
spacing * (glyphs.length + 1);
|
||
const maxH = Math.max(...glyphs.map(([, m]) => m.rows)) + 2 * spacing;
|
||
const atlas = new cv.Mat(maxH, totalW, cv.CV_8UC1, new cv.Scalar(0));
|
||
|
||
const meta: AtlasMeta = { height, glyphs: [] };
|
||
let x = spacing;
|
||
for (const [char, m] of glyphs) {
|
||
const view = atlas.roi(new cv.Rect(x, spacing, m.cols, m.rows));
|
||
m.copyTo(view);
|
||
view.delete();
|
||
meta.glyphs.push({
|
||
char,
|
||
x,
|
||
y: spacing,
|
||
w: m.cols,
|
||
h: m.rows,
|
||
source: "fixture",
|
||
});
|
||
x += m.cols + spacing;
|
||
}
|
||
|
||
const rgba = new cv.Mat();
|
||
cv.cvtColor(atlas, rgba, cv.COLOR_GRAY2RGBA);
|
||
writePng(join(OUT_DIR, `${name}.png`), {
|
||
width: rgba.cols,
|
||
height: rgba.rows,
|
||
data: new Uint8ClampedArray(rgba.data),
|
||
});
|
||
writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2));
|
||
atlas.delete();
|
||
rgba.delete();
|
||
console.info(
|
||
`${name}: ${glyphs.length} glyphs -> ${OUT_DIR}/${name}.{png,json}`,
|
||
);
|
||
}
|
||
|
||
mkdirSync(OUT_DIR, { recursive: true });
|
||
|
||
// One crop per char per source: each source keeps its own first-instance map,
|
||
// and the atlases carry every source's crops side by side.
|
||
const nameEntries: [string, Mat][] = [];
|
||
const paintEntries: [string, Mat][] = [];
|
||
const statEntries: [string, Mat][] = [];
|
||
const teamEntries: [string, Mat][] = [];
|
||
const lobbyEntries: [string, Mat][] = [];
|
||
const lineEntries: [string, Mat][] = [];
|
||
|
||
for (const source of SOURCES) {
|
||
console.info(`harvesting ${source.frame}`);
|
||
gray = await loadGray(source.frame);
|
||
|
||
const nameGlyphs: [string, Mat][] = [];
|
||
ROW_CENTERS.forEach((cy, row) => {
|
||
// name region may include paint digits for long names; stop where digits start
|
||
harvest(
|
||
{ ...nameRoi(cy), w: 196 },
|
||
source.names[row]!,
|
||
nameGlyphs,
|
||
source.nameSplitHints[row] ?? [],
|
||
);
|
||
});
|
||
nameEntries.push(...nameGlyphs);
|
||
|
||
const paintGlyphs = new Map<string, Mat>();
|
||
ROW_CENTERS.forEach((cy, row) => {
|
||
harvest(paintRoi(cy), source.paints[row]!, paintGlyphs);
|
||
});
|
||
paintEntries.push(...paintGlyphs.entries());
|
||
|
||
const statGlyphs = new Map<string, Mat>();
|
||
ROW_CENTERS.forEach((cy, row) => {
|
||
for (const i of [0, 1, 2] as const) {
|
||
harvest(statRoi(cy, i), source.stats[row]![i]!, statGlyphs);
|
||
}
|
||
});
|
||
statEntries.push(...statGlyphs.entries());
|
||
|
||
const teamGlyphs = new Map<string, Mat>();
|
||
TEAM_SCORE_ROIS.forEach((roi, i) => {
|
||
harvest(roi, source.teamScores[i]!, teamGlyphs, [], TEAM_BIN_THRESHOLD);
|
||
});
|
||
teamEntries.push(...teamGlyphs.entries());
|
||
|
||
// Header tags: lobby line (BlitzMain ~24px), then mode (BlitzBold ~35px) and
|
||
// stage (BlitzMain ~22px) side by side.
|
||
if (source.header) {
|
||
const { lobby, mode, stage } = source.header;
|
||
const lobbyGlyphs = new Map<string, Mat>();
|
||
harvest(
|
||
lobby.roi,
|
||
lobby.label,
|
||
lobbyGlyphs,
|
||
lobby.splitHints,
|
||
lobby.threshold,
|
||
);
|
||
lobbyEntries.push(...lobbyGlyphs.entries());
|
||
|
||
const modeGlyphs = new Map<string, Mat>();
|
||
harvest(mode.roi, mode.label, modeGlyphs, mode.splitHints, mode.threshold);
|
||
const stageGlyphs = new Map<string, Mat>();
|
||
harvest(
|
||
stage.roi,
|
||
stage.label,
|
||
stageGlyphs,
|
||
stage.splitHints,
|
||
stage.threshold,
|
||
);
|
||
lineEntries.push(...modeGlyphs.entries(), ...stageGlyphs.entries());
|
||
}
|
||
|
||
gray.delete();
|
||
}
|
||
|
||
// Replay-browser fixtures: the code line renders in FOT-RowdyStd, which no
|
||
// live-scoreboard atlas covers. Every occurrence kept (array), like names.
|
||
const REPLAY_SOURCES: { frame: string; code: string }[] = [
|
||
{
|
||
frame:
|
||
"scoreboard-battle-log-replay/private-battle-splat-zones-hagglefish/frame.jpeg",
|
||
code: "R6KE-D064-3CXD-XVKL",
|
||
},
|
||
{
|
||
frame:
|
||
"scoreboard-battle-log-replay/anarchy-open-rainmaker-knockout-museum/frame.png",
|
||
code: "RWYQ-4X37-M1EL-EGGQ",
|
||
},
|
||
{
|
||
frame:
|
||
"scoreboard-battle-log-replay/private-battle-crableg-capital/frame.png",
|
||
code: "R1V4-PAHW-GGM2-PD9S",
|
||
},
|
||
// Heavily compressed stream captures: font-rendered templates lose to fixture
|
||
// crops of lookalikes here (E beat a real F by 0.1+), so the chars they cover
|
||
// need crops at this fidelity. brinewater-1411/marlin stay out as generalization checks.
|
||
{
|
||
frame:
|
||
"scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1404/frame.png",
|
||
code: "R80B-00DL-WF4X-V3CA",
|
||
},
|
||
{
|
||
frame:
|
||
"scoreboard-battle-log-replay/x-battle-rainmaker-brinewater-1416/frame.png",
|
||
code: "RUH3-3NEF-F5FY-PAJL",
|
||
},
|
||
{
|
||
frame:
|
||
"scoreboard-battle-log-replay/x-battle-rainmaker-urchin-1434/frame.png",
|
||
code: "RUCT-5HNH-XWDC-J51U",
|
||
},
|
||
];
|
||
|
||
const codeEntries: [string, Mat][] = [];
|
||
for (const source of REPLAY_SOURCES) {
|
||
console.info(`harvesting ${source.frame}`);
|
||
gray = await loadGreen(source.frame);
|
||
const codeGlyphs: [string, Mat][] = [];
|
||
harvest(REPLAY_CODE_ROI, source.code, codeGlyphs);
|
||
codeEntries.push(...codeGlyphs);
|
||
gray.delete();
|
||
}
|
||
|
||
// Death splash-tag names (BlitzBold + Rowdy kana at ~46px). The atlas is
|
||
// nominal 42 with the detector upscaling to 46 at load (see build-glyph-atlas.ts),
|
||
// so native-size crops shrink to 42 here.
|
||
const TAG_BIN_THRESHOLD = 160; // TAG_NAME_BIN_THRESHOLD in death/index.ts
|
||
const TAG_ATLAS_HEIGHT = 42;
|
||
const DEATH_TAG_SOURCES: {
|
||
frame: string;
|
||
label: string;
|
||
mergeHints?: number[];
|
||
}[] = [
|
||
{
|
||
frame: "death/fxg-supaatan-slosher/frame.png",
|
||
label: "FxG スパータン",
|
||
// パ renders as two disconnected strokes; bridge them into one crop
|
||
mergeHints: [340],
|
||
},
|
||
{
|
||
frame: "death/classic-squiffer-jp/frame.png",
|
||
label: "ごあんぜんに",
|
||
// に's bar and body never connect; bridge them into one crop
|
||
mergeHints: [431],
|
||
},
|
||
{
|
||
frame: "death/wipeout-52-gal/frame.png",
|
||
label: "さんだいめドパかげ",
|
||
// だ (1px column gap), い, ド, パ, げ all segment as disconnected
|
||
// strokes/marks; bridge each into one crop
|
||
mergeHints: [176, 236, 366, 408, 528],
|
||
},
|
||
];
|
||
|
||
const tagEntries: [string, Mat][] = [];
|
||
for (const source of DEATH_TAG_SOURCES) {
|
||
console.info(`harvesting ${source.frame}`);
|
||
gray = await loadTagBand(source.frame);
|
||
const tagGlyphs: [string, Mat][] = [];
|
||
harvest(
|
||
{ x: 0, y: 0, w: gray.cols, h: gray.rows },
|
||
source.label,
|
||
tagGlyphs,
|
||
[],
|
||
TAG_BIN_THRESHOLD,
|
||
source.mergeHints ?? [],
|
||
);
|
||
gray.delete();
|
||
const factor = TAG_ATLAS_HEIGHT / TAG_NAME_TEXT_HEIGHT;
|
||
for (const [char, mat] of tagGlyphs) {
|
||
const scaledMat = new cv.Mat();
|
||
cv.resize(mat, scaledMat, new cv.Size(0, 0), factor, factor, cv.INTER_AREA);
|
||
mat.delete();
|
||
tagEntries.push([char, scaledMat]);
|
||
}
|
||
}
|
||
|
||
// JA death-message lines (condensed Kurokane/Rowdy blend at ~40px; native-size
|
||
// atlas, matched unscaled by the JA read path). ROIs are fixture-specific tight
|
||
// boxes around each line so scene ink outside the burst stays out of the runs.
|
||
const DEATH_JA_BIN_THRESHOLD = 190; // SPLAT_TEXT_BIN_THRESHOLD in death/rois.ts
|
||
const DEATH_JA_SOURCES: {
|
||
frame: string;
|
||
lines: { roi: Roi; label: string; mergeHints?: number[] }[];
|
||
}[] = [
|
||
{
|
||
frame: "death/classic-squiffer-jp/frame.png",
|
||
lines: [
|
||
// ッ renders as two disconnected strokes; bridge them into one crop
|
||
{
|
||
roi: { x: 825, y: 358, w: 275, h: 58 },
|
||
label: "スクイックリンα で",
|
||
mergeHints: [917],
|
||
},
|
||
{ roi: { x: 880, y: 410, w: 155, h: 56 }, label: "やられた!" },
|
||
],
|
||
},
|
||
];
|
||
|
||
const deathJaEntries: [string, Mat][] = [];
|
||
for (const source of DEATH_JA_SOURCES) {
|
||
console.info(`harvesting ${source.frame}`);
|
||
gray = await loadGray(source.frame);
|
||
for (const line of source.lines) {
|
||
harvest(
|
||
line.roi,
|
||
line.label,
|
||
deathJaEntries,
|
||
[],
|
||
DEATH_JA_BIN_THRESHOLD,
|
||
line.mergeHints ?? [],
|
||
);
|
||
}
|
||
gray.delete();
|
||
}
|
||
|
||
writeAtlas("scoreboard-names", 22, nameEntries);
|
||
writeAtlas("scoreboard-paint-digits", 28, paintEntries);
|
||
writeAtlas("scoreboard-stat-digits", 17, statEntries);
|
||
writeAtlas("scoreboard-team-digits", 33, teamEntries);
|
||
writeAtlas("scoreboard-header-lobby", 19, lobbyEntries);
|
||
writeAtlas("scoreboard-header-line", 24, lineEntries);
|
||
writeAtlas("scoreboard-replay-code", CODE_TEXT_HEIGHT, codeEntries);
|
||
writeAtlas("death-tag-name", TAG_ATLAS_HEIGHT, tagEntries);
|
||
writeAtlas("death-weapon-ja", 40, deathJaEntries);
|