mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Scanner WIP (#3239)
This commit is contained in:
@@ -17,6 +17,7 @@ import * as SkillRepository from "~/features/mmr/SkillRepository.server";
|
||||
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
|
||||
import * as ScrimMapListRepository from "~/features/scrims/ScrimMapListRepository.server";
|
||||
import * as ScrimMapRepository from "~/features/scrims/ScrimMapRepository.server";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
@@ -417,6 +418,87 @@ export function buildCases(fx: Fixtures): {
|
||||
}),
|
||||
);
|
||||
|
||||
// ScannerIngestRepository
|
||||
add(
|
||||
"ScannerIngestRepository.gamesPlayedByUserInTournament",
|
||||
fx.scannerIngest,
|
||||
(ingest) =>
|
||||
ScannerIngestRepository.gamesPlayedByUserInTournament({
|
||||
userId: ingest.povUserId,
|
||||
tournamentId: ingest.tournamentId,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.gamesPlayedByUserSince",
|
||||
fx.scannerIngest,
|
||||
(ingest) =>
|
||||
ScannerIngestRepository.gamesPlayedByUserSince({
|
||||
userId: ingest.povUserId,
|
||||
since: ingest.sinceTimestamp,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.castedGamesInTournament",
|
||||
fx.castedTournamentId,
|
||||
(tournamentId) =>
|
||||
ScannerIngestRepository.castedGamesInTournament(tournamentId),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.gamesInGroupMatch",
|
||||
fx.heavyGroupMatchId,
|
||||
(groupMatchId) => ScannerIngestRepository.gamesInGroupMatch(groupMatchId),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.sendouqGamesPlayedByUserSince",
|
||||
fx.scannerIngestSendouq,
|
||||
(sendouq) =>
|
||||
ScannerIngestRepository.sendouqGamesPlayedByUserSince({
|
||||
userId: sendouq.userId,
|
||||
since: sendouq.sinceTimestamp,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.tournamentActivityAt",
|
||||
fx.scannerIngest,
|
||||
(ingest) =>
|
||||
ScannerIngestRepository.tournamentActivityAt({
|
||||
userId: ingest.povUserId,
|
||||
at: ingest.atMs,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.groupMatchIdAt",
|
||||
fx.scannerIngestSendouq,
|
||||
(sendouq) =>
|
||||
ScannerIngestRepository.groupMatchIdAt({
|
||||
userId: sendouq.userId,
|
||||
at: sendouq.atMs,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.staffTournamentIdsAt",
|
||||
both(fx.calendarAuthorId, fx.scannerIngest),
|
||||
([userId, ingest]) =>
|
||||
ScannerIngestRepository.staffTournamentIdsAt({
|
||||
userId,
|
||||
at: ingest.atMs,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.findScoreboardsByTournamentMatchId",
|
||||
fx.heavyTournamentMatchId,
|
||||
(tournamentMatchId) =>
|
||||
ScannerIngestRepository.findScoreboardsByTournamentMatchId(
|
||||
tournamentMatchId,
|
||||
),
|
||||
);
|
||||
add(
|
||||
"ScannerIngestRepository.gamesInTournamentMatch",
|
||||
fx.heavyTournamentMatchId,
|
||||
(tournamentMatchId) =>
|
||||
ScannerIngestRepository.gamesInTournamentMatch(tournamentMatchId),
|
||||
);
|
||||
|
||||
// ScrimMapListRepository
|
||||
add(
|
||||
"ScrimMapListRepository.findMapListsByScrimPostId",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sub } from "date-fns";
|
||||
import { sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
|
||||
@@ -97,6 +98,18 @@ export interface Fixtures {
|
||||
apiTokenUserId: number | null;
|
||||
logInLinkCode: string | null;
|
||||
modNoteId: number | null;
|
||||
scannerIngest: {
|
||||
povUserId: number;
|
||||
tournamentId: number;
|
||||
atMs: number;
|
||||
sinceTimestamp: number;
|
||||
} | null;
|
||||
scannerIngestSendouq: {
|
||||
userId: number;
|
||||
atMs: number;
|
||||
sinceTimestamp: number;
|
||||
} | null;
|
||||
castedTournamentId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +183,9 @@ export async function resolveFixtures(): Promise<Fixtures> {
|
||||
apiTokenUserId: await resolveApiTokenUserId(),
|
||||
logInLinkCode: await resolveLogInLinkCode(),
|
||||
modNoteId: await resolveModNoteId(),
|
||||
scannerIngest: await resolveScannerIngest(),
|
||||
scannerIngestSendouq: await resolveScannerIngestSendouq(),
|
||||
castedTournamentId: await resolveCastedTournamentId(),
|
||||
};
|
||||
|
||||
const nullFixtures = Object.entries(fixtures)
|
||||
@@ -1132,3 +1148,113 @@ async function resolveModNoteId() {
|
||||
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
const SCANNER_INGEST_SINCE_WINDOW_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
async function resolveScannerIngest() {
|
||||
const participantRow = await db
|
||||
.selectFrom("TournamentMatchGameResultParticipant")
|
||||
.select(({ fn }) => ["userId", fn.countAll<number>().as("count")])
|
||||
.groupBy("userId")
|
||||
.orderBy("count", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!participantRow) return null;
|
||||
|
||||
const latestGame = await db
|
||||
.selectFrom("TournamentMatchGameResultParticipant")
|
||||
.innerJoin(
|
||||
"TournamentMatchGameResult",
|
||||
"TournamentMatchGameResult.id",
|
||||
"TournamentMatchGameResultParticipant.matchGameResultId",
|
||||
)
|
||||
.innerJoin(
|
||||
"TournamentMatch",
|
||||
"TournamentMatch.id",
|
||||
"TournamentMatchGameResult.matchId",
|
||||
)
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.select([
|
||||
"TournamentMatchGameResult.createdAt",
|
||||
"TournamentStage.tournamentId",
|
||||
])
|
||||
.where(
|
||||
"TournamentMatchGameResultParticipant.userId",
|
||||
"=",
|
||||
participantRow.userId,
|
||||
)
|
||||
.orderBy("TournamentMatchGameResult.createdAt", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!latestGame) return null;
|
||||
|
||||
return {
|
||||
povUserId: participantRow.userId,
|
||||
tournamentId: latestGame.tournamentId,
|
||||
atMs: latestGame.createdAt * 1000,
|
||||
sinceTimestamp: latestGame.createdAt - SCANNER_INGEST_SINCE_WINDOW_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveScannerIngestSendouq() {
|
||||
const memberRow = await db
|
||||
.selectFrom("GroupMember")
|
||||
.select(({ fn }) => ["userId", fn.countAll<number>().as("count")])
|
||||
.groupBy("userId")
|
||||
.orderBy("count", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!memberRow) return null;
|
||||
|
||||
const latestMatch = await db
|
||||
.selectFrom("GroupMatch")
|
||||
.select("GroupMatch.createdAt")
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.where("GroupMember.userId", "=", memberRow.userId)
|
||||
.where((memberEb) =>
|
||||
memberEb.or([
|
||||
memberEb(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
memberEb.ref("GroupMatch.alphaGroupId"),
|
||||
),
|
||||
memberEb(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
memberEb.ref("GroupMatch.bravoGroupId"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy("GroupMatch.createdAt", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!latestMatch) return null;
|
||||
|
||||
return {
|
||||
userId: memberRow.userId,
|
||||
atMs: latestMatch.createdAt * 1000,
|
||||
sinceTimestamp: latestMatch.createdAt - SCANNER_INGEST_SINCE_WINDOW_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCastedTournamentId() {
|
||||
const row = await db
|
||||
.selectFrom("Tournament")
|
||||
.select("id")
|
||||
.where("castedMatchesInfo", "is not", null)
|
||||
.orderBy(sql`length("castedMatchesInfo")`, "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
@@ -24,8 +25,13 @@ export function setup() {
|
||||
|
||||
/**
|
||||
* Ensures the SQLite file at `dbPath` has every migration applied: creates it
|
||||
* if missing, applies pending migrations, and rebuilds it from scratch if it
|
||||
* contains a migration that no longer exists on disk.
|
||||
* if missing, applies pending migrations, and rebuilds it from scratch if an
|
||||
* already applied migration no longer exists on disk or its contents changed
|
||||
* since it was applied.
|
||||
*
|
||||
* Rebuilding on changed contents matters because a branch edits the migration
|
||||
* it added rather than stacking a new one, so the file name kysely tracks stays
|
||||
* the same while the schema it produces does not.
|
||||
*/
|
||||
export function ensureMigratedDb(dbPath: string) {
|
||||
const resolvedPath = path.resolve(ROOT_DIR, dbPath);
|
||||
@@ -39,7 +45,9 @@ export function ensureMigratedDb(dbPath: string) {
|
||||
const onDisk = migrationFilesOnDisk();
|
||||
|
||||
const hasDrift =
|
||||
applied === null || applied.some((name) => !onDisk.has(name));
|
||||
applied === null ||
|
||||
applied.some((name) => !onDisk.has(name)) ||
|
||||
readContentsMarker(resolvedPath) !== migrationContentsHash();
|
||||
if (hasDrift) {
|
||||
deleteDbFiles(resolvedPath);
|
||||
migrateUp(resolvedPath);
|
||||
@@ -53,6 +61,29 @@ export function ensureMigratedDb(dbPath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fingerprint of every migration's name and contents. */
|
||||
function migrationContentsHash() {
|
||||
const hash = createHash("sha256");
|
||||
for (const file of fs.readdirSync(MIGRATIONS_DIR).sort()) {
|
||||
hash.update(file);
|
||||
hash.update(fs.readFileSync(path.join(MIGRATIONS_DIR, file)));
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
/** Sidecar recording the migration contents the database was built from. */
|
||||
function contentsMarkerPath(dbPath: string) {
|
||||
return `${dbPath}.migrations`;
|
||||
}
|
||||
|
||||
function readContentsMarker(dbPath: string) {
|
||||
try {
|
||||
return fs.readFileSync(contentsMarkerPath(dbPath), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function migrationFilesOnDisk() {
|
||||
return new Set(
|
||||
fs
|
||||
@@ -90,6 +121,7 @@ function deleteDbFiles(dbPath: string) {
|
||||
for (const suffix of ["", "-shm", "-wal"]) {
|
||||
fs.rmSync(`${dbPath}${suffix}`, { force: true });
|
||||
}
|
||||
fs.rmSync(contentsMarkerPath(dbPath), { force: true });
|
||||
}
|
||||
|
||||
function migrateUp(dbPath: string) {
|
||||
@@ -98,4 +130,5 @@ function migrateUp(dbPath: string) {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, DB_PATH: dbPath },
|
||||
});
|
||||
fs.writeFileSync(contentsMarkerPath(dbPath), migrationContentsHash());
|
||||
}
|
||||
|
||||
776
scripts/scanner/bootstrap-atlas-from-fixture.ts
Normal file
776
scripts/scanner/bootstrap-atlas-from-fixture.ts
Normal file
@@ -0,0 +1,776 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* Bootstrap glyph atlases from the labeled reference fixtures: slices glyph
|
||||
* templates straight out of the reference 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 character and recognition takes the best-scoring one.
|
||||
* build-glyph-atlas.ts then fills in the rest of the charset from the
|
||||
* fonts, preserving these fixture-tagged glyphs.
|
||||
*
|
||||
* Usage: pnpm scanner:bootstrap-atlas
|
||||
* Writes public/assets/glyphs/scoreboard-{names,paint-digits,stat-digits}.{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[];
|
||||
/**
|
||||
* Split hints are absolute x positions where merged segments must be cut
|
||||
* (keyed by row index) — e.g. the "Te" in Teddy renders as one segment,
|
||||
* with the exact boundary shifting a couple of pixels between sources.
|
||||
*/
|
||||
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 with an outline on the colored team box, so they get
|
||||
* their own atlas rather than reusing paint digits. This fixture only
|
||||
* contributes '5' and '0' — later fixtures extend the set (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: {},
|
||||
},
|
||||
{
|
||||
// This capture renders names slightly smaller than the reference: its
|
||||
// 'T' (9x15) loses to the taller reference crops, and its baseline dots
|
||||
// are pure homoglyphs the font templates can't split — '.' renders 4px,
|
||||
// '・' 5px, so exact crops separate them via the ink-coverage penalty.
|
||||
// Only the dot/T rows are labeled; the rest add nothing new.
|
||||
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 the detector reads it
|
||||
* (see src/core/detectors/death/index.ts): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero out ink components touching the band border, exactly as the
|
||||
* detector does before parsing (see clearBorderBlobs in death/index.ts):
|
||||
* banner art and title-line slivers enter at the band edges, and without
|
||||
* this they join the column runs (mismatching the label) or push a crop's
|
||||
* tight box past the band edge.
|
||||
*/
|
||||
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 that belong to one glyph (multi-stroke kana
|
||||
// like パ segments as two strokes): a hint x inside the gap between two
|
||||
// consecutive runs joins them into a single crop.
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 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, so each occurrence is a distinct, equally-authoritative exemplar.
|
||||
*/
|
||||
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. Letters land on different subpixel phases,
|
||||
// so keep every occurrence (array collector), 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 on these (E beat a real F by 0.1+), so the
|
||||
// chars they cover (8 B F J N T U 5 among them) 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 the builder
|
||||
// comment in scripts/scanner/build-glyph-atlas.ts), so native-size crops shrink to
|
||||
// 42 here and come back to native after the load-time upscale.
|
||||
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; the
|
||||
// atlas is native-size, matched unscaled by the detector's 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);
|
||||
481
scripts/scanner/build-glyph-atlas.ts
Normal file
481
scripts/scanner/build-glyph-atlas.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* Build glyph atlases by rendering the Splatoon fonts at the exact
|
||||
* scoreboard render sizes, calibrated against the reference fixture:
|
||||
*
|
||||
* paint digits BlitzMain 34px (tight '0' height ~29px at 1080p)
|
||||
* stat digits BlitzMain 20px (~17px)
|
||||
* team digits BlitzBold 36px (~28px — team totals use the bold face,
|
||||
* which is why they can't reuse paint glyphs)
|
||||
* names BlitzMain 20px (cap height ~17px), full charset:
|
||||
* ASCII + Latin-1 + kana + common symbols (~330 glyphs)
|
||||
*
|
||||
* The fonts ship with the game and are not committed — drop them into
|
||||
* assets/fonts/ (see README). Fixture-crop bootstrapping
|
||||
* (scripts/scanner/bootstrap-atlas-from-fixture.ts) remains as a cross-check and as
|
||||
* the fallback when the fonts are unavailable.
|
||||
*
|
||||
* Usage: pnpm scanner:build-glyph-atlas
|
||||
* Writes public/assets/glyphs/scoreboard-*.{png,json}
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
|
||||
import {
|
||||
DEATH_MESSAGE_TEMPLATES,
|
||||
LOCALIZED_WEAPON_NAMES,
|
||||
} from "../../app/features/scanner/core/detectors/death/localized-messages";
|
||||
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
|
||||
import type { AtlasMeta } from "../../app/features/scanner/core/glyphs";
|
||||
import {
|
||||
ALL_LOBBY_ENTRIES,
|
||||
ALL_MODE_ENTRIES,
|
||||
ALL_MODE_LABELS,
|
||||
ALL_STAGE_ENTRIES,
|
||||
RESULT_TAG_ENTRIES,
|
||||
} from "../../app/features/scanner/core/localized";
|
||||
import { SCANNER_ASSETS_DIR } from "../../app/features/scanner/node/assets-dir";
|
||||
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
|
||||
import { readFontCoverage } from "./otf-cmap";
|
||||
|
||||
/**
|
||||
* Atlases are hybrids: glyphs harvested from labeled fixtures
|
||||
* (scripts/scanner/bootstrap-atlas-from-fixture.ts, tagged source:"fixture") are exact
|
||||
* in-game pixels and are preserved across rebuilds; font-rendered glyphs
|
||||
* fill in the rest of the charset. Recognition takes the best-scoring glyph,
|
||||
* so fixture crops dominate wherever they exist.
|
||||
*/
|
||||
|
||||
const FONTS_DIR = new URL("../../assets/fonts", import.meta.url).pathname;
|
||||
const OUT_DIR = join(SCANNER_ASSETS_DIR, "glyphs");
|
||||
|
||||
const FONT_FILES = {
|
||||
BlitzMain: "BlitzMain.otf",
|
||||
BlitzBold: "BlitzBold.otf",
|
||||
/** replay-browser code line and VICTORY/DEFEAT panel tags */
|
||||
Rowdy: "FOT-RowdyStd-EB.otf",
|
||||
/** JA death message (together with Rowdy — see death-weapon-ja) */
|
||||
Kurokane: "FOT-KurokaneStd-EB.otf",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Which codepoints each font actually maps: canvas silently substitutes a
|
||||
* system font for the rest (the Blitz cuts have no kanji/hangul/hanzi), so
|
||||
* charsets are filtered through this before rendering — the CJK languages'
|
||||
* ideographs drop out of the localized charsets instead of baking
|
||||
* wrong-font glyphs into the atlases.
|
||||
*/
|
||||
const fontCoverage: Record<string, (codepoint: number) => boolean> = {};
|
||||
|
||||
for (const [family, file] of Object.entries(FONT_FILES)) {
|
||||
const path = join(FONTS_DIR, file);
|
||||
if (!existsSync(path)) {
|
||||
console.error(
|
||||
[
|
||||
`font not found: ${path}`,
|
||||
"",
|
||||
"The Splatoon fonts are proprietary and not committed. Get",
|
||||
"Decrypted/BlitzMain.otf and Decrypted/BlitzBold.otf (e.g. from the",
|
||||
"splatoon3-fonts repo) into assets/fonts/, or fall back to",
|
||||
" pnpm scanner:bootstrap-atlas",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
GlobalFonts.registerFromPath(path, family);
|
||||
fontCoverage[family] = readFontCoverage(path);
|
||||
}
|
||||
|
||||
const DIGITS = "0123456789";
|
||||
|
||||
function nameCharset(): string[] {
|
||||
const chars: string[] = [];
|
||||
for (let c = 33; c <= 126; c++) chars.push(String.fromCharCode(c)); // ASCII
|
||||
for (let c = 0xa1; c <= 0xff; c++) chars.push(String.fromCharCode(c)); // Latin-1
|
||||
for (let c = 0x3041; c <= 0x3096; c++) chars.push(String.fromCharCode(c)); // hiragana
|
||||
for (let c = 0x30a1; c <= 0x30fa; c++) chars.push(String.fromCharCode(c)); // katakana
|
||||
chars.push("・", "ー", "、", "。", "「", "」");
|
||||
chars.push("★", "☆", "●", "♪"); // name decorations
|
||||
return [...new Set(chars)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Greek letters players stylize scoreboard names with, added per attested
|
||||
* fixture need only ("Rιppιng_H"): most of the block are homoglyphs of latin
|
||||
* or kana at capture fidelity (η~n, ε~c, Γ~か strokes...) and displace
|
||||
* correct matches on ranking noise, so it is not included wholesale — and
|
||||
* even a few extra narrow glyphs shift an atlas's median width enough to
|
||||
* change wide-segment splitting, so it stays out of the death-tag charset
|
||||
* until a death fixture attests it.
|
||||
*/
|
||||
const NAME_GREEK = "ια"; // ι: "Rιppιng_H", α: "◇Dαrz™" (special-symbols fixture)
|
||||
|
||||
/**
|
||||
* The rest of the in-game name editor's symbol pickers (sendou.ink's
|
||||
* IN_GAME_NAME_CHARACTER_CATEGORIES: "symbols" + "cjk-symbols"), minus what
|
||||
* nameCharset() already carries via ASCII/Latin-1/kana and minus chars the
|
||||
* Blitz cmap doesn't map (ˊˋ𝑓⁀⚪⚫◻◼⍑ — canvas would render a system-font
|
||||
* substitute; nameSymbols() re-checks at build time). "•" stays out too:
|
||||
* BlitzMain's own bullet is a 4px dot, the on-screen full-size circle comes
|
||||
* from "●" via RENDER_ALIASES. The tilde is the fullwidth "~" (U+FF5E) only
|
||||
* — the wave dash "〜" (U+301C) is a pixel-identical homoglyph that would
|
||||
* duel it on ranking noise, and FF5E is the form the fixture labels attest.
|
||||
* Like NAME_GREEK, scoreboard-names only (not death-tag) until attested.
|
||||
*/
|
||||
const NAME_SYMBOLS =
|
||||
"′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞√∀⊂⊃∴∵∂№♭♀♂◎◇◆△▲▽▼†※™" + "『』【】〈〉《》〔〕々〆〇〃~";
|
||||
|
||||
/**
|
||||
* Render the key char's glyph but emit it as the value char: in-game names
|
||||
* show "•" as the full-size filled circle (BlitzMain's own "•" is a 4px dot
|
||||
* that never appears on screen), and fixture labels write it as "•".
|
||||
*/
|
||||
const RENDER_ALIASES: Record<string, string> = { "●": "•" };
|
||||
|
||||
interface GlyphBitmap {
|
||||
char: string;
|
||||
w: number;
|
||||
h: number;
|
||||
/** grayscale, white-on-black (alpha coverage) */
|
||||
data: Uint8Array;
|
||||
source: "fixture" | "font";
|
||||
}
|
||||
|
||||
/** Carry fixture-harvested glyphs over from the existing atlas, if any. */
|
||||
async function readFixtureGlyphs(name: string): Promise<GlyphBitmap[]> {
|
||||
const pngPath = join(OUT_DIR, `${name}.png`);
|
||||
const jsonPath = join(OUT_DIR, `${name}.json`);
|
||||
if (!existsSync(pngPath) || !existsSync(jsonPath)) return [];
|
||||
const meta = JSON.parse(readFileSync(jsonPath, "utf8")) as AtlasMeta;
|
||||
const png = await readImage(pngPath);
|
||||
return meta.glyphs
|
||||
.filter((g) => g.source === "fixture")
|
||||
.map((g) => {
|
||||
const data = new Uint8Array(g.w * g.h);
|
||||
for (let y = 0; y < g.h; y++) {
|
||||
for (let x = 0; x < g.w; x++) {
|
||||
data[y * g.w + x] =
|
||||
png.data[((g.y + y) * png.width + (g.x + x)) * 4]!;
|
||||
}
|
||||
}
|
||||
return { char: g.char, w: g.w, h: g.h, data, source: "fixture" as const };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one glyph and tight-crop it via the alpha channel. xScale < 1
|
||||
* condenses the glyph horizontally (the JA death message renders its font
|
||||
* squeezed to ~3/4 width in-game).
|
||||
*/
|
||||
function renderGlyph(
|
||||
family: string,
|
||||
px: number,
|
||||
char: string,
|
||||
xScale = 1,
|
||||
): GlyphBitmap | null {
|
||||
const size = px * 4;
|
||||
const canvas = createCanvas(size, size);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "white";
|
||||
ctx.font = `${px}px ${family}`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.scale(xScale, 1);
|
||||
ctx.fillText(char, px / xScale, size / 2);
|
||||
const img = ctx.getImageData(0, 0, size, size);
|
||||
let x0 = size;
|
||||
let x1 = -1;
|
||||
let y0 = size;
|
||||
let y1 = -1;
|
||||
let ink = 0;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
if (img.data[(y * size + x) * 4 + 3]! > 128) {
|
||||
if (x < x0) x0 = x;
|
||||
if (x > x1) x1 = x;
|
||||
if (y < y0) y0 = y;
|
||||
if (y > y1) y1 = y;
|
||||
ink++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (x1 < 0 || ink < 4) return null;
|
||||
const pad = 1;
|
||||
const w = x1 - x0 + 1 + 2 * pad;
|
||||
const h = y1 - y0 + 1 + 2 * pad;
|
||||
const data = new Uint8Array(w * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const sx = x0 - pad + x;
|
||||
const sy = y0 - pad + y;
|
||||
if (sx < 0 || sy < 0 || sx >= size || sy >= size) continue;
|
||||
data[y * w + x] = img.data[(sy * size + sx) * 4 + 3]!;
|
||||
}
|
||||
}
|
||||
return { char, w, h, data, source: "font" };
|
||||
}
|
||||
|
||||
/** Pack glyphs into rows (wrapping at maxWidth) and write PNG + JSON. */
|
||||
function writeAtlas(name: string, height: number, glyphs: GlyphBitmap[]): void {
|
||||
const spacing = 2;
|
||||
const maxWidth = 1600;
|
||||
const meta: AtlasMeta = { height, glyphs: [] };
|
||||
let x = spacing;
|
||||
let y = spacing;
|
||||
let rowH = 0;
|
||||
let atlasW = 0;
|
||||
for (const g of glyphs) {
|
||||
if (x + g.w + spacing > maxWidth) {
|
||||
x = spacing;
|
||||
y += rowH + spacing;
|
||||
rowH = 0;
|
||||
}
|
||||
meta.glyphs.push({ char: g.char, x, y, w: g.w, h: g.h, source: g.source });
|
||||
x += g.w + spacing;
|
||||
rowH = Math.max(rowH, g.h);
|
||||
atlasW = Math.max(atlasW, x);
|
||||
}
|
||||
const atlasH = y + rowH + spacing;
|
||||
|
||||
const pixels = new Uint8ClampedArray(atlasW * atlasH * 4);
|
||||
for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255;
|
||||
meta.glyphs.forEach((m, i) => {
|
||||
const g = glyphs[i]!;
|
||||
for (let gy = 0; gy < g.h; gy++) {
|
||||
for (let gx = 0; gx < g.w; gx++) {
|
||||
const v = g.data[gy * g.w + gx]!;
|
||||
const o = ((m.y + gy) * atlasW + (m.x + gx)) * 4;
|
||||
pixels[o] = v;
|
||||
pixels[o + 1] = v;
|
||||
pixels[o + 2] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
writePng(join(OUT_DIR, `${name}.png`), {
|
||||
width: atlasW,
|
||||
height: atlasH,
|
||||
data: pixels,
|
||||
});
|
||||
writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2));
|
||||
console.info(
|
||||
`${name}: ${glyphs.length} glyphs (${atlasW}x${atlasH}) -> ${name}.{png,json}`,
|
||||
);
|
||||
}
|
||||
|
||||
interface AtlasPart {
|
||||
family: string;
|
||||
/** in-game render size varies by ±1px; multiple template sizes per char */
|
||||
pxs: number[];
|
||||
chars: string[];
|
||||
/** horizontal condensation applied in-game (default 1 = none) */
|
||||
xScale?: number;
|
||||
}
|
||||
|
||||
async function build(
|
||||
name: string,
|
||||
height: number,
|
||||
parts: AtlasPart[],
|
||||
): Promise<void> {
|
||||
const glyphs: GlyphBitmap[] = await readFixtureGlyphs(name);
|
||||
const fixtureCount = glyphs.length;
|
||||
const missing: string[] = [];
|
||||
for (const { family, pxs, chars, xScale } of parts) {
|
||||
for (const ch of chars) {
|
||||
let found = false;
|
||||
for (const px of pxs) {
|
||||
const g = renderGlyph(family, px, ch, xScale ?? 1);
|
||||
if (g) {
|
||||
glyphs.push({ ...g, char: RENDER_ALIASES[ch] ?? ch });
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) missing.push(ch);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.info(
|
||||
`${name}: ${missing.length} chars not renderable, skipped: ${missing.join("")}`,
|
||||
);
|
||||
}
|
||||
console.info(
|
||||
`${name}: carrying over ${fixtureCount} fixture-harvested glyphs`,
|
||||
);
|
||||
writeAtlas(name, height, glyphs);
|
||||
}
|
||||
|
||||
/** NAME_SYMBOLS chars the font actually maps (see the constant's comment). */
|
||||
function nameSymbols(family: string): string[] {
|
||||
const covers = fontCoverage[family]!;
|
||||
return [...NAME_SYMBOLS].filter((ch) => covers(ch.codePointAt(0)!));
|
||||
}
|
||||
|
||||
/** Unique non-space characters across a set of known strings. */
|
||||
function charsOf(entries: readonly string[]): string[] {
|
||||
return [...new Set(entries.flatMap((e) => [...e.replace(/ /g, "")]))];
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
await build("scoreboard-paint-digits", 29, [
|
||||
{ family: "BlitzMain", pxs: [34], chars: [...DIGITS] },
|
||||
]);
|
||||
await build("scoreboard-stat-digits", 17, [
|
||||
{ family: "BlitzMain", pxs: [20], chars: [...DIGITS] },
|
||||
]);
|
||||
await build("scoreboard-team-digits", 28, [
|
||||
{ family: "BlitzBold", pxs: [36], chars: [...DIGITS] },
|
||||
]);
|
||||
await build("scoreboard-names", 17, [
|
||||
{
|
||||
family: "BlitzMain",
|
||||
pxs: [19, 20],
|
||||
chars: [...nameCharset(), ...NAME_GREEK, ...nameSymbols("BlitzMain")],
|
||||
},
|
||||
]);
|
||||
/**
|
||||
* Localized closed-set charsets (all 14 game languages; the canonical
|
||||
* English strings are included by construction), restricted to the Latin
|
||||
* scripts (< U+0250) the fixtures attest: like the Greek block in the name
|
||||
* charset, wholesale Cyrillic/kana/ideograph glyphs displace Latin matches
|
||||
* on ranking noise (adding them regressed the English fixtures), so the
|
||||
* non-Latin languages' entries stay in the closed sets — ready to snap —
|
||||
* but their glyphs wait for fixtures to tune against. Chars the font
|
||||
* doesn't map are dropped too: canvas would silently render a system-font
|
||||
* substitute into the atlas.
|
||||
*/
|
||||
function localizedChars(texts: readonly string[], family: string): string[] {
|
||||
const covers = fontCoverage[family]!;
|
||||
return charsOf(texts).filter((ch) => {
|
||||
const cp = ch.codePointAt(0)!;
|
||||
return cp < 0x250 && covers(cp);
|
||||
});
|
||||
}
|
||||
|
||||
const lobbyTexts = ALL_LOBBY_ENTRIES.map((e) => e.text);
|
||||
const modeTexts = ALL_MODE_ENTRIES.map((e) => e.text);
|
||||
const stageTexts = ALL_STAGE_ENTRIES.map((e) => e.text);
|
||||
const resultTexts = RESULT_TAG_ENTRIES.map((e) => e.text);
|
||||
|
||||
// header: lobby tag is BlitzMain ~24px; the mode/stage line mixes bold mode
|
||||
// text with regular stage text, so its atlas carries both faces
|
||||
await build("scoreboard-header-lobby", 19, [
|
||||
{
|
||||
family: "BlitzMain",
|
||||
pxs: [23, 24],
|
||||
chars: localizedChars(lobbyTexts, "BlitzMain"),
|
||||
},
|
||||
]);
|
||||
await build("scoreboard-header-line", 24, [
|
||||
{
|
||||
family: "BlitzBold",
|
||||
pxs: [34, 35],
|
||||
chars: localizedChars(modeTexts, "BlitzBold"),
|
||||
},
|
||||
{
|
||||
family: "BlitzMain",
|
||||
pxs: [22, 23],
|
||||
chars: localizedChars(stageTexts, "BlitzMain"),
|
||||
},
|
||||
]);
|
||||
// replay browser: the code line and the VICTORY/DEFEAT panel tags render in
|
||||
// FOT-RowdyStd-EB (tight height 25px at ~32px, 30px at ~38px)
|
||||
await build("scoreboard-replay-code", 25, [
|
||||
{
|
||||
family: "Rowdy",
|
||||
pxs: [31, 32, 33],
|
||||
chars: [...DIGITS, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "-"],
|
||||
},
|
||||
]);
|
||||
await build("scoreboard-replay-result", 30, [
|
||||
{
|
||||
family: "Rowdy",
|
||||
pxs: [37, 38],
|
||||
chars: localizedChars(resultTexts, "Rowdy"),
|
||||
},
|
||||
]);
|
||||
// map-start intro splash: the big mode title on the center splat is
|
||||
// BlitzBold (~76px tight caps at 1080p; a px sweep against the fixture reads
|
||||
// best at 99-101), the stage name bottom-right is BlitzMain (~40px tight;
|
||||
// 46-48px render). The constant "MODE" label ("Kampfart", ...) is BlitzMain
|
||||
// too and is read with the stage atlas rescaled to its ~48px height, so its
|
||||
// chars ride along.
|
||||
await build("map-start-mode", 76, [
|
||||
{
|
||||
family: "BlitzBold",
|
||||
pxs: [99, 100, 101],
|
||||
chars: localizedChars(modeTexts, "BlitzBold"),
|
||||
},
|
||||
]);
|
||||
await build("map-start-stage", 40, [
|
||||
{
|
||||
family: "BlitzMain",
|
||||
pxs: [46, 47, 48],
|
||||
chars: localizedChars([...stageTexts, ...ALL_MODE_LABELS], "BlitzMain"),
|
||||
},
|
||||
]);
|
||||
// death screen: the localized "Splatted by <weapon>!" burst message (tight
|
||||
// caps ~28px; the face reads between the two Blitz cuts at capture
|
||||
// fidelity, so carry both and let recognition take the max) and the
|
||||
// splash-tag name, which is BlitzBold for latin but the angular Rowdy face
|
||||
// for kana
|
||||
const deathWeaponTexts = [
|
||||
...ALL_WEAPON_ENTRIES.map((e) => e.name),
|
||||
...Object.values(LOCALIZED_WEAPON_NAMES).flatMap((names) =>
|
||||
names.map((n) => n.text),
|
||||
),
|
||||
...DEATH_MESSAGE_TEMPLATES.flatMap((t) => [
|
||||
t.constText,
|
||||
t.weaponPre,
|
||||
t.weaponPost,
|
||||
]),
|
||||
];
|
||||
await build("death-weapon", 34, [
|
||||
{
|
||||
family: "BlitzMain",
|
||||
pxs: [40, 41],
|
||||
chars: localizedChars(deathWeaponTexts, "BlitzMain"),
|
||||
},
|
||||
{
|
||||
family: "BlitzBold",
|
||||
pxs: [43, 44],
|
||||
chars: localizedChars(deathWeaponTexts, "BlitzBold"),
|
||||
},
|
||||
]);
|
||||
// JA death message, a separate atlas read only by the JA line ROIs: mixing
|
||||
// kana into the Latin set would shift its median width (breaking wide-
|
||||
// segment splitting) and displace Latin matches on ranking noise. The
|
||||
// in-game JP face renders horizontally condensed and sits between the two
|
||||
// FOT cuts at capture fidelity — ス/ク read as Kurokane, で as Rowdy — so
|
||||
// carry both, at the (px, xScale) pairs that peaked in an NCC sweep against
|
||||
// the classic-squiffer-jp fixture. Latin/digit chars inside JP weapon names
|
||||
// (LACT-450, .52ガロン) ride along in both faces. Attested-fixture rule as
|
||||
// everywhere: KO/ZH names stay in the closed sets without an atlas.
|
||||
const deathJaTexts = [
|
||||
...(LOCALIZED_WEAPON_NAMES.JPja ?? []).map((n) => n.text),
|
||||
...DEATH_MESSAGE_TEMPLATES.filter((t) =>
|
||||
t.langs.some((l) => l.endsWith("ja")),
|
||||
).flatMap((t) => [t.constText, t.weaponPre, t.weaponPost]),
|
||||
];
|
||||
function coveredChars(texts: readonly string[], family: string): string[] {
|
||||
const covers = fontCoverage[family]!;
|
||||
return charsOf(texts).filter((ch) => covers(ch.codePointAt(0)!));
|
||||
}
|
||||
await build("death-weapon-ja", 40, [
|
||||
{
|
||||
family: "Kurokane",
|
||||
pxs: [44, 46],
|
||||
xScale: 0.75,
|
||||
chars: coveredChars(deathJaTexts, "Kurokane"),
|
||||
},
|
||||
{
|
||||
family: "Rowdy",
|
||||
pxs: [38, 40],
|
||||
xScale: 0.8,
|
||||
chars: coveredChars(deathJaTexts, "Rowdy"),
|
||||
},
|
||||
]);
|
||||
// nominal 42 with the detector scaling to 46 reads strictly better than a
|
||||
// native-46 render (the cubic upscale slightly fattens strokes the way the
|
||||
// in-game compositing does; a native render splits か into two segments)
|
||||
await build("death-tag-name", 42, [
|
||||
{ family: "BlitzBold", pxs: [53, 54], chars: nameCharset() },
|
||||
{ family: "Rowdy", pxs: [52, 54], chars: nameCharset() },
|
||||
]);
|
||||
436
scripts/scanner/build-localized-entries.ts
Normal file
436
scripts/scanner/build-localized-entries.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* Generate the localized closed sets from the splat3 repo's language dumps
|
||||
* (https://github.com/Leanny/splat3, data/language/<Lang>_full.json), so
|
||||
* ingestion works no matter which language the player runs the game in.
|
||||
* Detectors OCR whatever is on screen, snap it against every language's
|
||||
* entries, and always emit the sendou.ink id (canonical English text
|
||||
* exists only inside the snap tables).
|
||||
*
|
||||
* Sources per language:
|
||||
* CommonMsg/VS/VSRuleName modes (+ the _2L two-line intro-splash
|
||||
* wrap variants, e.g. "Muschel-\nchaos")
|
||||
* CommonMsg/VS/VSStageName stages (keyed via the USen values)
|
||||
* CommonMsg/MatchMode lobby tags (XMatch / Private)
|
||||
* LayoutMsg/Lobby_MenuMode_00 the intro splash's "MODE" label
|
||||
* LayoutMsg/Mng_Result_00 replay-browser VICTORY / DEFEAT tags
|
||||
* LayoutMsg/VS_Beaten_00 (999) the death-burst message; the weapon
|
||||
* placeholder sits on line 1 or 2
|
||||
* depending on language, so this becomes
|
||||
* a per-language template
|
||||
* CommonMsg/Weapon/WeaponName_* weapon names, mapped to the canonical
|
||||
* entries via their USen value
|
||||
*
|
||||
* Usage: pnpm scanner:build-localized-entries [path-to-splat3]
|
||||
* Writes app/features/scanner/core/localized-entries.ts
|
||||
* and app/features/scanner/core/detectors/death/localized-messages.ts
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
|
||||
import type { ScannerLobby } from "../../app/features/scanner/scanner-types";
|
||||
import { stageIds } from "../../app/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "../../app/modules/in-game-lists/types";
|
||||
import gameMisc from "../../locales/en/game-misc.json";
|
||||
|
||||
const SPLAT3_DIR =
|
||||
process.argv[2] ?? new URL("../../../splat3", import.meta.url).pathname;
|
||||
const LANG_DIR = join(SPLAT3_DIR, "data", "language");
|
||||
const OUT_ENTRIES = new URL(
|
||||
"../../app/features/scanner/core/localized-entries.ts",
|
||||
import.meta.url,
|
||||
).pathname;
|
||||
const OUT_MESSAGES = new URL(
|
||||
"../../app/features/scanner/core/detectors/death/localized-messages.ts",
|
||||
import.meta.url,
|
||||
).pathname;
|
||||
|
||||
const CANONICAL_LANG = "USen";
|
||||
const misc = gameMisc as Record<string, string>;
|
||||
|
||||
/** VSRuleName key -> ModeShort; USen values validate against the en locale. */
|
||||
const RULE_KEYS: Record<string, ModeShort> = {
|
||||
Pnt: "TW",
|
||||
Var: "SZ",
|
||||
Vlf: "TC",
|
||||
Vgl: "RM",
|
||||
Vcl: "CB",
|
||||
};
|
||||
|
||||
/** MatchMode key -> lobby code; USen values validate against these names. */
|
||||
const LOBBY_KEYS: Record<string, ScannerLobby> = {
|
||||
XMatch: "X",
|
||||
Bankara: "SERIES",
|
||||
BankaraOpen: "OPEN",
|
||||
Private: "PRIVATE",
|
||||
};
|
||||
|
||||
/** the English lobby tags as the game shows them, for USen validation */
|
||||
const LOBBY_ENGLISH: Record<ScannerLobby, string> = {
|
||||
X: "X Battle",
|
||||
SERIES: "Anarchy Battle (Series)",
|
||||
OPEN: "Anarchy Battle (Open)",
|
||||
PRIVATE: "Private Battle",
|
||||
};
|
||||
|
||||
type LangDump = Record<string, Record<string, string>>;
|
||||
|
||||
function loadLang(lang: string): LangDump {
|
||||
return JSON.parse(
|
||||
readFileSync(join(LANG_DIR, `${lang}_full.json`), "utf8"),
|
||||
) as LangDump;
|
||||
}
|
||||
|
||||
/** Drop [size=...]/[color=...]-style markup and collapse whitespace. */
|
||||
function clean(s: string): string {
|
||||
return s
|
||||
.replace(/\[[^\]]*\]/g, "")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** The case/space/diacritic-insensitive key entries are deduped on (mirrors text.ts). */
|
||||
function foldKey(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
/** sendou.ink locales use the ASCII apostrophe; the game dumps use ’. */
|
||||
function normalizeApostrophes(s: string): string {
|
||||
return s.replace(/[’‘]/g, "'");
|
||||
}
|
||||
|
||||
const languages = [
|
||||
...new Set(
|
||||
readdirSync(LANG_DIR)
|
||||
.filter((f) => f.endsWith("_full.json"))
|
||||
.map((f) => f.replace("_full.json", "")),
|
||||
),
|
||||
].sort();
|
||||
if (!languages.includes(CANONICAL_LANG)) {
|
||||
throw new Error(
|
||||
`canonical language ${CANONICAL_LANG} not found in ${LANG_DIR}`,
|
||||
);
|
||||
}
|
||||
|
||||
const dumps = new Map<string, LangDump>(languages.map((l) => [l, loadLang(l)]));
|
||||
const usen = dumps.get(CANONICAL_LANG)!;
|
||||
|
||||
// ---- validate the canonical sets against USen ------------------------------
|
||||
|
||||
for (const [key, mode] of Object.entries(RULE_KEYS)) {
|
||||
const value = clean(usen["CommonMsg/VS/VSRuleName"]![key]!);
|
||||
const expected = misc[`MODE_LONG_${mode}`]!;
|
||||
if (value !== expected)
|
||||
throw new Error(`USen rule ${key} is "${value}", expected "${expected}"`);
|
||||
}
|
||||
for (const [key, lobby] of Object.entries(LOBBY_KEYS)) {
|
||||
const value = clean(usen["CommonMsg/MatchMode"]![key]!);
|
||||
const expected = LOBBY_ENGLISH[lobby as ScannerLobby];
|
||||
if (value !== expected)
|
||||
throw new Error(`USen lobby ${key} is "${value}", expected "${expected}"`);
|
||||
}
|
||||
|
||||
/** English stage name (per the sendou.ink en locale) -> StageId. */
|
||||
const stageIdByEnglishName = new Map<string, StageId>(
|
||||
stageIds.map((stageId) => [misc[`STAGE_${stageId}`]!, stageId]),
|
||||
);
|
||||
/** VSStageName key -> StageId, via the USen values. */
|
||||
const stageKeys = new Map<string, StageId>();
|
||||
for (const [key, value] of Object.entries(usen["CommonMsg/VS/VSStageName"]!)) {
|
||||
const stageId = stageIdByEnglishName.get(value);
|
||||
if (stageId !== undefined) stageKeys.set(key, stageId);
|
||||
}
|
||||
for (const [name, stageId] of stageIdByEnglishName) {
|
||||
if (![...stageKeys.values()].includes(stageId)) {
|
||||
throw new Error(
|
||||
`stage "${name}" (id ${stageId}) not found in USen VSStageName`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-language closed sets ----------------------------------------------
|
||||
|
||||
interface LocalizedLobby {
|
||||
text: string;
|
||||
lobby: ScannerLobby;
|
||||
}
|
||||
interface LocalizedMode {
|
||||
text: string;
|
||||
mode: ModeShort;
|
||||
}
|
||||
interface LocalizedStage {
|
||||
text: string;
|
||||
stageId: StageId;
|
||||
}
|
||||
|
||||
interface LanguageEntries {
|
||||
lang: string;
|
||||
modeLabel: string;
|
||||
victory: string;
|
||||
defeat: string;
|
||||
lobbies: LocalizedLobby[];
|
||||
modes: LocalizedMode[];
|
||||
modeWraps: LocalizedMode[];
|
||||
stages: LocalizedStage[];
|
||||
}
|
||||
|
||||
const languageEntries: LanguageEntries[] = [];
|
||||
for (const lang of languages) {
|
||||
const d = dumps.get(lang)!;
|
||||
const rules = d["CommonMsg/VS/VSRuleName"]!;
|
||||
const modes: LocalizedMode[] = [];
|
||||
const modeWraps: LocalizedMode[] = [];
|
||||
for (const [key, mode] of Object.entries(RULE_KEYS)) {
|
||||
modes.push({ text: clean(rules[key]!), mode });
|
||||
// the intro splash renders the _2L wrap variant, hyphens included
|
||||
const wrap = clean(rules[`${key}_2L`]!.replace(/\n/g, " "));
|
||||
if (foldKey(wrap) !== foldKey(rules[key]!))
|
||||
modeWraps.push({ text: wrap, mode });
|
||||
}
|
||||
languageEntries.push({
|
||||
lang,
|
||||
modeLabel: clean(d["LayoutMsg/Lobby_MenuMode_00"]!.T_Rule_00!),
|
||||
victory: clean(d["LayoutMsg/Mng_Result_00"]!.T_Win_00!),
|
||||
defeat: clean(d["LayoutMsg/Mng_Result_00"]!.T_Lose_00!),
|
||||
lobbies: Object.entries(LOBBY_KEYS).map(([key, lobby]) => ({
|
||||
text: clean(d["CommonMsg/MatchMode"]![key]!),
|
||||
lobby,
|
||||
})),
|
||||
modes,
|
||||
modeWraps,
|
||||
stages: [...stageKeys.entries()].map(([key, stageId]) => ({
|
||||
text: clean(d["CommonMsg/VS/VSStageName"]![key]!),
|
||||
stageId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// A localized string that means one thing in language A and another in
|
||||
// language B would snap ambiguously in the flattened unions — reject.
|
||||
for (const category of ["lobbies", "modes", "modeWraps", "stages"] as const) {
|
||||
const seen = new Map<string, string | number>();
|
||||
for (const entries of languageEntries) {
|
||||
for (const entry of entries[category]) {
|
||||
const value =
|
||||
"lobby" in entry
|
||||
? entry.lobby
|
||||
: "mode" in entry
|
||||
? entry.mode
|
||||
: entry.stageId;
|
||||
const k = foldKey(entry.text);
|
||||
const prior = seen.get(k);
|
||||
if (prior !== undefined && prior !== value) {
|
||||
throw new Error(
|
||||
`${category}: "${entry.text}" maps to both "${prior}" and "${value}" across languages`,
|
||||
);
|
||||
}
|
||||
seen.set(k, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- death message templates -----------------------------------------------
|
||||
|
||||
const PLACEHOLDER = /\[group=[^\]]*\]/;
|
||||
/** stands in for the weapon placeholder while splitting the message */
|
||||
const SENTINEL = "\u0000";
|
||||
|
||||
interface DeathTemplate {
|
||||
langs: string[];
|
||||
weaponLine: 1 | 2;
|
||||
constText: string;
|
||||
weaponPre: string;
|
||||
weaponPost: string;
|
||||
}
|
||||
|
||||
const templates: DeathTemplate[] = [];
|
||||
for (const lang of languages) {
|
||||
const raw = dumps.get(lang)!["LayoutMsg/VS_Beaten_00"]!["999"]!;
|
||||
const lines = raw
|
||||
.split("\n")
|
||||
.map((l) => clean(l.replace(PLACEHOLDER, SENTINEL)));
|
||||
if (lines.length !== 2)
|
||||
throw new Error(`${lang}: death message is not two lines: ${raw}`);
|
||||
const weaponIndex = lines.findIndex((l) => l.includes(SENTINEL));
|
||||
if (weaponIndex < 0)
|
||||
throw new Error(`${lang}: no weapon placeholder: ${raw}`);
|
||||
const weaponLine = (weaponIndex + 1) as 1 | 2;
|
||||
const [pre, post] = lines[weaponLine - 1]!.split(SENTINEL) as [
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const constText = lines[weaponLine % 2]!;
|
||||
const existing = templates.find(
|
||||
(t) =>
|
||||
t.weaponLine === weaponLine &&
|
||||
t.constText === constText &&
|
||||
t.weaponPre === pre &&
|
||||
t.weaponPost === post,
|
||||
);
|
||||
if (existing) existing.langs.push(lang);
|
||||
else
|
||||
templates.push({
|
||||
langs: [lang],
|
||||
weaponLine,
|
||||
constText,
|
||||
weaponPre: pre,
|
||||
weaponPost: post,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- localized weapon names --------------------------------------------------
|
||||
|
||||
const WEAPON_MSGS = [
|
||||
"CommonMsg/Weapon/WeaponName_Main",
|
||||
"CommonMsg/Weapon/WeaponName_Sub",
|
||||
"CommonMsg/Weapon/WeaponName_Special",
|
||||
];
|
||||
|
||||
const canonicalWeaponNames = new Set(ALL_WEAPON_ENTRIES.map((e) => e.name));
|
||||
/** codename -> canonical entry name, via the USen value. */
|
||||
const weaponCodenames = new Map<string, string>();
|
||||
for (const msg of WEAPON_MSGS) {
|
||||
for (const [codename, value] of Object.entries(usen[msg]!)) {
|
||||
const name = normalizeApostrophes(value);
|
||||
if (canonicalWeaponNames.has(name))
|
||||
weaponCodenames.set(`${msg} ${codename}`, name);
|
||||
}
|
||||
}
|
||||
const unmapped = [...canonicalWeaponNames].filter(
|
||||
(n) => ![...weaponCodenames.values()].includes(n),
|
||||
);
|
||||
if (unmapped.length > 0) {
|
||||
console.warn(
|
||||
`WARNING: ${unmapped.length} weapon entries have no splat3 codename: ${unmapped.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** lang -> localized names that differ from the canonical English name. */
|
||||
const localizedWeaponNames: Record<string, { text: string; name: string }[]> =
|
||||
{};
|
||||
for (const lang of languages) {
|
||||
const d = dumps.get(lang)!;
|
||||
const seen = new Set<string>();
|
||||
const entries: { text: string; name: string }[] = [];
|
||||
for (const [key, name] of weaponCodenames) {
|
||||
const [msg, codename] = key.split(" ") as [string, string];
|
||||
const text = clean(d[msg]![codename] ?? "");
|
||||
if (!text || text === "-") continue;
|
||||
const k = foldKey(text);
|
||||
if (seen.has(k) || k === foldKey(name)) continue;
|
||||
seen.add(k);
|
||||
entries.push({ text, name });
|
||||
}
|
||||
if (entries.length > 0) localizedWeaponNames[lang] = entries;
|
||||
}
|
||||
|
||||
// ---- emit --------------------------------------------------------------------
|
||||
|
||||
const banner = (extra: string) =>
|
||||
`/**
|
||||
* GENERATED by scripts/scanner/build-localized-entries.ts from the splat3 repo's
|
||||
* language dumps — do not edit by hand; regenerate when the game adds
|
||||
* content. ${extra}
|
||||
*/`;
|
||||
|
||||
writeFileSync(
|
||||
OUT_ENTRIES,
|
||||
`${banner(
|
||||
`Localized versus-UI strings for every game language, each
|
||||
* mapped to the sendou.ink id it means (core/localized.ts derives the
|
||||
* flattened match sets detectors snap OCR output against).`,
|
||||
)}
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { ScannerLobby } from "../scanner-types";
|
||||
|
||||
export interface LocalizedLobby {
|
||||
text: string;
|
||||
lobby: ScannerLobby;
|
||||
}
|
||||
|
||||
export interface LocalizedMode {
|
||||
text: string;
|
||||
mode: ModeShort;
|
||||
}
|
||||
|
||||
export interface LocalizedStage {
|
||||
text: string;
|
||||
stageId: StageId;
|
||||
}
|
||||
|
||||
export interface LanguageEntries {
|
||||
lang: string;
|
||||
/** the intro splash's constant "MODE" label */
|
||||
modeLabel: string;
|
||||
/** replay-browser winner/loser panel tags */
|
||||
victory: string;
|
||||
defeat: string;
|
||||
lobbies: LocalizedLobby[];
|
||||
modes: LocalizedMode[];
|
||||
/** two-line intro-splash wrap variants ("Muschel-\\nchaos"), space-joined */
|
||||
modeWraps: LocalizedMode[];
|
||||
stages: LocalizedStage[];
|
||||
}
|
||||
|
||||
export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = ${JSON.stringify(
|
||||
languageEntries,
|
||||
null,
|
||||
2,
|
||||
)};
|
||||
`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
OUT_MESSAGES,
|
||||
`${banner(
|
||||
`Per-language death-burst message templates and localized
|
||||
* weapon names: the "Splatted by\\n<weapon>!" burst puts the weapon on line
|
||||
* 1 or 2 depending on language, with language-specific text around it.`,
|
||||
)}
|
||||
|
||||
export interface DeathMessageTemplate {
|
||||
/** languages sharing this exact template */
|
||||
langs: readonly string[];
|
||||
/** which burst line carries the weapon name */
|
||||
weaponLine: 1 | 2;
|
||||
/** the constant text on the other line (parse-time confirmation) */
|
||||
constText: string;
|
||||
/** constant text around the weapon name on its own line */
|
||||
weaponPre: string;
|
||||
weaponPost: string;
|
||||
}
|
||||
|
||||
export const DEATH_MESSAGE_TEMPLATES: readonly DeathMessageTemplate[] = ${JSON.stringify(
|
||||
templates,
|
||||
null,
|
||||
2,
|
||||
)};
|
||||
|
||||
export interface LocalizedWeaponName {
|
||||
text: string;
|
||||
/** canonical English entry name (weapon-names.ts) */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-language weapon names that differ from the canonical English name
|
||||
* (identical ones are omitted; match against the canonical set too).
|
||||
*/
|
||||
export const LOCALIZED_WEAPON_NAMES: Readonly<
|
||||
Record<string, readonly LocalizedWeaponName[]>
|
||||
> = ${JSON.stringify(localizedWeaponNames, null, 2)};
|
||||
`,
|
||||
);
|
||||
|
||||
console.info(
|
||||
`localized-entries: ${languages.length} languages, ` +
|
||||
`${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`,
|
||||
);
|
||||
console.info(
|
||||
`localized-messages: ${templates.length} death templates, ` +
|
||||
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`,
|
||||
);
|
||||
111
scripts/scanner/build-planner-signatures.ts
Normal file
111
scripts/scanner/build-planner-signatures.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* Build the planner signature atlas from the assets repo's planner renders.
|
||||
*
|
||||
* The sendou-ink/assets checkout's assets/planner-maps/ holds the full
|
||||
* planner PNGs (~340MB), named "<stageId>-<MODE>-<TYPE>.png" (MODE in
|
||||
* CB/RM/SZ/TC/TW; TYPE in OVER/MINI/ITEMS). This tool reduces each
|
||||
* PLANNER_TYPE render to the ink-invariant structural signature
|
||||
* (app/features/scanner/core/detectors/minimap/stage.ts) and packs all of them,
|
||||
* quantized to uint8, into a single grayscale atlas PNG plus a manifest
|
||||
* (keys "<stageId>-<MODE>") — a few hundred KB the minimap detector loads
|
||||
* to identify the stage. Output goes to the assets checkout
|
||||
* (SCANNER_ASSETS_DIR/planner); shipping a regen means pushing the assets repo.
|
||||
*
|
||||
* pnpm scanner:build-planner-signatures
|
||||
*/
|
||||
import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadOpenCV } from "../../app/features/scanner/core/cv";
|
||||
import {
|
||||
PLANNER_SIG_H,
|
||||
PLANNER_SIG_W,
|
||||
type PlannerManifest,
|
||||
plannerSignature,
|
||||
} from "../../app/features/scanner/core/detectors/minimap/stage";
|
||||
import {
|
||||
type FrameData,
|
||||
normalizeFrame,
|
||||
toMat,
|
||||
} from "../../app/features/scanner/core/image";
|
||||
import { SCANNER_ASSETS_DIR } from "../../app/features/scanner/node/assets-dir";
|
||||
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
|
||||
|
||||
/** which render variant the signatures are built from */
|
||||
const PLANNER_TYPE = process.env.SCANNER_PLANNER_TYPE ?? "MINI";
|
||||
const SRC_DIR =
|
||||
process.env.SCANNER_PLANNER_MAPS_DIR ??
|
||||
new URL("../../../assets/assets/planner-maps", import.meta.url).pathname;
|
||||
const OUT_DIR =
|
||||
process.env.SCANNER_PLANNER_OUT_DIR ?? join(SCANNER_ASSETS_DIR, "planner");
|
||||
const COLS = 5;
|
||||
|
||||
await loadOpenCV();
|
||||
|
||||
const files = readdirSync(SRC_DIR)
|
||||
.filter((f) => f.endsWith(`-${PLANNER_TYPE}.png`))
|
||||
.sort((a, b) => {
|
||||
const [sa, ma] = a.split("-");
|
||||
const [sb, mb] = b.split("-");
|
||||
return Number(sa) - Number(sb) || ma!.localeCompare(mb!);
|
||||
});
|
||||
if (files.length === 0) {
|
||||
throw new Error(`no ${PLANNER_TYPE} planner PNGs in ${SRC_DIR}`);
|
||||
}
|
||||
|
||||
const rows = Math.ceil(files.length / COLS);
|
||||
const atlasW = COLS * PLANNER_SIG_W;
|
||||
const atlasH = rows * PLANNER_SIG_H;
|
||||
const atlas: FrameData = {
|
||||
width: atlasW,
|
||||
height: atlasH,
|
||||
data: new Uint8ClampedArray(atlasW * atlasH * 4),
|
||||
};
|
||||
const keys: string[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]!;
|
||||
const key = file.replace(`-${PLANNER_TYPE}.png`, "");
|
||||
keys.push(key);
|
||||
|
||||
const img = toMat(await readImage(join(SRC_DIR, file)));
|
||||
const norm = normalizeFrame(img);
|
||||
const sig = plannerSignature(norm);
|
||||
img.delete();
|
||||
norm.delete();
|
||||
|
||||
// quantize to uint8 (load re-normalizes to unit L2, so the scale is free)
|
||||
let max = 0;
|
||||
for (const v of sig) if (v > max) max = v;
|
||||
const scale = max > 0 ? 255 / max : 0;
|
||||
|
||||
const tx = (i % COLS) * PLANNER_SIG_W;
|
||||
const ty = Math.floor(i / COLS) * PLANNER_SIG_H;
|
||||
for (let y = 0; y < PLANNER_SIG_H; y++) {
|
||||
for (let x = 0; x < PLANNER_SIG_W; x++) {
|
||||
const v = Math.round(sig[y * PLANNER_SIG_W + x]! * scale);
|
||||
const o = ((ty + y) * atlasW + (tx + x)) * 4;
|
||||
atlas.data[o] = v;
|
||||
atlas.data[o + 1] = v;
|
||||
atlas.data[o + 2] = v;
|
||||
atlas.data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
writePng(join(OUT_DIR, "signatures.png"), atlas);
|
||||
const manifest: PlannerManifest = {
|
||||
width: PLANNER_SIG_W,
|
||||
height: PLANNER_SIG_H,
|
||||
cols: COLS,
|
||||
keys,
|
||||
};
|
||||
writeFileSync(
|
||||
join(OUT_DIR, "manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
|
||||
console.info(
|
||||
`packed ${files.length} signatures into ${atlasW}x${atlasH} atlas -> ${OUT_DIR}`,
|
||||
);
|
||||
93
scripts/scanner/dump-crops.ts
Normal file
93
scripts/scanner/dump-crops.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* ROI calibration helper: normalize a frame to 1080p, then dump crops
|
||||
* (optionally scaled up) and/or a grid overlay for visual inspection.
|
||||
*
|
||||
* Usage:
|
||||
* vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/dump-crops.ts <image> <outdir> grid
|
||||
* vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/dump-crops.ts <image> <outdir> x,y,w,h[,scale][:label] ...
|
||||
*/
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadOpenCV } from "../../app/features/scanner/core/cv";
|
||||
import {
|
||||
cropRoi,
|
||||
matToFrameData,
|
||||
normalizeFrame,
|
||||
toMat,
|
||||
} from "../../app/features/scanner/core/image";
|
||||
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
|
||||
|
||||
const [imagePath, outDir, ...specs] = process.argv.slice(2);
|
||||
if (!imagePath || !outDir || specs.length === 0) {
|
||||
console.error(
|
||||
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/dump-crops.ts <image> <outdir> (grid | x,y,w,h[,scale][:label])...",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cv = await loadOpenCV();
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const src = toMat(await readImage(imagePath));
|
||||
const frame = normalizeFrame(src);
|
||||
src.delete();
|
||||
|
||||
for (const spec of specs) {
|
||||
if (spec === "grid") {
|
||||
const overlay = frame.clone();
|
||||
for (let x = 0; x < overlay.cols; x += 20) {
|
||||
const major = x % 100 === 0;
|
||||
cv.line(
|
||||
overlay,
|
||||
new cv.Point(x, 0),
|
||||
new cv.Point(x, overlay.rows),
|
||||
new cv.Scalar(255, 0, major ? 0 : 255, 255),
|
||||
major ? 2 : 1,
|
||||
);
|
||||
}
|
||||
for (let y = 0; y < overlay.rows; y += 20) {
|
||||
const major = y % 100 === 0;
|
||||
cv.line(
|
||||
overlay,
|
||||
new cv.Point(0, y),
|
||||
new cv.Point(overlay.cols, y),
|
||||
new cv.Scalar(255, 0, major ? 0 : 255, 255),
|
||||
major ? 2 : 1,
|
||||
);
|
||||
}
|
||||
writePng(join(outDir, "grid.png"), matToFrameData(overlay));
|
||||
overlay.delete();
|
||||
continue;
|
||||
}
|
||||
const [rect = "", label] = spec.split(":");
|
||||
const [x, y, w, h, scale = 1] = rect.split(",").map(Number);
|
||||
if ([x, y, w, h].some((v) => !Number.isFinite(v))) {
|
||||
console.error(`bad spec: ${spec}`);
|
||||
continue;
|
||||
}
|
||||
const cx = Math.max(0, Math.min(x!, frame.cols - 1));
|
||||
const cy = Math.max(0, Math.min(y!, frame.rows - 1));
|
||||
const crop = cropRoi(frame, {
|
||||
x: cx,
|
||||
y: cy,
|
||||
w: Math.min(w!, frame.cols - cx),
|
||||
h: Math.min(h!, frame.rows - cy),
|
||||
});
|
||||
const out = new cv.Mat();
|
||||
cv.resize(
|
||||
crop,
|
||||
out,
|
||||
new cv.Size(0, 0),
|
||||
Number(scale),
|
||||
Number(scale),
|
||||
cv.INTER_NEAREST,
|
||||
);
|
||||
const name = label ?? `crop-${x}-${y}-${w}x${h}`;
|
||||
writePng(join(outDir, `${name}.png`), matToFrameData(out));
|
||||
crop.delete();
|
||||
out.delete();
|
||||
}
|
||||
|
||||
frame.delete();
|
||||
console.info(`wrote crops to ${outDir}`);
|
||||
61
scripts/scanner/otf-cmap.ts
Normal file
61
scripts/scanner/otf-cmap.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* Minimal OpenType cmap reader: which codepoints does a font actually map?
|
||||
*
|
||||
* @napi-rs/canvas silently falls back to a system font for characters the
|
||||
* requested family lacks (the Blitz cuts carry Latin/Greek/Cyrillic/kana
|
||||
* but no kanji, hangul or hanzi), which would bake wrong-font glyphs into
|
||||
* the atlases. The builder filters every charset through this before
|
||||
* rendering. Supports the two subtable formats the game fonts use:
|
||||
* format 4 (BMP segments) and format 12 (grouped full-range).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export function readFontCoverage(path: string): (codepoint: number) => boolean {
|
||||
const buf = readFileSync(path);
|
||||
const numTables = buf.readUInt16BE(4);
|
||||
let cmapOffset = -1;
|
||||
for (let i = 0; i < numTables; i++) {
|
||||
const rec = 12 + i * 16;
|
||||
if (buf.toString("latin1", rec, rec + 4) === "cmap") {
|
||||
cmapOffset = buf.readUInt32BE(rec + 8);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cmapOffset < 0) throw new Error(`${path}: no cmap table`);
|
||||
|
||||
// prefer a full-repertoire format-12 subtable, else the BMP format 4
|
||||
const encodingCount = buf.readUInt16BE(cmapOffset + 2);
|
||||
let best = -1;
|
||||
let bestFormat = -1;
|
||||
for (let i = 0; i < encodingCount; i++) {
|
||||
const rec = cmapOffset + 4 + i * 8;
|
||||
const subtable = cmapOffset + buf.readUInt32BE(rec + 4);
|
||||
const format = buf.readUInt16BE(subtable);
|
||||
if (format === 12 || (format === 4 && bestFormat !== 12)) {
|
||||
best = subtable;
|
||||
bestFormat = format;
|
||||
}
|
||||
}
|
||||
if (best < 0) throw new Error(`${path}: no format 4/12 cmap subtable`);
|
||||
|
||||
const ranges: [number, number][] = [];
|
||||
if (bestFormat === 12) {
|
||||
const nGroups = buf.readUInt32BE(best + 12);
|
||||
for (let g = 0; g < nGroups; g++) {
|
||||
const rec = best + 16 + g * 12;
|
||||
ranges.push([buf.readUInt32BE(rec), buf.readUInt32BE(rec + 4)]);
|
||||
}
|
||||
} else {
|
||||
const segCount = buf.readUInt16BE(best + 6) / 2;
|
||||
const endCodes = best + 14;
|
||||
const startCodes = endCodes + segCount * 2 + 2;
|
||||
for (let s = 0; s < segCount; s++) {
|
||||
const start = buf.readUInt16BE(startCodes + s * 2);
|
||||
const end = buf.readUInt16BE(endCodes + s * 2);
|
||||
if (start !== 0xffff) ranges.push([start, end]);
|
||||
}
|
||||
}
|
||||
return (codepoint) =>
|
||||
ranges.some(([start, end]) => codepoint >= start && codepoint <= end);
|
||||
}
|
||||
145
scripts/scanner/overlay-rois.ts
Normal file
145
scripts/scanner/overlay-rois.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* Draw all scoreboard ROIs on a (normalized) frame for visual calibration.
|
||||
* Usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log]
|
||||
*/
|
||||
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
|
||||
import * as death from "../../app/features/scanner/core/detectors/death/rois";
|
||||
import * as mapStart from "../../app/features/scanner/core/detectors/map-start/rois";
|
||||
import * as minimap from "../../app/features/scanner/core/detectors/minimap/rois";
|
||||
import * as sb from "../../app/features/scanner/core/detectors/scoreboard/rois";
|
||||
import * as bl from "../../app/features/scanner/core/detectors/scoreboard-battle-log/rois";
|
||||
import * as replay from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois";
|
||||
import {
|
||||
matToFrameData,
|
||||
normalizeFrame,
|
||||
type Roi,
|
||||
toMat,
|
||||
} from "../../app/features/scanner/core/image";
|
||||
import { readImage, writePng } from "../../app/features/scanner/node/image-io";
|
||||
|
||||
const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] =
|
||||
process.argv.slice(2);
|
||||
if (!imagePath) {
|
||||
console.error(
|
||||
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log|death|map-start|minimap]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cv = await loadOpenCV();
|
||||
const src = toMat(await readImage(imagePath));
|
||||
const frame = normalizeFrame(src);
|
||||
src.delete();
|
||||
|
||||
function rect(m: Mat, roi: Roi, color: [number, number, number]) {
|
||||
cv.rectangle(
|
||||
m,
|
||||
new cv.Point(roi.x, roi.y),
|
||||
new cv.Point(roi.x + roi.w, roi.y + roi.h),
|
||||
new cv.Scalar(...color, 255),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
if (detector === "scoreboard") {
|
||||
for (const cy of sb.ROW_CENTERS) {
|
||||
rect(frame, sb.weaponRoi(cy), [255, 0, 0]);
|
||||
rect(frame, sb.nameRoi(cy), [0, 255, 0]);
|
||||
rect(frame, sb.paintRoi(cy), [0, 128, 255]);
|
||||
for (const i of [0, 1, 2] as const)
|
||||
rect(frame, sb.statRoi(cy, i), [255, 0, 255]);
|
||||
rect(frame, sb.gateDarkProbe(cy), [255, 255, 0]);
|
||||
}
|
||||
for (const roi of sb.TEAM_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
|
||||
for (const roi of sb.GATE_PANEL_PROBES) rect(frame, roi, [255, 255, 0]);
|
||||
} else if (detector === "scoreboard-battle-log-replay") {
|
||||
for (const dx of replay.PANEL_XS) {
|
||||
for (const cy of replay.ROW_CENTERS) {
|
||||
rect(frame, replay.weaponRoi(cy, dx), [255, 0, 0]);
|
||||
rect(frame, replay.nameRoi(cy, dx), [0, 255, 0]);
|
||||
rect(frame, replay.paintRoi(cy, dx), [0, 128, 255]);
|
||||
rect(frame, replay.paintSuffixRoi(cy, dx), [0, 255, 255]);
|
||||
for (const i of [0, 1, 2] as const)
|
||||
rect(frame, replay.statRoi(cy, dx, i), [255, 0, 255]);
|
||||
rect(frame, replay.gateFlatProbe(cy, dx), [255, 255, 0]);
|
||||
}
|
||||
rect(frame, replay.teamScoreRoi(dx), [0, 128, 255]);
|
||||
rect(frame, replay.resultTagRoi(dx), [255, 128, 0]);
|
||||
}
|
||||
for (const roi of replay.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
|
||||
for (const roi of replay.GATE_GAP_PROBES) rect(frame, roi, [255, 255, 0]);
|
||||
rect(frame, replay.HEADER_TOP_BAND, [0, 255, 0]);
|
||||
rect(frame, replay.HEADER_BOTTOM_BAND, [0, 255, 0]);
|
||||
rect(frame, replay.REPLAY_CODE_ROI, [0, 255, 0]);
|
||||
} else if (detector === "scoreboard-battle-log") {
|
||||
for (const dy of bl.PANEL_DYS) {
|
||||
for (const base of bl.ROW_CENTERS) {
|
||||
const cy = base + dy;
|
||||
rect(frame, bl.weaponRoi(cy), [255, 0, 0]);
|
||||
rect(frame, bl.nameRoi(cy), [0, 255, 0]);
|
||||
rect(frame, bl.paintRoi(cy), [0, 128, 255]);
|
||||
rect(frame, bl.paintSuffixRoi(cy), [0, 255, 255]);
|
||||
for (const i of [0, 1, 2] as const)
|
||||
rect(frame, bl.statRoi(cy, i), [255, 0, 255]);
|
||||
rect(frame, bl.gateDarkProbe(cy), [255, 255, 0]);
|
||||
rect(frame, bl.povArrowRoi(cy), [255, 128, 0]);
|
||||
rect(frame, bl.specialIconRoi(cy), [255, 0, 0]);
|
||||
}
|
||||
rect(frame, bl.teamScoreRoi(dy), [0, 128, 255]);
|
||||
rect(frame, bl.resultTagRoi(dy), [255, 128, 0]);
|
||||
}
|
||||
for (const roi of bl.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
|
||||
for (const roi of bl.GATE_COLOR_PROBES) rect(frame, roi, [255, 255, 0]);
|
||||
rect(frame, bl.HEADER_TOP_BAND, [0, 255, 0]);
|
||||
rect(frame, bl.HEADER_BOTTOM_BAND, [0, 255, 0]);
|
||||
} else if (detector === "death") {
|
||||
rect(frame, death.SPLAT_LINE1_ROI, [0, 255, 0]);
|
||||
rect(frame, death.WEAPON_LINE_ROI, [255, 0, 0]);
|
||||
for (let row = 0; row < death.ABILITY_ROWS; row++) {
|
||||
rect(frame, death.abilityMainRoi(row), [0, 128, 255]);
|
||||
for (const slot of [0, 1, 2])
|
||||
rect(frame, death.abilitySubRoi(row, slot), [255, 0, 255]);
|
||||
}
|
||||
rect(frame, death.TAG_NAME_OUTER, [0, 255, 0]);
|
||||
for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) {
|
||||
rect(frame, roi, [255, 255, 0]);
|
||||
}
|
||||
} else if (detector === "map-start") {
|
||||
rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]);
|
||||
rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]);
|
||||
rect(frame, mapStart.STAGE_ROI, [0, 128, 255]);
|
||||
rect(frame, mapStart.GATE_INK_BAND, [0, 255, 255]);
|
||||
for (const roi of mapStart.GATE_DARK_PROBES) rect(frame, roi, [255, 255, 0]);
|
||||
} else if (detector === "minimap") {
|
||||
for (const card of minimap.CARD_LAYOUTS) {
|
||||
rect(frame, card.name, [0, 255, 0]);
|
||||
rect(frame, card.weapon, [255, 0, 0]);
|
||||
rect(frame, card.subTile, [0, 255, 255]);
|
||||
for (const [cx, cy] of card.badges)
|
||||
rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]);
|
||||
rect(frame, card.cross, [255, 0, 255]);
|
||||
}
|
||||
for (const cy of minimap.ENEMY_ROW_CYS) {
|
||||
rect(frame, minimap.enemyWeaponRoi(cy), [255, 0, 0]);
|
||||
rect(frame, minimap.enemySubTileRoi(cy), [0, 255, 255]);
|
||||
for (const cx of minimap.ENEMY_BADGE_XS)
|
||||
rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]);
|
||||
rect(frame, minimap.enemyCrossRoi(cy), [255, 0, 255]);
|
||||
}
|
||||
for (const roi of [
|
||||
minimap.GATE_CLOSE_BRIGHT,
|
||||
minimap.GATE_SPAWN_BRIGHT,
|
||||
...minimap.GATE_CLOSE_DARK_PROBES,
|
||||
...minimap.GATE_SPAWN_DARK_PROBES,
|
||||
]) {
|
||||
rect(frame, roi, [255, 255, 0]);
|
||||
}
|
||||
} else {
|
||||
console.error(`unknown detector "${detector}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writePng(outPath, matToFrameData(frame));
|
||||
frame.delete();
|
||||
console.info(`wrote ${outPath}`);
|
||||
82
scripts/scanner/replay-frames.ts
Normal file
82
scripts/scanner/replay-frames.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* CLI harness: replay a directory of extracted VoD frames through the full
|
||||
* detector registry driven by a DetectorScheduler, mirroring the analyzer
|
||||
* worker's chunk scan — to reproduce scheduling-dependent misses offline.
|
||||
* This is the tool for "the browser scan missed an event that a fixture
|
||||
* parses fine": extract the surrounding footage with
|
||||
* ffmpeg -ss <startT> -i vod.mkv -t 30 -vf fps=6 frames/f%04d.png
|
||||
* then replay it and watch which checks the scheduler ran and what they saw.
|
||||
*
|
||||
* Usage: pnpm scanner:replay <framesDir> <startT> <fps>
|
||||
* (frames are ffmpeg-numbered f0001.png..; t = startT + (n-1)/fps)
|
||||
*/
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadOpenCV } from "../../app/features/scanner/core/cv";
|
||||
import { MAP_START_EVENT_TYPE } from "../../app/features/scanner/core/detectors/map-start/index";
|
||||
import {
|
||||
createAllDetectors,
|
||||
SCOREBOARD_EVENT_TYPES,
|
||||
} from "../../app/features/scanner/core/detectors/registry";
|
||||
import { DetectorScheduler } from "../../app/features/scanner/core/detectors/scheduler";
|
||||
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
|
||||
import { readImage } from "../../app/features/scanner/node/image-io";
|
||||
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
|
||||
|
||||
const [framesDir, startTArg, fpsArg] = process.argv.slice(2);
|
||||
if (!framesDir || !startTArg || !fpsArg) {
|
||||
console.error("usage: pnpm scanner:replay <framesDir> <startT> <fps>");
|
||||
process.exit(1);
|
||||
}
|
||||
const startT = Number(startTArg);
|
||||
const fps = Number(fpsArg);
|
||||
|
||||
await loadOpenCV();
|
||||
const detectors = createAllDetectors(await loadScoreboardResources());
|
||||
const scheduler = new DetectorScheduler(detectors, {
|
||||
matchOpeningTypes: [MAP_START_EVENT_TYPE],
|
||||
matchClosingTypes: SCOREBOARD_EVENT_TYPES,
|
||||
});
|
||||
scheduler.reset(startT);
|
||||
|
||||
const files = readdirSync(framesDir)
|
||||
.filter((f) => f.endsWith(".png"))
|
||||
.sort();
|
||||
|
||||
for (const [i, file] of files.entries()) {
|
||||
const t = startT + i / fps;
|
||||
const due = scheduler.dueDetectors(t);
|
||||
if (due.length === 0) continue;
|
||||
const image = await readImage(join(framesDir, file));
|
||||
const src = toMat(image);
|
||||
const frame = normalizeFrame(src);
|
||||
src.delete();
|
||||
for (const detector of detectors) {
|
||||
if (!due.includes(detector.id)) continue;
|
||||
const gate = detector.gate(frame);
|
||||
scheduler.recordGate(detector.id, t, gate.pass, gate.signature);
|
||||
if (!gate.pass) {
|
||||
if (process.env.LOG_GATES?.includes(detector.id)) {
|
||||
console.log(
|
||||
`${t.toFixed(2)} ${file} ${detector.id} gate=${gate.score.toFixed(3)} FAIL`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!scheduler.shouldParse(detector.id, t)) {
|
||||
console.log(
|
||||
`${t.toFixed(2)} ${file} ${detector.id} gate=${gate.score.toFixed(3)} PARSE-SUPPRESSED`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const events = detector.parse(frame, t, gate);
|
||||
scheduler.recordParse(detector.id, t, events);
|
||||
console.log(
|
||||
`${t.toFixed(2)} ${file} ${detector.id} gate=${gate.score.toFixed(3)} events=[${events
|
||||
.map((e) => `${e.type}@${e.confidence.toFixed(3)}`)
|
||||
.join(", ")}]`,
|
||||
);
|
||||
}
|
||||
frame.delete();
|
||||
}
|
||||
513
scripts/scanner/report.ts
Normal file
513
scripts/scanner/report.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* Accuracy report across all fixtures — separate from pass/fail testing.
|
||||
* Runs each detector over its own fixture directory and prints aggregate
|
||||
* per-field accuracy plus character error rate (CER) for names, the metric
|
||||
* that drives glyph atlas expansion.
|
||||
*
|
||||
* Usage: pnpm scanner:report
|
||||
*/
|
||||
import { loadOpenCV } from "../../app/features/scanner/core/cv";
|
||||
import {
|
||||
createDeathDetector,
|
||||
type DeathData,
|
||||
} from "../../app/features/scanner/core/detectors/death/index";
|
||||
import {
|
||||
createMapStartDetector,
|
||||
type MapStartData,
|
||||
} from "../../app/features/scanner/core/detectors/map-start/index";
|
||||
import {
|
||||
createMinimapDetector,
|
||||
type MinimapData,
|
||||
} from "../../app/features/scanner/core/detectors/minimap/index";
|
||||
import { createScoreboardDetector } from "../../app/features/scanner/core/detectors/scoreboard/index";
|
||||
import { createScoreboardBattleLogDetector } from "../../app/features/scanner/core/detectors/scoreboard-battle-log/index";
|
||||
import {
|
||||
createScoreboardBattleLogReplayDetector,
|
||||
type ScoreboardBattleLogReplayData,
|
||||
} from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/index";
|
||||
import {
|
||||
createScoreboardOwnDetector,
|
||||
type ScoreboardOwnData,
|
||||
} from "../../app/features/scanner/core/detectors/scoreboard-own/index";
|
||||
import type { Detector } from "../../app/features/scanner/core/detectors/types";
|
||||
import {
|
||||
loadFixtures,
|
||||
runDetectorOnFixture,
|
||||
} from "../../app/features/scanner/node/fixtures";
|
||||
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
|
||||
|
||||
await loadOpenCV();
|
||||
const resources = await loadScoreboardResources();
|
||||
|
||||
interface Config {
|
||||
label: string;
|
||||
detector: Detector<Partial<ScoreboardBattleLogReplayData>>;
|
||||
fixturesDir: string;
|
||||
event: string;
|
||||
}
|
||||
|
||||
const configs: Config[] = [
|
||||
{
|
||||
label: "scoreboard",
|
||||
detector: createScoreboardDetector(resources),
|
||||
fixturesDir: "scoreboard",
|
||||
event: "Scoreboard",
|
||||
},
|
||||
{
|
||||
label: "scoreboard-battle-log-replay",
|
||||
detector: createScoreboardBattleLogReplayDetector(resources),
|
||||
fixturesDir: "scoreboard-battle-log-replay",
|
||||
event: "ScoreboardBattleLogReplay",
|
||||
},
|
||||
{
|
||||
label: "scoreboard-battle-log",
|
||||
detector: createScoreboardBattleLogDetector(resources),
|
||||
fixturesDir: "scoreboard-battle-log",
|
||||
event: "ScoreboardBattleLog",
|
||||
},
|
||||
];
|
||||
|
||||
interface Tally {
|
||||
ok: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Levenshtein distance for CER. */
|
||||
function editDistance(a: string, b: string): number {
|
||||
const dp = Array.from({ length: a.length + 1 }, (_, i) => {
|
||||
const row = new Array<number>(b.length + 1).fill(0);
|
||||
row[0] = i;
|
||||
return row;
|
||||
});
|
||||
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
dp[i]![j] = Math.min(
|
||||
dp[i - 1]![j]! + 1,
|
||||
dp[i]![j - 1]! + 1,
|
||||
dp[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
return dp[a.length]![b.length]!;
|
||||
}
|
||||
|
||||
function pct(t: Tally): string {
|
||||
if (t.total === 0) return " n/a";
|
||||
return `${((100 * t.ok) / t.total).toFixed(1).padStart(5)}% (${t.ok}/${t.total})`;
|
||||
}
|
||||
|
||||
for (const config of configs) {
|
||||
// Shared negatives count toward gate accuracy (expectPositive is false).
|
||||
const fixtures = [
|
||||
...loadFixtures(config.fixturesDir),
|
||||
...loadFixtures("negative"),
|
||||
];
|
||||
|
||||
const tally = {
|
||||
gate: { ok: 0, total: 0 } as Tally,
|
||||
header: { ok: 0, total: 0 } as Tally,
|
||||
timestamp: { ok: 0, total: 0 } as Tally,
|
||||
replayCode: { ok: 0, total: 0 } as Tally,
|
||||
matchScores: { ok: 0, total: 0 } as Tally,
|
||||
weapons: { ok: 0, total: 0 } as Tally,
|
||||
names: { ok: 0, total: 0 } as Tally,
|
||||
paint: { ok: 0, total: 0 } as Tally,
|
||||
stats: { ok: 0, total: 0 } as Tally,
|
||||
};
|
||||
let charEdits = 0;
|
||||
let charTotal = 0;
|
||||
const nameMisses: string[] = [];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const { gate, events } = await runDetectorOnFixture(
|
||||
config.detector,
|
||||
fixture,
|
||||
);
|
||||
const expectPositive = fixture.expected.event === config.event;
|
||||
tally.gate.total++;
|
||||
if (gate.pass === expectPositive) tally.gate.ok++;
|
||||
if (!expectPositive || !events[0]) continue;
|
||||
|
||||
const event = events[0];
|
||||
const expected = fixture.expected.data ?? {};
|
||||
if (expected.mode !== undefined) {
|
||||
tally.header.total++;
|
||||
if (
|
||||
event.data.lobby === (expected.lobby ?? null) &&
|
||||
event.data.mode === (expected.mode ?? null) &&
|
||||
event.data.stage === (expected.stage ?? null)
|
||||
) {
|
||||
tally.header.ok++;
|
||||
}
|
||||
}
|
||||
if (expected.timestamp !== undefined) {
|
||||
tally.timestamp.total++;
|
||||
if (event.data.timestamp === expected.timestamp) tally.timestamp.ok++;
|
||||
}
|
||||
if (expected.replayCode !== undefined) {
|
||||
tally.replayCode.total++;
|
||||
if (event.data.replayCode === expected.replayCode) tally.replayCode.ok++;
|
||||
}
|
||||
if (expected.matchScores) {
|
||||
tally.matchScores.total++;
|
||||
if (
|
||||
JSON.stringify(event.data.matchScores) ===
|
||||
JSON.stringify(expected.matchScores)
|
||||
) {
|
||||
tally.matchScores.ok++;
|
||||
}
|
||||
}
|
||||
(expected.players ?? []).forEach((want, i) => {
|
||||
const got = event.data.players?.[i];
|
||||
if (!got) return;
|
||||
if (want.weaponId !== undefined) {
|
||||
tally.weapons.total++;
|
||||
if (got.weaponId === want.weaponId) tally.weapons.ok++;
|
||||
}
|
||||
if (want.name !== undefined) {
|
||||
tally.names.total++;
|
||||
const dist = editDistance(got.name, want.name);
|
||||
charEdits += dist;
|
||||
charTotal += want.name.length;
|
||||
if (dist === 0) tally.names.ok++;
|
||||
else
|
||||
nameMisses.push(
|
||||
`${fixture.name} row${i}: "${got.name}" != "${want.name}"`,
|
||||
);
|
||||
}
|
||||
if (want.paint !== undefined) {
|
||||
tally.paint.total++;
|
||||
if (got.paint === want.paint) tally.paint.ok++;
|
||||
}
|
||||
if (want.ka !== undefined) {
|
||||
tally.stats.total++;
|
||||
if (
|
||||
got.ka === want.ka &&
|
||||
got.d === (want.d ?? null) &&
|
||||
got.s === (want.s ?? null)
|
||||
) {
|
||||
tally.stats.ok++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.info(`\n=== ${config.label} (${fixtures.length} fixtures) ===`);
|
||||
console.info(`gate ${pct(tally.gate)}`);
|
||||
console.info(`header ${pct(tally.header)}`);
|
||||
if (tally.timestamp.total > 0) {
|
||||
console.info(`timestamp ${pct(tally.timestamp)}`);
|
||||
}
|
||||
if (tally.replayCode.total > 0) {
|
||||
console.info(`replayCode ${pct(tally.replayCode)}`);
|
||||
}
|
||||
console.info(`matchScores ${pct(tally.matchScores)}`);
|
||||
console.info(`weapons ${pct(tally.weapons)}`);
|
||||
console.info(`names ${pct(tally.names)}`);
|
||||
console.info(`paint ${pct(tally.paint)}`);
|
||||
console.info(`stats ${pct(tally.stats)}`);
|
||||
console.info(
|
||||
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
|
||||
);
|
||||
if (nameMisses.length > 0) {
|
||||
console.info("name misses:");
|
||||
for (const m of nameMisses) console.info(` ${m}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Map-start fixtures only carry mode + stage, so they get their own pass.
|
||||
{
|
||||
const detector = createMapStartDetector(resources);
|
||||
const fixtures = loadFixtures("map-start");
|
||||
const tally = {
|
||||
gate: { ok: 0, total: 0 } as Tally,
|
||||
mode: { ok: 0, total: 0 } as Tally,
|
||||
stage: { ok: 0, total: 0 } as Tally,
|
||||
};
|
||||
const misses: string[] = [];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const { gate, events } = await runDetectorOnFixture<MapStartData>(
|
||||
detector,
|
||||
fixture,
|
||||
);
|
||||
const expectPositive = fixture.expected.event === "MapStart";
|
||||
tally.gate.total++;
|
||||
if (gate.pass === expectPositive) tally.gate.ok++;
|
||||
if (!expectPositive || !events[0]) continue;
|
||||
const event = events[0];
|
||||
const expected = fixture.expected.data ?? {};
|
||||
if (expected.mode !== undefined) {
|
||||
tally.mode.total++;
|
||||
if (event.data.mode === expected.mode) tally.mode.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name}: mode "${event.data.mode}" != "${expected.mode}"`,
|
||||
);
|
||||
}
|
||||
if (expected.stage !== undefined) {
|
||||
tally.stage.total++;
|
||||
if (event.data.stage === expected.stage) tally.stage.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`\n=== map-start (${fixtures.length} fixtures) ===`);
|
||||
console.info(`gate ${pct(tally.gate)}`);
|
||||
console.info(`mode ${pct(tally.mode)}`);
|
||||
console.info(`stage ${pct(tally.stage)}`);
|
||||
if (misses.length > 0) {
|
||||
console.info("misses:");
|
||||
for (const m of misses) console.info(` ${m}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Scoreboard-own fixtures carry header + own weapon + ability grid.
|
||||
{
|
||||
const detector = createScoreboardOwnDetector(resources);
|
||||
const fixtures = [
|
||||
...loadFixtures("scoreboard-own"),
|
||||
...loadFixtures("negative"),
|
||||
];
|
||||
const tally = {
|
||||
gate: { ok: 0, total: 0 } as Tally,
|
||||
header: { ok: 0, total: 0 } as Tally,
|
||||
weapon: { ok: 0, total: 0 } as Tally,
|
||||
abilities: { ok: 0, total: 0 } as Tally,
|
||||
};
|
||||
const misses: string[] = [];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const { gate, events } = await runDetectorOnFixture<ScoreboardOwnData>(
|
||||
detector,
|
||||
fixture,
|
||||
);
|
||||
const expectPositive = fixture.expected.event === "ScoreboardOwn";
|
||||
tally.gate.total++;
|
||||
if (gate.pass === expectPositive) tally.gate.ok++;
|
||||
if (!expectPositive || !events[0]) continue;
|
||||
const event = events[0];
|
||||
const expected = fixture.expected.data ?? {};
|
||||
if (expected.mode !== undefined) {
|
||||
tally.header.total++;
|
||||
if (
|
||||
event.data.lobby === (expected.lobby ?? null) &&
|
||||
event.data.mode === (expected.mode ?? null) &&
|
||||
event.data.stage === (expected.stage ?? null)
|
||||
) {
|
||||
tally.header.ok++;
|
||||
} else {
|
||||
misses.push(
|
||||
`${fixture.name}: header "${event.data.lobby}/${event.data.mode}/${event.data.stage}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (expected.weaponId !== undefined) {
|
||||
tally.weapon.total++;
|
||||
if (event.data.weaponId === expected.weaponId) tally.weapon.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`,
|
||||
);
|
||||
}
|
||||
(expected.abilities ?? []).forEach((wantRow, row) => {
|
||||
wantRow.forEach((want, slot) => {
|
||||
tally.abilities.total++;
|
||||
const got = event.data.abilities[row]?.[slot];
|
||||
if (got === want) tally.abilities.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.info(`\n=== scoreboard-own (${fixtures.length} fixtures) ===`);
|
||||
console.info(`gate ${pct(tally.gate)}`);
|
||||
console.info(`header ${pct(tally.header)}`);
|
||||
console.info(`weapon ${pct(tally.weapon)}`);
|
||||
console.info(`abilities ${pct(tally.abilities)}`);
|
||||
if (misses.length > 0) {
|
||||
console.info("misses:");
|
||||
for (const m of misses) console.info(` ${m}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Minimap fixtures carry teammates/enemies, so they get their own pass.
|
||||
{
|
||||
const detector = createMinimapDetector(resources);
|
||||
const fixtures = [...loadFixtures("minimap"), ...loadFixtures("negative")];
|
||||
const tally = {
|
||||
gate: { ok: 0, total: 0 } as Tally,
|
||||
weapons: { ok: 0, total: 0 } as Tally,
|
||||
names: { ok: 0, total: 0 } as Tally,
|
||||
abilities: { ok: 0, total: 0 } as Tally,
|
||||
stage: { ok: 0, total: 0 } as Tally,
|
||||
};
|
||||
let charEdits = 0;
|
||||
let charTotal = 0;
|
||||
const misses: string[] = [];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const { gate, events } = await runDetectorOnFixture<MinimapData>(
|
||||
detector,
|
||||
fixture,
|
||||
);
|
||||
const expectPositive = fixture.expected.event === "Minimap";
|
||||
tally.gate.total++;
|
||||
if (gate.pass === expectPositive) tally.gate.ok++;
|
||||
if (!expectPositive || !events[0]) continue;
|
||||
const event = events[0];
|
||||
const expected = fixture.expected.data ?? {};
|
||||
|
||||
const sides: [
|
||||
string,
|
||||
{
|
||||
name?: string | null;
|
||||
weaponId?: number | null;
|
||||
abilities?: (string | null)[];
|
||||
}[],
|
||||
{
|
||||
name?: string | null;
|
||||
weaponId: number | null;
|
||||
abilities: (string | null)[];
|
||||
}[],
|
||||
][] = [
|
||||
["teammate", expected.teammates ?? [], event.data.teammates],
|
||||
["enemy", expected.enemies ?? [], event.data.enemies],
|
||||
];
|
||||
for (const [side, wants, gots] of sides) {
|
||||
wants.forEach((want, i) => {
|
||||
const got = gots[i];
|
||||
if (!got) return;
|
||||
if (want.weaponId !== undefined) {
|
||||
tally.weapons.total++;
|
||||
if (got.weaponId === want.weaponId) tally.weapons.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name} ${side}${i}: weapon ${got.weaponId} != ${want.weaponId}`,
|
||||
);
|
||||
}
|
||||
if (want.name !== undefined && want.name !== null) {
|
||||
tally.names.total++;
|
||||
const gotName = got.name ?? "";
|
||||
const dist = editDistance(gotName, want.name);
|
||||
charEdits += dist;
|
||||
charTotal += want.name.length;
|
||||
if (dist === 0) tally.names.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name} ${side}${i}: name "${gotName}" != "${want.name}"`,
|
||||
);
|
||||
}
|
||||
(want.abilities ?? []).forEach((wantId, slot) => {
|
||||
tally.abilities.total++;
|
||||
const gotId = got.abilities[slot] ?? null;
|
||||
if (gotId === wantId) tally.abilities.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (expected.stage !== undefined) {
|
||||
tally.stage.total++;
|
||||
if (event.data.stage === expected.stage) tally.stage.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`\n=== minimap (${fixtures.length} fixtures) ===`);
|
||||
console.info(`gate ${pct(tally.gate)}`);
|
||||
console.info(`weapons ${pct(tally.weapons)}`);
|
||||
console.info(`names ${pct(tally.names)}`);
|
||||
console.info(`abilities ${pct(tally.abilities)}`);
|
||||
console.info(`stage ${pct(tally.stage)}`);
|
||||
console.info(
|
||||
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
|
||||
);
|
||||
if (misses.length > 0) {
|
||||
console.info("misses:");
|
||||
for (const m of misses) console.info(` ${m}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Death fixtures carry a different data shape (weapon text, ability grid,
|
||||
// splash-tag name), so they get their own pass instead of a Config entry.
|
||||
{
|
||||
const detector = createDeathDetector(resources);
|
||||
const fixtures = loadFixtures("death");
|
||||
const tally = {
|
||||
gate: { ok: 0, total: 0 } as Tally,
|
||||
weapon: { ok: 0, total: 0 } as Tally,
|
||||
abilities: { ok: 0, total: 0 } as Tally,
|
||||
names: { ok: 0, total: 0 } as Tally,
|
||||
};
|
||||
let charEdits = 0;
|
||||
let charTotal = 0;
|
||||
const misses: string[] = [];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const { gate, events } = await runDetectorOnFixture<DeathData>(
|
||||
detector,
|
||||
fixture,
|
||||
);
|
||||
const expectPositive = fixture.expected.event === "Death";
|
||||
tally.gate.total++;
|
||||
if (gate.pass === expectPositive) tally.gate.ok++;
|
||||
if (!expectPositive || !events[0]) continue;
|
||||
const event = events[0];
|
||||
const expected = fixture.expected.data ?? {};
|
||||
if (expected.weaponId !== undefined) {
|
||||
tally.weapon.total++;
|
||||
if (event.data.weaponId === expected.weaponId) tally.weapon.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`,
|
||||
);
|
||||
}
|
||||
(expected.abilities ?? []).forEach((wantRow, row) => {
|
||||
wantRow.forEach((want, slot) => {
|
||||
tally.abilities.total++;
|
||||
const got = event.data.abilities[row]?.[slot];
|
||||
if (got === want) tally.abilities.ok++;
|
||||
else
|
||||
misses.push(
|
||||
`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
if (expected.name !== undefined) {
|
||||
tally.names.total++;
|
||||
const got = event.data.name ?? "";
|
||||
const dist = editDistance(got, expected.name);
|
||||
charEdits += dist;
|
||||
charTotal += expected.name.length;
|
||||
if (dist === 0) tally.names.ok++;
|
||||
else misses.push(`${fixture.name}: name "${got}" != "${expected.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`\n=== death (${fixtures.length} fixtures) ===`);
|
||||
console.info(`gate ${pct(tally.gate)}`);
|
||||
console.info(`weapon ${pct(tally.weapon)}`);
|
||||
console.info(`abilities ${pct(tally.abilities)}`);
|
||||
console.info(`names ${pct(tally.names)}`);
|
||||
console.info(
|
||||
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
|
||||
);
|
||||
if (misses.length > 0) {
|
||||
console.info("misses:");
|
||||
for (const m of misses) console.info(` ${m}`);
|
||||
}
|
||||
}
|
||||
52
scripts/scanner/run-fixtures.ts
Normal file
52
scripts/scanner/run-fixtures.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
||||
/**
|
||||
* CLI harness: run the ScoreboardDetector over fixtures and print everything
|
||||
* it saw — gate result, parsed fields, per-field scores, top weapon candidates.
|
||||
*
|
||||
* Usage: pnpm scanner:fixtures [case-name-substring]
|
||||
*/
|
||||
import { loadOpenCV } from "../../app/features/scanner/core/cv";
|
||||
import {
|
||||
createScoreboardDetector,
|
||||
type ScoreboardRowDebug,
|
||||
} from "../../app/features/scanner/core/detectors/scoreboard/index";
|
||||
import {
|
||||
loadFixtures,
|
||||
runDetectorOnFixture,
|
||||
} from "../../app/features/scanner/node/fixtures";
|
||||
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
|
||||
|
||||
const filter = process.argv[2];
|
||||
|
||||
await loadOpenCV();
|
||||
const detector = createScoreboardDetector(await loadScoreboardResources());
|
||||
|
||||
const fixtures = loadFixtures("scoreboard").filter(
|
||||
(f) => !filter || f.name.includes(filter),
|
||||
);
|
||||
if (fixtures.length === 0) {
|
||||
console.error("no fixtures matched");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const { gate, events } = await runDetectorOnFixture(detector, fixture);
|
||||
console.info(`\n=== ${fixture.name}`);
|
||||
console.info(`gate: pass=${gate.pass} score=${gate.score.toFixed(3)}`);
|
||||
for (const event of events) {
|
||||
console.info(`event confidence=${event.confidence.toFixed(3)}`);
|
||||
console.info(`matchScores: ${JSON.stringify(event.data.matchScores)}`);
|
||||
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
|
||||
event.data.players.forEach((p, i) => {
|
||||
const dbg = rows[i];
|
||||
const top = dbg?.weapon?.top
|
||||
.map((t) => `${t.id}:${t.score.toFixed(2)}`)
|
||||
.join(" ");
|
||||
console.info(
|
||||
` row${i}: name="${p.name}"(${dbg?.nameScore.toFixed(2)}) ` +
|
||||
`weapon=${p.weaponId} [${top}] paint=${p.paint}(${dbg?.paintScore.toFixed(2)}) ` +
|
||||
`ka=${p.ka} d=${p.d} s=${p.s} statScores=${dbg?.statScores.map((s) => s.toFixed(2)).join(",")}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
18
scripts/scanner/vite-node.config.ts
Normal file
18
scripts/scanner/vite-node.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Config for running the scanner scripts with vite-node. Deliberately minimal:
|
||||
* the root vite.config.ts pre-bundles @techstark/opencv-js for the browser
|
||||
* worker, and vite-node would resolve that browser prebundle (which
|
||||
* crashes on __dirname in Node). Without the include, vite-node
|
||||
* externalizes the dep to a plain require of the (patched) CJS bundle.
|
||||
*/
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
noDiscovery: true,
|
||||
exclude: ["@techstark/opencv-js"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user