Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,249 @@
# Scanner — Splatoon match-event detection
Browser app (route `/scanner`, dev-only until promoted) that watches OBS
Virtual Camera footage, VoD files, or screenshots, detects Splatoon 3 UI
screens with OpenCV.js in a Web Worker, and parses them into events speaking
sendou.ink ids (`ModeShort`/`StageId`/weapon ids/`Ability`). Events aggregate
client-side into `ScannerMatch` objects (`core/scanner-match.ts`) — one
detected game per object, every field nullable — which feed `/ingest`
(features/scanner-ingest) and the `/vods/new` prefill. Imported from the
emberz repo; see `MIGRATION.md` there.
Deliberate convention exceptions (dev tool, ported wholesale): the UI is
English-only (no i18next), `tests/node-test-compat.ts` uses a default export
to stay a `node:test` drop-in, and the suites assert with `node:assert/strict`
rather than the repo-wide `expect`. Keep whichever file you touch on the
idiom it already uses — a half-migration would leave three idioms behind.
## Commands
```sh
pnpm test:scanner # golden-file suite over tests/fixtures/ (Vitest, Node)
pnpm test:unit:browser # includes tests/logic/ — the fixture-free half, see below
pnpm scanner:report # accuracy table + name character error rate across fixtures
pnpm scanner:fixtures [name-substring] # run detectors over matching fixtures, verbose
pnpm scanner:replay <dir> <startT> <fps> # replay ffmpeg-extracted frames through the scheduler+detectors
pnpm scanner:scan-vod <video> # VoD-tab scan as a CLI (ffmpeg): video in, events CSV out
pnpm scanner:status-audit <events.csv> # diff the CSV's timeline vs scoreboard D/S, rank fixture candidates
pnpm scanner:bootstrap-atlas # harvest labeled fixture crops into the glyph atlases
pnpm scanner:build-glyph-atlas # add the font-rendered charset (fonts required, see below)
pnpm scanner:build-localized-entries # regen localized closed sets from ../splat3
pnpm scanner:build-planner-signatures # regen the minimap stage-ID atlas from the assets repo
```
Scanner scripts run through `vite-node -c scripts/scanner/vite-node.config.ts`:
the root vite config pre-bundles `@techstark/opencv-js` for the browser worker
and vite-node must not consume that prebundle. The package is pnpm-patched
(`patches/`) to wrap its thenable CJS export as `{ cvReadyPromise }`,
unwrapped in `core/cv.ts`.
## Architecture
```mermaid
sequenceDiagram
participant Cap as capture (sampler / vod-frames)
participant W as analyzer.worker (OpenCV)
participant TL as TimelineBuilder
participant MB as match-builder
participant UI as Live/VoD tab
participant ING as /ingest (scanner-ingest)
participant DB as IngestedMatch / IngestedMatchLink
Cap->>W: frame + t (live/screenshot/seek) — VoD: worker decodes its own slice
W->>W: scheduler dueDetectors() → gate() → parse()
W-->>TL: DetectedEvents
TL-->>UI: deduped timeline (IndexedDB on Live)
UI->>MB: buildScannerMatches(events)
MB-->>UI: ScannerMatch[] + source events
UI->>ING: POST { matches } (Live: on match close / scan end, VoD: whole scan)
ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else content sequence ≥2)
ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge, context hints)
ING->>DB: link matches to game results → IngestedMatchLink (POV weapon → ReportedWeapon; scoreboards derived at read time)
Note over UI: VoD "Add VoD": ScannerMatch → slim prefill param → /vods/new
```
- `core/` is pure (mats in, events/matches out) and runs in the worker, the
Screenshot tab, and Node tests. No DOM/browser APIs; Node-only helpers live
in `node/`. Pure data/type imports from `~/modules` and
`~/features/build-analyzer/data` are fine — zod and the app config graph
are not (schemas live in `scanner-schemas.ts`; core only `import type`s
the shapes).
- `core/match-builder.ts` turns a timeline into `ScannerMatch`es: a MapStart
opens a match, a scoreboard closes one (claiming the last 8 min of deaths
when the intro was missed), minimaps group per map by confirmed stage
change and >5 min gap. An event belongs to at most one match; deaths
reveal enemy builds (`ability-harvest.ts`). Partial matches are fine —
scanner-ingest merges them server-side. Senders filter with
`ingestSkipReasons`: private/unread lobby only, and no games a disconnect
cut short (scoreless + counter left more time than the footage did, or
replayed right after on the same map — the latter is a VoD-scan filter in
practice since it only resolves after the fact).
- The route (`routes/scanner.tsx`) is SSR-guarded: the client tree loads via
`React.lazy` after `useHydrated`; nothing from `core/worker/capture/store`
may be imported at route-module top level.
- Eight detectors: `scoreboard` (results screen),
`scoreboard-battle-log-replay` (replay-browser detail),
`scoreboard-battle-log` (Recent Battles detail — same data sans replay
code, panels stacked), `scoreboard-own` (personal results), `death`
(respawn overlay), `map-start` (match intro), `minimap` (in-match overlay
+ casted 8-player spectator variant), `objective` (ranked counter overlay:
counts, penalties, holder, match timer — a mode-discriminated union with
only the SZ member so far). The objective parse also emits a second
event type per read: `PlayerStatus`
(`core/detectors/objective/player-status.ts`), per-player special/dead
flags off the icon strip flanking the timer (POV and casted spectator
geometries; D-pad camera badges prove the cast layout, but broadcasts
can hide them while keeping cast geometry, so a badge-less frame scores
both geometries on how decisively the bodies read and sticks with the
established layout unless the other wins clearly — the special-ready
wash also pulses, so its dim trough is told apart from a splat by its
pale body, and a cast-layout ready read must also see a washed
(ink-poor) body: pale backdrop or the lead banner leaking past an icon
edge fakes the shoulder glow on the overhead map view's badge-less
strip), with
the same `time` value so the two reads pair downstream; its fixtures
live under `tests/fixtures/player-status/`. Within a side the strip's
slot order is the lobby seating, while the results scoreboard re-sorts
each team per game (attested in the sendou-triton VoD: strip [Planetz,
.52, Neo Splash, Snipewriter] vs rows [.52, Neo Splash, Snipewriter,
Planetz], and the orders differ per game while the seating holds) — so
every 5th counter read also samples a `StripWeapons` evidence event: a
ranked weapon-icon match per alive slot (the squid plate's team ink is
hue-knocked-out to flat grey first; splatted slots grey the render out
and are skipped). Single reads rank the true weapon top-1 only about
half the time; the builder aggregates them across the match — plus the
minimap cards' parsed weapons, whose column order mirrors the strip
seating (attested for the enemy column) — and takes the best-scoring of
the 24 slot→row assignments against the scoreboard's weapons
(`core/slot-row-assignment.ts`), falling back to as-drawn order on thin
or tied evidence. The POV overlay's teammate diamond follows neither
order and maps by card name instead. Strip-weapon fixtures live under
`tests/fixtures/strip-weapons/`. The builder additionally
flips sub-2s dead-flag runs flanked by dense opposite reads — a splat
outlasts the respawn wait, so those are misread blips (background ink
bleeding through a crossed-out icon) — and bridges sub-10s not-ready
gaps between ready reads when no death inside the gap explains them (no
special regains that fast, so the gap is the wash's dim pulse trough). Objective reads land on `ScannerMatch` as
progress samples anchored to the game clock; broadcast replay wipes re-run
an earlier moment with the counter intact, so the builder keeps only the
dominant cluster of clock-zero projections (`t + time`) and drops replay
reads outright (timerless reads follow their preceding anchored
neighbor). A displayed count only ever
ticks down, so the builder keeps each side's longest non-increasing score
run and voids reads off it (surviving OCR blips chart as gaps, not dips). Each read also carries a
per-side team ink color (`core/ink-color.ts` — the plate fill in
control, the digit ink otherwise): casted footage keeps the specced
player's team on the left plate, so the builder orients samples by ink
hue and anchors them to `teams` order via the minimap sub-tile colors
(casts never show a results screen). Reads grouping into a match
whose detected mode is not SZ are lookalike misreads: the builder nulls
that match's `objective` and callers discard the events
(`invalidObjectiveEvents`; Live also stops collecting once a MapStart
reveals a non-SZ mode). PlayerStatus reads follow the objective pipeline
wholesale: same replay-wipe anchor, cast orientation inherited from the
nearest counter read, nulled together on non-SZ matches, and rendered as
per-player splat/special bands (`~/components/PlayerStatusTimeline.tsx`,
shared with the match page) above the objective chart. Minimap reads
feed the same samples: every card/row carries `dead` (respawn
cross-out) and `specialReady` (special camo) flags, merged in timerless
on the shared replay anchor — and mode-agnostic, so a known non-SZ
match keeps its minimap-sourced samples while its counter/status
misreads are voided. Parsing details
are in each detector's module
header; accuracy-critical matching internals in `core/glyphs.ts` and
`core/detectors/scoreboard/weapons.ts` — read those before touching
recognition code.
- Scheduling (`core/detectors/scheduler.ts`): the per-session
DetectorScheduler decides which detectors see a frame. Failing gates are
re-checked every `searchIntervalS` (0.25s — produced VoDs cut screens to
~1s, and gates are ~ms-cheap); a passing gate drops to the dense refine
cadence (`refineIntervalS` overrides for expensive parses). Suppression
ends a refinement streak on parse-count stagnation AND ~3s elapsed (the
floor spans entry animations), or immediately at `sufficientConfidence`
(set just under each detector's measured clean-read floor); death adds
`rearmCooldownS`. Battle-log/replay gates return a content `signature` so
browsing distinct entries re-parses once per battle instead of dropping
the gate. `checkIntervalS` hard-caps both phases; `attachFrame: false`
keeps continuously-firing events from storing a frame PNG each. Frames no
detector is due for skip canvas readback, and everything is counted in
`core/detectors/telemetry.ts` — but only when the VoD tab is opened with
`?telemetry=true` (nothing links there); otherwise the workers skip
collection and the panel stays hidden. A match's objective reads render
as one step-line timeline
(`~/components/ObjectiveTimeline.tsx`, shared with the match page).
- VoD scans (`components/VodPage.tsx`): on the WebCodecs path each worker
demuxes + decodes its own contiguous slice (mediabunny in the worker — no
frames cross the main thread). When the scheduler reports calm (no gate
pass for a quiet period, no open match), the worker skims
keyframe-to-keyframe (hop capped at 2.5s so short screens can't hide),
snapping back to dense decode on any gate pass. The seek fallback drives
one worker and widens its stride over calm footage the same way.
- Recognition is language-agnostic: OCR output snaps against every game
language at once (`core/localized-entries.ts`, generated) and events carry
sendou ids. English display names come from `components/labels.ts`.
- ROI coordinates live in each detector's `rois.ts`, in canonical 1920×1080
space; every frame is normalized to that size first.
- New event types implement `Detector` (`core/detectors/types.ts`): a cheap
`gate(mat)` at sample rate plus `parse(mat, t)` when the gate fires.
Register in `core/detectors/registry.ts`.
## Assets (CDN) and fonts
Weapon/ability/special/sub template sources are the site's shared game icons
in the **sendou-ink/assets repo** under `assets/img/**` (`.avif`; ids from
`~/modules/in-game-lists`, plus the scanner-only `UNKNOWN` ability badge —
`toAbilityWithUnknown` narrows template ids back to sendou ids).
Scanner-specific sets — glyph atlases and the planner signature atlas — live
here under `public/scanner/v1/**` (override with `SCANNER_ASSETS_DIR`; the
version segment bumps on breaking atlas-format changes). xxx: the atlases are
in `public/` only while the feature is in development — move them to the
assets repo (and the worker back to the CDN base) later.
- Browser/worker: icons from `Config.staticAssetsUrl` at `img/**` (base URL
rides the worker init message; the DO Space needs CORS for GET from
sendou.ink + localhost); atlases same-origin from `/scanner/v1/**`. Local
dev against fresh icon regens:
`npx serve /Users/kalle/Developer/assets/assets -l 9100 --cors` and
`VITE_STATIC_ASSETS_URL=http://localhost:9100` in `.env`.
- Node (tests/scripts): atlases from `public/scanner/v1`, icons from the
`../assets` checkout, never the CDN. AVIF decodes through `sharp`
(`node/image-io.ts`) — `@napi-rs/canvas` mis-decodes AVIF partial-alpha.
- Atlas regens overwrite `public/scanner/v1` in place and ship with the app
build.
Fonts are proprietary and gitignored: `BlitzMain.otf`, `BlitzBold.otf`,
`FOT-RowdyStd-EB.otf`, `FOT-KurokaneStd-EB.otf` in `assets/fonts/` (repo
root; from the splatoon3-fonts repo). Atlas builders fail loudly without
them. Names and row digits use BlitzMain; team totals BlitzBold; the replay
code line and VICTORY/DEFEAT tags FOT-RowdyStd-EB; the JP death message mixes
condensed Kurokane and Rowdy (`death-weapon-ja`). Regeneration order:
`scanner:bootstrap-atlas` (fixture crops win via tie-break) →
`scanner:build-glyph-atlas`; localized sets via
`scanner:build-localized-entries` (expects a splat3 checkout at `../splat3`)
then the atlas rebuild; planner atlas via `scanner:build-planner-signatures`
(reads the assets repo's `assets/planner-maps/`, MINI variant).
## Tests
`tests/*.test.ts` are the golden-file suites: they read frames from
`tests/fixtures/` and need game icons from a sibling `sendou-ink/assets`
checkout, so they run in their own Vitest project (`vitest.scanner.config.ts`)
and stay out of CI.
`tests/logic/*.test.ts` are pure logic over synthetic events — no images, no
assets checkout — so they belong to the `unit` project and do run in CI. Put
new tests there whenever they can be written without a frame.
## Fixtures
A test case is a directory `tests/fixtures/<detector>/<case-name>/` with
`frame.png|jpg` (raw capture, never re-encoded) and `expected.json` (partial
expectations, sendou ids; `stageLabel`/`weaponLabel` are informational for
the human corrector — tests compare only ids). Negative cases
(`{ "event": "none" }`) go in the shared `tests/fixtures/negative/`; every
detector's suite sweeps them. Every live misread should become a fixture —
the live app's "Save fixture" button exports the byte-exact analyzed frame
plus a prefilled `expected.json`. **Fixture ground-truth labels are
hand-corrected by the user (the Splatoon domain authority) — treat them as
definitive over any matcher output.** Fixtures are committed as plain blobs
(no LFS for now); keep additions deliberate — fixture IO is isolated in
`node/fixtures.ts` if a retreat to LFS/an external corpus is needed.

View File

@@ -0,0 +1,75 @@
/**
* Capture layer: OBS Virtual Camera in via getUserMedia, frames out as
* ImageBitmaps at a low sample rate. The interface downstream is just
* (bitmap, t) — a WHIP/MediaMTX transport can replace this file later.
*/
export async function openVirtualCamera(
deviceId?: string,
): Promise<MediaStream> {
return navigator.mediaDevices.getUserMedia({
video: {
deviceId: deviceId ? { exact: deviceId } : undefined,
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
});
}
export async function listVideoInputs(): Promise<MediaDeviceInfo[]> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((d) => d.kind === "videoinput");
}
export type FrameHandler = (bitmap: ImageBitmap, t: number) => void;
// xxx: hidden tabs can still be frozen/discarded outright, which suspends the
// workers too. A silent looping <audio> marks the page as playing audio and
// exempts it from both intensive throttling and freezing — measure whether we
// need it before adding it.
/**
* Sample frames from a playing video element at ~fps. The clock is a
* setInterval in a dedicated worker (ticker.worker.ts): rAF and
* requestVideoFrameCallback pause entirely in hidden tabs, but worker timers
* keep firing and the camera stream keeps decoding, so capture continues
* while the user is in another tab or window. Timestamps come from
* performance.now() (monotonic, which the timeline's merge windows rely on)
* rather than mediaTime, which Firefox never advances for MediaStream-backed
* videos anyway. Returns a stop function.
*/
export function startSampler(
video: HTMLVideoElement,
fps: number,
onFrame: FrameHandler,
): () => void {
const ticker = new Worker(new URL("./ticker.worker.ts", import.meta.url), {
type: "module",
});
ticker.postMessage(1000 / fps);
let stopped = false;
let sampling = false;
ticker.onmessage = async () => {
if (stopped || sampling) return;
sampling = true;
try {
const bitmap = await createImageBitmap(video);
if (stopped) {
bitmap.close();
return;
}
onFrame(bitmap, performance.now() / 1000);
} catch {
// video not ready — skip this frame
} finally {
sampling = false;
}
};
return () => {
stopped = true;
ticker.terminate();
};
}

View File

@@ -0,0 +1,9 @@
/**
* Interval clock for the frame sampler. Lives in a worker because worker
* timers keep firing while the tab is hidden, unlike main-thread rAF /
* requestVideoFrameCallback / throttled setInterval. Receives the interval
* in ms, then posts a tick forever.
*/
self.onmessage = (e: MessageEvent<number>) => {
setInterval(() => self.postMessage(0), e.data);
};

View File

@@ -0,0 +1,95 @@
/**
* VoD scan entry points. The primary path probes whether WebCodecs (via
* mediabunny) can decode the file — if so, the analyzer workers each demux
* and decode their own time slice of it (worker/analyzer.worker.ts) and no
* frames cross the main thread at all. When the container/codec can't be
* read that way, the fallback seek-steps a <video> element, which handles
* anything the browser can play at the cost of per-seek latency; its stride
* is supplied per step so the caller can widen it over calm footage.
*/
import { ALL_FORMATS, BlobSource, Input } from "mediabunny";
interface VodFrame {
/** the consumer owns the frame and must close() it */
frame: ImageBitmap;
/** seconds into the video */
t: number;
}
/**
* Whether mediabunny + WebCodecs can decode `file`, and its duration if so.
*/
export async function probeWebCodecs(
file: File,
): Promise<{ duration: number } | null> {
const input = new Input({
formats: ALL_FORMATS,
source: new BlobSource(file),
});
try {
const track = await input.getPrimaryVideoTrack();
if (track && (await track.canDecode())) {
return { duration: await input.computeDuration([track]) };
}
return null;
} catch {
return null;
} finally {
input.dispose();
}
}
/**
* Open a seek-stepping scan over `video` (which must already have the file
* loaded; metadata is awaited here). `nextStrideS` is consulted after every
* yielded frame, so analysis feedback can adjust the step on the fly.
*/
export async function openSeekScan(
video: HTMLVideoElement,
nextStrideS: () => number,
): Promise<{ duration: number; frames: AsyncGenerator<VodFrame> }> {
await loadMetadata(video);
if (!Number.isFinite(video.duration)) {
throw new Error("video has no known duration — cannot scan by seeking");
}
return { duration: video.duration, frames: seekFrames(video, nextStrideS) };
}
async function* seekFrames(
video: HTMLVideoElement,
nextStrideS: () => number,
): AsyncGenerator<VodFrame> {
for (let t = 0; t < video.duration; ) {
await seekTo(video, t);
yield { frame: await createImageBitmap(video), t };
t += Math.max(0.01, nextStrideS());
}
}
function loadMetadata(video: HTMLVideoElement): Promise<void> {
return new Promise((resolve, reject) => {
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) return resolve();
video.addEventListener("loadedmetadata", () => resolve(), { once: true });
video.addEventListener(
"error",
() =>
reject(
new Error(video.error?.message || "cannot decode this file as video"),
),
{ once: true },
);
});
}
function seekTo(video: HTMLVideoElement, t: number): Promise<void> {
return new Promise((resolve) => {
if (
video.currentTime === t &&
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
) {
return resolve();
}
video.addEventListener("seeked", () => resolve(), { once: true });
video.currentTime = t;
});
}

View File

@@ -0,0 +1,13 @@
button.abilityTrigger {
height: auto;
padding: var(--s-0-5);
background: var(--color-bg-higher);
border: 1px solid var(--color-border-high);
border-radius: var(--radius-selector);
& img {
display: block;
width: var(--field-size-icon);
height: var(--field-size-icon);
}
}

View File

@@ -0,0 +1,63 @@
/**
* Gear-ability grid (3 rows: head/clothes/shoes, each [main, sub, sub, sub])
* shared by the death card and the scoreboard player popover.
*/
import { Button } from "react-aria-components";
import { Ability } from "~/components/Ability";
import { SendouPopover } from "~/components/elements/Popover";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import styles from "./AbilityGrid.module.css";
import eventCardStyles from "./EventCard.module.css";
const ROW_LABELS = ["head", "clothes", "shoes"] as const;
export function AbilityGrid({
abilities,
}: {
abilities: AbilityWithUnknown[][];
}) {
return (
<table className={eventCardStyles.players}>
<tbody>
{abilities.map((row, i) => (
<tr key={i}>
<td>{ROW_LABELS[i]}</td>
{row.map((id, j) => (
<td key={j}>
<Ability ability={id} size={j === 0 ? "SUBTINY" : "TINY"} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
/**
* Click-to-toggle popover showing a player's ability grid; the trigger is
* the head-main ability icon.
*/
export function AbilityPopover({
abilities,
}: {
abilities: AbilityWithUnknown[][];
}) {
const trigger = abilities[0]?.[0];
if (!trigger) return null;
return (
<SendouPopover
trigger={
<Button
className={styles.abilityTrigger}
aria-label="Show abilities (from death events)"
>
<Ability ability={trigger} size="TINY" />
</Button>
}
>
<AbilityGrid abilities={abilities} />
</SendouPopover>
);
}

View File

@@ -0,0 +1,40 @@
.body {
display: flex;
align-items: center;
gap: var(--s-3);
padding-block: var(--s-0-5);
}
.info {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
min-width: 0;
}
.name {
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weapon {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.abilities {
display: flex;
align-items: center;
gap: var(--s-2-5);
margin-inline-start: auto;
flex-wrap: wrap;
}
.gear {
display: flex;
align-items: center;
gap: var(--s-0-5);
}

View File

@@ -0,0 +1,82 @@
import clsx from "clsx";
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import styles from "./DeathCard.module.css";
import eventCardStyles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { weaponLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function DeathCard(props: {
t: number;
confidence: number;
data: DeathData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const weaponName = weaponLabel(data.weaponType, data.weaponId);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={DEATH_EVENT_TYPE}
label="death"
/>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "Death" }}
/>
</div>
<div className={clsx(eventCardStyles.teams, eventCardStyles.death)}>
<div className={eventCardStyles.team}>
<div className={styles.body}>
{data.weaponId !== null && data.weaponType === "MAIN" ? (
<WeaponImage
weaponSplId={data.weaponId as MainWeaponId}
variant="build"
size={28}
className={eventCardStyles.weaponIcon}
/>
) : null}
<div className={styles.info}>
<span className={styles.name}>
splatted by <b>{data.name ?? "?"}</b>
</span>
<span className={styles.weapon}>{weaponName ?? "?"}</span>
</div>
<div className={styles.abilities}>
{data.abilities.map((row, i) => (
<div key={i} className={styles.gear}>
{row.map((id, j) => (
<Ability
key={j}
ability={id}
size={j === 0 ? "SUBTINY" : "TINY"}
/>
))}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
/* card chrome shared by every detected-event card, plus the sendou.ink send
status strip the feed pages hang under one */
.card {
background: var(--color-bg-high);
border-radius: var(--radius-box);
/* MatchCard's nested event list tightens this */
padding: var(--scanner-card-padding, var(--s-4));
min-width: 0;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--s-2) var(--s-4);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
margin-bottom: var(--s-2);
align-items: center;
}
.teams {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: var(--s-2);
}
/* single hug-content box (own-results cards) */
.solo {
grid-template-columns: max-content;
}
/* single full-width box (death cards) */
.death {
grid-template-columns: 1fr;
}
/* lighter than a solid slab: translucent fill + hairline so the boxes read
as grouping, not chrome */
.team {
border-radius: var(--radius-field);
padding: var(--s-2) var(--s-2-5);
background: color-mix(in oklab, var(--color-bg) 45%, transparent);
border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent);
min-width: 0;
overflow-x: auto;
& h3 {
margin: 0 0 var(--s-1-5);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-high);
}
&.win h3 {
color: var(--color-error-high);
}
&.lose h3 {
color: var(--color-info-high);
}
}
.players {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& td {
padding: var(--s-0-5) var(--s-1-5);
white-space: nowrap;
&.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
}
.solo .players {
width: auto;
}
.weaponCell {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
/* round black pucks, same language as the match card's weapon row */
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
/* sendou.ink send status strip under a feed card */
.sendWrap {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.sendStrip {
display: flex;
gap: var(--s-3);
align-items: center;
padding: var(--s-1) var(--s-4);
border-radius: var(--radius-box);
background: var(--color-bg-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.sent {
color: var(--color-success-high);
background: var(--color-success-low);
}
.failed {
color: var(--color-error-high);
background: var(--color-error-low);
}
.queued,
.sending {
color: var(--color-info-high);
background: var(--color-info-low);
}
.sendError {
color: inherit;
font-weight: var(--weight-body);
}

View File

@@ -0,0 +1,175 @@
/**
* Single dispatch point from a detected event to its card component, shared
* by the live feed and the VoD feed. Frames are loaded lazily through
* `getFrame` (IndexedDB keeps them out of the listed records); the Inspect
* action (open the frame in the screenshot page in a new browser tab, so
* the running scan is left undisturbed) is derived from it here so pages
* don't duplicate the wiring.
*/
import clsx from "clsx";
import { SCANNER_PAGE } from "~/utils/urls";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import { newInspectKey, putInspectFrame } from "../store/inspect";
import { DeathCard } from "./DeathCard";
import styles from "./EventCard.module.css";
import type { FixtureData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { MapStartCard } from "./MapStartCard";
import { MinimapCard } from "./MinimapCard";
import { ObjectiveCard } from "./ObjectiveCard";
import { PlayerStatusCard } from "./PlayerStatusCard";
import { ScoreboardCard } from "./ScoreboardCard";
import { ScoreboardOwnCard } from "./ScoreboardOwnCard";
import { StripWeaponsCard } from "./StripWeaponsCard";
export type GetFrame = () => Promise<Blob | null | undefined>;
export function EventCard(props: {
type: string;
t: number;
confidence: number;
data: FixtureData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame; enables Inspect + fixture export */
getFrame?: GetFrame;
/** Scoreboard only: abilities harvested from the match's death events */
abilities?: PlayerAbilityMap;
/** sendou.ink /ingest status of this event; absent = never attempted */
send?: SendStatus;
/** when set, shows a Send/Retry button that sends this event's match batch */
onSend?: () => void;
}) {
const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props;
// window.open must run synchronously in the click gesture (popup blockers);
// the frame write catches up and the new tab polls for it
const onInspect = getFrame
? () => {
const key = newInspectKey();
window.open(
scannerSearchParams.href(SCANNER_PAGE, {
tab: "screenshot",
inspect: key,
}),
"_blank",
);
void getFrame().then((frame) => {
if (frame) void putInspectFrame(key, frame);
});
}
: undefined;
const shared = { t, confidence, thumbnail, detectedAt, getFrame, onInspect };
const card = renderCard(type, data, shared, props.abilities);
if (!props.send && !props.onSend) return card;
return (
<div className={styles.sendWrap}>
{card}
<SendStrip send={props.send} onSend={props.onSend} />
</div>
);
}
const SEND_LABELS: Record<SendStatus["state"], string> = {
queued: "queued",
sending: "sending…",
sent: "sent",
unlinked: "waiting for report",
failed: "failed",
};
function SendStrip({
send,
onSend,
}: {
send?: SendStatus;
onSend?: () => void;
}) {
const state = send?.state;
const formatSentAt = useEventTimeFormatter();
return (
<div className={clsx(styles.sendStrip, state ? styles[state] : null)}>
<span>
sendou.ink: {state ? SEND_LABELS[state] : "not sent"}
{state === "sent" && send ? ` ${formatSentAt(send.at)}` : null}
</span>
{send?.error ? (
<span className={styles.sendError}>{send.error}</span>
) : null}
{onSend && state !== "sent" && state !== "sending" ? (
<button type="button" onClick={onSend}>
{state === "failed" ? "Retry" : "Send"}
</button>
) : null}
</div>
);
}
function renderCard(
type: string,
data: FixtureData,
shared: {
t: number;
confidence: number;
thumbnail?: string;
detectedAt?: number;
getFrame?: GetFrame;
onInspect?: () => void;
},
abilities?: PlayerAbilityMap,
) {
return type === DEATH_EVENT_TYPE ? (
<DeathCard {...shared} data={data as DeathData} />
) : type === MAP_START_EVENT_TYPE ? (
<MapStartCard {...shared} data={data as MapStartData} />
) : type === SCOREBOARD_OWN_EVENT_TYPE ? (
<ScoreboardOwnCard {...shared} data={data as ScoreboardOwnData} />
) : type === MINIMAP_EVENT_TYPE ? (
<MinimapCard {...shared} data={data as MinimapData} />
) : type === OBJECTIVE_EVENT_TYPE ? (
<ObjectiveCard {...shared} data={data as ObjectiveData} />
) : type === PLAYER_STATUS_EVENT_TYPE ? (
<PlayerStatusCard {...shared} data={data as PlayerStatusData} />
) : type === STRIP_WEAPONS_EVENT_TYPE ? (
<StripWeaponsCard {...shared} data={data as StripWeaponsData} />
) : (
<ScoreboardCard
{...shared}
eventType={type}
data={data as ScoreboardData}
abilities={abilities}
/>
);
}

View File

@@ -0,0 +1,47 @@
/**
* Shared lucide glyph per detected-event type, so the events summary line
* and the event cards mark each type with the same icon.
*/
import {
CircleHelp,
History,
type LucideIcon,
Map as MapIcon,
Play,
RotateCcw,
Skull,
Target,
Trophy,
User,
} from "lucide-react";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
const EVENT_TYPE_ICONS: Record<string, LucideIcon> = {
[MAP_START_EVENT_TYPE]: Play,
[DEATH_EVENT_TYPE]: Skull,
[MINIMAP_EVENT_TYPE]: MapIcon,
[OBJECTIVE_EVENT_TYPE]: Target,
[SCOREBOARD_EVENT_TYPE]: Trophy,
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: RotateCcw,
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: History,
[SCOREBOARD_OWN_EVENT_TYPE]: User,
};
export function EventTypeIcon({
type,
size = 12,
}: {
type: string;
size?: number;
}) {
const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp;
return <Icon size={size} aria-hidden />;
}

View File

@@ -0,0 +1,37 @@
.summary {
display: flex;
align-items: center;
gap: var(--s-2-5);
flex-wrap: wrap;
padding: var(--s-0-5) var(--s-1);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.type {
display: inline-flex;
align-items: center;
gap: 5px;
font-variant-numeric: tabular-nums;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-full);
background: var(--color-bg-higher);
color: var(--color-text-high);
}
button.toggle {
border: none;
background: transparent;
color: var(--color-text-accent);
height: auto;
padding: 0;
font-size: var(--font-2xs);
}

View File

@@ -0,0 +1,65 @@
/**
* One light line summarizing raw detections as per-type counts, with a
* toggle for their event card feed. Callers pass only events not covered
* by a match card — a fallback for detections not yet grouped, hidden
* entirely once everything lives in a match.
*/
import * as R from "remeda";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
import styles from "./EventsSummary.module.css";
import { EventTypeIcon } from "./EventTypeIcon";
const EVENT_TYPE_LABELS: Record<string, string> = {
[MAP_START_EVENT_TYPE]: "map start",
[DEATH_EVENT_TYPE]: "death",
[MINIMAP_EVENT_TYPE]: "minimap",
[OBJECTIVE_EVENT_TYPE]: "objective",
[SCOREBOARD_EVENT_TYPE]: "scoreboard",
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: "replay scoreboard",
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: "battle log",
[SCOREBOARD_OWN_EVENT_TYPE]: "own result",
};
export function EventsSummary({
events,
open,
onToggle,
}: {
events: ReadonlyArray<{ type: string }>;
open: boolean;
onToggle: () => void;
}) {
const counts = R.countBy(events, (event) => event.type);
const sorted = Object.entries(counts).toSorted((a, b) => b[1] - a[1]);
return (
<div className={styles.summary}>
{sorted.map(([type, count]) => {
const label = EVENT_TYPE_LABELS[type] ?? type;
return (
<span
key={type}
className={styles.type}
title={`${count} ${label}${count === 1 ? "" : "s"}`}
>
<span className={styles.icon}>
<EventTypeIcon type={type} />
</span>
×{count}
</span>
);
})}
<button type="button" className={styles.toggle} onClick={onToggle}>
{open ? "Hide events" : "Show events"}
</button>
</div>
);
}

View File

@@ -0,0 +1,39 @@
/* an unstyled button so the image itself is the target */
button.thumbButton {
height: auto;
padding: 0;
border: none;
background: none;
margin-inline-start: auto;
border-radius: var(--radius-field);
&:hover {
filter: brightness(1.2);
}
}
.thumb {
display: block;
height: 40px;
width: auto;
border-radius: var(--radius-field);
}
.dialog {
width: max-content;
max-width: min(96vw, 1400px);
}
.frameFull {
display: block;
max-width: 100%;
max-height: calc(80dvh - 9rem);
border-radius: var(--radius-field);
}
.frameActions {
display: flex;
justify-content: center;
gap: var(--s-3);
margin-block-start: var(--s-3);
}

View File

@@ -0,0 +1,109 @@
/**
* Analyzed-frame preview in an event card's meta row. Clicking it opens the
* frame big in a dialog — the exact lossless frame once its lazy loader
* resolves, the thumbnail as a stand-in until then. The frame actions
* (Inspect, Save fixture) live under the dialog image.
*/
import { ExternalLink, FlaskConical } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import styles from "./FrameThumb.module.css";
import { type FixtureData, saveFixtureFromEvent } from "./fixture-export";
export function FrameThumb({
thumbnail,
getFrame,
onInspect,
fixture,
}: {
thumbnail?: string;
getFrame?: () => Promise<Blob | null | undefined>;
/** opens the frame in the screenshot page in a new browser tab */
onInspect?: () => void;
/** enables Save fixture: the event's payload and fixture type label */
fixture?: { data: FixtureData; type: string };
}) {
const [open, setOpen] = useState(false);
const [frameUrl, setFrameUrl] = useState<string | null>(null);
const frameUrlRef = useRef<string | null>(null);
useEffect(
() => () => {
if (frameUrlRef.current) URL.revokeObjectURL(frameUrlRef.current);
},
[],
);
if (!thumbnail) return null;
const show = () => {
setOpen(true);
if (!getFrame || frameUrlRef.current) return;
void getFrame().then((frame) => {
if (!frame || frameUrlRef.current) return;
frameUrlRef.current = URL.createObjectURL(frame);
setFrameUrl(frameUrlRef.current);
});
};
const onSaveFixture =
getFrame && fixture
? () =>
void getFrame().then(
(frame) =>
frame && saveFixtureFromEvent(frame, fixture.data, fixture.type),
)
: undefined;
return (
<>
<button
type="button"
className={styles.thumbButton}
title="View frame"
onClick={show}
>
<img className={styles.thumb} src={thumbnail} alt="analyzed frame" />
</button>
{open ? (
<SendouDialog
isDismissable
aria-label="Analyzed frame"
className={styles.dialog}
onClose={() => setOpen(false)}
>
<img
className={styles.frameFull}
src={frameUrl ?? thumbnail}
alt="analyzed frame"
/>
{onInspect || onSaveFixture ? (
<div className={styles.frameActions}>
{onInspect ? (
<SendouButton
size="small"
icon={<ExternalLink />}
onPress={onInspect}
>
Inspect
</SendouButton>
) : null}
{onSaveFixture ? (
<SendouButton
size="small"
variant="outlined"
icon={<FlaskConical />}
onPress={onSaveFixture}
>
Save fixture
</SendouButton>
) : null}
</div>
) : null}
</SendouDialog>
) : null}
</>
);
}

View File

@@ -0,0 +1,34 @@
/* the global select is full width, which would break the controls row */
.deviceSelect {
appearance: none;
width: auto;
max-width: 20rem;
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
background-image: var(--field-icon-chevron);
background-position: center right var(--field-padding);
background-size: var(--field-size-icon) auto;
background-repeat: no-repeat;
color: var(--color-text);
cursor: pointer;
font-family: inherit;
font-size: var(--font-xs);
height: var(--field-size-sm);
padding: 0 calc(var(--field-size-icon) + var(--field-padding) + var(--s-2)) 0
var(--field-padding);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
outline: none;
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}

View File

@@ -0,0 +1,581 @@
import clsx from "clsx";
import { Camera, Ellipsis, FileText, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { GameTimeline } from "~/components/GameTimeline";
import {
listVideoInputs,
openVirtualCamera,
startSampler,
} from "../capture/sampler";
import { connectAbilities } from "../core/ability-harvest";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "../core/detectors/objective/strip-weapons";
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent, GateResult } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import {
buildScannerMatches,
ingestSkipReasons,
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import {
clearEvents,
deleteEvents,
listEvents,
loadEventFrame,
type StoredEvent,
saveEvent,
updateEventsSend,
} from "../store/events";
import { AnalyzerClient } from "../worker/client";
import { withoutRepeatEvents } from "./dedupe-events";
import { EventCard } from "./EventCard";
import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import styles from "./LivePage.module.css";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { playerStatusTeams } from "./player-status-view";
import {
aggregateSendStatus,
matchContaining,
retryableUnlinkedMatches,
type SendouUser,
sendMatches,
unsentMatches,
} from "./sendou-ingest";
import { thumbnailFromBlob } from "./thumbnail";
const SAMPLE_FPS = 2;
/**
* How often a running capture rechecks whether a match sendou.ink could not
* link yet is due for another attempt (the backoff itself lives in
* sendou-ingest.ts).
*/
const UNLINKED_RETRY_TICK_MS = 15_000;
/** The scan knows the on-screen sides only, not who is playing. */
const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
/** Event types the ingested matches are built from — the only ones with a send status. */
const INGESTABLE_TYPES = [
MAP_START_EVENT_TYPE,
DEATH_EVENT_TYPE,
MINIMAP_EVENT_TYPE,
...SCOREBOARD_EVENT_TYPES,
];
type Status = "idle" | "loading" | "watching" | "detected" | "error";
export function LivePage({
sendouUser,
}: {
/** sendou.ink login shown in the header; undefined = probing, null = not logged in */
sendouUser: SendouUser | null | undefined;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const clientRef = useRef<AnalyzerClient | null>(null);
const timelineRef = useRef(new TimelineBuilder());
const storedIdsRef = useRef(new WeakMap<DetectedEvent, number>());
const latestParseRef = useRef<{ type: string; data: FixtureData } | null>(
null,
);
const gatesRef = useRef(new Map<string, GateResult>());
const stopRef = useRef<(() => void) | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// the open match is known to be a non-SZ mode, so counter reads are
// misreads of another mode's overlay and are not collected at all
const objectiveBlockedRef = useRef(false);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [deviceId, setDeviceId] = useState<string>("");
const [status, setStatus] = useState<Status>("idle");
const [gateScore, setGateScore] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [feed, setFeed] = useState<StoredEvent[]>([]);
const [running, setRunning] = useState(false);
const [sendouError, setSendouError] = useState<string | null>(null);
const [eventsOpen, setEventsOpen] = useState(false);
const [liveSend, setLiveSend] = useState(false);
const liveSendRef = useRef(false);
const sendingRef = useRef(false);
const refreshFeed = useCallback(() => {
void (async () => {
const events = await listEvents();
// objective reads grouped into a known non-SZ match slipped past the
// live block (e.g. the mode read arrived after them) — delete them
const invalid = new Set(
invalidObjectiveEvents(buildScannerMatches(events)),
);
if (invalid.size > 0) {
await deleteEvents(
[...invalid]
.map((event) => event.id)
.filter((id): id is number => id !== undefined),
);
}
setFeed(
events
.filter((event) => !invalid.has(event))
.sort(
(a, b) => b.detectedAt - a.detectedAt || (b.id ?? 0) - (a.id ?? 0),
),
);
})();
}, []);
useEffect(() => {
refreshFeed();
return () => {
stopRef.current?.();
if (retryTimerRef.current) clearInterval(retryTimerRef.current);
clientRef.current?.dispose();
};
}, [refreshFeed]);
/** Sends the matches `include` selects; serialized so sends never overlap. */
const send = async (
include: (built: BuiltMatch<StoredEvent>) => boolean,
{ manual = false } = {},
) => {
if (sendingRef.current) return;
sendingRef.current = true;
if (manual) setSendouError(null);
try {
const events = await listEvents();
const { sentMatches, failedMatches } = await sendMatches({
events,
include,
onStatus: refreshFeed,
});
if (manual && sentMatches + failedMatches === 0) {
setSendouError("nothing to send — no complete match selected");
}
} finally {
sendingRef.current = false;
refreshFeed();
}
};
/** `withLiveSend`: send each match to sendou.ink as it closes. */
const start = async (withLiveSend: boolean) => {
setError(null);
setSendouError(null);
setStatus("loading");
liveSendRef.current = withLiveSend;
setLiveSend(withLiveSend);
// a restart may land mid-another-match; collect until its mode is known
objectiveBlockedRef.current = false;
try {
const video = videoRef.current!;
const stream = await openVirtualCamera(deviceId || undefined);
video.srcObject = stream;
await video.play();
setDevices(await listVideoInputs());
clientRef.current ??= new AnalyzerClient(
(result) => {
// one result arrives per detector per frame; status reflects
// whether any of them fired
gatesRef.current.set(result.detector, result.gate);
const gates = [...gatesRef.current.values()];
setGateScore(Math.max(...gates.map((g) => g.score)));
if (!result.gate.pass) {
if (!gates.some((g) => g.pass)) setStatus("watching");
return;
}
setStatus("detected");
for (const event of result.events as DetectedEvent<FixtureData>[]) {
latestParseRef.current = { type: event.type, data: event.data };
if (
(event.type === OBJECTIVE_EVENT_TYPE ||
event.type === PLAYER_STATUS_EVENT_TYPE) &&
objectiveBlockedRef.current
) {
continue;
}
const action = timelineRef.current.push(event);
if (action.action === "added" || action.action === "replaced") {
if (event.type === MAP_START_EVENT_TYPE) {
const mode = (event.data as MapStartData).mode;
objectiveBlockedRef.current = mode !== null && mode !== "SZ";
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
objectiveBlockedRef.current = false;
}
const stale =
action.action === "replaced"
? storedIdsRef.current.get(action.replaced)
: undefined;
void (async () => {
const thumbnail = result.frame
? await thumbnailFromBlob(result.frame)
: undefined;
// reusing the replaced event's id keeps match card keys
// stable, so repeat detections don't remount the cards
const id = await saveEvent(
event,
thumbnail,
result.frame,
stale,
);
storedIdsRef.current.set(event, id);
if (
liveSendRef.current &&
INGESTABLE_TYPES.includes(event.type)
) {
if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
// a scoreboard closes its match — send it
refreshFeed();
await send(
(built) =>
matchContaining(id)(built) && unsentMatches(built),
);
} else {
await updateEventsSend([id], {
state: "queued",
at: Date.now(),
});
}
}
refreshFeed();
})();
}
}
},
(message) => {
setError(message);
setStatus("error");
},
);
await clientRef.current.whenReady();
stopRef.current = startSampler(video, SAMPLE_FPS, (bitmap, t) => {
clientRef.current?.analyze(bitmap, t);
});
// a match sent the moment its scoreboard closed usually beats the
// players to reporting the game, so sendou.ink had nothing to link
// it to; give those another go while the capture runs
retryTimerRef.current ??= setInterval(() => {
if (liveSendRef.current) void send(retryableUnlinkedMatches);
}, UNLINKED_RETRY_TICK_MS);
setStatus("watching");
setRunning(true);
} catch (e) {
setError(String(e));
setStatus("error");
}
};
const builtMatches = buildScannerMatches(feed);
const skipReasons = ingestSkipReasons(builtMatches);
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
// strip weapon evidence is assignment input, not a detection worth a card
const ungroupedFeed = feed.filter(
(e) => !groupedEvents.has(e) && e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
const abilityMap = connectAbilities(feed);
const stop = () => {
stopRef.current?.();
stopRef.current = null;
if (retryTimerRef.current) clearInterval(retryTimerRef.current);
retryTimerRef.current = null;
const video = videoRef.current;
if (video?.srcObject instanceof MediaStream) {
for (const track of video.srcObject.getTracks()) track.stop();
video.srcObject = null;
}
setRunning(false);
setStatus("idle");
// the scan ending is the last match boundary — flush what's unsent
// (partials are safe: the server merges them into fuller resends)
if (liveSendRef.current) void send(unsentMatches);
liveSendRef.current = false;
setLiveSend(false);
};
return (
<div>
<div className={scannerStyles.controls}>
{!running ? (
<>
<button
type="button"
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => void start(true)}
>
<Send aria-hidden />
Start capture
</button>
<button
type="button"
className={scannerStyles.outlined}
onClick={() => void start(false)}
>
Start capture (no sending)
</button>
</>
) : (
<>
<button type="button" onClick={stop}>
Stop
</button>
<button
type="button"
className={scannerStyles.outlined}
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => {
liveSendRef.current = !liveSend;
setLiveSend(!liveSend);
}}
>
<Send aria-hidden />
{liveSend ? "Stop sending" : "Start sending"}
</button>
</>
)}
<select
className={styles.deviceSelect}
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
>
<option value="">Default camera (OBS Virtual Camera)</option>
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || d.deviceId.slice(0, 8)}
</option>
))}
</select>
<span
className={clsx(scannerStyles.status, {
[scannerStyles.detected]: status === "detected",
[scannerStyles.watching]: status === "watching",
[scannerStyles.idle]:
status !== "detected" && status !== "watching",
})}
>
{status}
{gateScore !== null ? ` · gate ${gateScore.toFixed(2)}` : null}
</span>
{liveSend ? (
<span className={clsx(scannerStyles.status, scannerStyles.watching)}>
sending matches live
</span>
) : null}
<LiveMenu
canSaveFixture={running}
canSend={Boolean(sendouUser) && feed.length > 0}
hasEvents={feed.length > 0}
onSaveFixture={() =>
void saveFixture(videoRef.current!, latestParseRef.current)
}
onDownloadCsv={() =>
downloadEventsCsv(
`live-events-${new Date().toISOString().slice(0, 19).replaceAll(":", "-")}.csv`,
// feed is newest-first for display; export in chronological order
feed.toSorted(
(a, b) =>
a.detectedAt - b.detectedAt || (a.id ?? 0) - (b.id ?? 0),
),
)
}
onSendUnsent={() => void send(unsentMatches, { manual: true })}
onClearFeed={() => {
if (!window.confirm("Clear all detected events?")) return;
void clearEvents().then(refreshFeed);
}}
/>
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{sendouError ? (
<p className={scannerStyles.error}>{sendouError}</p>
) : null}
<div className={scannerStyles.liveLayout}>
<video
ref={videoRef}
className={scannerStyles.preview}
muted
playsInline
/>
<div className={scannerStyles.feed}>
{feed.length === 0 ? (
<p className={scannerStyles.score}>No detections yet.</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
keyOf={(built) => built.sources[0]!.id!}
renderMatch={(built, justFormed) => {
const id = built.sources[0]!.id!;
const skipReason = skipReasons.get(built);
// counter reads render as one timeline chart, not a card each --
// from the builder's samples, whose sides are team-stable (raw
// reads follow the specced player on casts); a non-SZ match's
// reads (objective null) are never shown
const objectiveEvents = (
built.match.objective?.samples ?? []
).map((sample) => ({ t: sample.t, data: sample }));
const statusSamples = built.match.playerStatus?.samples ?? [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) =>
e.type !== OBJECTIVE_EVENT_TYPE &&
e.type !== PLAYER_STATUS_EVENT_TYPE &&
e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
const newest = built === builtMatches.at(-1);
return (
<MatchCard
match={built.match}
live={running && newest && built.match.winner === null}
inProgress={newest && built.match.winner === null}
skipReason={skipReason}
justFormed={justFormed}
send={aggregateSendStatus(built.sources)}
onSend={
sendouUser && !skipReason
? () => void send(matchContaining(id), { manual: true })
: undefined
}
>
<GameTimeline
objectiveEvents={objectiveEvents}
playerStatusSamples={statusSamples}
teams={playerStatusTeams(built.match, SCANNER_TEAM_LABELS)}
/>
{cardEvents.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
abilities={abilityMap.get(e)}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
/>
))}
</MatchCard>
);
}}
/>
{ungroupedFeed.length > 0 ? (
<EventsSummary
events={ungroupedFeed}
open={eventsOpen}
onToggle={() => setEventsOpen(!eventsOpen)}
/>
) : null}
{eventsOpen
? ungroupedFeed.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
abilities={abilityMap.get(e)}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
send={e.send}
onSend={
sendouUser &&
e.id !== undefined &&
INGESTABLE_TYPES.includes(e.type)
? () =>
void send(matchContaining(e.id!), { manual: true })
: undefined
}
/>
))
: null}
</div>
</div>
</div>
);
}
/** The capture's occasional actions, behind one icon-only menu. */
function LiveMenu({
canSaveFixture,
canSend,
hasEvents,
onSaveFixture,
onDownloadCsv,
onSendUnsent,
onClearFeed,
}: {
canSaveFixture: boolean;
canSend: boolean;
hasEvents: boolean;
onSaveFixture: () => void;
onDownloadCsv: () => void;
onSendUnsent: () => void;
onClearFeed: () => void;
}) {
return (
<SendouMenu
trigger={
<SendouButton
icon={<Ellipsis />}
className={scannerStyles.iconMenu}
aria-label="More actions"
/>
}
>
<SendouMenuItem
icon={<Camera />}
isDisabled={!canSaveFixture}
onAction={onSaveFixture}
>
Save frame as fixture
</SendouMenuItem>
<SendouMenuItem
icon={<FileText />}
isDisabled={!hasEvents}
onAction={onDownloadCsv}
>
CSV
</SendouMenuItem>
<SendouMenuItem
icon={<Send />}
isDisabled={!canSend}
onAction={onSendUnsent}
>
Send unsent to sendou.ink
</SendouMenuItem>
<SendouMenuItem
icon={<Trash2 />}
isDestructive
isDisabled={!hasEvents}
onAction={onClearFeed}
>
Clear feed
</SendouMenuItem>
</SendouMenu>
);
}

View File

@@ -0,0 +1,46 @@
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function MapStartCard(props: {
t: number;
confidence: number;
data: MapStartData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={MAP_START_EVENT_TYPE}
label="map start"
/>
<span>
<b>{modeLabel(data.mode) ?? "?"}</b> · {stageLabel(data.stage) ?? "?"}
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "MapStart" }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,391 @@
.group {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.matchCard {
container: match-card / inline-size;
position: sticky;
top: var(--layout-sticky-top);
z-index: 1;
overflow: hidden;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
/* only cards the scan just formed animate in — see MatchCard's `enter` */
&.enter {
animation: scanner-card-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* one overlay element all send-state effects draw on */
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
&.sending {
box-shadow: inset 0 0 0 1px var(--color-info-low);
&::after {
background-image: linear-gradient(
105deg,
transparent 40%,
rgb(255 255 255 / 0.09) 50%,
transparent 60%
);
background-size: 250% 100%;
animation: scanner-sheen 1.4s linear infinite;
}
}
&.flashSent::after {
animation: scanner-sent-pop 0.8s ease-out both;
}
&.live {
box-shadow: inset 0 0 0 1px var(--color-info-low);
}
}
@keyframes scanner-card-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
}
@keyframes scanner-sheen {
from {
background-position: 130% 0;
}
to {
background-position: -130% 0;
}
}
@keyframes scanner-sent-pop {
0% {
opacity: 1;
box-shadow:
inset 0 0 0 2px var(--color-success),
inset 0 0 32px var(--color-success-low);
}
100% {
opacity: 0;
box-shadow:
inset 0 0 0 2px var(--color-success),
inset 0 0 32px var(--color-success-low);
}
}
@keyframes scanner-shake {
20% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
80% {
transform: translateX(-2px);
}
}
.main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-2) var(--s-3);
padding: var(--s-3) var(--s-4);
min-height: 66px;
}
.matchCard.flashFailed .main {
animation: scanner-shake 0.35s ease;
}
.mode {
flex-shrink: 0;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
}
.headline {
display: flex;
flex-direction: column;
gap: var(--s-1);
/* claims row space so the score/chips can never crush the stage name
out of view in a narrow feed column; wraps instead */
flex: 1 1 0;
min-width: 150px;
}
.title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1) var(--s-2);
}
.stage {
font-size: var(--font-sm);
font-weight: var(--weight-extra);
line-height: 1.1;
}
.meta {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.weapons {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-0-5);
& .vs {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
margin: 0 5px;
}
}
/* a team is one wrapping unit: the eight never break up mid-team */
.weaponRow {
display: flex;
align-items: center;
gap: var(--s-0-5);
}
.weapon {
flex-shrink: 0;
width: 32px;
height: 32px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
&.pov {
outline: var(--border-style-high);
background-color: var(--color-bg-high);
outline-offset: 1px;
}
}
.side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-1-5);
margin-inline-start: auto;
align-self: stretch;
flex-shrink: 0;
}
.matchScore {
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
line-height: 1;
padding: 5px var(--s-3);
border-radius: var(--radius-full);
background: color-mix(in oklab, var(--color-bg) 72%, transparent);
backdrop-filter: blur(8px);
& .win {
color: var(--color-error-high);
}
& .lose {
color: var(--color-info-high);
}
}
/* undo the scanner-wide solid button look for the banner icon button */
button.expand {
flex-shrink: 0;
margin-block-start: auto;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--radius-full);
background: color-mix(in oklab, var(--color-bg) 55%, transparent);
backdrop-filter: blur(8px);
color: var(--color-text-high);
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
& svg {
width: var(--field-size-icon);
height: var(--field-size-icon);
}
&:hover {
color: var(--color-text);
background: color-mix(in oklab, var(--color-bg) 78%, transparent);
}
&.expanded {
transform: rotate(180deg);
}
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
padding: var(--s-0-5) var(--s-2-5);
border-radius: var(--radius-full);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
border: var(--border-style);
background-color: var(--color-bg);
color: var(--color-text-high);
white-space: nowrap;
& .dot {
width: 7px;
height: 7px;
border-radius: var(--radius-full);
background: currentColor;
}
&.live {
color: var(--color-error);
border-color: var(--color-error-low);
text-transform: uppercase;
letter-spacing: 0.08em;
& .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
}
&.inProgress .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
&.queued,
&.sending {
color: var(--color-info-high);
border-color: var(--color-info-low);
background-color: var(--color-info-low);
& .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
}
&.sent {
color: var(--color-success-high);
border-color: var(--color-success-low);
background-color: var(--color-success-low);
& a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: var(--color-text-high);
}
}
}
&.unlinked {
color: var(--color-warning-high);
border-color: var(--color-warning-low);
background-color: var(--color-warning-low);
}
&.failed {
color: var(--color-error-high);
border-color: var(--color-error-low);
background-color: var(--color-error-low);
}
}
@keyframes scanner-pulse {
50% {
opacity: 0.25;
transform: scale(0.8);
}
}
.error {
padding: 0 var(--s-4) var(--s-2-5);
font-size: var(--font-2xs);
color: var(--color-error);
}
/* source event cards, nested under their match card via an indent rail */
.events {
--scanner-card-padding: var(--s-2-5) 14px;
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-inline-start: var(--s-2-5);
padding-inline-start: var(--s-3);
border-inline-start: var(--border-width) solid var(--color-border);
animation: scanner-detail-in 0.25s ease both;
}
@keyframes scanner-detail-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.setDivider {
display: flex;
align-items: center;
gap: var(--s-2-5);
margin-top: var(--s-1-5);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
letter-spacing: 0.08em;
&::before,
&::after {
content: "";
flex: 1;
height: var(--border-width);
background-color: var(--color-border);
}
}
/* the two teams' weapons no longer fit beside each other: one row each */
@container match-card (width < 460px) {
.weapons {
flex-direction: column;
align-items: flex-start;
row-gap: var(--s-1);
& .vs {
display: none;
}
}
}
@media (prefers-reduced-motion: reduce) {
.matchCard,
.matchCard.sending::after,
.matchCard.flashSent::after,
.matchCard.flashFailed .main,
.events,
.chip.live .dot,
.chip.inProgress .dot,
.chip.queued .dot,
.chip.sending .dot {
animation: none;
}
}

View File

@@ -0,0 +1,352 @@
/**
* Glanceable card for one ScannerMatch in the live feed: stage banner
* background, mode + stage, score, team weapons, and the match's /ingest
* status. Expanding the card reveals the source event cards below it,
* so the raw per-event view stays one click away.
*/
import clsx from "clsx";
import { ChevronDown } from "lucide-react";
import type * as React from "react";
import { useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { ModeImage, WeaponImage } from "~/components/Image";
import { matchScoresFromObjective } from "~/components/objective-timeline-utils";
import { StageBannerBox } from "~/components/StageBannerBox";
import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls";
import type { IngestSkipReason } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import type { SendStatus } from "../store/events";
import { formatTime, useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
import styles from "./MatchCard.module.css";
/** the game score a knockout wins at */
const KO_MATCH_SCORE = 100;
const SEND_CHIP_LABELS: Record<Exclude<SendStatus["state"], "sent">, string> = {
queued: "queued",
sending: "sending…",
unlinked: "waiting for report",
failed: "failed",
};
export function MatchCard({
match,
send,
onSend,
live = false,
inProgress = false,
skipReason,
justFormed = false,
children,
}: {
match: ScannerMatch;
/** the match's /ingest status, aggregated from its source events */
send?: SendStatus;
/** when set, shows a Send/Retry button for this match */
onSend?: () => void;
/** still being played: no closing scoreboard yet and the scan is running */
live?: boolean;
/**
* the newest match, still being formulated (no result yet) — shows an
* "in progress" chip in the score slot; at most one card should get this
*/
inProgress?: boolean;
/** set = ingestSkipReasons held the match back from /ingest */
skipReason?: IngestSkipReason;
/** the scan just formed this match — play the enter animation */
justFormed?: boolean;
/** expandable detail content, typically the source event cards */
children?: React.ReactNode;
}) {
const [expanded, setExpanded] = useState(false);
// fixed at mount: re-rendering must not cut the animation short, and a card
// remounting for another reason (switching lobby tabs) must not replay it
const [enter] = useState(justFormed);
// one-shot flash animations only on a state *change*, so already-sent
// matches don't replay the glow on every mount
const [prevSendState, setPrevSendState] = useState(send?.state);
const [flash, setFlash] = useState<"sent" | "failed" | null>(null);
if (prevSendState !== send?.state) {
setPrevSendState(send?.state);
setFlash(
send?.state === "sent" || send?.state === "failed" ? send.state : null,
);
}
const meta = [
modeLabel(match.mode),
skipReason === "lobby" ? lobbyLabel(match.lobby) : null,
match.startsAt !== null ? timeRangeLabel(match) : null,
match.replayCode,
match.cast ? "cast" : null,
]
.filter(Boolean)
.join(" · ");
const inner = (
<>
<div className={styles.main}>
{match.mode !== null ? (
<ModeImage mode={match.mode} size={30} className={styles.mode} />
) : null}
<div className={styles.headline}>
<div className={styles.title}>
<div className={styles.stage}>
{stageLabel(match.stage) ?? "Unknown stage"}
</div>
<StatusChip send={send} skipReason={skipReason} live={live} />
</div>
{meta ? <div className={styles.meta}>{meta}</div> : null}
<TeamWeapons match={match} />
</div>
<div className={styles.side}>
{live ? (
<span className={clsx(styles.chip, styles.live)}>
<span className={styles.dot} />
live
</span>
) : (
<Score match={match} inProgress={inProgress} />
)}
{onSend && send?.state !== "sent" && send?.state !== "sending" ? (
<button type="button" onClick={onSend}>
{send?.state === "failed" || send?.state === "unlinked"
? "Retry"
: "Send"}
</button>
) : null}
{children ? (
<SendouButton
variant="minimal"
size="small"
shape="circle"
icon={<ChevronDown />}
className={clsx(styles.expand, { [styles.expanded]: expanded })}
aria-expanded={expanded}
aria-label={expanded ? "Hide events" : "Show events"}
onPress={() => setExpanded(!expanded)}
/>
) : null}
</div>
</div>
{send?.state === "failed" && send.error ? (
<div className={styles.error}>{send.error}</div>
) : null}
</>
);
const className = clsx(
styles.matchCard,
send?.state ? styles[send.state] : null,
{
[styles.enter]: enter,
[styles.live]: live,
[styles.flashSent]: flash === "sent",
[styles.flashFailed]: flash === "failed",
},
);
const card =
match.stage !== null ? (
<StageBannerBox stageId={match.stage} className={className}>
{inner}
</StageBannerBox>
) : (
<div className={className}>{inner}</div>
);
if (!children) return card;
return (
<div className={styles.group}>
{card}
{expanded ? <div className={styles.events}>{children}</div> : null}
</div>
);
}
/** Labeled rule above the newest card of each set in the feed. */
export function SetDivider({ number }: { number: number }) {
return <div className={styles.setDivider}>Set {number}</div>;
}
function timeRangeLabel(match: ScannerMatch): string {
const start = formatTime(match.startsAt!);
return match.endsAt !== null && match.endsAt !== match.startsAt
? `${start}–${formatTime(match.endsAt)}`
: start;
}
function Score({
match,
inProgress,
}: {
match: ScannerMatch;
inProgress: boolean;
}) {
if (match.matchScores === null) {
if (!inProgress) return null;
return (
<span className={clsx(styles.chip, styles.inProgress)}>
<span className={styles.dot} />
in progress
</span>
);
}
const [alpha, bravo] = match.matchScores;
const objectiveScores = matchScoresFromObjective(
match.objective?.samples ?? [],
);
// scoreboard-sourced matches list the winners first
const winnerKnown = match.winner !== null;
return (
<div className={styles.matchScore}>
<span className={winnerKnown ? styles.win : undefined}>
{scoreLabel(alpha, objectiveScores[0])}
</span>
<span> – </span>
<span className={winnerKnown ? styles.lose : undefined}>
{scoreLabel(bravo, objectiveScores[1])}
</span>
</div>
);
}
/**
* A 100 only happens on a knockout, shown the way players say it. A knockout's
* loser gets no score of its own, so the objective counter's last read stands
* in — parenthesized, since the scan read it off the video and may have lost
* sight of the counter before the game ended.
*/
function scoreLabel(
score: number | null,
objectiveScore: number | null,
): string {
if (score !== null && score > 0) {
return score === KO_MATCH_SCORE ? "KO" : String(score);
}
if (objectiveScore !== null) {
return objectiveScore === KO_MATCH_SCORE ? "(KO)" : `(${objectiveScore})`;
}
return score === null ? "?" : String(score);
}
interface TeamWeapon {
weaponId: MainWeaponId;
/** the scan's own player — highlighted among the eight */
pov: boolean;
}
function TeamWeapons({ match }: { match: ScannerMatch }) {
const weaponsOf = (team: 0 | 1): TeamWeapon[] =>
match.teams[team].players
.map((player, index) => ({
weaponId: player.weaponId,
pov: match.pov?.team === team && match.pov.index === index,
}))
.filter((weapon): weapon is TeamWeapon => weapon.weaponId !== null);
const alpha = weaponsOf(0);
const bravo = weaponsOf(1);
if (alpha.length + bravo.length === 0) return null;
return (
<div className={styles.weapons}>
{alpha.length > 0 ? <WeaponRow weapons={alpha} /> : null}
{alpha.length > 0 && bravo.length > 0 ? (
<span className={styles.vs}>vs</span>
) : null}
{bravo.length > 0 ? <WeaponRow weapons={bravo} /> : null}
</div>
);
}
/** One team's weapons, kept together when the card is too narrow for both. */
function WeaponRow({ weapons }: { weapons: TeamWeapon[] }) {
return (
<div className={styles.weaponRow}>
{weapons.map((weapon, i) => (
<WeaponImage
key={i}
weaponSplId={weapon.weaponId}
variant="build"
size={22}
className={clsx(styles.weapon, { [styles.pov]: weapon.pov })}
/>
))}
</div>
);
}
function StatusChip({
send,
skipReason,
live,
}: {
send?: SendStatus;
skipReason?: IngestSkipReason;
live: boolean;
}) {
const formatSentAt = useEventTimeFormatter();
if (skipReason) {
return (
<span className={styles.chip}>
{skipReason === "disconnect" ? "disconnect" : "not ingested"}
</span>
);
}
if (send?.state === "sent") {
return (
<span
className={clsx(styles.chip, styles.sent)}
title={`ingested ${formatSentAt(send.at)}`}
>
✓
{send.link ? (
<a
href={ingestedMatchUrl(send.link)}
target="_blank"
rel="noreferrer"
>
{ingestedMatchLabel(send.link)}
</a>
) : null}
</span>
);
}
if (send) {
return (
<span
className={clsx(styles.chip, styles[send.state])}
title={send.error}
>
{send.state === "queued" || send.state === "sending" ? (
<span className={styles.dot} />
) : null}
{SEND_CHIP_LABELS[send.state]}
</span>
);
}
if (live) return null;
return <span className={styles.chip}>not sent</span>;
}
function ingestedMatchUrl(link: IngestedMatchLink): string {
return link.type === "tournament"
? tournamentMatchPage({
tournamentId: link.tournamentId,
matchId: link.matchId,
})
: sendouQMatchPage(link.groupMatchId);
}
function ingestedMatchLabel(link: IngestedMatchLink): string {
return link.type === "tournament"
? `Match ID #${link.matchId}`
: `SQ Match ID #${link.groupMatchId}`;
}

View File

@@ -0,0 +1,5 @@
.matchList {
display: flex;
flex-direction: column;
gap: var(--s-3);
}

View File

@@ -0,0 +1,138 @@
/**
* Lobby tabs over the match feed: private battles (the games /ingest cares
* about) split from X Battles and everything else, so a scan that picked up
* ranked play between tournament sets stays readable. Sets are a private
* battle concept — consecutive games between the same two teams — so only
* that tab gets set dividers, and set numbers are assigned within the tab.
*/
import { Fragment, useEffect, useRef } from "react";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import type { ScannerLobby } from "../scanner-types";
import { SetDivider } from "./MatchCard";
import styles from "./MatchLobbyTabs.module.css";
type LobbyGroup = "private" | "x" | "other";
const LOBBY_GROUPS: LobbyGroup[] = ["private", "x", "other"];
const LOBBY_GROUP_LABELS: Record<LobbyGroup, string> = {
private: "Private Battle",
x: "X Battle",
other: "Other",
};
const NO_KEYS: ReadonlySet<React.Key> = new Set();
export function MatchLobbyTabs<E extends DetectedEvent>({
matches,
keyOf,
renderMatch,
}: {
/** built matches in chronological order (oldest first) */
matches: readonly BuiltMatch<E>[];
/** stable render key for one match, typically its first source event's id */
keyOf: (built: BuiltMatch<E>) => React.Key;
/** `justFormed`: the match appeared while the page was open (enter animation) */
renderMatch: (built: BuiltMatch<E>, justFormed: boolean) => React.ReactNode;
}) {
const justFormedKeys = useJustFormedKeys(matches.map(keyOf));
const groups = LOBBY_GROUPS.map((group) => ({
group,
matches: matches.filter((built) => lobbyGroup(built.match.lobby) === group),
})).filter(({ matches: groupMatches }) => groupMatches.length > 0);
if (groups.length === 0) return null;
return (
<SendouTabs>
<SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTab key={group} id={group} number={groupMatches.length}>
{LOBBY_GROUP_LABELS[group]}
</SendouTab>
))}
</SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTabPanel key={group} id={group} className={styles.matchList}>
<MatchList
matches={groupMatches}
sets={group === "private"}
keyOf={keyOf}
justFormedKeys={justFormedKeys}
renderMatch={renderMatch}
/>
</SendouTabPanel>
))}
</SendouTabs>
);
}
function lobbyGroup(lobby: ScannerLobby | null): LobbyGroup {
if (lobby === "PRIVATE") return "private";
if (lobby === "X") return "x";
return "other";
}
/**
* Keys of the matches that showed up since the previous render — the ones a
* scan just formed. A list arriving whole (the live feed loaded from storage,
* a saved VoD opened) is not "just formed": every card would animate in at
* once for something the user did not watch happen.
*/
function useJustFormedKeys(keys: React.Key[]): ReadonlySet<React.Key> {
const seenRef = useRef<Set<React.Key> | null>(null);
const seen = seenRef.current;
// after commit, not during render: under StrictMode the render runs twice
// and the second pass would find every key already seen
useEffect(() => {
seenRef.current = new Set(keys);
});
if (seen === null) return NO_KEYS;
const justFormed = keys.filter((key) => !seen.has(key));
return justFormed.length === keys.length && keys.length > 1
? NO_KEYS
: new Set(justFormed);
}
function MatchList<E extends DetectedEvent>({
matches,
sets,
keyOf,
justFormedKeys,
renderMatch,
}: {
matches: readonly BuiltMatch<E>[];
sets: boolean;
keyOf: (built: BuiltMatch<E>) => React.Key;
justFormedKeys: ReadonlySet<React.Key>;
renderMatch: (built: BuiltMatch<E>, justFormed: boolean) => React.ReactNode;
}) {
const setNumbers = sets ? assignMatchSets(matches.map((b) => b.match)) : [];
const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
// newest match on top; the builder keeps ascending time order
return [...matches].reverse().map((built, reverseIndex) => {
const index = matches.length - 1 - reverseIndex;
const key = keyOf(built);
return (
<Fragment key={key}>
{showSetDividers && setNumbers[index + 1] !== setNumbers[index] ? (
<SetDivider number={setNumbers[index]!} />
) : null}
{renderMatch(built, justFormedKeys.has(key))}
</Fragment>
);
});
}

View File

@@ -0,0 +1,27 @@
/* time + confidence + event type read as one unit, the detail text after it
sits further away */
.pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1);
}
/* neutral chip, the icon carries the accent and documents the value — full
label on hover via title */
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: var(--s-0-5) var(--s-2-5);
border-radius: var(--radius-full);
border: var(--border-style);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
& svg {
color: var(--color-text-accent);
}
}

View File

@@ -0,0 +1,39 @@
/**
* The pill group every event card's meta row opens with: video timestamp,
* detection confidence and the event type, kept tight together so the free-form
* detail text that follows reads as a separate group.
*/
import { Clock, Gauge } from "lucide-react";
import { EventTypeIcon } from "./EventTypeIcon";
import { formatTime } from "./format";
import styles from "./MetaChips.module.css";
export function MetaPills({
t,
confidence,
type,
label,
}: {
t: number;
confidence: number;
type: string;
label: string;
}) {
return (
<div className={styles.pills}>
<span className={styles.chip} title="Video timestamp">
<Clock size={12} aria-hidden />
{formatTime(t)}
</span>
<span className={styles.chip} title="Detection confidence">
<Gauge size={12} aria-hidden />
{(confidence * 100).toFixed(0)}%
</span>
<span className={styles.chip}>
<EventTypeIcon type={type} />
{label}
</span>
</div>
);
}

View File

@@ -0,0 +1,96 @@
.player {
display: flex;
align-items: center;
gap: var(--s-2);
padding-block: 3px;
font-size: var(--font-xs);
}
.weaponMissing {
flex-shrink: 0;
width: 24px;
text-align: center;
color: var(--color-text-high);
}
/* the minimap cards are tighter than the scanner's default weapon puck */
.weapon {
flex-shrink: 0;
width: 24px;
height: 24px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
.name {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.abilities {
display: flex;
align-items: center;
gap: var(--s-0-5);
flex-shrink: 0;
}
.statusChip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: var(--radius-full);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
&.dead {
background: var(--color-error-low);
color: var(--color-error-high);
}
&.special {
background: var(--color-warning-low);
color: var(--color-warning-high);
}
}
/* d-pad chevron / face-button pucks, same round-solid language as weapon icons */
.slotMarker {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
background: var(--color-bg-badge);
color: #fff;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
& svg {
width: 13px;
height: 13px;
}
&.right svg {
transform: rotate(90deg);
}
&.down svg {
transform: rotate(180deg);
}
&.left svg {
transform: rotate(-90deg);
}
}

View File

@@ -0,0 +1,159 @@
import clsx from "clsx";
import { ChevronUp } from "lucide-react";
import type { ReactNode } from "react";
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
type MinimapEnemy,
type MinimapTeammate,
} from "../core/detectors/minimap/index";
import type { CardSlot } from "../core/detectors/minimap/rois";
import eventCardStyles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
import styles from "./MinimapCard.module.css";
const ENEMY_SLOT_LETTERS = ["A", "B", "X", "Y"] as const;
function AbilityRow({
abilities,
}: {
abilities: (AbilityWithUnknown | null)[];
}) {
return (
<>
{abilities.map((id, i) =>
id ? (
<Ability key={i} ability={id} size="TINY" />
) : (
<span key={i} title="unreadable badge">
?
</span>
),
)}
</>
);
}
function TeammateMarker({ slot }: { slot: CardSlot }) {
if (slot === "self") {
return <span className={styles.slotMarker}>●</span>;
}
return (
<span className={clsx(styles.slotMarker, styles[slot])}>
<ChevronUp strokeWidth={3.5} aria-label={slot} role="img" />
</span>
);
}
function PlayerRow({
marker,
player,
}: {
marker: ReactNode;
player: MinimapTeammate | MinimapEnemy;
}) {
return (
<div className={styles.player}>
{marker}
{player.weaponId !== null ? (
<WeaponImage
weaponSplId={player.weaponId}
variant="build"
size={24}
className={styles.weapon}
/>
) : (
<span className={styles.weaponMissing}>?</span>
)}
<span className={styles.name}>{player.name ?? ""}</span>
{player.dead ? (
<span
className={clsx(styles.statusChip, styles.dead)}
title="respawning (struck out)"
>
✕
</span>
) : null}
{player.specialReady ? (
<span
className={clsx(styles.statusChip, styles.special)}
title="special ready (camo)"
>
★
</span>
) : null}
<span className={styles.abilities}>
<AbilityRow abilities={player.abilities} />
</span>
</div>
);
}
export function MinimapCard(props: {
t: number;
confidence: number;
data: MinimapData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={MINIMAP_EVENT_TYPE}
label="minimap"
/>
{data.stage !== null ? <span>{stageLabel(data.stage)}</span> : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "Minimap" }}
/>
</div>
<div className={eventCardStyles.teams}>
<div className={eventCardStyles.team}>
<h3>Team</h3>
{data.teammates.map((p) => (
<PlayerRow
key={p.slot}
marker={<TeammateMarker slot={p.slot} />}
player={p}
/>
))}
</div>
{data.enemies.length > 0 ? (
<div className={eventCardStyles.team}>
<h3>Enemies</h3>
{data.enemies.map((p, i) => (
<PlayerRow
key={i}
marker={
<span className={styles.slotMarker}>
{ENEMY_SLOT_LETTERS[i] ?? i + 1}
</span>
}
player={p}
/>
))}
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
export function ObjectiveCard(props: {
t: number;
confidence: number;
data: ObjectiveData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) => {
const score = data.score[index] ?? "?";
const penalty =
data.penalty[index] !== null ? ` (+${data.penalty[index]})` : "";
return `${score}${penalty}`;
};
const holder = data.control.findIndex(Boolean);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={OBJECTIVE_EVENT_TYPE}
label="objective"
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>
{side(0)} – {side(1)}
</b>
{holder >= 0
? ` · ${holder === 0 ? "alpha" : "bravo"} in control`
: null}
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: OBJECTIVE_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
export function PlayerStatusCard(props: {
t: number;
confidence: number;
data: PlayerStatusData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) =>
data.dead[index]
.map((dead, slot) => (dead ? "✕" : data.special[index][slot] ? "★" : "·"))
.join("");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={PLAYER_STATUS_EVENT_TYPE}
label={`players (${data.layout})`}
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>
{side(0)} – {side(1)}
</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: PLAYER_STATUS_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
.topbar {
display: flex;
align-items: center;
gap: var(--s-6);
padding-bottom: var(--s-3);
border-bottom: var(--border-width) solid var(--color-bg-high);
margin-bottom: var(--s-4);
& nav {
display: flex;
gap: var(--s-2);
& a {
color: var(--color-text-high);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
text-decoration: none;
padding: var(--s-1) var(--s-2-5);
border-radius: var(--radius-field);
&:hover {
color: var(--color-text);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&.active {
color: var(--color-text-accent);
background: var(--color-bg-high);
}
}
}
}

View File

@@ -0,0 +1,58 @@
import clsx from "clsx";
import { Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useSearchParam } from "~/modules/search-params/hooks";
import { SCANNER_PAGE } from "~/utils/urls";
import scannerStyles from "../scanner.module.css";
import {
SCANNER_TABS,
type ScannerTab,
scannerSearchParams,
} from "../scanner-search-params";
import { LivePage } from "./LivePage";
import styles from "./ScannerApp.module.css";
import { ScreenshotPage } from "./ScreenshotPage";
import type { SendouUser } from "./sendou-ingest";
import { VodPage } from "./VodPage";
const TAB_LABELS: Record<ScannerTab, string> = {
live: "Live",
screenshot: "Screenshot",
vod: "VoD",
};
export function ScannerApp() {
const [tab] = useSearchParam(scannerSearchParams, "tab");
const rootUser = useUser();
const sendouUser: SendouUser | null = rootUser
? { id: rootUser.id, username: rootUser.username }
: null;
const page =
tab === "screenshot" ? (
<ScreenshotPage />
) : tab === "vod" ? (
<VodPage sendouUser={sendouUser} />
) : (
<LivePage sendouUser={sendouUser} />
);
return (
<div className={scannerStyles.app}>
<header className={styles.topbar}>
<nav>
{SCANNER_TABS.map((tabOption) => (
<Link
key={tabOption}
to={scannerSearchParams.href(SCANNER_PAGE, { tab: tabOption })}
className={clsx({ [styles.active]: tab === tabOption })}
>
{TAB_LABELS[tabOption]}
</Link>
))}
</nav>
</header>
{page}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import type {
ScoreboardData,
ScoreboardPlayer,
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import scannerStyles from "../scanner.module.css";
import { AbilityPopover } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import type { CardData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
function PlayerRows({
players,
offset,
abilities,
}: {
players: ScoreboardPlayer[];
/** index of the first row within the full 8-player list */
offset: number;
abilities?: PlayerAbilityMap;
}) {
return (
<table className={styles.players}>
<tbody>
{players.map((p, i) => (
<tr key={i}>
<td>
<span className={styles.weaponCell}>
{p.weaponId !== null ? (
<WeaponImage
weaponSplId={p.weaponId}
variant="build"
size={28}
className={styles.weaponIcon}
/>
) : null}
{abilities?.has(offset + i) ? (
<AbilityPopover abilities={abilities.get(offset + i)!} />
) : null}
</span>
</td>
<td>{p.name || "?"}</td>
<td className={styles.num}>{p.paint ?? "?"}p</td>
<td className={styles.num}>
{p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
</td>
</tr>
))}
</tbody>
</table>
);
}
function teamHeading(label: string, data: CardData, side: 0 | 1): string {
const score = data.matchScores[side];
return score !== null ? `${label} — ${score}` : label;
}
export function ScoreboardCard(props: {
t: number;
confidence: number;
data: ScoreboardData;
/** DetectedEvent type; replay cards add timestamp/code details */
eventType?: string;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
/** when set, shows an Inspect button (open this match in the screenshot page) */
onInspect?: () => void;
/** abilities harvested from this match's death events, by player index */
abilities?: PlayerAbilityMap;
}) {
const { t, confidence, thumbnail, detectedAt, getFrame, onInspect } = props;
const eventType = props.eventType ?? "Scoreboard";
const data = props.data as CardData;
const isReplay = eventType === SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE;
const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={eventType}
label={
isReplay
? "replay scoreboard"
: isScoreboardBattleLog
? "battle log"
: "scoreboard"
}
/>
{data.mode !== null || data.stage !== null ? (
<span>
{[
lobbyLabel(data.lobby),
modeLabel(data.mode),
stageLabel(data.stage),
]
.filter(Boolean)
.join(" · ")}
</span>
) : null}
{data.timestamp ? <span>{data.timestamp}</span> : null}
{data.replayCode ? (
<span className={scannerStyles.score}>{data.replayCode}</span>
) : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: eventType }}
/>
</div>
<div className={styles.teams}>
<div className={clsx(styles.team, styles.win)}>
<h3>{teamHeading("Victory", data, 0)}</h3>
<PlayerRows
players={data.players.slice(0, 4)}
offset={0}
abilities={props.abilities}
/>
</div>
<div className={clsx(styles.team, styles.lose)}>
<h3>{teamHeading("Defeat", data, 1)}</h3>
<PlayerRows
players={data.players.slice(4, 8)}
offset={4}
abilities={props.abilities}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { AbilityGrid } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function ScoreboardOwnCard(props: {
t: number;
confidence: number;
data: ScoreboardOwnData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={SCOREBOARD_OWN_EVENT_TYPE}
label="own results"
/>
<span>
{[
lobbyLabel(data.lobby),
modeLabel(data.mode),
stageLabel(data.stage),
]
.map((v) => v ?? "?")
.join(" · ")}
</span>
<span>
weapon <b>{mainWeaponLabel(data.weaponId) ?? "?"}</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "ScoreboardOwn" }}
/>
</div>
<div className={clsx(styles.teams, styles.solo)}>
<div className={styles.team}>
{data.weaponId !== null ? (
<WeaponImage
weaponSplId={data.weaponId}
variant="build"
size={28}
className={styles.weaponIcon}
/>
) : null}
<AbilityGrid abilities={data.abilities} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
.frame {
position: relative;
margin-bottom: var(--s-4);
& canvas {
width: 100%;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
}
/* ---- per-detector gate results ---- */
.gateList {
display: grid;
grid-template-columns: repeat(3, max-content) 1fr;
column-gap: var(--s-4);
row-gap: var(--s-1-5);
align-items: baseline;
font-size: var(--font-xs);
margin-block: var(--s-4);
}
.gateRow {
display: contents;
}
.gateBadge {
justify-self: start;
align-self: center;
padding: 0 var(--s-1-5);
border-radius: var(--radius-selector);
background: var(--color-bg-high);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
white-space: nowrap;
}
.gateRow:not(.fired) .gateName {
color: var(--color-text-high);
}
.gateRow.fired {
& .gateBadge {
background: var(--color-success-low);
color: var(--color-success-high);
}
& .gateName {
font-weight: var(--weight-bold);
}
}
.gateScore {
font-variant-numeric: tabular-nums;
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.gateNote {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
/* ---- fired detector's parse detail ---- */
.detail {
display: flex;
flex-direction: column;
gap: var(--s-3);
margin-block: var(--s-4);
}
.detailStats {
display: flex;
flex-wrap: wrap;
gap: var(--s-1-5);
}
.stat {
display: inline-flex;
align-items: baseline;
gap: var(--s-1-5);
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
background: var(--color-bg-high);
font-size: var(--font-xs);
}
.statLabel {
color: var(--color-text-high);
font-size: var(--font-2xs);
white-space: nowrap;
}
.statValue {
font-weight: var(--weight-bold);
}
.statRaw {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.detailCrops {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
align-items: end;
& figure {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
& canvas {
display: block;
max-width: 100%;
background: #000;
border-radius: var(--radius-selector);
border: var(--border-width) solid var(--color-bg-high);
}
& figcaption {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
}
/* one pill per player slot */
.statusSlots {
display: inline-flex;
gap: var(--s-2);
align-self: center;
}
.statusSide {
display: inline-flex;
gap: var(--s-0-5);
}
.statusSlot {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.25rem;
block-size: 1.25rem;
border-radius: var(--radius-selector);
background: var(--color-bg-higher);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
&.dead {
background: var(--color-error-low);
color: var(--color-error-high);
}
&.special {
background: var(--color-warning-low);
color: var(--color-warning-high);
}
}
/* ---- per-row parse inspector ---- */
.inspector {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& th {
text-align: left;
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
padding: var(--s-1-5);
border-bottom: var(--border-style);
}
& td {
padding: var(--s-1-5);
border-bottom: 1px solid var(--color-bg-high);
vertical-align: middle;
}
& canvas {
display: block;
background: #000;
border-radius: var(--radius-selector);
}
}
.candidates {
display: flex;
gap: var(--s-2);
align-items: center;
}
.candidate {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.weaponCandidates .candidate:first-child {
color: var(--color-success);
}
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
/* the ROI crops set the width; scroll them instead of the whole page */
@container scanner (width < 700px) {
.inspector {
display: block;
overflow-x: auto;
}
}

View File

@@ -0,0 +1,967 @@
import clsx from "clsx";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mainWeaponImageUrl } from "~/utils/urls";
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
import type { DeathData } from "../core/detectors/death/index";
import * as death from "../core/detectors/death/rois";
import type { MapStartData } from "../core/detectors/map-start/index";
import * as mapStart from "../core/detectors/map-start/rois";
import type { MinimapData } from "../core/detectors/minimap/index";
import * as minimap from "../core/detectors/minimap/rois";
import type { ObjectiveData } from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
type PlayerStatusLayout,
} from "../core/detectors/objective/player-status";
import * as objective from "../core/detectors/objective/rois";
import type { ScoreboardRowDebug } from "../core/detectors/scoreboard/index";
import * as sb from "../core/detectors/scoreboard/rois";
import * as bl from "../core/detectors/scoreboard-battle-log/rois";
import * as replay from "../core/detectors/scoreboard-battle-log-replay/rois";
import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index";
import * as own from "../core/detectors/scoreboard-own/rois";
import type { DetectedEvent } from "../core/detectors/types";
import scannerStyles from "../scanner.module.css";
import { scannerSearchParams } from "../scanner-search-params";
import { claimInspectFrame } from "../store/inspect";
import { AnalyzerClient } from "../worker/client";
import type { WorkerResponse } from "../worker/protocol";
import { downloadEventsCsv } from "./events-csv";
import { type CardData, downloadExpectedJson } from "./fixture-export";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
weaponLabel,
} from "./labels";
import styles from "./ScreenshotPage.module.css";
type Result = Extract<WorkerResponse, { kind: "result" }>;
/** Draw a ROI crop from the normalized frame, scaled up. */
function RoiCrop(props: {
frame: HTMLCanvasElement;
roi: Roi;
scale?: number;
}) {
const { frame, roi, scale = 1.5 } = props;
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current!;
canvas.width = Math.round(roi.w * scale);
canvas.height = Math.round(roi.h * scale);
const ctx = canvas.getContext("2d")!;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(
frame,
roi.x,
roi.y,
roi.w,
roi.h,
0,
0,
canvas.width,
canvas.height,
);
}, [frame, roi.x, roi.y, roi.w, roi.h, scale]);
return <canvas ref={ref} />;
}
function Stat(props: { label: string; raw?: unknown; children: ReactNode }) {
return (
<span className={styles.stat}>
<span className={styles.statLabel}>{props.label}</span>
<span className={styles.statValue}>{props.children}</span>
{props.raw != null && props.raw !== "" ? (
<span className={styles.statRaw}>raw: {String(props.raw)}</span>
) : null}
</span>
);
}
function LabeledCrop(props: {
label: string;
frame: HTMLCanvasElement;
roi: Roi;
scale?: number;
}) {
return (
<figure>
<RoiCrop frame={props.frame} roi={props.roi} scale={props.scale} />
<figcaption>{props.label}</figcaption>
</figure>
);
}
/** One pill per player slot: number = alive, ★ = special held, ✗ = splatted. */
function StatusSlots(props: { data: PlayerStatusData }) {
return (
<span className={styles.statusSlots}>
{([0, 1] as const).map((side) => (
<span key={side} className={styles.statusSide}>
{props.data.dead[side].map((dead, slot) => {
const special = !dead && props.data.special[side][slot];
return (
<span
key={slot}
className={clsx(styles.statusSlot, {
[styles.dead]: dead,
[styles.special]: special,
})}
>
{dead ? "✗" : special ? "★" : slot + 1}
</span>
);
})}
</span>
))}
</span>
);
}
/** Per-row parse ROIs, in the same order as the event's players array. */
interface RowRois {
weapon: Roi;
name: Roi;
paint: Roi;
stats: [Roi, Roi, Roi];
}
function scoreboardRows(): RowRois[] {
return sb.ROW_CENTERS.map((cy) => ({
weapon: sb.weaponRoi(cy),
name: sb.nameRoi(cy),
paint: sb.paintRoi(cy),
stats: [sb.statRoi(cy, 0), sb.statRoi(cy, 1), sb.statRoi(cy, 2)],
}));
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function battleLogRows(winnerSide: string): RowRois[] {
const panels = winnerSide === "bottom" ? [bl.PANEL_DY, 0] : [0, bl.PANEL_DY];
return panels.flatMap((dy) =>
bl.ROW_CENTERS.map((base) => {
const cy = base + dy;
return {
weapon: bl.weaponRoi(cy),
name: bl.nameRoi(cy),
paint: bl.paintRoi(cy),
stats: [bl.statRoi(cy, 0), bl.statRoi(cy, 1), bl.statRoi(cy, 2)] as [
Roi,
Roi,
Roi,
],
};
}),
);
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function replayRows(winnerSide: string): RowRois[] {
const panels =
winnerSide === "right" ? [replay.PANEL_DX, 0] : [0, replay.PANEL_DX];
return panels.flatMap((dx) =>
replay.ROW_CENTERS.map((cy) => ({
weapon: replay.weaponRoi(cy, dx),
name: replay.nameRoi(cy, dx),
paint: replay.paintRoi(cy, dx),
stats: [
replay.statRoi(cy, dx, 0),
replay.statRoi(cy, dx, 1),
replay.statRoi(cy, dx, 2),
] as [Roi, Roi, Roi],
})),
);
}
/** Same marker language as the objective status pills: ✗ dead, ★ special. */
function playerFlags(p: { dead: boolean; specialReady: boolean }) {
return `${p.dead ? " ✗" : ""}${p.specialReady ? " ★" : ""}`;
}
function formatTimer(time: number | null) {
if (time === null) return "?:??";
return `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`;
}
/** One-line recap of what a fired detector read, for the gate list. */
function gateSummary(result: Result): string | null {
const event = result.events[0];
if (!event) return null;
const confidence = `${((event.confidence ?? 0) * 100).toFixed(1)}% conf`;
switch (result.detector) {
case "death": {
const data = event.data as DeathData;
return `${confidence} · splatted by ${weaponLabel(data.weaponType, data.weaponId) ?? "?"} (${data.name ?? "?"})`;
}
case "map-start": {
const data = event.data as MapStartData;
return `${confidence} · ${modeLabel(data.mode) ?? "?"} · ${stageLabel(data.stage) ?? "?"}`;
}
case "scoreboard-own": {
const data = event.data as ScoreboardOwnData;
return `${confidence} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")} · ${mainWeaponLabel(data.weaponId) ?? "?"}`;
}
case "minimap": {
const data = event.data as MinimapData;
const players = data.teammates
.map((p) => `${p.name ?? "?"}${playerFlags(p)}`)
.join(", ");
return `${confidence} · ${data.stage ?? "?"} · ${players}`;
}
case "objective": {
const data = event.data as unknown as ObjectiveData;
return `${confidence} · ${formatTimer(data.time)} · score ${data.score[0] ?? "?"}–${data.score[1] ?? "?"}`;
}
default: {
const data = event.data as CardData;
return `${confidence} · scores ${JSON.stringify(data.matchScores)} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")}`;
}
}
}
/** Band covering one side's player-status icon strip, for the crop view. */
function statusStripRoi(layout: PlayerStatusLayout, side: 0 | 1): Roi {
const centers =
layout === "pov"
? objective.STATUS_SLOT_CENTERS_POV[side]
: layout === "cast"
? objective.STATUS_SLOT_CENTERS_CAST[side]
: objective.STATUS_SLOT_CENTERS_CAST_MIRROR[side];
const first = centers[0]!;
const last = centers[centers.length - 1]!;
return { x: first - 55, y: 25, w: last - first + 110, h: 115 };
}
function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
const rect = (roi: Roi, color: string) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.strokeRect(roi.x, roi.y, roi.w, roi.h);
};
if (detector === "death") {
rect(death.SPLAT_LINE1_ROI, "#34d399");
rect(death.WEAPON_LINE_ROI, "#f87171");
for (let row = 0; row < death.ABILITY_ROWS; row++) {
rect(death.abilityMainRoi(row), "#60a5fa");
for (const slot of [0, 1, 2])
rect(death.abilitySubRoi(row, slot), "#e879f9");
}
rect(death.TAG_NAME_OUTER, "#4ade80");
for (const roi of [
...death.GATE_BURST_PROBES,
...death.GATE_PANEL_PROBES,
]) {
rect(roi, "#facc15");
}
return;
}
if (detector === "objective") {
rect(objective.TIMER_DIGIT_ROI, "#34d399");
for (const side of [0, 1] as const) {
rect(objective.SCORE_ROIS[side], "#f87171");
rect(objective.PENALTY_ROIS[side], "#fb923c");
rect(objective.PLATE_PROBE_ROIS[side], "#facc15");
}
for (const [centers, box, color] of [
[
objective.STATUS_SLOT_CENTERS_POV,
objective.STATUS_BODY_BOX_POV,
"#60a5fa",
],
[
objective.STATUS_SLOT_CENTERS_CAST,
objective.STATUS_BODY_BOX_CAST,
"#e879f9",
],
] as const) {
for (const cx of centers.flat()) {
rect({ x: cx + box.dx, y: box.y, w: box.w, h: box.h }, color);
}
}
return;
}
if (detector === "map-start") {
rect(mapStart.MODE_LABEL_ROI, "#34d399");
rect(mapStart.MODE_BLOCK_ROI, "#f87171");
rect(mapStart.STAGE_ROI, "#60a5fa");
rect(mapStart.GATE_INK_BAND, "#e879f9");
for (const roi of mapStart.GATE_DARK_PROBES) rect(roi, "#facc15");
return;
}
if (detector === "scoreboard-own") {
rect(own.WEAPON_TITLE_BAND, "#f87171");
for (let row = 0; row < own.GEAR_ROWS; row++) {
rect(own.gearMainRoi(row), "#60a5fa");
for (const slot of [0, 1, 2]) rect(own.gearSubRoi(row, slot), "#e879f9");
rect(own.gateStripProbe(row), "#facc15");
}
for (const roi of [
...own.GATE_PANEL_PROBES,
...own.GATE_TITLE_TEXT_PROBES,
]) {
rect(roi, "#facc15");
}
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
return;
}
if (detector === "minimap") {
for (const card of minimap.CARD_LAYOUTS) {
rect(card.name, "#4ade80");
rect(card.weapon, "#f87171");
rect(card.subTile, "#22d3ee");
for (const [cx, cy] of card.badges)
rect(minimap.badgeRoi(cx, cy), "#60a5fa");
rect(card.cross, "#e879f9");
}
for (const cy of minimap.ENEMY_ROW_CYS) {
rect(minimap.enemyWeaponRoi(cy), "#f87171");
rect(minimap.enemySubTileRoi(cy), "#22d3ee");
for (const cx of minimap.ENEMY_BADGE_XS)
rect(minimap.badgeRoi(cx, cy), "#60a5fa");
rect(minimap.enemyCrossRoi(cy), "#e879f9");
}
for (const roi of [
...minimap.GATE_CLOSE_X_BRIGHT,
minimap.GATE_SPAWN_BRIGHT,
...minimap.GATE_CLOSE_DARK_PROBES,
...minimap.GATE_CLOSE_X_DARK,
...minimap.GATE_SPAWN_DARK_PROBES,
]) {
rect(roi, "#facc15");
}
return;
}
if (detector === "scoreboard-battle-log") {
for (const dy of bl.PANEL_DYS) {
for (const base of bl.ROW_CENTERS) {
const cy = base + dy;
rect(bl.weaponRoi(cy), "#f87171");
rect(bl.nameRoi(cy), "#4ade80");
rect(bl.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(bl.statRoi(cy, i), "#e879f9");
rect(bl.gateDarkProbe(cy), "#facc15");
}
rect(bl.teamScoreRoi(dy), "#60a5fa");
rect(bl.resultTagRoi(dy), "#fb923c");
}
for (const roi of bl.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of bl.GATE_COLOR_PROBES) rect(roi, "#facc15");
rect(bl.HEADER_TOP_BAND, "#34d399");
rect(bl.HEADER_BOTTOM_BAND, "#34d399");
return;
}
if (detector === "scoreboard-battle-log-replay") {
for (const dx of replay.PANEL_XS) {
for (const cy of replay.ROW_CENTERS) {
rect(replay.weaponRoi(cy, dx), "#f87171");
rect(replay.nameRoi(cy, dx), "#4ade80");
rect(replay.paintRoi(cy, dx), "#60a5fa");
for (const i of [0, 1, 2] as const)
rect(replay.statRoi(cy, dx, i), "#e879f9");
rect(replay.gateFlatProbe(cy, dx), "#facc15");
}
rect(replay.teamScoreRoi(dx), "#60a5fa");
rect(replay.resultTagRoi(dx), "#fb923c");
}
for (const roi of replay.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of replay.GATE_GAP_PROBES) rect(roi, "#facc15");
rect(replay.HEADER_TOP_BAND, "#34d399");
rect(replay.HEADER_BOTTOM_BAND, "#34d399");
rect(replay.REPLAY_CODE_ROI, "#34d399");
return;
}
for (const cy of sb.ROW_CENTERS) {
rect(sb.weaponRoi(cy), "#f87171");
rect(sb.nameRoi(cy), "#4ade80");
rect(sb.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(sb.statRoi(cy, i), "#e879f9");
rect(sb.gateDarkProbe(cy), "#facc15");
}
for (const roi of sb.TEAM_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of sb.MATCH_SCORE_ROIS) rect(roi, "#fb923c");
for (const roi of sb.GATE_PANEL_PROBES) rect(roi, "#facc15");
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
}
export function ScreenshotPage() {
const displayRef = useRef<HTMLCanvasElement>(null);
const clientRef = useRef<AnalyzerClient | null>(null);
const resultRef = useRef<(r: Result) => void>(() => {});
const doneRef = useRef<() => void>(() => {});
const [frame, setFrame] = useState<HTMLCanvasElement | null>(null);
const [results, setResults] = useState<Record<string, Result>>({});
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [over, setOver] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
clientRef.current = new AnalyzerClient(
(r) => resultRef.current(r),
(message) => {
setError(message);
setBusy(false);
},
() => doneRef.current(),
// one-shot analyses: re-running the same screenshot must always parse
{ suppressSteadyFrames: false },
);
return () => clientRef.current?.dispose();
}, []);
// the detector whose inspector/overlay is shown: the one that fired
const active = Object.values(results).find((r) => r.events.length > 0);
const activeDetector = active?.detector ?? "scoreboard";
useEffect(() => {
if (!frame) return;
const display = displayRef.current!;
display.width = CANONICAL_WIDTH;
display.height = CANONICAL_HEIGHT;
const ctx = display.getContext("2d")!;
ctx.drawImage(frame, 0, 0);
drawOverlay(ctx, activeDetector);
}, [frame, activeDetector]);
const analyze = useCallback(async (file: File | Blob) => {
// one file at a time: in-flight worker results would land in the next
// file's state through the shared resultRef otherwise
if (busyRef.current) return;
busyRef.current = true;
setError(null);
setBusy(true);
setResults({});
try {
const bitmap = await createImageBitmap(file);
// normalized frame for local crop display, same as the pipeline does
const norm = document.createElement("canvas");
norm.width = CANONICAL_WIDTH;
norm.height = CANONICAL_HEIGHT;
norm
.getContext("2d")!
.drawImage(bitmap, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT);
setFrame(norm);
resultRef.current = (r) => {
setResults((prev) => ({ ...prev, [r.detector]: r }));
};
doneRef.current = () => {
busyRef.current = false;
setBusy(false);
};
const client = clientRef.current!;
await client.whenReady();
if (!client.analyze(bitmap, 0)) {
busyRef.current = false;
setBusy(false);
}
} catch (e) {
setError(String(e));
busyRef.current = false;
setBusy(false);
}
}, []);
// frame handed off from an Inspect click in another browser tab; the
// handoff write races this tab's load, so the claim polls briefly
const [inspectKey, setInspectKey] = useSearchParam(
scannerSearchParams,
"inspect",
);
useEffect(() => {
if (!inspectKey) return;
setInspectKey(null);
claimInspectFrame(inspectKey).then(
(frame) => {
if (frame) void analyze(frame);
else
setError(
"Inspected frame did not arrive — go back to the other tab and press Inspect again",
);
},
(e) => setError(String(e)),
);
}, [inspectKey, setInspectKey, analyze]);
const event = active?.events[0] as DetectedEvent<CardData> | undefined;
const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[];
const isReplay = activeDetector === "scoreboard-battle-log-replay";
const isScoreboardBattleLog = activeDetector === "scoreboard-battle-log";
const isDeath = activeDetector === "death";
const isMapStart = activeDetector === "map-start";
const isOwn = activeDetector === "scoreboard-own";
const isMinimap = activeDetector === "minimap";
const isObjective = activeDetector === "objective";
const winnerSide = String(event?.debug?.winnerSide ?? "left");
const rowRois = isReplay
? replayRows(winnerSide)
: isScoreboardBattleLog
? battleLogRows(winnerSide)
: scoreboardRows();
return (
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) void analyze(file);
}}
>
Drop a frame (PNG/JPEG) here, or{" "}
<label>
pick a file
<input
type="file"
accept="image/png,image/jpeg"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-picking the same file
if (file) void analyze(file);
}}
/>
</label>
{busy ? " — analyzing…" : null}
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
<div
className={styles.frame}
style={{ display: frame ? "block" : "none" }}
>
<canvas ref={displayRef} />
</div>
{frame && !busy ? (
<p>
<button
type="button"
onClick={() =>
downloadExpectedJson(event?.data ?? null, event?.type)
}
>
Download expected.json
</button>{" "}
<button
type="button"
disabled={!event}
onClick={() =>
downloadEventsCsv(
"screenshot-events.csv",
Object.values(results).flatMap((r) => r.events),
)
}
>
Download CSV
</button>
</p>
) : null}
{Object.keys(results).length > 0 ? (
<div className={styles.gateList}>
{Object.values(results).map((result) => (
<div
key={result.detector}
className={clsx(styles.gateRow, {
[styles.fired]: result.gate.pass,
})}
>
<span className={styles.gateBadge}>
{result.gate.pass ? "fired" : "no fire"}
</span>
<span className={styles.gateName}>{result.detector}</span>
<span className={styles.gateScore}>
{result.gate.score.toFixed(3)}
</span>
<span className={styles.gateNote}>{gateSummary(result)}</span>
</div>
))}
</div>
) : null}
{frame && event && isReplay ? (
<div className={styles.detail}>
<div className={styles.detailStats}>
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
<Stat label="code" raw={event.debug?.codeRaw}>
{event.data.replayCode ?? "?"}
</Stat>
<Stat label="match scores">
{JSON.stringify(event.data.matchScores)}
</Stat>
<Stat label="winner panel">{winnerSide}</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="header"
frame={frame}
roi={replay.HEADER_TOP_BAND}
/>
<LabeledCrop
label="replay code"
frame={frame}
roi={replay.REPLAY_CODE_ROI}
/>
</div>
</div>
) : null}
{frame && event && isScoreboardBattleLog ? (
<div className={styles.detail}>
<div className={styles.detailStats}>
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
<Stat label="match scores">
{JSON.stringify(event.data.matchScores)}
</Stat>
<Stat label="winner panel">{winnerSide}</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="header top"
frame={frame}
roi={bl.HEADER_TOP_BAND}
/>
<LabeledCrop
label="header bottom"
frame={frame}
roi={bl.HEADER_BOTTOM_BAND}
/>
</div>
</div>
) : null}
{frame && event && isDeath ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as DeathData;
return (
<>
<div className={styles.detailStats}>
<Stat label="weapon" raw={event.debug?.weaponRaw}>
{weaponLabel(data.weaponType, data.weaponId) ?? "?"}
</Stat>
<Stat label="name" raw={event.debug?.nameRaw}>
{data.name ?? "?"}
</Stat>
<Stat label="abilities">
{data.abilities.map((row) => row.join(" ")).join(" | ")}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="weapon line"
frame={frame}
roi={death.WEAPON_LINE_ROI}
/>
<LabeledCrop
label="name tag"
frame={frame}
roi={death.TAG_NAME_OUTER}
/>
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isMapStart ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MapStartData;
return (
<>
<div className={styles.detailStats}>
<Stat label="mode" raw={event.debug?.modeReading}>
{modeLabel(data.mode) ?? "?"}
</Stat>
<Stat label="stage" raw={event.debug?.stageReading}>
{stageLabel(data.stage) ?? "?"}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="mode block"
frame={frame}
roi={mapStart.MODE_BLOCK_ROI}
scale={0.75}
/>
<LabeledCrop
label="stage"
frame={frame}
roi={mapStart.STAGE_ROI}
/>
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isOwn ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ScoreboardOwnData;
return (
<>
<div className={styles.detailStats}>
<Stat label="weapon" raw={event.debug?.weaponReading}>
{mainWeaponLabel(data.weaponId) ?? "?"}
</Stat>
<Stat label="abilities">
{data.abilities.map((row) => row.join(" ")).join(" | ")}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="title band"
frame={frame}
roi={own.WEAPON_TITLE_BAND}
/>
{[0, 1, 2].map((row) => (
<LabeledCrop
key={row}
label={`gear ${row + 1}`}
frame={frame}
roi={{
x: own.GEAR_MAIN_CXS[row]! - 36,
y: own.GEAR_BADGE_CY - 32,
w: 200,
h: 64,
}}
/>
))}
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isMinimap ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MinimapData;
return (
<>
<div className={styles.detailStats}>
<Stat label="stage">{stageLabel(data.stage) ?? "?"}</Stat>
<Stat label="view">
{data.spectator ? "spectator map" : "POV overlay"}
</Stat>
<Stat label="team">
{data.teammates
.map(
(p) =>
`${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
)
.join(", ") || "—"}
</Stat>
{data.enemies.length > 0 ? (
<Stat label="enemies">
{data.enemies
.map(
(p) =>
`${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
)
.join(", ")}
</Stat>
) : null}
</div>
{!data.spectator ? (
<div className={styles.detailCrops}>
{minimap.CARD_LAYOUTS.map((card) => (
<LabeledCrop
key={card.slot}
label={`slot ${card.slot}`}
frame={frame}
roi={card.name}
/>
))}
</div>
) : null}
</>
);
})()}
</div>
) : null}
{frame && event && isObjective ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ObjectiveData;
const status = active?.events.find(
(e) => e.type === PLAYER_STATUS_EVENT_TYPE,
) as DetectedEvent<PlayerStatusData> | undefined;
return (
<>
<div className={styles.detailStats}>
<Stat label="timer">{formatTimer(data.time)}</Stat>
<Stat label="score">
{data.score[0] ?? "?"}–{data.score[1] ?? "?"}
</Stat>
<Stat label="penalty">
{data.penalty[0] ?? "—"} / {data.penalty[1] ?? "—"}
</Stat>
<Stat label="control">
{data.control[0]
? "left"
: data.control[1]
? "right"
: "none"}
</Stat>
{status ? (
<>
<Stat label="layout">{status.data.layout}</Stat>
<Stat label="players">
<StatusSlots data={status.data} />
</Stat>
</>
) : null}
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="left count"
frame={frame}
roi={objective.SCORE_ROIS[0]}
/>
<LabeledCrop
label="timer"
frame={frame}
roi={objective.TIMER_DIGIT_ROI}
/>
<LabeledCrop
label="right count"
frame={frame}
roi={objective.SCORE_ROIS[1]}
/>
{status
? ([0, 1] as const).map((side) => (
<LabeledCrop
key={side}
label={side === 0 ? "left team" : "right team"}
frame={frame}
roi={statusStripRoi(status.data.layout, side)}
scale={0.75}
/>
))
: null}
</div>
</>
);
})()}
</div>
) : null}
{frame &&
event &&
!isDeath &&
!isMapStart &&
!isOwn &&
!isMinimap &&
!isObjective ? (
<table className={styles.inspector}>
<thead>
<tr>
<th>row</th>
<th>weapon crop</th>
<th>top candidates</th>
<th>name</th>
<th>paint</th>
<th>stats (splats / deaths / specials)</th>
</tr>
</thead>
<tbody>
{rowRois.map((roi, i) => {
const player = event.data.players[i];
const dbg = rows[i];
return (
<tr key={i}>
<td>{i}</td>
<td>
<RoiCrop frame={frame} roi={roi.weapon} />
</td>
<td>
<div
className={clsx(
styles.candidates,
styles.weaponCandidates,
)}
>
{dbg?.weapon?.top.map((c) => (
<span className={styles.candidate} key={c.id}>
<img
className={styles.weaponIcon}
src={`${mainWeaponImageUrl(Number(c.id) as MainWeaponId)}.avif`}
alt={c.id}
/>
{c.id}
<span className={scannerStyles.score}>
{c.score.toFixed(3)}
</span>
</span>
))}
</div>
</td>
<td>
<RoiCrop frame={frame} roi={roi.name} />
<b>{player?.name || "—"}</b>{" "}
<span className={scannerStyles.score}>
{dbg?.nameScore.toFixed(3)}
</span>
</td>
<td>
<RoiCrop frame={frame} roi={roi.paint} />
<b>{player?.paint ?? "—"}</b>{" "}
<span className={scannerStyles.score}>
{dbg?.paintScore.toFixed(3)}
</span>
</td>
<td>
<div className={styles.candidates}>
{([0, 1, 2] as const).map((s) => (
<span className={styles.candidate} key={s}>
<RoiCrop frame={frame} roi={roi.stats[s]} scale={2} />
<b>{[player?.ka, player?.d, player?.s][s] ?? "—"}</b>
<span className={scannerStyles.score}>
{dbg?.statScores[s].toFixed(2)}
</span>
</span>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { mainWeaponLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function StripWeaponsCard(props: {
t: number;
confidence: number;
data: StripWeaponsData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) =>
data.slots[index]
.map((candidates) =>
candidates === null
? "✕"
: (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"),
)
.join(" | ");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={STRIP_WEAPONS_EVENT_TYPE}
label={`strip weapons (${data.layout})`}
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>{side(0)}</b> vs <b>{side(1)}</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: STRIP_WEAPONS_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
/* internal navigation styled like the sibling action buttons */
.linkButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
padding: 0 var(--field-padding);
height: var(--field-size-sm);
border: var(--border-style-accent);
border-radius: var(--radius-field);
background: var(--color-text-accent);
color: var(--color-text-inverse);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
white-space: nowrap;
text-decoration: none;
& > svg {
width: 16px;
height: 16px;
}
&:active {
transform: translateY(1px);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.vodList {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.vodItem {
display: flex;
align-items: center;
gap: var(--s-3);
background: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-3) var(--s-4);
}
.vodName {
font-size: var(--font-sm);
font-weight: var(--weight-bold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vodMeta {
flex: 1;
}
/* the shared destructive variant loses to the scanner's own button look */
button.vodDelete {
width: var(--field-size-sm);
padding: 0;
border-color: var(--color-error);
background: var(--color-error);
outline-color: var(--color-error);
}
.telemetry {
margin: var(--s-2) 0;
font-size: var(--font-2xs);
color: var(--color-text-high);
& summary {
cursor: pointer;
}
& table {
margin-top: var(--s-1-5);
border-collapse: collapse;
& th,
& td {
padding: var(--s-0-5) var(--s-2-5) var(--s-0-5) 0;
text-align: right;
font-variant-numeric: tabular-nums;
&:first-child {
text-align: left;
}
}
}
}
@container scanner (width < 700px) {
.vodItem {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,939 @@
/**
* VoD tab: load a video file and scan it for scoreboard matches as fast as
* decoding allows — no real-time playback. On the primary (WebCodecs) path
* the file's duration is split into one contiguous slice per analyzer
* worker and each worker demuxes, decodes, schedules and analyzes its slice
* by itself (worker/analyzer.worker.ts): no frames cross the main thread,
* scheduling state is exact per slice, and calm stretches are skimmed by
* keyframe hops instead of decoded frame-by-frame. The seek fallback drives
* a <video> element through a single worker, widening its stride over calm
* footage. Each match can be opened in the screenshot page with the exact
* frame that was analyzed.
*
* Completed scans are persisted to IndexedDB keyed by file name
* (src/store/vods.ts); the default view lists them for reinspection.
*/
import clsx from "clsx";
import { Download, FileText, Send, Trash2, Video } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { GameTimeline } from "~/components/GameTimeline";
import { useSearchParam } from "~/modules/search-params/hooks";
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "../core/detectors/objective/strip-weapons";
import {
mergeScanTelemetry,
type ScanTelemetry,
} from "../core/detectors/telemetry";
import type { DetectedEvent } from "../core/detectors/types";
import {
buildScannerMatches,
ingestSkipReasons,
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import {
deleteVod,
listVods,
loadVodEventFrame,
loadVodEvents,
saveVod,
saveVodResultsSend,
type VodResultsSend,
type VodSummary,
} from "../store/vods";
import {
AnalyzerClient,
type DoneInfo,
defaultScanWorkerCount,
} from "../worker/client";
import { withoutRepeatEvents } from "./dedupe-events";
import { EventCard, type GetFrame } from "./EventCard";
import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import type { FixtureData } from "./fixture-export";
import { formatTime, useEventDateTimeFormatter } from "./format";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { playerStatusTeams } from "./player-status-view";
import {
countIngestableMatches,
type SendouUser,
sendVodResults,
} from "./sendou-ingest";
import { sendouUpload } from "./sendou-upload";
import { thumbnailFromBlob } from "./thumbnail";
import styles from "./VodPage.module.css";
/** The scan knows the on-screen sides only, not who is playing. */
const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
/** seek-fallback stride while the worker reports activity */
const SEEK_ACTIVE_STRIDE_S = 0.25;
/**
* seek-fallback stride over calm footage (nothing detected for a while, no
* match open) — small enough that the screens that can start activity from
* dead air (results ~10s, match intro ~7s) still get sampled
*/
const SEEK_CALM_STRIDE_S = 2.5;
type Status = "idle" | "scanning" | "done" | "error";
interface VodMatch {
event: DetectedEvent<FixtureData>;
/**
* render identity — inherited when a better read replaces the event, so
* match cards keyed on it don't remount (replaying the enter animation)
*/
key: number;
thumbnail?: string;
/** lossless PNG of the exact frame the detector analyzed (live scan) */
frame?: Blob;
/** stored VoD: id to load the frame from the vod-frames store */
frameId?: number;
}
interface Progress {
t: number;
duration: number;
/** scan speed as a multiple of realtime */
rate: number;
}
/** "Send results" progress/outcome shown next to the button. */
type ResultsSend =
| { state: "sending"; sent: number; total: number }
| ({ state: "done" } & VodResultsSend);
export function VodPage({
sendouUser,
}: {
/** sendou.ink login shown in the header; undefined = probing, null = not logged in */
sendouUser: SendouUser | null | undefined;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const previewRef = useRef<HTMLCanvasElement>(null);
const clientsRef = useRef<AnalyzerClient[]>([]);
// cancels the in-flight chunk scans of the previous scan, if any
const abortScanRef = useRef<(() => void) | null>(null);
// seek fallback: latest per-frame done info + the waiter for the next one
const doneInfoRef = useRef<DoneInfo | null>(null);
const frameDoneRef = useRef<(() => void) | null>(null);
const timelineRef = useRef(new TimelineBuilder());
// latest gate score from any worker; flushed to state on the UI throttle
const gateScoreRef = useRef<number | null>(null);
const abortRef = useRef({ aborted: false });
const urlRef = useRef<string | null>(null);
// source of truth for matches (state mirrors it) so the scan loop can
// persist the final list without waiting on React
const matchesRef = useRef<VodMatch[]>([]);
// in-flight thumbnail work; awaited before persisting a finished scan
const sideWorkRef = useRef<Promise<void>[]>([]);
const nextMatchKeyRef = useRef(0);
// telemetry collection is baked into the workers at init, so a change of
// the search param needs a fresh pool
const clientsCollectTelemetryRef = useRef(false);
const [fileName, setFileName] = useState<string | null>(null);
/** live scan vs. reopened saved VoD (no video element for the latter) */
const [source, setSource] = useState<"scan" | "stored">("scan");
const [status, setStatus] = useState<Status>("idle");
const [method, setMethod] = useState<string | null>(null);
const [gateScore, setGateScore] = useState<number | null>(null);
const [progress, setProgress] = useState<Progress | null>(null);
const [matches, setMatches] = useState<VodMatch[]>([]);
const [vods, setVods] = useState<VodSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [telemetry, setTelemetry] = useState<ScanTelemetry | null>(null);
const [over, setOver] = useState(false);
const [eventsOpen, setEventsOpen] = useState(false);
const [resultsSend, setResultsSend] = useState<ResultsSend | null>(null);
// opt-in via ?telemetry=true only; nothing in the UI links to it
const [collectTelemetry] = useSearchParam(scannerSearchParams, "telemetry");
const formatSavedAt = useEventDateTimeFormatter();
const abilityMap = connectAbilities(matches.map((m) => m.event));
const builtMatches = buildScannerMatches(matches.map((m) => m.event));
const skipReasons = ingestSkipReasons(builtMatches);
// mirrors sendVodResults' ingestable-match order, which the send outcome's
// links are keyed by
const ingestableBuilt = builtMatches.filter((b) => !skipReasons.has(b));
const linkByIngestableIndex = new Map(
(resultsSend?.state === "done" ? (resultsSend.links ?? []) : []).map(
(linked) => [linked.matchIndex, linked.link] as const,
),
);
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
// strip weapon evidence is assignment input, not a detection worth a card
const ungroupedMatches = matches.filter(
(m) =>
!groupedEvents.has(m.event) && m.event.type !== STRIP_WEAPONS_EVENT_TYPE,
);
// "Send results" sends the whole scan in one go, so its outcome maps
// onto every ingestable card; a partial failure (some chunks sent, some
// not) can't be attributed per match — the bulk status text covers it
const bulkSend: SendStatus | undefined =
resultsSend?.state === "sending"
? { state: "sending", at: 0 }
: resultsSend?.state === "done" && resultsSend.error === null
? { state: "sent", at: resultsSend.at }
: resultsSend?.state === "done" && resultsSend.sent === 0
? { state: "failed", at: resultsSend.at }
: undefined;
// only offered once the whole VoD has been processed (a stored VoD is a
// completed scan by construction)
const upload =
status === "done" ? sendouUpload(matches.map((m) => m.event)) : null;
// "Send results" — the /ingest counterpart of live sending: the
// scan's ingestable ScannerMatches POSTed in one go
const resultsMatchCount =
status === "done" ? countIngestableMatches(matches.map((m) => m.event)) : 0;
const refreshVods = useCallback(async () => {
try {
setVods(await listVods());
} catch {
// listing failures are non-fatal; the scan UI still works
}
}, []);
const uploadResults = async () => {
const events = matchesRef.current.map((m) => m.event);
setResultsSend({
state: "sending",
sent: 0,
total: countIngestableMatches(events),
});
const report = await sendVodResults(events, (sent, total) =>
setResultsSend({ state: "sending", sent, total }),
);
const outcome: VodResultsSend = {
sent: report.sentMatches,
total: report.totalMatches,
error: report.error,
at: Date.now(),
links: report.links,
};
setResultsSend({ state: "done", ...outcome });
// the scan is saved under its file name, so its send outcome can be
// restored when the VoD is reopened
if (fileName) {
await saveVodResultsSend(fileName, outcome);
await refreshVods();
}
};
useEffect(() => {
void refreshVods();
return () => {
abortRef.current.aborted = true;
abortScanRef.current?.();
for (const client of clientsRef.current) client.dispose();
clientsRef.current = [];
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
};
}, [refreshVods]);
const scan = async (file: File) => {
abortRef.current.aborted = true;
abortScanRef.current?.();
const abort = { aborted: false };
abortRef.current = abort;
setError(null);
setTelemetry(null);
setMatches([]);
setResultsSend(null);
setEventsOpen(false);
setProgress(null);
setGateScore(null);
setMethod(null);
setFileName(file.name);
setSource("scan");
setStatus("scanning");
try {
// the element is used by the seek fallback and for post-scan review
const video = videoRef.current!;
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
urlRef.current = URL.createObjectURL(file);
video.src = urlRef.current;
if (clientsCollectTelemetryRef.current !== collectTelemetry) {
for (const client of clientsRef.current) client.dispose();
clientsRef.current = [];
clientsCollectTelemetryRef.current = collectTelemetry;
}
if (clientsRef.current.length === 0) {
clientsRef.current = Array.from(
{ length: defaultScanWorkerCount() },
() =>
new AnalyzerClient(
(result) => {
gateScoreRef.current = result.gate.score;
if (!result.gate.pass) return;
for (const event of result.events as DetectedEvent<FixtureData>[]) {
const action = timelineRef.current.push(event);
if (action.action !== "added" && action.action !== "replaced")
continue;
const frame = result.frame;
sideWorkRef.current.push(
(async () => {
const thumbnail = frame
? await thumbnailFromBlob(frame)
: undefined;
const replaced =
action.action === "replaced"
? matchesRef.current.find(
(m) => m.event === action.replaced,
)
: undefined;
const next = matchesRef.current.filter(
(m) => m !== replaced,
);
next.push({
event,
key: replaced?.key ?? nextMatchKeyRef.current++,
thumbnail,
frame,
});
next.sort((a, b) => a.event.t - b.event.t);
matchesRef.current = next;
setMatches(next);
})().catch(() => {}),
);
}
},
(message) => {
frameDoneRef.current?.();
frameDoneRef.current = null;
setError(message);
setStatus("error");
},
(_t, info: DoneInfo) => {
doneInfoRef.current = info;
frameDoneRef.current?.();
frameDoneRef.current = null;
},
{ collectTelemetry },
),
);
}
const clients = clientsRef.current;
await Promise.all(clients.map((c) => c.whenReady()));
// let work still in flight from an aborted scan finish before the
// timeline resets, so its results can't bleed into this scan
await Promise.all(clients.map((c) => c.whenIdle()));
if (abort.aborted) return;
matchesRef.current = [];
sideWorkRef.current = [];
timelineRef.current = new TimelineBuilder();
const started = performance.now();
const finalize = async (duration: number) => {
await Promise.all(sideWorkRef.current);
if (abort.aborted) return;
matchesRef.current = withoutInvalidObjectives(matchesRef.current);
setMatches(matchesRef.current);
setProgress((p) => (p ? { ...p, t: duration } : p));
setStatus("done");
await saveVod(
{ name: file.name, savedAt: Date.now(), duration },
matchesRef.current.map((m) => ({
type: m.event.type,
t: m.event.t,
confidence: m.event.confidence,
data: m.event.data,
thumbnail: m.thumbnail,
frame: m.frame,
})),
);
await refreshVods();
};
const probe = await probeWebCodecs(file);
if (abort.aborted) return;
if (probe) {
// each worker demuxes, decodes and analyzes its own slice of
// the file; the main thread only aggregates progress
setMethod("webcodecs");
const { duration } = probe;
const chunkSpan = duration / clients.length;
const chunks = clients.map((client, i) => ({
client,
tStart: i * chunkSpan,
tEnd: i === clients.length - 1 ? duration : (i + 1) * chunkSpan,
t: i * chunkSpan,
done: false,
telemetry: null as ScanTelemetry | null,
}));
abortScanRef.current = () => {
for (const client of clients) client.abortChunk();
};
const mergedTelemetry = () => {
const parts = chunks.flatMap((c) =>
c.telemetry ? [c.telemetry] : [],
);
return parts.length > 0 ? mergeScanTelemetry(parts) : null;
};
let lastUiUpdate = Number.NEGATIVE_INFINITY;
const pushUiUpdate = () => {
const now = performance.now();
if (now - lastUiUpdate < 250) return;
lastUiUpdate = now;
const covered = R.sumBy(
chunks,
(c) => Math.min(c.t, c.tEnd) - c.tStart,
);
const elapsed = (now - started) / 1000;
setGateScore(gateScoreRef.current);
setProgress({
t: covered,
duration,
rate: elapsed > 0 ? covered / elapsed : 0,
});
setTelemetry(mergedTelemetry());
};
await Promise.all(
chunks.map((chunk, chunkIndex) =>
chunk.client
.scanChunk(
{ file, chunkIndex, tStart: chunk.tStart, tEnd: chunk.tEnd },
(progress) => {
chunk.t = progress.t;
chunk.telemetry = progress.telemetry;
if (progress.preview) {
// show one chunk at a time: the earliest still running
if (chunks.find((c) => !c.done) === chunk) {
drawPreview(previewRef.current, progress.preview);
}
progress.preview.close();
}
pushUiUpdate();
},
)
.then((chunkTelemetry) => {
chunk.done = true;
chunk.t = chunk.tEnd;
chunk.telemetry = chunkTelemetry;
}),
),
);
if (abort.aborted) return;
setTelemetry(mergedTelemetry());
await finalize(duration);
return;
}
// seek fallback: one worker, one frame in flight; the worker's calm
// signal widens the stride over dead air
setMethod("seek");
const strideRef = { current: SEEK_ACTIVE_STRIDE_S };
const vod = await openSeekScan(video, () => strideRef.current);
if (abort.aborted) return;
const client = clients[0]!;
let lastUiUpdate = Number.NEGATIVE_INFINITY;
for await (const { frame, t } of vod.frames) {
if (abort.aborted) {
frame.close();
break;
}
const now = performance.now();
if (now - lastUiUpdate >= 250) {
lastUiUpdate = now;
// the preview draw must precede analyze — transferring the
// frame to the worker detaches it
drawPreview(previewRef.current, frame);
setGateScore(gateScoreRef.current);
const elapsed = (now - started) / 1000;
setProgress({
t,
duration: vod.duration,
rate: elapsed > 0 ? t / elapsed : 0,
});
if (doneInfoRef.current) setTelemetry(doneInfoRef.current.telemetry);
}
await new Promise<void>((resolve) => {
frameDoneRef.current = resolve;
if (!client.analyze(frame, t)) resolve();
});
strideRef.current = doneInfoRef.current?.calm
? SEEK_CALM_STRIDE_S
: SEEK_ACTIVE_STRIDE_S;
}
if (doneInfoRef.current) setTelemetry(doneInfoRef.current.telemetry);
await finalize(vod.duration);
} catch (e) {
if (!abort.aborted) {
abortScanRef.current?.();
setError(String(e));
setStatus("error");
}
}
};
const openStored = async (vod: VodSummary) => {
const name = vod.name;
abortRef.current.aborted = true;
abortScanRef.current?.();
setTelemetry(null);
try {
const events = await loadVodEvents(name);
// VoDs saved before objective reads were mode-gated may carry them
const loaded = withoutInvalidObjectives(
events.map((e) => ({
event: {
type: e.type,
t: e.t,
confidence: e.confidence,
data: e.data as FixtureData,
},
key: nextMatchKeyRef.current++,
thumbnail: e.thumbnail,
frameId: e.hasFrame ? e.id : undefined,
})),
);
matchesRef.current = loaded;
setMatches(loaded);
setResultsSend(
vod.resultsSend ? { state: "done", ...vod.resultsSend } : null,
);
setEventsOpen(false);
setFileName(name);
setSource("stored");
setStatus("done");
setError(null);
setProgress(null);
setGateScore(null);
setMethod(null);
} catch (e) {
setError(String(e));
}
};
const removeVod = async (name: string) => {
await deleteVod(name);
await refreshVods();
};
const backToList = () => {
abortRef.current.aborted = true;
abortScanRef.current?.();
setTelemetry(null);
matchesRef.current = [];
setMatches([]);
setResultsSend(null);
setEventsOpen(false);
setFileName(null);
setStatus("idle");
setError(null);
setProgress(null);
setGateScore(null);
setMethod(null);
void refreshVods();
};
const showVodView = fileName !== null;
return (
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) void scan(file);
}}
>
Drop a VoD (video file) here, or{" "}
<label>
pick a file
<input
type="file"
accept="video/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-picking the same file
if (file) void scan(file);
}}
/>
</label>
</div>
<div className={scannerStyles.controls}>
{showVodView ? (
<>
<button type="button" onClick={backToList}>
← All VoDs
</button>
<span
className={clsx(scannerStyles.status, {
[scannerStyles.watching]: status === "scanning",
[scannerStyles.detected]: status === "done",
[scannerStyles.idle]:
status !== "scanning" && status !== "done",
})}
>
{source === "stored" ? "saved" : status}
{fileName ? ` · ${fileName}` : null}
{method ? ` · ${method}` : null}
{gateScore !== null && status === "scanning"
? ` · gate ${gateScore.toFixed(2)}`
: null}
</span>
{progress ? (
<span className={scannerStyles.score}>
{formatTime(progress.t)} / {formatTime(progress.duration)}
{progress.duration > 0
? ` (${Math.round((progress.t / progress.duration) * 100)}%)`
: null}
{progress.rate > 0
? ` · ${progress.rate.toFixed(0)}× realtime`
: null}
</span>
) : null}
{upload?.url ? (
<Link to={upload.url} className={styles.linkButton}>
<Video aria-hidden />
Add VoD
</Link>
) : null}
{upload?.problem ? (
<span className={scannerStyles.score}>
upload unavailable: {upload.problem}
</span>
) : null}
{resultsMatchCount > 0 ? (
<button
type="button"
disabled={!sendouUser || resultsSend?.state === "sending"}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => void uploadResults()}
>
<Send aria-hidden />
Send results
</button>
) : null}
{resultsSend ? (
<span
className={clsx(scannerStyles.score, {
[scannerStyles.error]:
resultsSend.state === "done" && Boolean(resultsSend.error),
})}
>
{resultsSend.state === "sending"
? `sending match ${Math.min(resultsSend.sent + 1, resultsSend.total)}/${resultsSend.total}…`
: resultsSend.error
? `sent ${resultsSend.sent}/${resultsSend.total} matches — ${resultsSend.error}`
: `sent ${resultsSend.sent}/${resultsSend.total} matches to sendou.ink`}
</span>
) : null}
{matches.length > 0 ? (
<ExportMenu
fileName={fileName}
events={matches.map((m) => m.event)}
/>
) : null}
</>
) : null}
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{showVodView && telemetry ? (
<TelemetryPanel telemetry={telemetry} />
) : null}
{!showVodView ? (
<div className={styles.vodList}>
{vods.length === 0 ? (
<p className={scannerStyles.score}>
No saved VoDs yet — scan one and it will show up here.
</p>
) : null}
{vods.map((vod) => (
<div key={vod.name} className={styles.vodItem}>
<span className={styles.vodName}>{vod.name}</span>
<span className={clsx(scannerStyles.score, styles.vodMeta)}>
{vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "}
{formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)}
</span>
<button type="button" onClick={() => void openStored(vod)}>
Open
</button>
<FormWithConfirm
dialogHeading={`Delete saved analysis of "${vod.name}"?`}
onConfirm={() => void removeVod(vod.name)}
>
<SendouButton
variant="destructive"
size="small"
shape="square"
className={styles.vodDelete}
icon={<Trash2 />}
aria-label="Delete"
/>
</FormWithConfirm>
</div>
))}
</div>
) : null}
<div
className={scannerStyles.liveLayout}
style={{
display: showVodView ? undefined : "none",
// a reopened saved VoD has no video to review — give the feed the full width
gridTemplateColumns: source === "stored" ? "1fr" : undefined,
}}
>
<div style={{ display: source === "scan" ? undefined : "none" }}>
<canvas
ref={previewRef}
className={scannerStyles.preview}
style={{ display: status === "scanning" ? "block" : "none" }}
/>
<video
ref={videoRef}
className={scannerStyles.preview}
muted
playsInline
controls
style={{
display: fileName && status !== "scanning" ? "block" : "none",
}}
/>
</div>
<div className={scannerStyles.feed}>
{matches.length === 0 ? (
<p className={scannerStyles.score}>
{status === "scanning"
? "Scanning — matches appear here as scoreboards are detected."
: "No matches found in this VoD."}
</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
keyOf={(built) => vodMatchByEvent.get(built.sources[0]!)!.key}
renderMatch={(built, justFormed) => {
const skipReason = skipReasons.get(built);
const link = linkByIngestableIndex.get(
ingestableBuilt.indexOf(built),
);
const send = skipReason ? undefined : bulkSend;
// counter reads render as one timeline chart, not a card each --
// from the builder's samples, whose sides are team-stable (raw
// reads follow the specced player on casts); a non-SZ match's
// reads (objective null) are never shown
const objectiveEvents = (
built.match.objective?.samples ?? []
).map((sample) => ({ t: sample.t, data: sample }));
const statusSamples = built.match.playerStatus?.samples ?? [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) =>
e.type !== OBJECTIVE_EVENT_TYPE &&
e.type !== PLAYER_STATUS_EVENT_TYPE &&
e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
return (
<MatchCard
match={built.match}
inProgress={
status === "scanning" &&
built === builtMatches.at(-1) &&
built.match.winner === null
}
skipReason={skipReason}
justFormed={justFormed}
send={
send?.state === "sent" && link ? { ...send, link } : send
}
>
<GameTimeline
objectiveEvents={objectiveEvents}
playerStatusSamples={statusSamples}
teams={playerStatusTeams(built.match, SCANNER_TEAM_LABELS)}
/>
{cardEvents.map((e) => {
const vodMatch = vodMatchByEvent.get(e);
return (
<EventCard
key={vodMatch?.key ?? `${e.type}-${e.t}`}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data}
abilities={abilityMap.get(e)}
thumbnail={vodMatch?.thumbnail}
getFrame={vodMatch ? frameLoader(vodMatch) : undefined}
/>
);
})}
</MatchCard>
);
}}
/>
{ungroupedMatches.length > 0 ? (
<EventsSummary
events={ungroupedMatches.map((m) => m.event)}
open={eventsOpen}
onToggle={() => setEventsOpen(!eventsOpen)}
/>
) : null}
{/* newest detection on top; storage keeps ascending video-time order */}
{eventsOpen
? ungroupedMatches
.toReversed()
.map((m) => (
<EventCard
key={m.key}
type={m.event.type}
t={m.event.t}
confidence={m.event.confidence}
data={m.event.data}
abilities={abilityMap.get(m.event)}
thumbnail={m.thumbnail}
getFrame={frameLoader(m)}
/>
))
: null}
</div>
</div>
</div>
);
}
/** Export formats of the scanned events, behind one icon-only menu. */
function ExportMenu({
fileName,
events,
}: {
fileName: string;
events: DetectedEvent<FixtureData>[];
}) {
return (
<SendouMenu
trigger={
<SendouButton
icon={<Download />}
className={scannerStyles.iconMenu}
aria-label="Export"
/>
}
>
<SendouMenuItem
icon={<FileText />}
onAction={() =>
downloadEventsCsv(
`${fileName.replace(/\.[^.]+$/, "")}-events.csv`,
events,
)
}
>
CSV
</SendouMenuItem>
</SendouMenu>
);
}
/** Drops objective reads that grouped into a known non-SZ match (misreads). */
function withoutInvalidObjectives(matches: VodMatch[]): VodMatch[] {
const invalid = new Set(
invalidObjectiveEvents(buildScannerMatches(matches.map((m) => m.event))),
);
return invalid.size > 0
? matches.filter((m) => !invalid.has(m.event))
: matches;
}
function frameLoader(m: VodMatch): GetFrame | undefined {
return m.frame
? () => Promise.resolve(m.frame)
: m.frameId !== undefined
? () => loadVodEventFrame(m.frameId!)
: undefined;
}
function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) {
const detectors = Object.entries(telemetry.detectors).sort(([a], [b]) =>
a.localeCompare(b),
);
const coveredS = telemetry.activeVideoS + telemetry.skimVideoS;
return (
<details className={styles.telemetry}>
<summary>
telemetry · analyzed {telemetry.analyzedFrames}/
{telemetry.decodedFrames} decoded frames
{coveredS > 0
? ` · skimmed ${formatTime(telemetry.skimVideoS)} of ${formatTime(coveredS)}`
: null}
{telemetry.wallMs > 0
? ` · ${formatTime(telemetry.wallMs / 1000)} cpu`
: null}
</summary>
<table>
<thead>
<tr>
<th>detector</th>
<th>checks</th>
<th>gate pass</th>
<th>gate ms</th>
<th>parses</th>
<th>parse ms</th>
<th>suppressed</th>
</tr>
</thead>
<tbody>
{detectors.map(([id, d]) => (
<tr key={id}>
<td>{id}</td>
<td>{d.checks}</td>
<td>{d.gatePasses}</td>
<td>{Math.round(d.gateMs)}</td>
<td>{d.parses}</td>
<td>{Math.round(d.parseMs)}</td>
<td>{d.suppressedParses}</td>
</tr>
))}
</tbody>
</table>
</details>
);
}
function drawPreview(
canvas: HTMLCanvasElement | null,
frame: ImageBitmap | VideoFrame,
): void {
if (!canvas) return;
const width = "displayWidth" in frame ? frame.displayWidth : frame.width;
const height = "displayHeight" in frame ? frame.displayHeight : frame.height;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
canvas.getContext("2d")!.drawImage(frame, 0, 0);
}

View File

@@ -0,0 +1,42 @@
/**
* Display-side cleanup for a match card's source event list: a long map-open
* can fragment into several minimap events (the timeline's merge window
* tracks a drifting `t`, and VoD results arrive out of order), so a minimap
* that shows nothing new versus the previous one is pure noise. Names are
* ignored in the comparison — OCR wobbles on them while the actual content
* (stage, weapons, ability reads) is unchanged. Stored events are untouched;
* this only filters what gets rendered.
*/
import { isDeepEqual, omit } from "remeda";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
export function withoutRepeatEvents<T extends { type: string; data: unknown }>(
events: readonly T[],
): T[] {
const result: T[] = [];
let previousMinimap: MinimapData | null = null;
for (const event of events) {
if (event.type === MINIMAP_EVENT_TYPE) {
const data = event.data as MinimapData;
const repeat =
previousMinimap !== null &&
isDeepEqual(comparable(previousMinimap), comparable(data));
previousMinimap = data;
if (repeat) continue;
}
result.push(event);
}
return result;
}
function comparable(data: MinimapData) {
return {
...data,
teammates: data.teammates.map((player) => omit(player, ["name"])),
enemies: data.enemies.map((player) => omit(player, ["name"])),
};
}

View File

@@ -0,0 +1,336 @@
/**
* Flatten detected events into a single CSV for download. One row per event;
* the event types share columns where they overlap (lobby/mode/stage,
* weapon/name/abilities) and a scoreboard's eight player rows — or the
* minimap's teammates+enemies, or the objective HUD's icon strip — are packed
* into one cell, matching the compact per-event view of the live feed.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import {
SCOREBOARD_EVENT_TYPE,
type ScoreboardData,
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import {
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
type ScoreboardBattleLogReplayData,
} from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { formatClock, formatTime } from "./format";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
weaponLabel,
} from "./labels";
export interface CsvEvent {
type: string;
/** video/stream time in seconds */
t: number;
/** wall-clock time of detection (live capture only) */
detectedAt?: number;
confidence: number;
data: unknown;
}
const HEADER = [
"type",
"time",
"t_seconds",
"detected_at",
"confidence",
"lobby",
"mode",
"stage",
"winner_score",
"loser_score",
"pov",
"weapon",
"name",
"abilities",
"players",
"replay_code",
"replay_timestamp",
];
type Cell = string | number | null | undefined;
function csvCell(value: Cell): string {
if (value === null || value === undefined) return "";
const s = String(value);
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
}
/** [head, clothes, shoes] rows of [main, sub, sub, sub] ability ids */
function formatAbilities(rows: string[][]): string {
return rows.map((row) => row.join("+")).join(" | ");
}
/** the minimap's flat [head, clothes, shoes] main-ability row */
function formatMinimapAbilities(abilities: (string | null)[]): string {
return abilities.map((a) => a ?? "?").join("+");
}
function formatMinimapPlayers(data: MinimapData): string {
const fmt = (
label: string,
p: {
name: string | null;
weaponId: number | null;
abilities: (string | null)[];
dead: boolean;
specialReady: boolean;
},
) =>
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}` +
`${p.dead ? " · splatted" : ""}${p.specialReady ? " · special" : ""}`;
return [
...data.teammates.map((p) => fmt(p.slot, p)),
...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)),
].join("; ");
}
/** one team's four slots as top-candidate weapon names, ✕ = splatted */
function formatStripWeaponsSide(data: StripWeaponsData, side: 0 | 1): string {
return data.slots[side]
.map((candidates) =>
candidates === null
? "✕"
: (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"),
)
.join(" | ");
}
/** one team's four icons as ✕ splatted / ★ special ready / · alive */
function formatPlayerStatusSide(data: PlayerStatusData, side: 0 | 1): string {
return data.dead[side]
.map((dead, slot) => (dead ? "✕" : data.special[side][slot] ? "★" : "·"))
.join("");
}
function formatPlayers(data: ScoreboardData): string {
return data.players
.map(
(p, i) =>
`${i < 4 ? "W" : "L"} ${p.name} · ${p.weaponId ?? "?"} · ${p.paint ?? "?"}p ` +
`${p.ka ?? "?"}/${p.d ?? "?"}/${p.s ?? "?"}`,
)
.join("; ");
}
function eventCells(event: CsvEvent): Cell[] {
const base: Cell[] = [
event.type,
formatTime(event.t),
Math.round(event.t * 1000) / 1000,
event.detectedAt === undefined
? ""
: new Date(event.detectedAt).toISOString(),
Math.round(event.confidence * 1000) / 1000,
];
switch (event.type) {
case DEATH_EVENT_TYPE: {
const d = event.data as DeathData;
return [
...base,
"",
"",
"",
"",
"",
"",
weaponLabel(d.weaponType, d.weaponId),
d.name,
formatAbilities(d.abilities),
"",
"",
"",
];
}
case MAP_START_EVENT_TYPE: {
const d = event.data as MapStartData;
return [
...base,
"",
modeLabel(d.mode),
stageLabel(d.stage),
"",
"",
"",
"",
"",
"",
"",
"",
"",
];
}
case SCOREBOARD_OWN_EVENT_TYPE: {
const d = event.data as ScoreboardOwnData;
return [
...base,
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
"",
"",
"",
mainWeaponLabel(d.weaponId),
"",
formatAbilities(d.abilities),
"",
"",
"",
];
}
case OBJECTIVE_EVENT_TYPE: {
const d = event.data as ObjectiveData;
const sideText = (side: 0 | 1) =>
`${d.score[side] ?? "?"}${d.penalty[side] !== null ? ` (+${d.penalty[side]})` : ""}${d.control[side] ? " ctrl" : ""}`;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
modeLabel(d.mode),
"",
"",
"",
"",
"",
"",
"",
`${clock}${sideText(0)} vs ${sideText(1)}`,
"",
"",
];
}
case MINIMAP_EVENT_TYPE: {
const d = event.data as MinimapData;
const self = d.teammates.find((p) => p.slot === "self");
return [
...base,
"", // lobby
"", // mode (not readable from the minimap)
stageLabel(d.stage), // stage (planner-signature match)
"", // winner_score
"", // loser_score
self?.name,
self ? mainWeaponLabel(self.weaponId) : "",
"",
self ? formatMinimapAbilities(self.abilities) : "",
formatMinimapPlayers(d),
"",
"",
];
}
case PLAYER_STATUS_EVENT_TYPE: {
const d = event.data as PlayerStatusData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatPlayerStatusSide(d, 0)} vs ${formatPlayerStatusSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case STRIP_WEAPONS_EVENT_TYPE: {
const d = event.data as StripWeaponsData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatStripWeaponsSide(d, 0)} vs ${formatStripWeaponsSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case SCOREBOARD_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE: {
// the three scoreboard reads share the base shape
const d = event.data as ScoreboardData &
Partial<ScoreboardBattleLogReplayData>;
return [
...base,
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
d.matchScores[0],
d.matchScores[1],
d.povIndex === null ? "" : d.players[d.povIndex]?.name,
"",
"",
"",
formatPlayers(d),
d.replayCode ?? "",
d.timestamp ?? "",
];
}
default:
return [...base, ...Array(HEADER.length - base.length).fill("")];
}
}
export function eventsToCsv(events: CsvEvent[]): string {
const lines = [HEADER.join(",")];
for (const event of events)
lines.push(eventCells(event).map(csvCell).join(","));
return `${lines.join("\r\n")}\r\n`;
}
export function downloadEventsCsv(filename: string, events: CsvEvent[]): void {
const blob = new Blob([eventsToCsv(events)], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,305 @@
/**
* "Save as fixture": download the raw captured frame as PNG plus an
* expected.json prefilled from the detector's own output, so labeling a new
* fixture is review-and-correct instead of data entry.
*/
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { mainWeaponLabel, stageLabel, weaponLabel } from "./labels";
/** Scoreboard data with the replay extras present when the event has them. */
export type CardData = ScoreboardData &
Partial<Pick<ScoreboardBattleLogReplayData, "timestamp" | "replayCode">>;
/** Any detector's event payload that can prefill a fixture. */
export type FixtureData =
| CardData
| DeathData
| MapStartData
| ScoreboardOwnData
| MinimapData
| ObjectiveData
| PlayerStatusData
| StripWeaponsData;
function isDeath(_data: FixtureData, eventType: string): _data is DeathData {
return eventType === DEATH_EVENT_TYPE;
}
function download(name: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
function buildExpectedJson(
data: FixtureData | null,
eventType = "Scoreboard",
): string {
if (!data) {
return `${JSON.stringify({ event: "none" }, null, 2)}\n`;
}
if (isDeath(data, eventType)) {
const label = weaponLabel(data.weaponType, data.weaponId);
return `${JSON.stringify(
{
event: eventType,
data: {
...(label !== null && { weaponLabel: label }),
...(data.weaponId !== null && { weaponId: data.weaponId }),
...(data.weaponType !== null && { weaponType: data.weaponType }),
abilities: data.abilities,
...(data.name !== null && { name: data.name }),
},
},
null,
2,
)}\n`;
}
if (eventType === SCOREBOARD_OWN_EVENT_TYPE) {
const own = data as ScoreboardOwnData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(own.lobby !== null && { lobby: own.lobby }),
...(own.mode !== null && { mode: own.mode }),
...(own.stage !== null && {
stage: own.stage,
stageLabel: stageLabel(own.stage),
}),
...(own.weaponId !== null && {
weaponLabel: mainWeaponLabel(own.weaponId),
weaponId: own.weaponId,
}),
abilities: own.abilities,
},
},
null,
2,
)}\n`;
}
if (eventType === MINIMAP_EVENT_TYPE) {
const minimap = data as MinimapData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(minimap.stage !== null && {
stage: minimap.stage,
stageLabel: stageLabel(minimap.stage),
}),
...(minimap.spectator && { spectator: true }),
teammates: minimap.teammates.map((p) => ({
slot: p.slot,
name: p.name,
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
dead: p.dead,
specialReady: p.specialReady,
})),
enemies: minimap.enemies.map((p) => ({
...(minimap.spectator && { name: p.name }),
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
dead: p.dead,
specialReady: p.specialReady,
})),
},
},
null,
2,
)}\n`;
}
if (eventType === OBJECTIVE_EVENT_TYPE) {
const objective = data as ObjectiveData;
return `${JSON.stringify(
{
event: eventType,
data: {
mode: objective.mode,
time: objective.time,
score: objective.score,
penalty: objective.penalty,
control: objective.control,
},
},
null,
2,
)}\n`;
}
if (eventType === PLAYER_STATUS_EVENT_TYPE) {
const status = data as PlayerStatusData;
return `${JSON.stringify(
{
event: eventType,
data: {
layout: status.layout,
time: status.time,
special: status.special,
dead: status.dead,
},
},
null,
2,
)}\n`;
}
if (eventType === STRIP_WEAPONS_EVENT_TYPE) {
const strip = data as StripWeaponsData;
return `${JSON.stringify(
{
event: eventType,
data: {
layout: strip.layout,
time: strip.time,
// the top candidate per slot; hand-correct to the true weapons
weapons: strip.slots.map((side) =>
side.map((candidates) => candidates?.[0]?.weaponId ?? null),
),
weaponLabels: strip.slots.map((side) =>
side.map((candidates) =>
mainWeaponLabel(candidates?.[0]?.weaponId ?? null),
),
),
},
},
null,
2,
)}\n`;
}
// NB: not a type-predicate helper — CardData is structurally assignable to
// MapStartData, so a predicate would narrow the fall-through case to never
if (eventType === MAP_START_EVENT_TYPE) {
const mapStart = data as MapStartData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(mapStart.mode !== null && { mode: mapStart.mode }),
...(mapStart.stage !== null && {
stage: mapStart.stage,
stageLabel: stageLabel(mapStart.stage),
}),
},
},
null,
2,
)}\n`;
}
const card = data as CardData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(card.lobby !== null && { lobby: card.lobby }),
...(card.mode !== null && { mode: card.mode }),
...(card.stage !== null && {
stage: card.stage,
stageLabel: stageLabel(card.stage),
}),
...(card.timestamp != null && { timestamp: card.timestamp }),
...(card.replayCode != null && { replayCode: card.replayCode }),
matchScores: card.matchScores,
players: card.players.map((p) => ({
name: p.name,
weaponId: p.weaponId,
paint: p.paint,
ka: p.ka,
d: p.d,
s: p.s,
})),
},
},
null,
2,
)}\n`;
}
/**
* expected.json alone, for a frame the user already has on disk (Screenshot
* page). Null data produces the negative-fixture form.
*/
export function downloadExpectedJson(
data: FixtureData | null,
eventType?: string,
): void {
download(
"expected.json",
new Blob([buildExpectedJson(data, eventType)], {
type: "application/json",
}),
);
}
/**
* Fixture export for a live detection: the stored PNG is the byte-exact
* frame the detector analyzed, paired with that event's own parse output.
*/
export function saveFixtureFromEvent(
frame: Blob,
data: FixtureData,
eventType: string,
): void {
download("frame.png", frame);
download(
"expected.json",
new Blob([buildExpectedJson(data, eventType)], {
type: "application/json",
}),
);
}
export async function saveFixture(
video: HTMLVideoElement,
latest: { type: string; data: FixtureData } | null,
): Promise<void> {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")!.drawImage(video, 0, 0);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png"),
);
if (!blob) throw new Error("could not encode frame");
download("frame.png", blob);
download(
"expected.json",
new Blob([buildExpectedJson(latest?.data ?? null, latest?.type)], {
type: "application/json",
}),
);
}

View File

@@ -0,0 +1,37 @@
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
/** hh:mm:ss for feed rows, CSV cells, and scan progress. */
export function formatTime(t: number): string {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = Math.floor(t % 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/** the match timer's M:SS (215 → "3:35") */
export function formatClock(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
/**
* Locale-aware time-of-day formatter for detection timestamps (epoch ms)
* shown in scanner cards, in place of a raw `toLocaleTimeString()` call.
*/
export function useEventTimeFormatter(): (ms: number) => string {
const { formatter } = useDateTimeFormat({ timeStyle: "medium" });
return (ms: number) => formatter.format(new Date(ms));
}
/**
* Locale-aware date+time formatter for absolute timestamps (epoch ms), e.g.
* a saved VoD's scan time, in place of a raw `toLocaleString()` call.
*/
export function useEventDateTimeFormatter(): (ms: number) => string {
const { formatter } = useDateTimeFormat({
dateStyle: "medium",
timeStyle: "medium",
});
return (ms: number) => formatter.format(new Date(ms));
}

View File

@@ -0,0 +1,59 @@
/**
* English display labels for the ids scanner events carry. UI-only: events and
* detectors speak sendou ids (§ scanner-types.ts); these helpers turn them back
* into human-readable names for cards, CSV export, and the fixture
* exporter's informational *Label fields.
*/
import type {
MainWeaponId,
ModeShort,
SpecialWeaponId,
StageId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import gameMisc from "../../../../locales/en/game-misc.json";
import {
ALL_WEAPON_ENTRIES,
type WeaponType,
} from "../core/detectors/death/weapon-names";
import type { ScannerLobby } from "../scanner-types";
const MISC = gameMisc as Record<string, string>;
const WEAPON_NAME_BY_KIND_AND_ID = new Map(
ALL_WEAPON_ENTRIES.map((e) => [`${e.type}:${e.id}`, e.name]),
);
export function weaponLabel(
type: WeaponType | null,
id: MainWeaponId | SubWeaponId | SpecialWeaponId | null,
): string | null {
if (type === null || id === null) return null;
return WEAPON_NAME_BY_KIND_AND_ID.get(`${type}:${id}`) ?? String(id);
}
export function mainWeaponLabel(id: MainWeaponId | null): string | null {
return id === null ? null : weaponLabel("MAIN", id);
}
export function stageLabel(stageId: StageId | null): string | null {
return stageId === null
? null
: (MISC[`STAGE_${stageId}`] ?? String(stageId));
}
export function modeLabel(mode: ModeShort | null): string | null {
return mode === null ? null : (MISC[`MODE_LONG_${mode}`] ?? mode);
}
const LOBBY_LABELS: Record<ScannerLobby, string> = {
X: "X Battle",
SERIES: "Anarchy Battle (Series)",
OPEN: "Anarchy Battle (Open)",
PRIVATE: "Private Battle",
};
export function lobbyLabel(lobby: ScannerLobby | null): string | null {
return lobby === null ? null : LOBBY_LABELS[lobby];
}

View File

@@ -0,0 +1,19 @@
/**
* Prop derivation for rendering a ScannerMatch's status samples with the
* shared <PlayerStatusTimeline />, used by the Live and VoD tabs.
*/
import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline";
import type { ScannerMatch } from "../core/scanner-match";
/** Row weapons per team from the match's known players, slots by index. */
export function playerStatusTeams(
match: ScannerMatch,
labels: readonly [string, string],
): [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam] {
return [0, 1].map((side) => ({
label: labels[side]!,
weapons: [0, 1, 2, 3].map(
(slot) => match.teams[side as 0 | 1].players[slot]?.weaponId ?? null,
),
})) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
}

View File

@@ -0,0 +1,301 @@
/**
* Browser client for sendou.ink's /ingest. The scanner pages run inside
* sendou.ink itself, so requests are same-origin: the session cookie rides
* along automatically and the logged-in user comes from the root loader
* (useUser) instead of an identity probe. sendou.ink authenticates the
* session user and resolves the tournament/match server-side.
*
* The unit of accounting is one ScannerMatch (core/match-builder.ts) —
* every source event's IndexedDB record tracks its outcome (the `send`
* status the feed cards display) — while the unit of transport is a request
* of up to `MAX_MATCHES_PER_REQUEST` of them. Resends are safe: sendou.ink
* dedupes matches by content hash, merges partials, and scoreboards
* first-ingest-wins.
*/
import * as R from "remeda";
import type {
IngestedMatchLink,
IngestResponse,
} from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, ingestSkipReasons } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import {
type SendStatus,
type StoredEvent,
updateEventsSend,
} from "../store/events";
const INGEST_URL = "/ingest";
/** /ingest accepts at most 50 matches per request (mirrors the server cap) */
const MAX_MATCHES_PER_REQUEST = 50;
/**
* How long after each unlinked send to try again. A live send usually beats
* the players to reporting the game, so the first attempts find nothing to
* link to; these delays cover the reporting lag without hammering. Running
* out of them gives up — the capture ending still makes one last attempt.
*/
const UNLINKED_RETRY_DELAYS_MS = [30_000, 2 * 60_000, 5 * 60_000];
export interface SendouUser {
id: number;
username: string;
}
export interface SendResult {
sentMatches: number;
failedMatches: number;
}
/**
* Builds the stored events into matches, POSTs the ingestable ones `include`
* selects, and records the outcome on every source event's `send` status
* (calling `onStatus` after each request's store writes so the feed can
* refresh).
*
* Matches go out as few requests as the server cap allows rather than one
* apiece: sendou.ink resolves a whole request at once, so a request carrying
* several matches anchors on their mode+stage sequence instead of guessing a
* set from one match's timestamp. A live send is a single match either way;
* catching up on a session's backlog is where it counts. One request resolves
* to one context, so a backlog spanning two of them links the larger and
* leaves the rest "unlinked" — the retry then carries only those, and they
* resolve to their own context.
*/
export async function sendMatches({
events,
include,
onStatus,
}: {
events: readonly StoredEvent[];
include: (built: BuiltMatch<StoredEvent>) => boolean;
onStatus: () => void;
}): Promise<SendResult> {
const allBuilt = ingestableBuilt(
buildScannerMatches(events.filter((e) => e.id !== undefined)),
);
const selected = allBuilt.filter(include);
await clearOrphanedQueued(events, allBuilt);
const result: SendResult = { sentMatches: 0, failedMatches: 0 };
for (const request of R.chunk(selected, MAX_MATCHES_PER_REQUEST)) {
const idsPerMatch = request.map((built) => built.sources.map((e) => e.id!));
await updateEventsSend(idsPerMatch.flat(), {
state: "sending",
at: Date.now(),
});
onStatus();
try {
const response = await postIngestMatches(
request.map((built) => built.match),
);
for (const [matchIndex, built] of request.entries()) {
const link = response.linkedMatches?.find(
(linked) => linked.matchIndex === matchIndex,
)?.link;
// stored but not linked, and sendou.ink knows which tournament or
// SendouQ match this is: the game is just not reported yet, so a
// later resend can still land it. Without a context there is nothing
// to wait for and the match is as done as it will get.
const unlinked = !link && response.contextResolved;
await updateEventsSend(idsPerMatch[matchIndex]!, {
state: unlinked ? "unlinked" : "sent",
at: Date.now(),
...(link ? { link } : null),
...(unlinked
? {
attempts:
(aggregateSendStatus(built.sources)?.attempts ?? 0) + 1,
}
: null),
});
}
result.sentMatches += request.length;
} catch (err) {
await updateEventsSend(idsPerMatch.flat(), {
state: "failed",
at: Date.now(),
error: err instanceof Error ? err.message : String(err),
});
result.failedMatches += request.length;
}
onStatus();
}
return result;
}
export interface VodResultsSendReport {
sentMatches: number;
totalMatches: number;
/** last failure's message; null when every request went through */
error: string | null;
/** links /ingest reported, keyed by index into the scan's ingestable matches */
links: Array<{ matchIndex: number; link: IngestedMatchLink }>;
}
/**
* One-go sender for the VoD tab's "Send results": builds a completed
* scan's events into matches and POSTs as many per request as the server
* cap allows — usually the whole scan in one request, so sendou.ink's
* content-based tournament resolution sees the full match sequence (its
* mode+stage order plus roster sides is near-unique in the user's history).
* No per-event status bookkeeping — VoD events don't live in the live feed
* store. Resending is safe (server-side dedupe/merge), so a partial failure
* can simply be retried whole.
*/
export async function sendVodResults(
events: readonly DetectedEvent[],
onProgress?: (sentMatches: number, totalMatches: number) => void,
): Promise<VodResultsSendReport> {
const matches = ingestableMatches(events);
let sentMatches = 0;
let error: string | null = null;
const links: VodResultsSendReport["links"] = [];
const chunks = R.chunk(matches, MAX_MATCHES_PER_REQUEST);
for (const [chunkIndex, request] of chunks.entries()) {
const offset = chunkIndex * MAX_MATCHES_PER_REQUEST;
try {
const response = await postIngestMatches(request);
for (const linked of response.linkedMatches ?? []) {
links.push({
matchIndex: offset + linked.matchIndex,
link: linked.link,
});
}
sentMatches += request.length;
onProgress?.(sentMatches, matches.length);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
}
return { sentMatches, totalMatches: matches.length, error, links };
}
/** The number of matches a set of events would send to /ingest. */
export function countIngestableMatches(
events: readonly DetectedEvent[],
): number {
return ingestableMatches(events).length;
}
/** Match selector: the match built from the given stored event. */
export function matchContaining(
id: number,
): (built: BuiltMatch<StoredEvent>) => boolean {
return (built) => built.sources.some((e) => e.id === id);
}
/**
* The single send status a match displays, folded from its source events:
* an in-flight send wins, then a failure, then success, then queued. Within
* a state the most recent change is shown.
*/
export function aggregateSendStatus(
sources: readonly StoredEvent[],
): SendStatus | undefined {
const statuses = sources
.map((e) => e.send)
.filter((status) => status !== undefined);
for (const state of [
"sending",
"failed",
"unlinked",
"sent",
"queued",
] as const) {
const ofState = statuses.filter((status) => status.state === state);
if (ofState.length > 0) {
return ofState.reduce((a, b) => (a.at >= b.at ? a : b));
}
}
return undefined;
}
/** Match selector: matches not yet sent (nor currently sending). */
export function unsentMatches(built: BuiltMatch<StoredEvent>): boolean {
return !built.sources.some(
(e) => e.send?.state === "sent" || e.send?.state === "sending",
);
}
/**
* Match selector: matches sendou.ink stored without a game to link them to,
* whose next attempt is due. Exhausting the backoff stops the retries.
*/
export function retryableUnlinkedMatches(
built: BuiltMatch<StoredEvent>,
): boolean {
const status = aggregateSendStatus(built.sources);
if (status?.state !== "unlinked") return false;
const delay = UNLINKED_RETRY_DELAYS_MS[(status.attempts ?? 1) - 1];
return delay !== undefined && Date.now() - status.at >= delay;
}
function ingestableMatches(events: readonly DetectedEvent[]): ScannerMatch[] {
return ingestableBuilt(buildScannerMatches(events)).map(
(built) => built.match,
);
}
function ingestableBuilt<E extends DetectedEvent>(
built: BuiltMatch<E>[],
): BuiltMatch<E>[] {
const skipped = ingestSkipReasons(built);
return built.filter((match) => !skipped.has(match));
}
async function postIngestMatches(
matches: ScannerMatch[],
): Promise<IngestResponse> {
const res = await fetch(INGEST_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ matches }),
});
if (!res.ok) {
throw new Error(
res.status === 401 ? "not logged in to sendou.ink" : await errorText(res),
);
}
return res.json();
}
/**
* Live sending marks events "queued" as they arrive; ones the match builder
* later leaves out (non-private match, older than the fallback window) would
* sit "queued" forever. Once a match boundary has passed them they can
* never join a future match, so clear their status back to "not sent".
*/
async function clearOrphanedQueued(
events: readonly StoredEvent[],
allBuilt: BuiltMatch<StoredEvent>[],
): Promise<void> {
const lastBoundaryT = Math.max(
...allBuilt.map((built) => built.sources.at(-1)!.t),
Number.NEGATIVE_INFINITY,
);
const builtIds = new Set(
allBuilt.flatMap((built) => built.sources.map((e) => e.id)),
);
const orphaned = events
.filter(
(e) =>
e.send?.state === "queued" &&
e.id !== undefined &&
!builtIds.has(e.id) &&
e.t <= lastBoundaryT,
)
.map((e) => e.id!);
if (orphaned.length > 0) await updateEventsSend(orphaned, undefined);
}
async function errorText(res: Response): Promise<string> {
const text = await res.text().catch(() => "");
return `POST /ingest -> ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`;
}

View File

@@ -0,0 +1,89 @@
/**
* "Upload to sendou.ink" link for a fully processed VoD: the detected events
* are built into ScannerMatches (core/match-builder.ts), projected onto the
* slim per-match rows the `ingest` search param of sendou.ink's /vods/new
* form carries (an `SP.json` param the search-params module compresses),
* which prefills a new VoD from them (the user adds the YouTube
* URL/title/date and fixes any misreads before submitting).
*
* The VoD type is auto-detected: footage containing the casted 8-player
* spectator map screen is a CAST VoD; anything else leaves the form's default
* type untouched. A match without a mode read prefills the form's SZ default,
* flagged `modeAssumed` — the fabricated default lives here, not on
* ScannerMatch.
*/
import type {
IngestVodMatchInput,
IngestVodPrefill,
} from "~/features/scanner-ingest/scanner-ingest-vod-schemas";
import { vodsNewSearchParams } from "~/features/vods/vods-search-params";
import { newVodPage } from "~/features/vods/vods-urls";
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists/types";
import type { DetectedEvent } from "../core/detectors/types";
import { buildScannerMatches } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
/**
* GET query params ride the request line, and servers/proxies commonly cap
* that around 8-16 KB. A VoD long enough to blow past this needs a POST
* flow, which /vods/new doesn't offer yet — surface that instead of
* emitting a URL the server would reject with an opaque error.
*/
const MAX_URL_LENGTH = 8000;
/** The prefill default for a match whose mode no source read. */
const DEFAULT_VOD_MODE = "SZ" satisfies ModeShort;
export interface SendouUpload {
/** prefilled /vods/new path (same-origin); null when nothing usable to send */
url: string | null;
/** set when matches exist but no usable URL could be built */
problem: string | null;
}
/** Builds the prefilled /vods/new link for a completed scan's events. */
export function sendouUpload(events: readonly DetectedEvent[]): SendouUpload {
const matches = buildScannerMatches(events)
.map((built) => built.match)
.filter((match) => match.teams.some((team) => team.players.length > 0));
if (matches.length === 0) return { url: null, problem: null };
const isCast = matches.some((match) => match.cast);
const payload: IngestVodPrefill = {
...(isCast ? { type: "CAST" as const } : null),
matches: matches.map(toPrefillMatch),
};
const result = vodsNewSearchParams.href(newVodPage(), { ingest: payload });
if (result.length > MAX_URL_LENGTH) {
return {
url: null,
problem:
`prefill URL is ${result.length} chars (limit ~${MAX_URL_LENGTH}) — ` +
"too many matches for a GET query param.",
};
}
return { url: result, problem: null };
}
function toPrefillMatch(match: ScannerMatch): IngestVodMatchInput {
return {
startsAt: match.startsAt ?? 0,
mode: match.mode ?? DEFAULT_VOD_MODE,
modeAssumed: match.mode === null,
stage: match.stage,
// /vods/new splits this at a fixed 4 slots per team, so pad short rosters
weapons: match.teams.flatMap((team) =>
Array.from({ length: 4 }, (_, i) => team.players[i]?.weaponId ?? null),
),
povWeapon: povWeaponId(match),
};
}
function povWeaponId(match: ScannerMatch): MainWeaponId | undefined {
if (!match.pov) return undefined;
return (
match.teams[match.pov.team].players[match.pov.index]?.weaponId ?? undefined
);
}

View File

@@ -0,0 +1,10 @@
/** Small JPEG data-URL thumbnail of an analyzed frame, for feed cards. */
export async function thumbnailFromBlob(frame: Blob): Promise<string> {
const bitmap = await createImageBitmap(frame);
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, 320, 180);
bitmap.close();
return canvas.toDataURL("image/jpeg", 0.7);
}

View File

@@ -0,0 +1,97 @@
/**
* Connect death events to scoreboard players: a death overlay shows the
* killer's splash-tag name, weapon, and full gear-ability grid, so every
* death in a match reveals one enemy player's build. Deaths are attributed
* to the next scoreboard-type event in the timeline (a match's deaths
* always precede its results screen), and matched to a player row by name
* and weapon id.
*/
import type {
AbilityWithUnknown,
MainWeaponId,
} from "~/modules/in-game-lists/types";
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type { ScoreboardData } from "./detectors/scoreboard/index";
import type { DetectedEvent } from "./detectors/types";
/** player row index (0-7) → [head, clothes, shoes] ability-id rows */
export type PlayerAbilityMap = Map<number, AbilityWithUnknown[][]>;
/** Any player row a death can be attributed against (scoreboard, minimap). */
interface HarvestablePlayer {
name: string | null;
weaponId: MainWeaponId | null;
}
/**
* Match a death's killer to a player row. Both signals are OCR output, so
* neither is trusted alone unless it is unambiguous: a combined name+weapon
* hit wins, then a unique name hit, then a unique weapon hit (two players on
* the same weapon with a misread name stay unattributed).
*/
function matchPlayer(
players: readonly HarvestablePlayer[],
death: DeathData,
): number | null {
const name = death.name?.trim().toLowerCase() || null;
const indices = players.map((_, i) => i);
const byName = name
? indices.filter(
(i) => (players[i]!.name ?? "").trim().toLowerCase() === name,
)
: [];
// scoreboard rows carry main-weapon ids; a sub/special credit says
// nothing about which main the killer holds
const byWeapon =
death.weaponId !== null && death.weaponType === "MAIN"
? indices.filter((i) => players[i]!.weaponId === death.weaponId)
: [];
const both = byName.filter((i) => byWeapon.includes(i));
if (both.length > 0) return both[0]!;
if (byName.length === 1) return byName[0]!;
if (byWeapon.length === 1) return byWeapon[0]!;
return null;
}
/**
* Harvest the builds one match's death events reveal: each death with a
* readable ability grid is attributed to a scoreboard player row.
*/
export function harvestAbilities(
players: readonly HarvestablePlayer[],
deaths: readonly DeathData[],
): PlayerAbilityMap {
const abilities: PlayerAbilityMap = new Map();
for (const death of deaths) {
if (death.abilities.length === 0) continue;
const index = matchPlayer(players, death);
if (index !== null) abilities.set(index, death.abilities);
}
return abilities;
}
/**
* For each scoreboard/replay event, harvest abilities from the death events
* since the previous scoreboard. Keyed by event object identity; events
* without any attributed death are absent from the result.
*/
export function connectAbilities(
events: readonly DetectedEvent[],
): Map<DetectedEvent, PlayerAbilityMap> {
const sorted = events.toSorted((a, b) => a.t - b.t);
const result = new Map<DetectedEvent, PlayerAbilityMap>();
let pendingDeaths: DeathData[] = [];
for (const event of sorted) {
if (event.type === DEATH_EVENT_TYPE) {
pendingDeaths.push(event.data as DeathData);
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
const players = (event.data as ScoreboardData).players;
const abilities = harvestAbilities(players, pendingDeaths);
if (abilities.size > 0) result.set(event, abilities);
pendingDeaths = [];
}
}
return result;
}

View File

@@ -0,0 +1,14 @@
/**
* Pure constants/types shared by UI and pipeline. No OpenCV dependency —
* the main-thread bundle must not pull in the WASM module (that lives in
* the worker).
*/
export const CANONICAL_WIDTH = 1920;
export const CANONICAL_HEIGHT = 1080;
export interface Roi {
x: number;
y: number;
w: number;
h: number;
}

View File

@@ -0,0 +1,90 @@
/**
* OpenCV.js singleton loader. Works in Node, browser main thread, and workers —
* the UMD bundle embeds its WASM, so no asset paths are involved.
*
* Everything in core/ obtains the cv namespace through getCV(); callers must
* await loadOpenCV() once at startup (worker bootstrap, test setup, tool entry).
*
* Gotcha of this build (5.0.0-release.1): `.data` and `.clone()` are broken
* on ROI views — `view.copyTo(freshMat)` before pixel access. Views are fine
* as inputs to cv calls.
*/
import cvModule from "@techstark/opencv-js";
export type CV = typeof cvModule;
export type Mat = InstanceType<CV["Mat"]>;
let cvInstance: CV | null = null;
let loading: Promise<CV> | null = null;
export function loadOpenCV(): Promise<CV> {
if (cvInstance) return Promise.resolve(cvInstance);
if (loading) return loading;
const attempt = (async () => {
// The package is patched (patches/@techstark__opencv-js…) to export
// { cvReadyPromise } instead of the bare ready-promise: a thenable (or
// default-wrapped thenable) module.exports leaks `then` through
// vite-node's CJS namespace proxy and crashes every Node-side import.
// Depending on bundler interop we see the wrapper (possibly nested
// under `default`) or, in bundles that took the UMD's non-CJS branch,
// the promise itself.
const raw = cvModule as {
cvReadyPromise?: unknown;
default?: { cvReadyPromise?: unknown };
} | null;
const mod: unknown =
raw?.cvReadyPromise ??
raw?.default?.cvReadyPromise ??
raw?.default ??
raw;
let cv: CV;
if (mod instanceof Promise) {
cv = await mod;
} else if ((mod as CV).Mat) {
cv = mod as CV;
} else {
await new Promise<void>((resolve) => {
(mod as { onRuntimeInitialized?: () => void }).onRuntimeInitialized =
resolve;
});
cv = mod as CV;
}
cvInstance = cv;
return cv;
})();
// a failed load must not poison the singleton — clear it so callers can retry
loading = attempt.catch((error) => {
loading = null;
throw error;
});
return loading;
}
export function getCV(): CV {
if (!cvInstance) {
throw new Error(
"OpenCV not loaded — await loadOpenCV() before using core/",
);
}
return cvInstance;
}
// The bundled type definitions mark the optional mask argument of these as
// required; thin wrappers restore the real (mask-less) signatures.
export interface MinMaxResult {
minVal: number;
maxVal: number;
minLoc: { x: number; y: number };
maxLoc: { x: number; y: number };
}
export function minMaxLoc(mat: Mat): MinMaxResult {
return (getCV() as unknown as { minMaxLoc(m: Mat): MinMaxResult }).minMaxLoc(
mat,
);
}
export function meanOf(mat: Mat): number[] {
return (getCV() as unknown as { mean(m: Mat): number[] }).mean(mat);
}

View File

@@ -0,0 +1,80 @@
/**
* Ability badge templates for the death-screen gear panel.
*
* Committed icon assets (assets/cv/abilities/) are bare icon art with
* alpha; on screen each sits centered on a near-black circular badge
* filling a known fraction of the slot. Templates composite the art onto a
* black square at that fraction, then resize to the candidate badge sizes —
* shaped like weapon templates so matchWeapon's NCC + ink-coverage scoring
* (see weapons.ts) applies directly. Mains (⌀~68) and subs (⌀~48) use
* different art fractions, so each role gets its own template set.
*/
import { getCV } from "../../cv";
import type { FrameData } from "../../image";
import { buildTemplateSizes, type WeaponTemplate } from "../scoreboard/weapons";
import {
ABILITY_INK_THRESHOLD,
ABILITY_MAIN_ART_RATIO,
ABILITY_MAIN_SIZES,
ABILITY_SUB_ART_RATIO,
ABILITY_SUB_SIZES,
} from "./rois";
/** Badge interior brightness (near-black circle on the dark panel). */
const BADGE_BACKGROUND = 10;
export interface AbilityTemplates {
mains: WeaponTemplate[];
subs: WeaponTemplate[];
}
/**
* Exported for the calibration tooling's art-ratio sweeps and for other
* screens' badge sets (scoreboard-own builds its sizes/threshold here).
* inkThreshold must match the one passed to matchWeapon, or the coverage
* ratio compares mismatched ink counts.
*/
export function buildAbilityRole(
icons: { id: string; image: FrameData }[],
sizes: readonly number[],
artRatio: number,
inkThreshold: number = ABILITY_INK_THRESHOLD,
): WeaponTemplate[] {
const cv = getCV();
return icons.map(({ id, image }) => {
// composite the icon art over the badge black, padded so the art
// occupies artRatio of the square (as it does of the badge on screen)
const side = Math.round(image.width / artRatio);
const offset = Math.floor((side - image.width) / 2);
const padded = new cv.Mat(
side,
side,
cv.CV_8UC3,
new cv.Scalar(BADGE_BACKGROUND, BADGE_BACKGROUND, BADGE_BACKGROUND),
);
const dst = padded.data;
const src = image.data;
for (let y = 0; y < image.height; y++) {
for (let x = 0; x < image.width; x++) {
const si = (y * image.width + x) * 4;
const a = src[si + 3]! / 255;
const di = ((y + offset) * side + x + offset) * 3;
dst[di] = Math.round(src[si]! * a + BADGE_BACKGROUND * (1 - a));
dst[di + 1] = Math.round(src[si + 1]! * a + BADGE_BACKGROUND * (1 - a));
dst[di + 2] = Math.round(src[si + 2]! * a + BADGE_BACKGROUND * (1 - a));
}
}
const templateSizes = buildTemplateSizes(padded, sizes, inkThreshold);
padded.delete();
return { id, sizes: templateSizes };
});
}
export function prepareAbilityTemplates(
icons: { id: string; image: FrameData }[],
): AbilityTemplates {
return {
mains: buildAbilityRole(icons, ABILITY_MAIN_SIZES, ABILITY_MAIN_ART_RATIO),
subs: buildAbilityRole(icons, ABILITY_SUB_SIZES, ABILITY_SUB_ART_RATIO),
};
}

View File

@@ -0,0 +1,832 @@
/**
* DeathDetector: parses the death cam overlay — "Splatted by <weapon>!"
* text, the killer's gear abilities (3 rows x [main, sub, sub, sub]), and
* the killer's name from the tilted splash tag.
*
* Weapon reads primarily as OCR text matched against per-language message
* templates (localized-messages.ts); the constant line doubles as a
* parse-time confirmation (else a lookalike gate hit emits nothing). Falls
* back to template-matching the burst weapon icon when the WIPEOUT banner
* covers the text, then to a candidate-lattice re-rank for low-fidelity
* captures, then to icon/text corroboration when neither is decisive alone
* (steps 2c/2d).
*/
import type {
AbilityWithUnknown,
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import { toAbilityWithUnknown } from "../../../scanner-types";
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
scaleGlyphSet,
} from "../../glyphs";
import { copyRoi, cropRoi, meanBrightness, type Roi } from "../../image";
import { closestEntry, matchKey, rankBy, rankByRead } from "../../text";
import type { ScoreboardResources } from "../scoreboard/index";
import { parseName } from "../scoreboard/names";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
DEATH_MESSAGE_TEMPLATES,
type DeathMessageTemplate,
LOCALIZED_WEAPON_NAMES,
} from "./localized-messages";
import {
ABILITY_INK_THRESHOLD,
ABILITY_ROWS,
ABILITY_SLOT_MIN_INK,
ABILITY_SUB_XS,
abilityMainRoi,
abilitySubRoi,
BURST_ICON_ROI,
GATE_BURST_PROBES,
GATE_DARK_MAX_MEAN,
GATE_ICON_MIN_MAX,
GATE_PANEL_PROBES,
GATE_TEXT_MAX_FRACTION,
GATE_TEXT_MIN_MAX,
gateAbilityProbe,
JA_CONST_LINE_ROI,
JA_WEAPON_LINE_ROI,
SPLAT_LINE1_ROI,
SPLAT_TEXT_BIN_THRESHOLD,
TAG_NAME_INNER,
TAG_NAME_OUTER,
TAG_NAME_TEXT_HEIGHT,
TAG_TILT_DEG,
WEAPON_LINE_ROI,
WEAPON_TEXT_HEIGHT,
} from "./rois";
import {
ALL_WEAPON_ENTRIES,
type WeaponEntry,
type WeaponType,
} from "./weapon-names";
export interface DeathData {
/**
* the killer's weapon id — a sendou main/sub/special weapon id, unique
* only within its kind (`weaponType` disambiguates); null if unreadable
*/
weaponId: MainWeaponId | SubWeaponId | SpecialWeaponId | null;
/** which kind of weapon got the splat; null when the weapon is unreadable */
weaponType: WeaponType | null;
/**
* killer's gear abilities, [head, clothes, shoes] rows of [main, sub...]
* ability ids; rows carry as many sub entries as the gear has slots (1-3)
*/
abilities: AbilityWithUnknown[][];
/** killer's splash-tag name; null if unreadable */
name: string | null;
}
export const DEATH_EVENT_TYPE = "Death";
/** The constant message line must read back at least this well to emit. */
const LINE1_MIN_SCORE = 0.5;
/**
* A Latin template's constant line reading at least this well settles the
* language and the (expensive) JA line reads are skipped. Fixture-measured:
* Latin-language frames read their template at 0.889+, while JA frames'
* best Latin-template score is 0.222.
*/
const LATIN_DECISIVE_SCORE = 0.85;
/** Snapped weapon reading below this is reported as null (kept in debug). */
const WEAPON_MIN_SCORE = 0.55;
/** Burst-icon fallback match below this is ignored (kept in debug). */
const BURST_ICON_MIN_SCORE = 0.52;
/**
* Candidate-lattice re-rank acceptance (rankByRead — its scores sit well
* below the plain-snap scale; see text.ts). On the 720p-upscaled JP
* frames that motivated it, correct picks score 0.25-0.47 with a margin
* of 0.056+ over the nearest other weapon, while wrong picks margin
* <= 0.03 — the margin, not the score, is the discriminator.
*/
const LATTICE_MIN_SCORE = 0.22;
const LATTICE_MIN_MARGIN = 0.05;
/**
* Burst-icon corroboration: below the decisive threshold the icon alone
* can't be trusted, but when the garbled text *independently* ranks the
* icon's weapon at (or within EPS of) its own top, the two weak signals
* agree out of ~350 candidates and the weapon is accepted. Floor sits at
* the weakest corroborated fixture positive (Splat Dualies at 0.33).
*/
const BURST_ICON_CORROBORATE_MIN_SCORE = 0.3;
const CORROBORATE_EPS = 0.02;
const TAG_NAME_BIN_THRESHOLD = 160;
/**
* The text-color refinement band is absolute closeness (255 - distance) to
* the estimated text color, so its threshold is tight by construction:
* 215 keeps pixels within 40 of the text color — glyph cores — while art
* highlights that leak past the banner-median band sit further away.
*/
const TAG_NAME_REFINE_BIN_THRESHOLD = 215;
/** Don't trust a text-color estimate taken from fewer ink pixels. */
const TAG_NAME_REFINE_MIN_INK = 200;
/**
* Split-banner detection: some banners paint the name band in two flat
* hues (diagonal splits). Any single background estimate turns the other
* half into one huge "ink" blob that border-clearing deletes together
* with the glyphs standing on it, so when a second quantized color bin
* both covers a real share of the band and sits far from the first, a
* third read candidate measures distance from the *nearest* of the two.
* The share floor keeps the text color itself (or sparse art) from being
* mistaken for a second background, which would erase the glyphs.
*/
const TAG_SPLIT_MIN_FRACTION = 0.15;
const TAG_SPLIT_MIN_CHANNEL_DISTANCE = 40;
interface WeaponCandidate {
/** the full weapon line as this template renders it, e.g. "Durch Klecksroller" */
text: string;
entry: WeaponEntry;
}
/** JA templates read through the JA atlas and the swapped-width line ROIs. */
function isJaTemplate(t: DeathMessageTemplate): boolean {
return t.langs.some((lang) => lang.endsWith("ja"));
}
/**
* The weapon-line strings a template can show: that language's localized
* names plus every canonical English name (localized-messages omits names
* identical to English), wrapped in the template's constant pre/post text.
*/
const templateCandidates = new Map<DeathMessageTemplate, WeaponCandidate[]>();
function candidatesFor(template: DeathMessageTemplate): WeaponCandidate[] {
let candidates = templateCandidates.get(template);
if (candidates) return candidates;
const byName = new Map(ALL_WEAPON_ENTRIES.map((e) => [e.name, e]));
const seen = new Set<string>();
candidates = [];
const push = (text: string, entry: WeaponEntry | undefined) => {
const k = matchKey(text);
if (!entry || seen.has(k)) return;
seen.add(k);
candidates!.push({
text: template.weaponPre + text + template.weaponPost,
entry,
});
};
for (const lang of template.langs) {
for (const { text, name } of LOCALIZED_WEAPON_NAMES[lang] ?? [])
push(text, byName.get(name));
}
for (const entry of ALL_WEAPON_ENTRIES) push(entry.name, entry);
templateCandidates.set(template, candidates);
return candidates;
}
export function createDeathDetector(
resources: ScoreboardResources,
): Detector<DeathData> {
const cv = getCV();
const scaled = (
set: GlyphSet | null | undefined,
height: number,
): GlyphSet | null => (set ? scaleGlyphSet(set, height / set.height) : null);
const weaponGlyphs = scaled(resources.deathWeaponGlyphs, WEAPON_TEXT_HEIGHT);
// JA glyphs match at native scale: the atlas mixes fixture crops with
// per-face renders already sized to the on-screen condensed text
const jaGlyphs = resources.deathWeaponJaGlyphs ?? null;
const tagNameGlyphs = scaled(
resources.deathTagNameGlyphs,
TAG_NAME_TEXT_HEIGHT,
);
const abilities = resources.abilities ?? null;
const burstWeapons = resources.deathBurstWeapons ?? null;
const mainById = new Map(
ALL_WEAPON_ENTRIES.filter((e) => e.type === "MAIN").map((e) => [e.id, e]),
);
function gate(frame: Mat): GateResult {
let darkOk = 0;
const darkProbes = [...GATE_BURST_PROBES, ...GATE_PANEL_PROBES];
for (const roi of darkProbes) {
if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const line1 = copyRoi(gray, SPLAT_LINE1_ROI);
const { maxVal } = minMaxLoc(line1);
const bin = new cv.Mat();
cv.threshold(line1, bin, GATE_TEXT_MIN_MAX, 255, cv.THRESH_BINARY);
line1.delete();
const whiteFraction = cv.countNonZero(bin) / (bin.rows * bin.cols);
bin.delete();
const textOk =
maxVal > GATE_TEXT_MIN_MAX &&
whiteFraction > 0.01 &&
whiteFraction < GATE_TEXT_MAX_FRACTION;
// max RGB channel, not gray: saturated icon art can be gray-dark (rois.ts)
let iconOk = 0;
for (const row of [0, 1, 2]) {
const probe = copyRoi(frame, gateAbilityProbe(row));
const d = probe.data;
const ch = probe.channels();
const n = probe.rows * probe.cols;
let maxCh = 0;
for (let i = 0; i < n; i++) {
const v = Math.max(d[i * ch]!, d[i * ch + 1]!, d[i * ch + 2]!);
if (v > maxCh) maxCh = v;
}
probe.delete();
if (maxCh > GATE_ICON_MIN_MAX) iconOk++;
}
gray.delete();
const score =
(darkOk / darkProbes.length + (textOk ? 1 : 0) + iconOk / 3) / 3;
return {
pass: darkOk === darkProbes.length && textOk && iconOk === 3,
score,
};
}
/** Crop the tilted tag, rotate it level, and return the name band crop. */
function levelTagInner(rgb: Mat): Mat {
const outer = copyRoi(rgb, TAG_NAME_OUTER);
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 inner = copyRoi(rotated, TAG_NAME_INNER);
rotated.delete();
return inner;
}
/** Per-channel median color of `inner`, over pixels where mask(i) holds. */
function medianColor(
inner: Mat,
mask?: (i: number) => boolean,
): [number, number, number] {
const n = inner.rows * inner.cols;
const px = inner.data;
const color: [number, number, number] = [0, 0, 0];
for (let c = 0; c < 3; c++) {
const hist = new Array<number>(256).fill(0);
let total = 0;
for (let i = 0; i < n; i++) {
if (mask && !mask(i)) continue;
hist[px[i * 3 + c]!]!++;
total++;
}
let acc = 0;
let v = 0;
for (; v < 255; v++) {
acc += hist[v]!;
if (acc >= total / 2) break;
}
color[c] = v;
}
return color;
}
/**
* Dominant colors of `inner`: most frequent quantized colors (5 bits/
* channel), clustered by proximity (else a hue straddling a quantization
* boundary under-reports, split across neighbor bins), each refined to
* its per-channel median with its share of the band. A second background
* estimator besides medianColor — neither wins everywhere (whole-image
* medians blend distinct populations into a color nobody has; the bin
* vote loses to a flat art blob out-voting a textured banner's true
* color) — so both are tried and the better read kept. The runner-up
* cluster feeds the split-banner candidate (see TAG_SPLIT_MIN_FRACTION).
*/
function dominantColors(
inner: Mat,
count: number,
): { color: [number, number, number]; fraction: number }[] {
const n = inner.rows * inner.cols;
const px = inner.data;
const bins = new Map<number, number>();
for (let i = 0; i < n; i++) {
const key =
((px[i * 3]! >> 3) << 10) |
((px[i * 3 + 1]! >> 3) << 5) |
(px[i * 3 + 2]! >> 3);
bins.set(key, (bins.get(key) ?? 0) + 1);
}
// greedy cluster of the top bins by quantized-center proximity
const CLUSTER_MAX_CHANNEL_DISTANCE = 24;
const top = [...bins.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
const centerOf = (key: number): [number, number, number] => [
((key >> 10) << 3) + 4,
(((key >> 5) & 31) << 3) + 4,
((key & 31) << 3) + 4,
];
const clusters: {
seed: [number, number, number];
keys: Set<number>;
count: number;
}[] = [];
for (const [key, binCount] of top) {
const c = centerOf(key);
const home = clusters.find((cl) =>
cl.seed.every(
(s, i) => Math.abs(s - c[i]!) <= CLUSTER_MAX_CHANNEL_DISTANCE,
),
);
if (home) {
home.keys.add(key);
home.count += binCount;
} else {
clusters.push({ seed: c, keys: new Set([key]), count: binCount });
}
}
return clusters
.sort((a, b) => b.count - a.count)
.slice(0, count)
.map(({ keys, count: clusterCount }) => {
const inCluster = (i: number) =>
keys.has(
((px[i * 3]! >> 3) << 10) |
((px[i * 3 + 1]! >> 3) << 5) |
(px[i * 3 + 2]! >> 3),
);
return {
color: medianColor(inner, inCluster),
fraction: clusterCount / n,
};
});
}
/**
* Text-ness map: per-pixel max-channel distance from the nearest of
* `colors` (one for solid banners, two hues for a split banner). Uses
* distance from banner color rather than a fixed brightness/polarity
* since text/banner colors vary per player (e.g. pink text on a
* light-blue banner has near-zero luminance contrast). `invert` flips to
* *closeness* for the text-color refinement pass.
*/
function distanceBand(
inner: Mat,
colors: readonly [number, number, number][],
invert: boolean,
): Mat {
const n = inner.rows * inner.cols;
const px = inner.data;
const band = new cv.Mat(inner.rows, inner.cols, cv.CV_8UC1);
const out = band.data;
for (let i = 0; i < n; i++) {
let d = 255;
for (const color of colors) {
const dc = Math.max(
Math.abs(px[i * 3]! - color[0]),
Math.abs(px[i * 3 + 1]! - color[1]),
Math.abs(px[i * 3 + 2]! - color[2]),
);
if (dc < d) d = dc;
}
out[i] = invert ? 255 - d : d;
}
return band;
}
/**
* Zero ink components touching the band border. Busy banner art also
* differs from the median banner color but continues past the name
* band's edges, while the name sits inside it (fixture extremes: dakuten
* at y=3, descender 2px above bottom) — left in place an edge blob
* merges into a glyph and corrupts the read.
*/
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 parse(frame: Mat, t: number): DetectedEvent<DeathData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const confidences: number[] = [];
// 1. read both burst lines and find the language template whose constant
// line reads back best — a gate hit matching none is a lookalike.
// Latin templates read the standard line boxes with the Latin atlas;
// JA templates read the swapped-width JA boxes (weapon line 1 wide,
// constant line 2 narrow — see rois.ts) with the JA atlas, so the two
// scripts never compete inside one glyph set.
let line1: RecognizedText | null = null;
let line2: RecognizedText | null = null;
let jaWeaponLine: RecognizedText | null = null;
let jaConstLine: RecognizedText | null = null;
let template: DeathMessageTemplate | null = null;
let line1Score = 0;
if (weaponGlyphs) {
const readLine = (roi: Roi, glyphs: GlyphSet) => {
const crop = cropRoi(gray, roi);
const read = recognizeText(crop, glyphs, {
binThreshold: SPLAT_TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
crop.delete();
return read;
};
line1 = readLine(SPLAT_LINE1_ROI, weaponGlyphs);
line2 = readLine(WEAPON_LINE_ROI, weaponGlyphs);
for (const t of DEATH_MESSAGE_TEMPLATES) {
if (isJaTemplate(t)) continue;
const constReading = t.weaponLine === 1 ? line2.text : line1.text;
const score = closestEntry(constReading, [t.constText])?.score ?? 0;
if (score > line1Score) {
line1Score = score;
template = t;
}
}
// the JA line reads cost ~2x the Latin ones (condensed-kana atlas),
// so they only run when no Latin template already owns the frame:
// measured constant-line scores separate cleanly (Latin frames read
// their template at 0.889+, JA frames' best Latin score is <= 0.222)
if (jaGlyphs && line1Score < LATIN_DECISIVE_SCORE) {
jaWeaponLine = readLine(JA_WEAPON_LINE_ROI, jaGlyphs);
jaConstLine = readLine(JA_CONST_LINE_ROI, jaGlyphs);
for (const t of DEATH_MESSAGE_TEMPLATES) {
if (!isJaTemplate(t)) continue;
const score =
closestEntry(jaConstLine.text, [t.constText])?.score ?? 0;
if (score > line1Score) {
line1Score = score;
template = t;
}
}
}
if (!template || line1Score < LINE1_MIN_SCORE) {
gray.delete();
rgb.delete();
return [];
}
}
// 2. the other line carries the weapon name; snap it to the template
// language's names (localized + canonical English)
let weapon: string | null = null;
let weaponId: DeathData["weaponId"] = null;
let weaponType: WeaponType | null = null;
let weaponScore = 0;
let weaponRaw: RecognizedText | null = null;
let plainRanked: { entry: WeaponCandidate; score: number }[] = [];
const accept = (entry: WeaponEntry, score: number) => {
weapon = entry.name;
weaponId = Number(entry.id) as NonNullable<DeathData["weaponId"]>;
weaponType = entry.type;
weaponScore = score;
};
if (weaponGlyphs && template) {
weaponRaw = isJaTemplate(template)
? jaWeaponLine
: template.weaponLine === 1
? line1
: line2;
const reading = weaponRaw!.text;
if (reading)
plainRanked = rankBy(reading, candidatesFor(template), (c) => c.text);
const match = plainRanked[0];
if (match) {
weaponScore = match.score;
if (match.score >= WEAPON_MIN_SCORE)
accept(match.entry.entry, match.score);
}
}
// 2b. text can be unreadable while the burst's weapon icon is intact
// (the WIPEOUT banner covers the weapon name line), so fall back to
// matching the icon against the main-weapon set at burst size. Only a
// decisive match is trusted: fixture positives score 0.55+ while the
// best off-target frame (icon displaced by a rainmaker line) hits 0.48.
let burstIcon: WeaponMatch | null = null;
if (weapon === null && burstWeapons) {
const crop = cropRoi(rgb, BURST_ICON_ROI);
burstIcon = matchWeapon(crop, burstWeapons);
crop.delete();
const entry =
burstIcon.score >= BURST_ICON_MIN_SCORE
? mainById.get(burstIcon.id)
: undefined;
if (entry) accept(entry, burstIcon.score);
}
// 2c. low-fidelity captures (720p upscaled to canonical) garble the
// per-segment top-1 read enough that the plain snap stays under
// WEAPON_MIN_SCORE, while the correct glyphs sit at rank 2-3 of the
// segments' candidate lists. Re-rank through those lists (rankByRead)
// and accept the top weapon when it clears the field decisively.
let latticeTop: {
entry: WeaponEntry;
score: number;
margin: number;
} | null = null;
let latticeRanked: { entry: WeaponCandidate; score: number }[] = [];
if (
weapon === null &&
weaponGlyphs &&
template &&
weaponRaw!.chars.length > 0
) {
latticeRanked = rankByRead(
weaponRaw!.chars,
candidatesFor(template),
(c) => c.text,
);
const top = latticeRanked[0]!;
// margin vs the nearest *other* weapon: the same weapon rides both
// its localized and English candidate lines
const runner = latticeRanked.find(
(r) => r.entry.entry !== top.entry.entry,
);
latticeTop = {
entry: top.entry.entry,
score: top.score,
margin: top.score - (runner?.score ?? 0),
};
if (
latticeTop.score >= LATTICE_MIN_SCORE &&
latticeTop.margin >= LATTICE_MIN_MARGIN
) {
accept(latticeTop.entry, latticeTop.score);
}
}
// 2d. neither signal is decisive alone, but if the burst icon's main
// weapon is also the text's best guess (plain or lattice ranking,
// within EPS of that ranking's top), the independent agreement is
// decisive together.
if (
weapon === null &&
burstIcon &&
burstIcon.score >= BURST_ICON_CORROBORATE_MIN_SCORE
) {
const entry = mainById.get(burstIcon.id);
if (entry) {
const bestFor = (ranked: { entry: WeaponCandidate; score: number }[]) =>
ranked.reduce(
(s, r) => (r.entry.entry === entry ? Math.max(s, r.score) : s),
0,
);
const supported =
(plainRanked.length > 0 &&
bestFor(plainRanked) >= plainRanked[0]!.score - CORROBORATE_EPS) ||
(latticeRanked.length > 0 &&
bestFor(latticeRanked) >=
latticeRanked[0]!.score - CORROBORATE_EPS);
if (supported) accept(entry, burstIcon.score);
}
}
if (weaponGlyphs && template) confidences.push(weaponScore);
// 3. ability grid; rows carry 1-3 sub circles (left-aligned, as many
// as the gear has slots), so a sub box without badge ink ends the row
const abilityRows: AbilityWithUnknown[][] = [];
const abilityDebug: (WeaponMatch | null)[][] = [];
if (abilities) {
for (let row = 0; row < ABILITY_ROWS; row++) {
const ids: AbilityWithUnknown[] = [];
const debug: (WeaponMatch | null)[] = [];
const mainCrop = cropRoi(rgb, abilityMainRoi(row));
const main = matchWeapon(mainCrop, abilities.mains, {
inkThreshold: ABILITY_INK_THRESHOLD,
});
mainCrop.delete();
ids.push(toAbilityWithUnknown(main.id) ?? "UNKNOWN");
debug.push(main);
confidences.push(Math.max(0, main.score));
for (let slot = 0; slot < ABILITY_SUB_XS.length; slot++) {
const crop = copyRoi(rgb, abilitySubRoi(row, slot));
const d = crop.data;
const n = crop.rows * crop.cols;
let ink = 0;
for (let i = 0; i < n; i++) {
const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!);
if (v > ABILITY_INK_THRESHOLD) ink++;
}
if (ink < ABILITY_SLOT_MIN_INK) {
crop.delete();
break;
}
const sub = matchWeapon(crop, abilities.subs, {
inkThreshold: ABILITY_INK_THRESHOLD,
});
crop.delete();
ids.push(toAbilityWithUnknown(sub.id) ?? "UNKNOWN");
debug.push(sub);
confidences.push(Math.max(0, sub.score));
}
abilityRows.push(ids);
abilityDebug.push(debug);
}
}
// 4. splash-tag name: read against the estimated banner color, then
// (since busy banner art also differs from that estimate and can
// survive as fake glyphs) against closeness to the text color
// estimated from pass 1's ink; whichever reads back more confidently
// wins. Both background estimators (dominantColors) run the same way.
let name: string | null = null;
let nameConfidence = 0;
let nameRaw = "";
let tagBackground: [number, number, number] | null = null;
let tagTextColor: [number, number, number] | null = null;
if (tagNameGlyphs) {
const spaceGap = Math.max(
7,
Math.round(tagNameGlyphs.medianWidth * 0.55),
);
const inner = levelTagInner(rgb);
const readWithBackground = (
backgrounds: readonly [number, number, number][],
) => {
const band = distanceBand(inner, backgrounds, false);
cv.normalize(band, band, 0, 255, cv.NORM_MINMAX);
clearBorderBlobs(band, TAG_NAME_BIN_THRESHOLD);
let parsed = parseName(band, tagNameGlyphs, {
spaceGap,
binThreshold: TAG_NAME_BIN_THRESHOLD,
});
let textColor: [number, number, number] | null = null;
const ink = band.data;
let inkCount = 0;
for (let i = 0; i < ink.length; i++)
if (ink[i]! > TAG_NAME_BIN_THRESHOLD) inkCount++;
if (inkCount >= TAG_NAME_REFINE_MIN_INK) {
textColor = medianColor(
inner,
(i) => ink[i]! > TAG_NAME_BIN_THRESHOLD,
);
const refined = distanceBand(inner, [textColor], true);
clearBorderBlobs(refined, TAG_NAME_REFINE_BIN_THRESHOLD);
const reparsed = parseName(refined, tagNameGlyphs, {
spaceGap,
binThreshold: TAG_NAME_REFINE_BIN_THRESHOLD,
});
refined.delete();
if (reparsed.confidence > parsed.confidence) parsed = reparsed;
}
band.delete();
return { parsed, background: backgrounds[0]!, textColor };
};
const median = medianColor(inner);
const dominants = dominantColors(inner, 2);
const dominant = dominants[0]!.color;
const candidates: [number, number, number][][] = [[median]];
if (dominant.some((c, i) => Math.abs(c - median[i]!) > 8))
candidates.push([dominant]);
const second = dominants[1];
if (
second &&
second.fraction >= TAG_SPLIT_MIN_FRACTION &&
second.color.some(
(c, i) => Math.abs(c - dominant[i]!) > TAG_SPLIT_MIN_CHANNEL_DISTANCE,
)
) {
candidates.push([dominant, second.color]);
}
// an empty read never beats one with glyphs (an estimate landing on
// the text color blanks the band, and recognizeText scores a
// segment-less band confidence 1); near-tied confidences resolve to
// the longer read, since confidence is the *min* char score and
// erasing most of the name can still read the survivors immaculately
const NEAR_TIE = 0.03;
const beats = (
a: { parsed: { name: string; confidence: number } },
b: typeof a,
) => {
const aRead = a.parsed.name.length > 0 ? 1 : 0;
const bRead = b.parsed.name.length > 0 ? 1 : 0;
if (aRead !== bRead) return aRead - bRead;
if (Math.abs(a.parsed.confidence - b.parsed.confidence) <= NEAR_TIE) {
return a.parsed.name.length - b.parsed.name.length;
}
return a.parsed.confidence - b.parsed.confidence;
};
let best = readWithBackground(candidates[0]!);
for (const backgrounds of candidates.slice(1)) {
const alt = readWithBackground(backgrounds);
if (beats(alt, best) > 0) best = alt;
}
inner.delete();
tagBackground = best.background;
tagTextColor = best.textColor;
nameRaw = best.parsed.raw.text;
if (best.parsed.name.length > 0) name = best.parsed.name;
nameConfidence = best.parsed.confidence;
confidences.push(nameConfidence);
}
gray.delete();
rgb.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: DEATH_EVENT_TYPE,
t,
confidence,
data: { weaponId, weaponType, abilities: abilityRows, name },
debug: {
weaponName: weapon,
line1: line1?.text,
line2: line2?.text,
jaWeaponLine: jaWeaponLine?.text,
jaConstLine: jaConstLine?.text,
line1Score,
messageLangs: template?.langs,
weaponRaw: weaponRaw?.text,
weaponScore,
weaponLattice: latticeTop && {
name: latticeTop.entry.name,
score: latticeTop.score,
margin: latticeTop.margin,
},
burstIcon: burstIcon && {
id: burstIcon.id,
score: burstIcon.score,
top: burstIcon.top,
},
abilityRows: abilityDebug.map((row) =>
row.map((m) => m && { top: m.top, score: m.score }),
),
nameRaw,
nameScore: nameConfidence,
tagBackground,
tagTextColor,
},
},
];
}
// the death cam's animated background flickers the gate; after a
// sufficient read the 4s rearm hold stays inside the timeline's 8s
// Death merge window, so every parse it skips would merge anyway.
// sufficientConfidence sits just under the measured clean-read floor
// (fixtures 0.750-0.825, confirmed scan events 0.751+); the refine and
// stagnation overrides cap what a ~1.4s parse can cost when a dirty
// read never reaches it
return {
id: "death",
refineIntervalS: 0.5,
sufficientConfidence: 0.74,
rearmCooldownS: 4,
maxStagnantParses: 3,
gate,
parse,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
/**
* ALL death-screen ROI coordinates, in canonical 1920x1080 space.
* Calibrated against the death/ fixtures via tools/dump-crops.ts,
* HoughCircles measurement, and bright-row profiling.
*
* The death cam overlays three fixed elements on live gameplay: a dark
* camo "splat burst" top-center (two-line "Splatted by" / "<weapon>!"
* message); the killer's gear panel bottom-left (three gear rows, each
* with one main-ability circle ⌀~68 plus three sub circles ⌀~48,
* divided by dashed lines); and the killer's splash tag bottom-right,
* tilted, name in large type. Probes sit in the overlays' opaque-dark
* parts (everything but the tag banner sits over the live scene).
*/
import type { Roi } from "../../canonical";
/** The constant "Splatted by" line (white text centered at x=960). */
export const SPLAT_LINE1_ROI: Roi = { x: 790, y: 362, w: 340, h: 52 };
/** The weapon name line ("Rapid Blaster Deco!"), centered, length varies. */
export const WEAPON_LINE_ROI: Roi = { x: 640, y: 414, w: 640, h: 48 };
/** White message text on the dark burst binarizes cleanly and high. */
export const SPLAT_TEXT_BIN_THRESHOLD = 190;
/** Tight cap height of the message text (atlas nominal height). */
export const WEAPON_TEXT_HEIGHT = 34;
/**
* Non-Latin weaponLine=1 langs (JA: "<weapon> で" / "やられた!") swap line
* widths: variable name gets the full-width box, the short constant line
* gets a narrow one. Both boxes run taller than Latin: kana overshoot the
* cap band both sides (JP line spans y=359..401 vs Latin's y=362 start).
*/
export const JA_WEAPON_LINE_ROI: Roi = { x: 640, y: 354, w: 640, h: 62 };
export const JA_CONST_LINE_ROI: Roi = { x: 790, y: 412, w: 340, h: 54 };
/**
* Killer's weapon icon, upright above the message text (~110px art;
* specials render team-tinted, unmatched). Templates at
* BURST_ICON_TEMPLATE_SIZES. A "Lost the Rainmaker!" style line shifts
* the icon out of this box; the text read handles those.
*/
export const BURST_ICON_ROI: Roi = { x: 785, y: 230, w: 190, h: 140 };
export const BURST_ICON_TEMPLATE_SIZES = [116, 124, 132] as const;
/** Gear panel rows: [head, clothes, shoes]. */
export const ABILITY_ROWS = 3;
/** Main-ability circle centers (⌀~68). */
const ABILITY_MAIN_X = 515;
const ABILITY_MAIN_YS = [696, 795, 887] as const;
/** Sub-ability circle centers (⌀~48), slightly below the main's center. */
export const ABILITY_SUB_XS = [578, 630, 682] as const;
const ABILITY_SUB_YS = [702, 797, 888] as const;
/**
* Search boxes around each circle; heights double as the size filter
* (matchTemplate skips templates taller than the ROI).
*/
export function abilityMainRoi(row: number): Roi {
const cy = ABILITY_MAIN_YS[row]!;
return { x: ABILITY_MAIN_X - 38, y: cy - 38, w: 76, h: 76 };
}
export function abilitySubRoi(row: number, slot: number): Roi {
const cx = ABILITY_SUB_XS[slot]!;
const cy = ABILITY_SUB_YS[row]!;
return { x: cx - 28, y: cy - 28, w: 56, h: 56 };
}
/** Template heights (px at 1080p) per circle role. */
export const ABILITY_MAIN_SIZES = [64, 68, 72] as const;
export const ABILITY_SUB_SIZES = [44, 48, 52] as const;
/**
* Icon art diameter as fraction of circle box: mains peak 1.0, subs 0.92
* — badges draw art nearly edge-to-edge, ring contributes ~nothing.
*/
export const ABILITY_MAIN_ART_RATIO = 1.0;
export const ABILITY_SUB_ART_RATIO = 0.92;
/**
* Ink threshold: badge near-black, icon art saturated-bright; panel is
* slightly translucent so a bright scene can ghost through — kept above that.
*/
export const ABILITY_INK_THRESHOLD = 90;
/**
* A gear row carries only as many sub circles as gear slots (1-3); an
* absent slot shows bare panel. Bright pixels (max channel >
* ABILITY_INK_THRESHOLD) separate cases: absent measures 0, every real
* badge measures 403+ even at the dimmest 720p capture.
*/
export const ABILITY_SLOT_MIN_INK = 200;
/**
* Splash tag name band. Tag renders tilted (baseline rises right by
* TAG_TILT_DEG); crop TAG_NAME_OUTER, rotate level, read TAG_NAME_INNER
* relative to the rotated crop.
*/
export const TAG_TILT_DEG = 3.0;
export const TAG_NAME_OUTER: Roi = { x: 1130, y: 770, w: 650, h: 140 };
/**
* Name band inside the rotated crop. Top edge must clear kana dakuten
* (old y=34 clipped them, misreading こ for ご); y=21 splits the title
* line above (ends outer y=17) with margin.
*/
export const TAG_NAME_INNER: Roi = { x: 20, y: 21, w: 610, h: 87 };
/** Tight cap height of the tag name text (atlas nominal height). */
export const TAG_NAME_TEXT_HEIGHT = 46;
/**
* Gate probes: burst camo is dark left/right of the text line and below
* the weapon line; gear panel is dark right of the sub circles.
*/
export const GATE_BURST_PROBES: readonly Roi[] = [
{ x: 750, y: 376, w: 36, h: 22 },
{ x: 1134, y: 376, w: 36, h: 22 },
{ x: 930, y: 466, w: 60, h: 18 },
];
export const GATE_PANEL_PROBES: readonly Roi[] = [
{ x: 720, y: 686, w: 30, h: 20 },
{ x: 720, y: 785, w: 30, h: 20 },
];
export const GATE_DARK_MAX_MEAN = 95;
/** SPLAT_LINE1_ROI must contain near-white pixels... */
export const GATE_TEXT_MIN_MAX = 210;
/** ...but not too many: it is a short text line, not a white panel. */
export const GATE_TEXT_MAX_FRACTION = 0.35;
/**
* Dark probes + white text also match the scoreboard screens, so the gate
* additionally requires bright icon art at all three main-ability centers
* (max RGB channel, not grayscale — saturated art can be gray-dark yet
* bright in max-channel). Death fixtures measure 244+, closest non-death
* row 184.
*/
export function gateAbilityProbe(row: number): Roi {
return {
x: ABILITY_MAIN_X - 14,
y: ABILITY_MAIN_YS[row]! - 14,
w: 28,
h: 28,
};
}
export const GATE_ICON_MIN_MAX = 200;

View File

@@ -0,0 +1,245 @@
/**
* English weapon names keyed by in-game weapon id, generated from
* sendou.ink locales/en/weapons.json (MAIN_/SUB_/SPECIAL_<id> entries;
* mains filtered to the assets/cv/main-weapons icon manifest).
* The death screen shows the killer's weapon as text ("Splatted by
* <name>!") and can credit a main, sub, or special, so the closed set
* that OCR output snaps to spans all three kinds. Ids are only unique
* within a kind (sub 0 = Splat Bomb, main 0 = Sploosh-o-matic).
*/
export type WeaponType = "MAIN" | "SUB" | "SPECIAL";
export interface WeaponEntry {
id: string;
name: string;
type: WeaponType;
}
const WEAPON_NAMES: ReadonlyMap<string, string> = new Map([
["0", "Sploosh-o-matic"],
["1", "Neo Sploosh-o-matic"],
["10", "Splattershot Jr."],
["11", "Custom Splattershot Jr."],
["20", "Splash-o-matic"],
["21", "Neo Splash-o-matic"],
["22", "Splash-o-matic GCK-O"],
["30", "Aerospray MG"],
["31", "Aerospray RG"],
["32", "Colorz Aerospray"],
["40", "Splattershot"],
["41", "Tentatek Splattershot"],
["42", "Glamorz Splattershot"],
["45", "Hero Shot Replica"],
["46", "Octo Shot Replica"],
["47", "Order Shot Replica"],
["50", ".52 Gal"],
["51", ".52 Gal Deco"],
["60", "N-ZAP '85"],
["61", "N-ZAP '89"],
["70", "Splattershot Pro"],
["71", "Forge Splattershot Pro"],
["72", "Splattershot Pro FRZ-N"],
["80", ".96 Gal"],
["81", ".96 Gal Deco"],
["82", "Clawz .96 Gal"],
["90", "Jet Squelcher"],
["91", "Custom Jet Squelcher"],
["92", "Jet Squelcher COB-R"],
["100", "Splattershot Nova"],
["101", "Annaki Splattershot Nova"],
["200", "Luna Blaster"],
["201", "Luna Blaster Neo"],
["205", "Order Blaster Replica"],
["210", "Blaster"],
["211", "Custom Blaster"],
["212", "Gleamz Blaster"],
["220", "Range Blaster"],
["221", "Custom Range Blaster"],
["230", "Clash Blaster"],
["231", "Clash Blaster Neo"],
["240", "Rapid Blaster"],
["241", "Rapid Blaster Deco"],
["250", "Rapid Blaster Pro"],
["251", "Rapid Blaster Pro Deco"],
["252", "Rapid Blaster Pro WNT-R"],
["260", "S-BLAST '92"],
["261", "S-BLAST '91"],
["300", "L-3 Nozzlenose"],
["301", "L-3 Nozzlenose D"],
["302", "Glitterz L-3 Nozzlenose"],
["310", "H-3 Nozzlenose"],
["311", "H-3 Nozzlenose D"],
["312", "H-3 Nozzlenose VIP-R"],
["400", "Squeezer"],
["401", "Foil Squeezer"],
["1000", "Carbon Roller"],
["1001", "Carbon Roller Deco"],
["1002", "Carbon Roller ANG-L"],
["1010", "Splat Roller"],
["1011", "Krak-On Splat Roller"],
["1015", "Order Roller Replica"],
["1020", "Dynamo Roller"],
["1021", "Gold Dynamo Roller"],
["1022", "Starz Dynamo Roller"],
["1030", "Flingza Roller"],
["1031", "Foil Flingza Roller"],
["1040", "Big Swig Roller"],
["1041", "Big Swig Roller Express"],
["1042", "Planetz Big Swig Roller"],
["1100", "Inkbrush"],
["1101", "Inkbrush Nouveau"],
["1110", "Octobrush"],
["1111", "Octobrush Nouveau"],
["1112", "Cometz Octobrush"],
["1115", "Orderbrush Replica"],
["1120", "Painbrush"],
["1121", "Painbrush Nouveau"],
["1122", "Painbrush BRN-Z"],
["2000", "Classic Squiffer"],
["2001", "New Squiffer"],
["2010", "Splat Charger"],
["2011", "Z+F Splat Charger"],
["2012", "Splat Charger CAM-O"],
["2015", "Order Charger Replica"],
["2020", "Splatterscope"],
["2021", "Z+F Splatterscope"],
["2022", "Splatterscope CAM-O"],
["2030", "E-liter 4K"],
["2031", "Custom E-liter 4K"],
["2040", "E-liter 4K Scope"],
["2041", "Custom E-liter 4K Scope"],
["2050", "Bamboozler 14 Mk I"],
["2051", "Bamboozler 14 Mk II"],
["2060", "Goo Tuber"],
["2061", "Custom Goo Tuber"],
["2070", "Snipewriter 5H"],
["2071", "Snipewriter 5B"],
["3000", "Slosher"],
["3001", "Slosher Deco"],
["3005", "Order Slosher Replica"],
["3010", "Tri-Slosher"],
["3011", "Tri-Slosher Nouveau"],
["3012", "Tri-Slosher ASH-N"],
["3020", "Sloshing Machine"],
["3021", "Sloshing Machine Neo"],
["3030", "Bloblobber"],
["3031", "Bloblobber Deco"],
["3040", "Explosher"],
["3041", "Custom Explosher"],
["3050", "Dread Wringer"],
["3051", "Dread Wringer D"],
["3052", "Hornz Dread Wringer"],
["4000", "Mini Splatling"],
["4001", "Zink Mini Splatling"],
["4002", "Mini Splatling RTL-R"],
["4010", "Heavy Splatling"],
["4011", "Heavy Splatling Deco"],
["4015", "Order Splatling Replica"],
["4020", "Hydra Splatling"],
["4021", "Custom Hydra Splatling"],
["4022", "Torrentz Hydra Splatling"],
["4030", "Ballpoint Splatling"],
["4031", "Ballpoint Splatling Nouveau"],
["4040", "Nautilus 47"],
["4041", "Nautilus 79"],
["4050", "Heavy Edit Splatling"],
["4051", "Heavy Edit Splatling Nouveau"],
["5000", "Dapple Dualies"],
["5001", "Dapple Dualies Nouveau"],
["5002", "Dapple Dualies NOC-T"],
["5010", "Splat Dualies"],
["5011", "Enperry Splat Dualies"],
["5012", "Twinklez Splat Dualies"],
["5015", "Order Dualie Replicas"],
["5020", "Glooga Dualies"],
["5021", "Glooga Dualies Deco"],
["5030", "Dualie Squelchers"],
["5031", "Custom Dualie Squelchers"],
["5032", "Hoofz Dualie Squelchers"],
["5040", "Dark Tetra Dualies"],
["5041", "Light Tetra Dualies"],
["5050", "Douser Dualies FF"],
["5051", "Custom Douser Dualies FF"],
["6000", "Splat Brella"],
["6001", "Sorella Brella"],
["6005", "Order Brella Replica"],
["6010", "Tenta Brella"],
["6011", "Tenta Sorella Brella"],
["6012", "Tenta Brella CRE-M"],
["6020", "Undercover Brella"],
["6021", "Undercover Sorella Brella"],
["6022", "Patternz Undercover Brella"],
["6030", "Recycled Brella 24 Mk I"],
["6031", "Recycled Brella 24 Mk II"],
["7010", "Tri-Stringer"],
["7011", "Inkline Tri-Stringer"],
["7012", "Bulbz Tri-Stringer"],
["7015", "Order Stringer Replica"],
["7020", "REEF-LUX 450"],
["7021", "REEF-LUX 450 Deco"],
["7022", "REEF-LUX 450 MIL-K"],
["7030", "Wellstring V"],
["7031", "Custom Wellstring V"],
["8000", "Splatana Stamper"],
["8001", "Splatana Stamper Nouveau"],
["8002", "Stickerz Splatana Stamper"],
["8005", "Order Splatana Replica"],
["8010", "Splatana Wiper"],
["8011", "Splatana Wiper Deco"],
["8012", "Splatana Wiper RUS-T"],
["8020", "Mint Decavitator"],
["8021", "Charcoal Decavitator"],
]);
const SUB_WEAPON_NAMES: ReadonlyMap<string, string> = new Map([
["0", "Splat Bomb"],
["1", "Suction Bomb"],
["2", "Burst Bomb"],
["3", "Sprinkler"],
["4", "Splash Wall"],
["5", "Fizzy Bomb"],
["6", "Curling Bomb"],
["7", "Autobomb"],
["8", "Squid Beakon"],
["9", "Point Sensor"],
["10", "Ink Mine"],
["11", "Toxic Mist"],
["12", "Angle Shooter"],
["13", "Torpedo"],
]);
const SPECIAL_WEAPON_NAMES: ReadonlyMap<string, string> = new Map([
["1", "Trizooka"],
["2", "Big Bubbler"],
["3", "Zipcaster"],
["4", "Tenta Missiles"],
["5", "Ink Storm"],
["6", "Booyah Bomb"],
["7", "Wave Breaker"],
["8", "Ink Vac"],
["9", "Killer Wail 5.1"],
["10", "Inkjet"],
["11", "Ultra Stamp"],
["12", "Crab Tank"],
["13", "Reefslider"],
["14", "Triple Inkstrike"],
["15", "Tacticooler"],
["16", "Super Chump"],
["17", "Kraken Royale"],
["18", "Triple Splashdown"],
["19", "Splattercolor Screen"],
]);
function entriesOf(
map: ReadonlyMap<string, string>,
type: WeaponType,
): WeaponEntry[] {
return [...map.entries()].map(([id, name]) => ({ id, name, type }));
}
/** Every weapon that can appear in the death message, across all kinds. */
export const ALL_WEAPON_ENTRIES: readonly WeaponEntry[] = [
...entriesOf(WEAPON_NAMES, "MAIN"),
...entriesOf(SUB_WEAPON_NAMES, "SUB"),
...entriesOf(SPECIAL_WEAPON_NAMES, "SPECIAL"),
];

View File

@@ -0,0 +1,365 @@
/**
* MapStartDetector: parses the match-intro splash — the mode title on the
* black center splat and the stage name bottom-right, both snapped to the
* localized closed sets (core/localized.ts) and reported under canonical
* English names.
*
* The mode title wraps to two lines for longer names ("Splat" / "Zones"),
* so parse finds text lines inside MODE_BLOCK_ROI by row projection, OCRs
* each band, and snaps the joined reading. The constant "MODE" label
* doubles as a parse-time confirmation (else a lookalike gate hit emits
* nothing). The splash sits on live gameplay, so bright stages leak
* background past the text edges: the title block is masked to near-dark
* for line finding only (raw crop OCRs better); the stage line reads its
* min channel (drops blue-tinted water gray keeps) under several
* binarizations, keeping the best-snapping one (see rois.ts).
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
scaleGlyphSet,
} from "../../glyphs";
import { copyRoi, meanBrightness, minChannel } from "../../image";
import {
ALL_MODE_ENTRIES,
ALL_MODE_LABELS,
ALL_STAGE_ENTRIES,
} from "../../localized";
import { closestBy } from "../../text";
import type { ScoreboardResources } from "../scoreboard/index";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
BLOCK_MASK_RADIUS,
GATE_DARK_MAX_MEAN,
GATE_DARK_PROBES,
GATE_INK_BAND,
GATE_INK_BAND_MAX_BRIGHT,
GATE_INK_BAND_MIN_DARK,
GATE_TEXT_MAX_FRACTION,
GATE_TEXT_MIN_FRACTION,
GATE_TEXT_MIN_MAX,
LINE_GAP_TOLERANCE,
LINE_MIN_HEIGHT,
LINE_MIN_ROW_PIXELS,
LINE_ROW_FRACTION,
MASK_DARK_MAX,
MODE_BLOCK_ROI,
MODE_LABEL_ROI,
MODE_LABEL_TEXT_HEIGHT,
MODE_TEXT_HEIGHT,
STAGE_BIN_THRESHOLD,
STAGE_MASK_RADIUS,
STAGE_RAW_BIN_THRESHOLDS,
STAGE_ROI,
STAGE_TEXT_HEIGHT,
TEXT_BIN_THRESHOLD,
} from "./rois";
export interface MapStartData {
mode: ModeShort | null;
stage: StageId | null;
}
export const MAP_START_EVENT_TYPE = "MapStart";
/** "MODE" must read back at least this well for parse to emit. */
const LABEL_MIN_SCORE = 0.5;
/** Accept a closed-set match only above this score (1 = exact). */
const MIN_MATCH_SCORE = 0.62;
interface LineBand {
y0: number;
y1: number;
}
/** Zero every pixel with no near-black pixel within `radius` of it. */
function maskNearDark(gray: Mat, radius: number): Mat {
const cv = getCV();
const dark = new cv.Mat();
cv.threshold(gray, dark, MASK_DARK_MAX, 255, cv.THRESH_BINARY_INV);
const kernel = cv.getStructuringElement(
cv.MORPH_ELLIPSE,
new cv.Size(2 * radius + 1, 2 * radius + 1),
);
const near = new cv.Mat();
cv.dilate(dark, near, kernel);
kernel.delete();
dark.delete();
const out = new cv.Mat(gray.rows, gray.cols, cv.CV_8UC1, new cv.Scalar(0));
gray.copyTo(out, near);
near.delete();
return out;
}
/** Find text line bands in a binarized block by row projection. */
function findLineBands(binary: Mat): LineBand[] {
const { rows, cols, data } = binary;
const counts = new Array<number>(rows).fill(0);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (data[y * cols + x]! > 0) counts[y]!++;
}
}
// dual threshold: a band needs rows above a cutoff scaled to the
// strongest row (background leaking past the splat's rim puts a
// scene-dependent noise floor under every row), then extends over a
// fixed floor so antialiased glyph tops/bottoms stay inside the band
const core = Math.max(
LINE_MIN_ROW_PIXELS,
LINE_ROW_FRACTION * Math.max(...counts),
);
const bands: LineBand[] = [];
let start = -1;
let gap = 0;
for (let y = 0; y < rows; y++) {
if (counts[y]! >= core) {
if (start < 0) start = y;
gap = 0;
} else if (start >= 0 && ++gap > LINE_GAP_TOLERANCE) {
bands.push({ y0: start, y1: y - gap + 1 });
start = -1;
}
}
if (start >= 0) bands.push({ y0: start, y1: rows - gap });
for (const band of bands) {
while (band.y0 > 0 && counts[band.y0 - 1]! >= LINE_MIN_ROW_PIXELS)
band.y0--;
while (band.y1 < rows && counts[band.y1]! >= LINE_MIN_ROW_PIXELS) band.y1++;
}
const merged: LineBand[] = [];
for (const band of bands) {
const last = merged[merged.length - 1];
if (last && band.y0 <= last.y1) last.y1 = Math.max(last.y1, band.y1);
else merged.push(band);
}
return merged.filter((b) => b.y1 - b.y0 >= LINE_MIN_HEIGHT);
}
/**
* Trim a line band to its text columns (on the masked binary), so the raw
* OCR crop excludes the background the wide block picks up at its edges.
*/
function bandExtent(
binary: Mat,
band: LineBand,
): { x0: number; x1: number } | null {
const { cols, data } = binary;
let x0 = -1;
let x1 = -1;
for (let x = 0; x < cols; x++) {
let count = 0;
for (let y = band.y0; y < band.y1; y++) {
if (data[y * cols + x]! > 0) count++;
}
if (count >= 2) {
if (x0 < 0) x0 = x;
x1 = x;
}
}
return x0 < 0 ? null : { x0, x1 };
}
export function createMapStartDetector(
resources: ScoreboardResources,
): Detector<MapStartData> {
const cv = getCV();
const scaled = (
set: GlyphSet | null | undefined,
height: number,
): GlyphSet | null => (set ? scaleGlyphSet(set, height / set.height) : null);
const modeGlyphs = scaled(resources.mapStartModeGlyphs, MODE_TEXT_HEIGHT);
const stageGlyphs = scaled(resources.mapStartStageGlyphs, STAGE_TEXT_HEIGHT);
const labelGlyphs = scaled(
resources.mapStartStageGlyphs,
MODE_LABEL_TEXT_HEIGHT,
);
function gate(frame: Mat): GateResult {
let darkOk = 0;
for (const roi of GATE_DARK_PROBES) {
if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const label = copyRoi(gray, MODE_LABEL_ROI);
const { maxVal } = minMaxLoc(label);
const bin = new cv.Mat();
cv.threshold(label, bin, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
label.delete();
const whiteFraction = cv.countNonZero(bin) / (bin.rows * bin.cols);
bin.delete();
const textOk =
maxVal > GATE_TEXT_MIN_MAX &&
whiteFraction > GATE_TEXT_MIN_FRACTION &&
whiteFraction < GATE_TEXT_MAX_FRACTION;
// the label-to-title gap core is solid ink on this screen: it must be
// near-totally dark (the scoreboards' pills aren't) and carry no bright
// pixels (the death burst's "Splatted by" line crosses it)
const band = copyRoi(gray, GATE_INK_BAND);
const bandPixels = band.rows * band.cols;
const bandBright = new cv.Mat();
cv.threshold(band, bandBright, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
const brightFraction = cv.countNonZero(bandBright) / bandPixels;
bandBright.delete();
const bandDark = new cv.Mat();
cv.threshold(band, bandDark, MASK_DARK_MAX, 255, cv.THRESH_BINARY_INV);
const darkFraction = cv.countNonZero(bandDark) / bandPixels;
bandDark.delete();
band.delete();
const inkOk =
brightFraction <= GATE_INK_BAND_MAX_BRIGHT &&
darkFraction >= GATE_INK_BAND_MIN_DARK;
gray.delete();
const score =
(darkOk / GATE_DARK_PROBES.length + (textOk ? 1 : 0) + (inkOk ? 1 : 0)) /
3;
return {
pass: darkOk === GATE_DARK_PROBES.length && textOk && inkOk,
score,
};
}
function parse(frame: Mat, t: number): DetectedEvent<MapStartData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
// 1. confirm the constant label — a gate hit without it is a lookalike
let label: RecognizedText | null = null;
let labelScore = 0;
if (labelGlyphs) {
const crop = copyRoi(gray, MODE_LABEL_ROI);
label = recognizeText(crop, labelGlyphs, {
binThreshold: TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
crop.delete();
labelScore = closestBy(label.text, ALL_MODE_LABELS, (l) => l)?.score ?? 0;
if (labelScore < LABEL_MIN_SCORE) {
gray.delete();
return [];
}
}
// 2. mode title: find the 1-2 text lines, OCR each, snap the joined text
let mode: ModeShort | null = null;
let modeScore = 0;
let modeReading = "";
if (modeGlyphs) {
const block = copyRoi(gray, MODE_BLOCK_ROI);
// find line bands on the masked block (bright background rows would
// merge/invent bands) but OCR the raw crop (masking clips strokes and
// costs accuracy); each band is trimmed to its text columns (also
// from the mask) since the full-width block picks up background junk
const masked = maskNearDark(block, BLOCK_MASK_RADIUS);
const binary = new cv.Mat();
cv.threshold(masked, binary, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
masked.delete();
const bands = findLineBands(binary);
const lines: string[] = [];
for (const band of bands) {
const extent = bandExtent(binary, band);
if (!extent) continue;
const pad = 3;
const y0 = Math.max(0, band.y0 - pad);
const x0 = Math.max(0, extent.x0 - pad);
const line = copyRoi(block, {
x: x0,
y: y0,
w: Math.min(block.cols, extent.x1 + 1 + pad) - x0,
h: Math.min(block.rows, band.y1 + pad) - y0,
});
const read = recognizeText(line, modeGlyphs, {
binThreshold: TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
line.delete();
if (read.text.trim()) lines.push(read.text.trim());
}
binary.delete();
block.delete();
modeReading = lines.join(" ");
const match = modeReading
? closestBy(modeReading, ALL_MODE_ENTRIES, (e) => e.text)
: null;
if (match) {
modeScore = match.score;
if (match.score >= MIN_MATCH_SCORE) mode = match.entry.mode;
}
}
// 3. stage name. Backdrop is live gameplay (dark water on one stage,
// a bright floor on another), so no single binarization works
// everywhere: read the near-dark-masked crop plus the raw crop at a
// few rising thresholds, and keep whichever snaps best.
let stage: StageId | null = null;
let stageScore = 0;
let stageReading = "";
if (stageGlyphs) {
const rgbaCrop = copyRoi(frame, STAGE_ROI);
const bright = minChannel(rgbaCrop);
rgbaCrop.delete();
const masked = maskNearDark(bright, STAGE_MASK_RADIUS);
const attempts: [Mat, number][] = [
[masked, STAGE_BIN_THRESHOLD],
...STAGE_RAW_BIN_THRESHOLDS.map((thr): [Mat, number] => [bright, thr]),
];
for (const [input, binThreshold] of attempts) {
const read = recognizeText(input, stageGlyphs, {
binThreshold,
minCharScore: 0.3,
});
const match = read.text
? closestBy(read.text, ALL_STAGE_ENTRIES, (e) => e.text)
: null;
if (match && match.score > stageScore) {
stageScore = match.score;
stageReading = read.text;
if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId;
}
}
masked.delete();
bright.delete();
}
gray.delete();
return [
{
type: MAP_START_EVENT_TYPE,
t,
// mean like every other detector: a clean mode read must survive an
// unreadable stage (map-start is the only mode source for VoD matches),
// not be zeroed by it
confidence: (modeScore + stageScore) / 2,
data: { mode, stage },
debug: {
label: label?.text,
labelScore,
modeReading,
modeScore,
stageReading,
stageScore,
},
},
];
}
// just under the measured clean-read floor (fixtures 0.795-0.864,
// confirmed scan events 0.854-1.0)
return {
id: "map-start",
sufficientConfidence: 0.79,
gate,
parse,
};
}

View File

@@ -0,0 +1,97 @@
/**
* ALL map-start ROI coordinates, in canonical 1920x1080 space.
* Calibrated against the map-start/ fixtures via row/column profiling.
*
* The match-intro splash overlays live gameplay: a black ink splat
* top-center with the constant "MODE" label (~48px caps), the mode title
* (~76px BlitzBold, up to two lines), and an objective subtitle; the
* stage name bottom-right (~40px); and eight players' splash tags along
* the left/right edges.
*/
import type { Roi } from "../../canonical";
/**
* The constant "MODE" label (white caps centered x=960), kept tight
* inside the label's black pill (x≈825-1110): on bright stages
* (Mahi-Mahi) a wider crop picks up background that garbles the read.
*/
export const MODE_LABEL_ROI: Roi = { x: 850, y: 268, w: 220, h: 62 };
export const MODE_LABEL_TEXT_HEIGHT = 48;
/**
* Mode title block below the label, one or two lines (parse finds lines
* by row projection, not fixed positions). Ends above the objective
* subtitle (~y678); wide enough for the longest title ("Herrschaft" /
* "Spetterzone" ~660px) — at 520 wide the German H/t clipped ("lerrschaf").
*/
export const MODE_BLOCK_ROI: Roi = { x: 620, y: 380, w: 680, h: 285 };
export const MODE_TEXT_HEIGHT = 76;
/** A block row is text when it has at least this many bright pixels... */
export const LINE_MIN_ROW_PIXELS = 40;
/**
* ...and at least this fraction of the block's strongest row: background
* leak past the splat rim sets a scene-dependent floor (Robo ROM-en's
* bright mall merged title + junk into one 227px band without this).
*/
export const LINE_ROW_FRACTION = 0.25;
/** Text rows closer than this merge into one line band. */
export const LINE_GAP_TOLERANCE = 8;
/** Discard bands shorter than this (splat-texture speckle, drips). */
export const LINE_MIN_HEIGHT = 40;
/** Stage name, bottom-right over live gameplay. */
export const STAGE_ROI: Roi = { x: 1300, y: 984, w: 600, h: 56 };
export const STAGE_TEXT_HEIGHT = 40;
/** White text on the near-black splat binarizes cleanly and high. */
export const TEXT_BIN_THRESHOLD = 190;
/**
* Bright-background suppression (light stages like Mahi-Mahi: water ~200
* gray, docks 230+): text counts only near a near-black pixel (title
* borders splat ink, stage name has a drop shadow). Radius must exceed
* stroke half-width or it eats glyph cores: ~12 for the title, ~6 for
* the stage line.
*/
export const MASK_DARK_MAX = 70;
export const BLOCK_MASK_RADIUS = 12;
export const STAGE_MASK_RADIUS = 6;
/**
* Stage line also reads the per-pixel min channel at a higher threshold
* (blue water drops via its low red channel, white glyphs stay near 255).
* When the mask fails on an all-bright backdrop (Robo ROM-en's mall
* floor), parse tries the raw crop at these thresholds and keeps the best.
*/
export const STAGE_BIN_THRESHOLD = 210;
export const STAGE_RAW_BIN_THRESHOLDS: readonly number[] = [225, 235, 245];
/**
* Gate probes: splat ink flanking the "MODE" label and before the title,
* on spots verified solid across fixtures (mean ≤8, well above ink yet
* under the dark scoreboard pills ~51+). Flanking pair sits outside x
* 838-1082, past the widest label text ("Kampfart", "Vechtstijl").
*/
export const GATE_DARK_PROBES: readonly Roi[] = [
{ x: 780, y: 280, w: 30, h: 20 },
{ x: 1105, y: 280, w: 30, h: 20 },
{ x: 850, y: 355, w: 30, h: 20 },
{ x: 1030, y: 355, w: 30, h: 20 },
];
export const GATE_DARK_MAX_MEAN = 45;
/**
* Core of the label-to-title gap is solid splat ink: near-total darkness,
* zero bright pixels. Death's "Splatted by" line runs through here
* (bright fraction 0.17+); scoreboard pills only reach ~0.83 dark.
*/
export const GATE_INK_BAND: Roi = { x: 840, y: 350, w: 230, h: 36 };
export const GATE_INK_BAND_MAX_BRIGHT = 0.005;
export const GATE_INK_BAND_MIN_DARK = 0.95;
/** MODE_LABEL_ROI must contain near-white pixels... */
export const GATE_TEXT_MIN_MAX = 210;
/** ...at a text fill fraction: "MODE" fills ~0.25 of the tight ROI, while
* stray white on the other screens stays under ~0.13. */
export const GATE_TEXT_MIN_FRACTION = 0.05;
export const GATE_TEXT_MAX_FRACTION = 0.35;

View File

@@ -0,0 +1,24 @@
/**
* Ability badge templates at the minimap cards' badge size (⌀~44 on both
* the own cards and the enemy panel; one role, mains only — the cards show
* no sub-ability slots). Built via the death panel's badge compositor.
*/
import type { FrameData } from "../../image";
import { buildAbilityRole } from "../death/abilities";
import type { WeaponTemplate } from "../scoreboard/weapons";
import {
BADGE_ART_RATIO,
BADGE_TEMPLATE_SIZES,
MINIMAP_ABILITY_INK_THRESHOLD,
} from "./rois";
export function prepareMinimapAbilityTemplates(
icons: { id: string; image: FrameData }[],
): WeaponTemplate[] {
return buildAbilityRole(
icons,
BADGE_TEMPLATE_SIZES,
BADGE_ART_RATIO,
MINIMAP_ABILITY_INK_THRESHOLD,
);
}

View File

@@ -0,0 +1,785 @@
/**
* MinimapDetector: parses the in-match map overlay (opened with X) — the
* own-team callout cards (name, main weapon, the three main-ability
* badges), the enemy panel rows (weapon, abilities; the game shows no
* enemy names) — plus the stage, matched from the drawn map (stage.ts).
* The goal is the most complete read of every card/row; map control is
* deliberately not reported.
*
* Two per-player screen states are reported (`dead`, `specialReady` — the
* match builder merges them into the death/special timeline alongside the
* icon-strip PlayerStatus reads) and steer the reads themselves:
* - a respawning player's card is struck through with a large team-color
* X that covers the name and badges (own cards also lose the weapon;
* enemy rows keep theirs — the X spares the row's weapon icon). Reading
* through the X yields garbage, so an occlusion probe skips the covered
* fields and reports them null;
* - a charged special swaps the card/row background for a light camo
* pattern; weapons are full-color icon art matched against art-cropped
* template sets composited for the surface actually behind them — bg-40
* for the translucent dark cards/rows, bg-150 for the camo (dark
* templates anti-correlate there) — so a corner-brightness probe picks
* the template set, ink threshold, and score floor per card.
*/
import type {
AbilityWithUnknown,
MainWeaponId,
StageId,
} from "~/modules/in-game-lists/types";
import { toAbilityWithUnknown, toMainWeaponId } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import {
copyRoi,
cropRoi,
laplacianAbs,
maxBrightness,
meanBrightness,
type Roi,
} from "../../image";
import { type InkRgb, meanInkColor } from "../../ink-color";
import type { ScoreboardResources } from "../scoreboard/index";
import { type ParsedName, parseName } from "../scoreboard/names";
import {
disambiguateWeaponBySub,
matchSpecial,
tiedWeaponsWithDistinctSubs,
} from "../scoreboard/specials";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
badgeRoi,
CARD_LAYOUTS,
type CardSlot,
CROSS_MIN_FRACTION,
CROSS_MIN_LAPLACIAN,
CROSS_SATURATION_MIN,
CROSS_VALUE_MIN,
ENEMY_BADGE_XS,
ENEMY_ROW_CYS,
enemyCrossRoi,
enemySubTileRoi,
enemyWeaponRoi,
GATE_BRIGHT_MIN_MAX,
GATE_CLOSE_DARK_PROBES,
GATE_CLOSE_X_BRIGHT,
GATE_CLOSE_X_DARK,
GATE_DARK_MAX_MEAN,
GATE_SPAWN_BRIGHT,
GATE_SPAWN_DARK_PROBES,
GATE_SPECTATOR_X_BRIGHT,
GATE_SPECTATOR_X_DARK,
MINIMAP_ABILITY_INK_THRESHOLD,
MINIMAP_WEAPON_INK_THRESHOLD,
NAME_BIN_THRESHOLD,
NAME_TEXT_HEIGHT,
PRESENCE_MIN_LAPLACIAN,
SPECIAL_READY_INK_THRESHOLD,
SPECIAL_READY_MAX_CORNER_SATURATION,
SPECIAL_READY_MIN_CORNER_MEAN,
SPECIAL_READY_WEAPON_MIN_SCORE,
SPECTATOR_ENEMY_DX,
SPECTATOR_NAME_TEXT_HEIGHTS,
SPECTATOR_SLOTS,
spectatorCardLayout,
WEAPON_BLEED_MIN_CORNER_MEAN,
WEAPON_MIN_SCORE,
} from "./rois";
import { matchStage, plannerSignature, type StageMatch } from "./stage";
export interface MinimapTeammate {
/** which callout card: super-jump slot, or the POV player's own card */
slot: CardSlot;
/** card name; null when covered by a respawn cross-out or unreadable */
name: string | null;
/** sendou main-weapon id; null when unreadable/covered */
weaponId: MainWeaponId | null;
/**
* the card's three main abilities, [head, clothes, shoes] (null per
* unreadable badge); empty when a respawn cross-out sits over the badges
*/
abilities: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out at the read */
dead: boolean;
/** on the light camo surface of a charged special at the read */
specialReady: boolean;
}
export interface MinimapEnemy {
/**
* the POV overlay shows no enemy names (always null there); the
* spectator screen does, so spectator rows carry them
*/
name: string | null;
/** readable even on struck rows: the cross-out spares the weapon icon */
weaponId: MainWeaponId | null;
abilities: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out at the read */
dead: boolean;
/** on the light camo surface of a charged special at the read */
specialReady: boolean;
}
export interface MinimapData {
/**
* sendou stage id, matched from the drawn map against the planner
* renders (stage.ts); null when no stage matched confidently or the
* planner signatures were not loaded. The mode is not identifiable this
* way (see stage.ts) and is left to the mode-bearing detectors.
*/
stage: StageId | null;
/**
* true when the frame is a casted stream's 8-player spectator map screen
* rather than the POV overlay: the alpha (left) column is reported as
* teammates (d-pad slots up/right/down/left) and the bravo (right)
* column as enemy rows — with names, which this screen shows
*/
spectator: boolean;
/** own-team callout cards; a slot missing from the frame is omitted */
teammates: MinimapTeammate[];
/** enemy panel rows, top to bottom */
enemies: MinimapEnemy[];
/**
* mean team-ink RGB per side sampled from the sub-weapon tiles
* ([teammates/alpha, enemies/bravo]); null when too little saturated
* ink. Anchors the objective counter's color-tracked sides to `teams`
* order on casted footage, which never shows a results screen.
*/
teamColors: [InkRgb | null, InkRgb | null];
}
export const MINIMAP_EVENT_TYPE = "Minimap";
/**
* Timeline content guard: minimap frames inside the merge window collapse
* only while every card/row keeps its dead/special state, so each flip a
* map-open catches (a respawn, a special charged or spent) stays its own
* event. Names, weapons and badges are not compared — OCR wobble on an
* unchanged screen is still the same state.
*/
export function sameMinimapStatusData(a: unknown, b: unknown): boolean {
const da = a as MinimapData;
const db = b as MinimapData;
const sameSide = (
xs: readonly { dead: boolean; specialReady: boolean }[],
ys: readonly { dead: boolean; specialReady: boolean }[],
): boolean =>
xs.length === ys.length &&
xs.every(
(x, i) =>
x.dead === ys[i]!.dead && x.specialReady === ys[i]!.specialReady,
);
return (
sameSide(da.teammates, db.teammates) && sameSide(da.enemies, db.enemies)
);
}
/** Badge match below this is reported as null (kept in debug). */
const ABILITY_MIN_SCORE = 0.45;
/**
* Light-camo (special-charged) probe: the dimmer of the two 8x8 top
* corners of the weapon box, its brightness and saturation. Camo
* backgrounds brighten both corners (140-165) and are gray-green
* unsaturated; on a dark card at least one corner stays dark even when
* avatar bleed or a cross-out stroke lights up the other, and a bright
* scene bleeding through the card lights both but stays colored (see
* SPECIAL_READY_MAX_CORNER_SATURATION).
*/
function minTopCorner(
gray: Mat,
hsv: Mat,
roi: Roi,
): { mean: number; saturation: number } {
const corners: Roi[] = [
{ x: roi.x, y: roi.y, w: 8, h: 8 },
{ x: roi.x + roi.w - 8, y: roi.y, w: 8, h: 8 },
];
const means = corners.map((c) => meanBrightness(gray, c));
const dimmer = corners[means[0]! <= means[1]! ? 0 : 1]!;
const crop = copyRoi(hsv, dimmer);
const n = crop.rows * crop.cols;
let satSum = 0;
for (let i = 0; i < n; i++) satSum += crop.data[i * 3 + 1]!;
crop.delete();
return { mean: Math.min(...means), saturation: satSum / n };
}
/** fraction of the probe that is saturated-and-bright (cross-out strokes) */
function saturatedFraction(hsv: Mat, roi: Roi): number {
const m = copyRoi(hsv, roi);
const n = m.rows * m.cols;
const md = m.data;
let hit = 0;
for (let i = 0; i < n; i++) {
if (
md[i * 3 + 1]! >= CROSS_SATURATION_MIN &&
md[i * 3 + 2]! >= CROSS_VALUE_MIN
) {
hit++;
}
}
m.delete();
return hit / n;
}
export function createMinimapDetector(
resources: ScoreboardResources,
): Detector<MinimapData> {
const cv = getCV();
const nameGlyphs: GlyphSet | null = resources.nameGlyphs
? scaleGlyphSet(
resources.nameGlyphs,
NAME_TEXT_HEIGHT / resources.nameGlyphs.height,
)
: null;
const spectatorNameGlyphs: GlyphSet[] = resources.nameGlyphs
? SPECTATOR_NAME_TEXT_HEIGHTS.map((h) =>
h === NAME_TEXT_HEIGHT
? nameGlyphs!
: scaleGlyphSet(
resources.nameGlyphs!,
h / resources.nameGlyphs!.height,
),
)
: [];
const cardWeapons = resources.minimapCardWeapons ?? null;
const lightWeapons = resources.minimapLightWeapons ?? null;
const badges = resources.minimapAbilities ?? null;
const subWeapons = resources.minimapSubWeapons ?? null;
const plannerStages = resources.plannerStages ?? null;
/** Identify the stage from the drawn map; contributes to confidence. */
function detectStage(frame: Mat, confidences: number[]): StageMatch | null {
if (!plannerStages?.length) return null;
const sig = plannerSignature(frame);
const match = matchStage(sig, plannerStages);
if (match) confidences.push(match.score);
return match;
}
function probeGate(
gray: Mat,
darkProbes: readonly Roi[],
brightProbes: readonly Roi[],
): GateResult {
let darkOk = 0;
for (const roi of darkProbes) {
if (meanBrightness(gray, roi) <= GATE_DARK_MAX_MEAN) darkOk++;
}
let brightOk = 0;
for (const roi of brightProbes) {
if (maxBrightness(gray, roi) >= GATE_BRIGHT_MIN_MAX) brightOk++;
}
return {
pass: darkOk === darkProbes.length && brightOk === brightProbes.length,
score: (darkOk / darkProbes.length + brightOk / brightProbes.length) / 2,
};
}
/** POV overlay chrome: close-button disc + Spawn Point pill. */
function overlayGate(gray: Mat): GateResult {
return probeGate(
gray,
[
...GATE_CLOSE_DARK_PROBES,
...GATE_CLOSE_X_DARK,
...GATE_SPAWN_DARK_PROBES,
],
[...GATE_CLOSE_X_BRIGHT, GATE_SPAWN_BRIGHT],
);
}
/** Spectator screen: the X jump-button disc beside the 8th player card. */
function spectatorGate(gray: Mat): GateResult {
return probeGate(gray, GATE_SPECTATOR_X_DARK, GATE_SPECTATOR_X_BRIGHT);
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const overlay = overlayGate(gray);
const spectator = spectatorGate(gray);
gray.delete();
return {
pass: overlay.pass || spectator.pass,
score: Math.max(overlay.score, spectator.score),
variant: spectator.pass ? "spectator" : "overlay",
};
}
function matchBadges(
rgb: Mat,
centers: readonly (readonly [number, number])[],
inkThreshold: number,
confidences: number[],
debugRow: (WeaponMatch | null)[],
): (AbilityWithUnknown | null)[] {
if (!badges) return [null, null, null];
return centers.map(([cx, cy]) => {
const crop = cropRoi(rgb, badgeRoi(cx, cy));
const match = matchWeapon(crop, badges, { inkThreshold });
crop.delete();
debugRow.push(match);
confidences.push(Math.max(0, match.score));
return match.score >= ABILITY_MIN_SCORE
? toAbilityWithUnknown(match.id)
: null;
});
}
/**
* Weapon icon match against the composite set for the surface behind
* it: light templates on the camo surface, dark templates on a dark
* card — and on a bright-bleed surface (see
* WEAPON_BLEED_MIN_CORNER_MEAN) both sets, better score wins, since
* neither composite matches scene bleed exactly.
*/
function matchSurfaceWeapon(
rgb: Mat,
roi: Roi,
lightSurface: boolean,
cornerMin: number,
): WeaponMatch | null {
const darkThreshold = Math.max(
MINIMAP_WEAPON_INK_THRESHOLD,
Math.round(cornerMin) + 50,
);
const crop = cropRoi(rgb, roi);
let match: WeaponMatch | null = null;
if (lightSurface) {
match = lightWeapons
? matchWeapon(crop, lightWeapons, {
inkThreshold: SPECIAL_READY_INK_THRESHOLD,
})
: null;
} else {
match = cardWeapons
? matchWeapon(crop, cardWeapons, { inkThreshold: darkThreshold })
: null;
if (lightWeapons && cornerMin >= WEAPON_BLEED_MIN_CORNER_MEAN) {
const bleed = matchWeapon(crop, lightWeapons, {
inkThreshold: SPECIAL_READY_INK_THRESHOLD,
});
if (match === null || bleed.score > match.score) match = bleed;
}
}
crop.delete();
return match;
}
/** The score floor for the surface the weapon was matched over. */
function weaponScoreFloor(lightSurface: boolean, cornerMin: number): number {
return lightSurface || cornerMin >= WEAPON_BLEED_MIN_CORNER_MEAN
? SPECIAL_READY_WEAPON_MIN_SCORE
: WEAPON_MIN_SCORE;
}
/**
* Near-tied weapon icons whose kits differ by sub (plain vs Custom
* Dualie Squelchers): let the card/row's team-tinted sub tile break the
* tie. Shape-only matching survives the tint, the camo surface, and a
* cross-out stroke clipping the tile.
*/
function resolveTieBySubTile(
rgb: Mat,
weapon: WeaponMatch,
tile: Roi,
): WeaponMatch {
if (!subWeapons?.length || !tiedWeaponsWithDistinctSubs(weapon))
return weapon;
const crop = cropRoi(rgb, tile);
const sub = matchSpecial(crop, subWeapons);
crop.delete();
return disambiguateWeaponBySub(weapon, sub);
}
/** Try the name band at each spectator glyph height; best read wins. */
function bestNameRead(gray: Mat, roi: Roi): ParsedName | null {
let best: ParsedName | null = null;
for (const set of spectatorNameGlyphs) {
const band = copyRoi(gray, roi);
const parsed = parseName(band, set, { binThreshold: NAME_BIN_THRESHOLD });
band.delete();
if (!best || parsed.confidence > best.confidence) best = parsed;
}
return best;
}
/**
* The spectator screen's 8-card grid doesn't share the overlay's ROIs
* (running the overlay parse against it reads phantom cards), so it gets
* its own card loop: same fields per card, both columns carry names.
*/
function parseSpectator(
frame: Mat,
gray: Mat,
t: number,
): DetectedEvent<MinimapData>[] {
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
const lap = laplacianAbs(gray);
const confidences: number[] = [];
const debug: Record<string, unknown> = { spectator: true };
const teammates: MinimapTeammate[] = [];
const enemies: MinimapEnemy[] = [];
const sideSubTiles: [Roi[], Roi[]] = [[], []];
const cardDebug: Record<string, unknown>[] = [];
for (const dx of [0, SPECTATOR_ENEMY_DX]) {
for (let row = 0; row < 4; row++) {
const layout = spectatorCardLayout(row, dx);
const presence = meanBrightness(lap, layout.name);
if (presence < PRESENCE_MIN_LAPLACIAN) {
cardDebug.push({ dx, row, presence, skipped: true });
continue;
}
sideSubTiles[dx === 0 ? 0 : 1].push(layout.subTile);
const crossFraction = saturatedFraction(hsv, layout.cross);
const crossLap = meanBrightness(lap, layout.cross);
const occluded =
crossFraction >= CROSS_MIN_FRACTION &&
crossLap >= CROSS_MIN_LAPLACIAN;
const corner = minTopCorner(gray, hsv, layout.weapon);
const cornerMin = corner.mean;
const lightSurface =
corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN &&
corner.saturation <= SPECIAL_READY_MAX_CORNER_SATURATION;
let name: string | null = null;
let nameRaw = "";
let weapon: WeaponMatch | null = null;
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (AbilityWithUnknown | null)[] = [];
// the spectator cross-out sits clear of the weapon ROI (like
// overlay enemy rows), so the weapon stays readable when struck
weapon = matchSurfaceWeapon(
rgb,
layout.weapon,
lightSurface,
cornerMin,
);
if (weapon) {
weapon = resolveTieBySubTile(rgb, weapon, layout.subTile);
confidences.push(Math.max(0, weapon.score));
}
if (!occluded) {
const parsed = bestNameRead(gray, layout.name);
if (parsed) {
nameRaw = parsed.raw.text;
if (parsed.name.length > 0) name = parsed.name;
confidences.push(parsed.confidence);
}
abilities = matchBadges(
rgb,
layout.badges,
Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50),
confidences,
badgeDebug,
);
}
cardDebug.push({
dx,
row,
presence,
crossFraction,
crossLap,
occluded,
cornerMin,
lightSurface,
nameRaw,
weapon,
badges: badgeDebug,
});
const floor = weaponScoreFloor(lightSurface, cornerMin);
const matched =
weapon !== null && weapon.score >= floor ? weapon : null;
const fields = {
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
};
if (dx === 0) {
teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields });
} else {
enemies.push(fields);
}
}
}
debug.cards = cardDebug;
const teamColors: [InkRgb | null, InkRgb | null] = [
meanInkColor(rgb, sideSubTiles[0]),
meanInkColor(rgb, sideSubTiles[1]),
];
const stageMatch = detectStage(frame, confidences);
debug.stage = stageMatch;
rgb.delete();
hsv.delete();
lap.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: MINIMAP_EVENT_TYPE,
t,
confidence,
data: {
stage: stageMatch?.stageId ?? null,
spectator: true,
teammates,
enemies,
teamColors,
},
debug,
},
];
}
function parse(
frame: Mat,
t: number,
gateResult?: GateResult,
): DetectedEvent<MinimapData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const isSpectator = gateResult?.variant
? gateResult.variant === "spectator"
: spectatorGate(gray).pass;
if (isSpectator) {
const events = parseSpectator(frame, gray, t);
gray.delete();
return events;
}
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
const lap = laplacianAbs(gray);
const confidences: number[] = [];
const debug: Record<string, unknown> = {};
// 1. own-team callout cards
const teammates: MinimapTeammate[] = [];
const sideSubTiles: [Roi[], Roi[]] = [[], []];
const cardDebug: Record<string, unknown>[] = [];
for (const layout of CARD_LAYOUTS) {
// presence: the card is crisp UI, absent slots show blurred scene
const presence = meanBrightness(lap, layout.name);
if (presence < PRESENCE_MIN_LAPLACIAN) {
cardDebug.push({ slot: layout.slot, presence, skipped: true });
continue;
}
const crossFraction = saturatedFraction(hsv, layout.cross);
const crossLap = meanBrightness(lap, layout.cross);
const occluded =
crossFraction >= CROSS_MIN_FRACTION && crossLap >= CROSS_MIN_LAPLACIAN;
const corner = minTopCorner(gray, hsv, layout.weapon);
const cornerMin = corner.mean;
const lightSurface =
corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN &&
corner.saturation <= SPECIAL_READY_MAX_CORNER_SATURATION;
let name: string | null = null;
let nameRaw = "";
let weapon: WeaponMatch | null = null;
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (AbilityWithUnknown | null)[] = [];
if (!occluded) {
if (nameGlyphs) {
const band = copyRoi(gray, layout.name);
const parsed = parseName(band, nameGlyphs, {
binThreshold: NAME_BIN_THRESHOLD,
});
band.delete();
nameRaw = parsed.raw.text;
if (parsed.name.length > 0) name = parsed.name;
confidences.push(parsed.confidence);
}
weapon = matchSurfaceWeapon(
rgb,
layout.weapon,
lightSurface,
cornerMin,
);
if (weapon) {
weapon = resolveTieBySubTile(rgb, weapon, layout.subTile);
confidences.push(Math.max(0, weapon.score));
}
abilities = matchBadges(
rgb,
layout.badges,
lightSurface
? Math.max(
MINIMAP_ABILITY_INK_THRESHOLD,
Math.round(cornerMin) + 50,
)
: MINIMAP_ABILITY_INK_THRESHOLD,
confidences,
badgeDebug,
);
}
cardDebug.push({
slot: layout.slot,
presence,
crossFraction,
crossLap,
occluded,
cornerMin,
cornerSaturation: corner.saturation,
lightSurface,
nameRaw,
weapon,
badges: badgeDebug,
});
const floor = weaponScoreFloor(lightSurface, cornerMin);
const matched = weapon !== null && weapon.score >= floor ? weapon : null;
// an occluding cross-out is itself proof the card is drawn
const hasEvidence =
occluded ||
name !== null ||
matched !== null ||
abilities.some((a) => a !== null);
if (!hasEvidence) continue;
sideSubTiles[0].push(layout.subTile);
teammates.push({
slot: layout.slot,
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
});
}
debug.cards = cardDebug;
// 2. enemy panel rows
const enemies: MinimapEnemy[] = [];
const enemyDebug: Record<string, unknown>[] = [];
for (const cy of ENEMY_ROW_CYS) {
const weaponRoi = enemyWeaponRoi(cy);
const presence = meanBrightness(lap, weaponRoi);
if (presence < PRESENCE_MIN_LAPLACIAN) {
enemyDebug.push({ cy, presence, skipped: true });
continue;
}
const crossFraction = saturatedFraction(hsv, enemyCrossRoi(cy));
const crossLap = meanBrightness(lap, enemyCrossRoi(cy));
const occluded =
crossFraction >= CROSS_MIN_FRACTION && crossLap >= CROSS_MIN_LAPLACIAN;
// light camo rows: pick the template variant by the weapon box's
// corner brightness and raise the ink threshold past that background
const corner = minTopCorner(gray, hsv, weaponRoi);
const cornerMin = corner.mean;
const lightSurface =
corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN &&
corner.saturation <= SPECIAL_READY_MAX_CORNER_SATURATION;
let weapon = matchSurfaceWeapon(rgb, weaponRoi, lightSurface, cornerMin);
if (weapon) {
weapon = resolveTieBySubTile(rgb, weapon, enemySubTileRoi(cy));
confidences.push(Math.max(0, weapon.score));
}
const badgeDebug: (WeaponMatch | null)[] = [];
const abilities: (AbilityWithUnknown | null)[] = occluded
? []
: matchBadges(
rgb,
ENEMY_BADGE_XS.map((cx) => [cx, cy] as const),
Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50),
confidences,
badgeDebug,
);
enemyDebug.push({
cy,
presence,
crossFraction,
crossLap,
occluded,
lightSurface,
cornerMin,
cornerSaturation: corner.saturation,
weapon,
badges: badgeDebug,
});
const floor = weaponScoreFloor(lightSurface, cornerMin);
const matched = weapon !== null && weapon.score >= floor ? weapon : null;
sideSubTiles[1].push(enemySubTileRoi(cy));
enemies.push({
name: null,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
});
}
debug.enemies = enemyDebug;
const teamColors: [InkRgb | null, InkRgb | null] = [
meanInkColor(rgb, sideSubTiles[0]),
meanInkColor(rgb, sideSubTiles[1]),
];
const stageMatch = detectStage(frame, confidences);
debug.stage = stageMatch;
gray.delete();
rgb.delete();
hsv.delete();
lap.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: MINIMAP_EVENT_TYPE,
t,
confidence,
data: {
stage: stageMatch?.stageId ?? null,
spectator: false,
teammates,
enemies,
teamColors,
},
debug,
},
];
}
// sufficientConfidence sits just under the measured clean-read floor
// (fixtures 0.746-0.800; confirmed scan events reach down to 0.699, and
// those below the floor fall back to stagnation). The refine override
// matters here because a map-open's confidence keeps fluctuating upward,
// resetting the stagnation counter — without it a ~0.9s parse runs at
// the dense cadence for the whole map-open
return {
id: "minimap",
refineIntervalS: 0.4,
sufficientConfidence: 0.73,
gate,
parse,
};
}

View File

@@ -0,0 +1,284 @@
/**
* ALL minimap ROI coordinates, in canonical 1920x1080 space. Calibrated
* against the minimap/ fixtures via tools/dump-crops.ts plus template
* relocation sweeps (badges sit on an exact 48px pitch, pinning origins).
*
* The in-match map overlay (X) draws over gaussian-blurred gameplay: four
* own-team cards (POV player bottom-left) and an enemy panel top-right,
* each with weapon icon art, sub/special tiles, three ability badges
* (⌀~44); a respawning player is struck through with a team-color X, and
* a charged special swaps the card background for gray-green camo.
* Constant chrome (close disc, Spawn Point pill) gates on shape since the
* pill label is localized.
*/
import type { Roi } from "../../canonical";
/** "self" and "down" never coexist: the POV overlay has no down slot (the
* player's own card replaces it), the spectator grid has no self card. */
export type CardSlot = "up" | "left" | "right" | "self" | "down";
export interface CardLayout {
slot: CardSlot;
/** name text band (BlitzMain caps ~29px plus outline/descender margin) */
name: Roi;
/** main-weapon silhouette box (icons render ~31-48px tall) */
weapon: Roi;
/** sub-weapon tile: saturated team-color art, the ink-color anchor */
subTile: Roi;
/** the three main-ability badge centers, 48px pitch */
badges: readonly (readonly [number, number])[];
/** cross-out probe at card center: name/pill are unsaturated, X core isn't */
cross: Roi;
}
/**
* Right card = left shifted +1352px (verified only on the struck fixture).
* Self card differs: avatar leftmost, larger name inset, no d-pad.
*/
export const CARD_LAYOUTS: readonly CardLayout[] = [
{
slot: "up",
name: { x: 872, y: 46, w: 300, h: 44 },
weapon: { x: 860, y: 83, w: 84, h: 54 },
subTile: { x: 932, y: 98, w: 38, h: 41 },
badges: [
[1066, 114],
[1114, 114],
[1162, 114],
],
cross: { x: 925, y: 78, w: 60, h: 32 },
},
{
slot: "left",
name: { x: 198, y: 492, w: 300, h: 44 },
weapon: { x: 193, y: 529, w: 84, h: 54 },
subTile: { x: 265, y: 544, w: 38, h: 41 },
badges: [
[392, 564],
[440, 564],
[488, 564],
],
cross: { x: 258, y: 524, w: 60, h: 32 },
},
{
slot: "right",
name: { x: 1550, y: 492, w: 300, h: 44 },
weapon: { x: 1545, y: 529, w: 84, h: 54 },
subTile: { x: 1617, y: 544, w: 38, h: 41 },
badges: [
[1744, 564],
[1792, 564],
[1840, 564],
],
cross: { x: 1610, y: 524, w: 60, h: 32 },
},
{
slot: "self",
name: { x: 126, y: 942, w: 300, h: 46 },
weapon: { x: 118, y: 985, w: 94, h: 55 },
subTile: { x: 193, y: 995, w: 38, h: 36 },
badges: [
[320, 1014],
[368, 1014],
[416, 1014],
],
cross: { x: 255, y: 968, w: 60, h: 32 },
},
];
/** Enemy panel row centers (65px pitch) and per-row element boxes. */
export const ENEMY_ROW_CYS = [82, 147, 213, 278] as const;
export function enemyWeaponRoi(cy: number): Roi {
return { x: 1541, y: cy - 26, w: 58, h: 52 };
}
export function enemySubTileRoi(cy: number): Roi {
return { x: 1602, y: cy - 20, w: 39, h: 40 };
}
export const ENEMY_BADGE_XS = [1730, 1778, 1826] as const;
/** X arms meet in the dark gap between special tile and first badge (unsaturated when clean). */
export function enemyCrossRoi(cy: number): Roi {
return { x: 1685, y: cy - 12, w: 28, h: 24 };
}
/** Badge search box (badges ⌀~44; the box height keeps larger sets out). */
export function badgeRoi(cx: number, cy: number): Roi {
return { x: cx - 26, y: cy - 26, w: 52, h: 52 };
}
export const BADGE_TEMPLATE_SIZES = [38, 42, 46] as const;
/** Badge art fills the circle like the death panel's mains. */
export const BADGE_ART_RATIO = 1.0;
/**
* Badge ink threshold: near-black circles vs the enemy panel's translucent
* pink bleed (~150-180 in corners) — a constant penalty, ranking intact.
*/
export const MINIMAP_ABILITY_INK_THRESHOLD = 90;
/** BlitzMain caps measure 28-29px on every card (self included). */
export const NAME_TEXT_HEIGHT = 29;
export const NAME_BIN_THRESHOLD = 170;
/** Cross-out probe: fraction of saturated+bright HSV pixels. Struck 0.26-0.38, clean <=0.01. */
export const CROSS_SATURATION_MIN = 110;
export const CROSS_VALUE_MIN = 110;
export const CROSS_MIN_FRACTION = 0.08;
/**
* A saturated probe alone is not a cross-out: the fully-rendered POV map
* screen draws bright team ink through the translucent cards/panel
* (attested phantom fractions 0.61-0.65 on the Um'ami fixture with
* everyone alive). The X stroke is crisp UI while the bleed is softened by
* the surface — mean |Laplacian| over the probe separates them (struck
* >=98 vs bleed <=34; the floor only gates the fraction, clean cards read
* up to ~62 of crisp line art at fraction ~0).
*/
export const CROSS_MIN_LAPLACIAN = 65;
/**
* Weapon templates built with cropToArt (54px card box needs padding
* trimmed to fit larger icons — Splatana Stamper unmatchable otherwise).
* Dark surfaces match a bg-40 composite, special-ready camo a bg-150 one.
*/
/**
* Sub-tile silhouettes matched shape-only (specials.ts): survives team
* tint/camo/cross-out, used to split near-tied main-weapon icons.
*/
export const SUB_TILE_TEMPLATE_SIZES = [24, 27, 30, 33, 36] as const;
export const CARD_WEAPON_BACKGROUND = 40;
export const SPECIAL_READY_BACKGROUND = 150;
export const MINIMAP_WEAPON_TEMPLATE_SIZES = [
40, 44, 48, 52, 56, 60, 64,
] as const;
export const MINIMAP_WEAPON_INK_THRESHOLD = CARD_WEAPON_BACKGROUND + 50;
export const SPECIAL_READY_INK_THRESHOLD = SPECIAL_READY_BACKGROUND + 50;
/**
* Corner mean above this = special-ready camo (measures 140-165); a dark
* card keeps at least one top corner <=90 despite avatar/cross-out bleed.
* The fully-rendered POV map screen can push a clean card's corners past
* the mean floor (attested 136.7 on the Um'ami fixture vs true camo
* >=139.9), so the camo call also requires the dimmer corner to be
* unsaturated like the gray-green camo — attested camo <=53 vs bright
* scene bleed >=67 (the dimmer corner, because a cross-out stroke can
* saturate the brighter one on a struck camo row). Margins are THIN on
* both probes — re-measure before moving either.
*/
export const SPECIAL_READY_MIN_CORNER_MEAN = 120;
export const SPECIAL_READY_MAX_CORNER_SATURATION = 60;
/**
* Camo surfaces score systematically lower (blob pattern depresses NCC):
* 0.45-0.61 on correct matches vs 0.77+ on dark surfaces.
*/
export const WEAPON_MIN_SCORE = 0.55;
export const SPECIAL_READY_WEAPON_MIN_SCORE = 0.42;
/**
* A non-camo card whose dimmer corner still reads this bright sits on
* scene bleed (the fully-rendered POV map screen lights the translucent
* surfaces to ~95-137): neither composite matches such a surface exactly,
* so both template sets are tried and the better score wins, under the
* camo floor — bleed depresses NCC the same way (attested true matches
* 0.55-0.66 there, and the E-liter card wins as the light set's 0.611
* over the dark set's misranked 0.599 — THIN, re-measure before touching).
* Dark cards (corners <=77 attested) keep the plain dark match.
*/
export const WEAPON_BLEED_MIN_CORNER_MEAN = 90;
/**
* Overlay is crisp over a blurred scene: mean |Laplacian| over the name
* band/weapon box separates a drawn element from see-through background.
*/
export const PRESENCE_MIN_LAPLACIAN = 8;
/**
* Close-button gate: white ✕ on a dark disc (center (110,92) ±4px). Bright
* probes trace the crossing point + four arms, dark probes sit in the
* cardinal gaps — a plain bright blob misses the arms and fails.
*/
export const GATE_CLOSE_X_BRIGHT: readonly Roi[] = [
{ x: 104, y: 88, w: 12, h: 10 },
{ x: 88, y: 72, w: 8, h: 10 },
{ x: 124, y: 72, w: 8, h: 10 },
{ x: 88, y: 108, w: 8, h: 10 },
{ x: 124, y: 108, w: 8, h: 10 },
];
export const GATE_CLOSE_X_DARK: readonly Roi[] = [
{ x: 104, y: 62, w: 12, h: 8 },
{ x: 104, y: 116, w: 12, h: 8 },
{ x: 76, y: 88, w: 8, h: 10 },
{ x: 136, y: 88, w: 8, h: 10 },
];
/** Dark ring/background just outside the close-button disc. */
export const GATE_CLOSE_DARK_PROBES: readonly Roi[] = [
{ x: 88, y: 50, w: 20, h: 14 },
{ x: 88, y: 132, w: 20, h: 14 },
{ x: 58, y: 88, w: 14, h: 20 },
{ x: 132, y: 88, w: 14, h: 20 },
];
/** The white jump-arrows icon left of the (localized) pill label. */
export const GATE_SPAWN_BRIGHT: Roi = { x: 888, y: 963, w: 60, h: 60 };
export const GATE_SPAWN_DARK_PROBES: readonly Roi[] = [
{ x: 812, y: 958, w: 26, h: 14 },
{ x: 950, y: 1024, w: 60, h: 12 },
];
export const GATE_DARK_MAX_MEAN = 85;
export const GATE_BRIGHT_MIN_MAX = 210;
/**
* Spectator-variant gate: casted streams show the 8-player spectator
* screen and often cover the overlay gate's corner chrome, so this gates
* on the rarely-covered X jump-button disc beside the 8th card
* (bottom-right, center (1424,712) ±4px). Measured margins: bright>=249 /
* dark<=65 against the shared 210/85 thresholds.
*/
export const GATE_SPECTATOR_X_BRIGHT: readonly Roi[] = [
{ x: 1418, y: 706, w: 12, h: 12 },
{ x: 1411, y: 692, w: 12, h: 12 },
{ x: 1425, y: 692, w: 12, h: 12 },
{ x: 1411, y: 720, w: 12, h: 12 },
{ x: 1425, y: 720, w: 12, h: 12 },
];
export const GATE_SPECTATOR_X_DARK: readonly Roi[] = [
{ x: 1399, y: 708, w: 8, h: 8 },
{ x: 1441, y: 708, w: 8, h: 8 },
{ x: 1420, y: 682, w: 8, h: 8 },
{ x: 1420, y: 734, w: 8, h: 8 },
];
/**
* Spectator card grid: four cards per column, 120px row pitch, right =
* left +1348px. Left column is alpha team, right is bravo (reported as
* enemy rows). Mirrors the overlay's card layout; no struck/special-ready
* fixture attested yet, so those probes reuse overlay thresholds untuned.
*/
export const SPECTATOR_SLOTS: readonly CardSlot[] = [
"up",
"right",
"down",
"left",
];
export const SPECTATOR_ENEMY_DX = 1348;
const SPECTATOR_ROW_PITCH = 120;
export function spectatorCardLayout(
row: number,
dx: number,
): Omit<CardLayout, "slot"> {
const dy = SPECTATOR_ROW_PITCH * row;
return {
name: { x: 198 + dx, y: 306 + dy, w: 310, h: 44 },
weapon: { x: 196 + dx, y: 350 + dy, w: 66, h: 54 },
subTile: { x: 264 + dx, y: 354 + dy, w: 38, h: 42 },
badges: [
[390 + dx, 374 + dy],
[438 + dx, 374 + dy],
[486 + dx, 374 + dy],
],
cross: { x: 344 + dx, y: 362 + dy, w: 20, h: 24 },
};
}
/**
* Spectator names tried at both heights, more confident read wins: blur
* across captures moves the best fit between 29 and 30 per card.
*/
export const SPECTATOR_NAME_TEXT_HEIGHTS = [29, 30] as const;

View File

@@ -0,0 +1,224 @@
/**
* Stage identification for the minimap overlay by matching the drawn map
* against sendou.ink planner renders (assets/cv/planner/, one PNG per
* stage x mode, packed as a signature atlas).
*
* The map is drawn sharp over blurred gameplay, so a Laplacian edge mask
* isolates it; dropping saturated (team-ink) edges leaves a structural,
* ink-invariant signature. Downscaling+blurring also absorbs the POV (map
* fills screen) vs spectator (map smaller, centered) scale/offset
* difference, letting one atlas serve both via a translation search.
*
* Stage separates cleanly this way (NCC leads the next stage by
* ~0.17-0.26). Mode does NOT — the objective marker is the only per-mode
* difference and ink-invariance strips it — so the atlas keeps all five
* renders per stage and reports only the winning tile's stage; mode comes
* from map-start/scoreboard header instead.
*/
import type { StageId } from "~/modules/in-game-lists/types";
import { getCV, type Mat } from "../../cv";
import type { FrameData } from "../../image";
/** Downscaled signature dimensions (canonical 1920x1080 / 16). */
export const PLANNER_SIG_W = 120;
export const PLANNER_SIG_H = 68;
/** |Laplacian| floor separating render edges from the blur (blur ~0-5). */
const EDGE_MIN = 24;
/** HSV saturation at/above which an edge pixel is team ink, not structure. */
const INK_SATURATION_MIN = 80;
/** Half-range (signature px, ~16x canonical) of the alignment search. */
const MATCH_RANGE = 8;
/** Below this best NCC, or this lead over the next stage, report nothing. */
const MIN_SCORE = 0.5;
const MIN_MARGIN = 0.05;
export interface PlannerStage {
/** "<stageId>-<MODE>", e.g. "6-SZ" */
key: string;
/** sendou stage id */
stageId: StageId;
/** unit-L2-normalized structural signature, row-major PLANNER_SIG_W x _H */
sig: Float32Array;
}
export interface StageMatch {
stageId: StageId;
/** best NCC of the winning stage */
score: number;
/** lead over the best-scoring other stage */
margin: number;
}
/**
* Ink-invariant structural signature of a canonical-normalized RGBA frame:
* downscaled, blurred, unit-L2-normalized float mask of the non-ink render
* edges. Shared by the build tool and the runtime matcher so the atlas and
* the live frame are computed identically.
*/
export function plannerSignature(frame: Mat): Float32Array {
const cv = getCV();
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
rgb.delete();
const lap = new cv.Mat();
cv.Laplacian(gray, lap, cv.CV_16S, 3);
gray.delete();
const edges = new cv.Mat();
cv.convertScaleAbs(lap, edges);
lap.delete();
const mask = new cv.Mat();
cv.threshold(edges, mask, EDGE_MIN, 255, cv.THRESH_BINARY);
edges.delete();
// drop saturated (ink) edges, keeping only the structural skeleton
const n = mask.rows * mask.cols;
const md = mask.data;
const hd = hsv.data;
for (let i = 0; i < n; i++) {
if (hd[i * 3 + 1]! >= INK_SATURATION_MIN) md[i] = 0;
}
hsv.delete();
const down = new cv.Mat();
cv.resize(
mask,
down,
new cv.Size(PLANNER_SIG_W, PLANNER_SIG_H),
0,
0,
cv.INTER_AREA,
);
mask.delete();
const blur = new cv.Mat();
cv.GaussianBlur(down, blur, new cv.Size(5, 5), 0);
down.delete();
const out = new Float32Array(PLANNER_SIG_W * PLANNER_SIG_H);
const bd = blur.data;
let sumSq = 0;
for (let i = 0; i < out.length; i++) {
out[i] = bd[i]!;
sumSq += out[i]! * out[i]!;
}
blur.delete();
const norm = Math.sqrt(sumSq) || 1;
for (let i = 0; i < out.length; i++) out[i]! /= norm;
return out;
}
/** Dot product of `a` against `b` shifted by (dx, dy) over their overlap. */
function shiftedDot(
a: Float32Array,
b: Float32Array,
dx: number,
dy: number,
): number {
let dot = 0;
for (let y = 0; y < PLANNER_SIG_H; y++) {
const sy = y + dy;
if (sy < 0 || sy >= PLANNER_SIG_H) continue;
const ar = y * PLANNER_SIG_W;
const br = sy * PLANNER_SIG_W;
for (let x = 0; x < PLANNER_SIG_W; x++) {
const sx = x + dx;
if (sx < 0 || sx >= PLANNER_SIG_W) continue;
dot += a[ar + x]! * b[br + sx]!;
}
}
return dot;
}
/** Best NCC of two unit signatures over a small translation search. */
function bestNcc(a: Float32Array, b: Float32Array): number {
let best = -1;
for (let dy = -MATCH_RANGE; dy <= MATCH_RANGE; dy += 2) {
for (let dx = -MATCH_RANGE; dx <= MATCH_RANGE; dx += 2) {
const v = shiftedDot(a, b, dx, dy);
if (v > best) best = v;
}
}
return best;
}
/**
* Identify the stage of a minimap frame's signature against the planner
* atlas. Returns null when no stage matches confidently (score floor) or two
* stages are too close to call (margin floor) — e.g. a stage not in the set.
*/
export function matchStage(
sig: Float32Array,
planners: readonly PlannerStage[],
): StageMatch | null {
if (planners.length === 0) return null;
const byStage = new Map<StageId, number>();
for (const p of planners) {
const score = bestNcc(sig, p.sig);
const prev = byStage.get(p.stageId);
if (prev === undefined || score > prev) byStage.set(p.stageId, score);
}
let bestId: StageId | null = null;
let best = -1;
let second = -1;
for (const [id, score] of byStage) {
if (score > best) {
second = best;
best = score;
bestId = id;
} else if (score > second) {
second = score;
}
}
const margin = second < 0 ? best : best - second;
if (bestId === null || best < MIN_SCORE || margin < MIN_MARGIN) return null;
return {
stageId: bestId,
score: Math.round(best * 1000) / 1000,
margin: Math.round(margin * 1000) / 1000,
};
}
export interface PlannerManifest {
width: number;
height: number;
/** tiles packed left-to-right, top-to-bottom, this many per row */
cols: number;
/** tile keys in packing order */
keys: string[];
}
/**
* Slice the packed signature atlas (grayscale uint8 tiles) back into
* unit-normalized PlannerStage signatures. Mirrors loadGlyphSet's atlas
* convention; the build tool writes the atlas + manifest.
*/
export function loadPlannerStages(
atlas: FrameData,
manifest: PlannerManifest,
): PlannerStage[] {
const { width, height, cols, keys } = manifest;
const aw = atlas.width;
const data = atlas.data;
return keys.map((key, i) => {
const tx = (i % cols) * width;
const ty = Math.floor(i / cols) * height;
const sig = new Float32Array(width * height);
let sumSq = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// atlas is RGBA; the tiles are grayscale, so read the red channel
const v = data[((ty + y) * aw + (tx + x)) * 4]!;
sig[y * width + x] = v;
sumSq += v * v;
}
}
const norm = Math.sqrt(sumSq) || 1;
for (let j = 0; j < sig.length; j++) sig[j]! /= norm;
const stageId = Number(key.split("-")[0]) as StageId;
return { key, stageId, sig };
});
}

View File

@@ -0,0 +1,453 @@
/**
* ObjectiveDetector: parses the ranked in-match counter overlay top-center —
* each team's count plate, the penalty pill under it, which team is in
* control (controlling team's plate keeps its saturated team-color fill;
* otherwise near-black fill with digits in the team's ink color — see
* rois.ts), and the M:SS match timer above the plates.
*
* Digits are read as the trailing digit run (banner.ts) under several
* channel extractions — team-color ink on a black plate needs the
* brightest channel (dark blue ink is near-black in luminance), ink on a
* team-color fill needs the darkest channel — every extraction is tried at
* each threshold/size and the best-scoring read wins. A gate hit with no
* readable count on either side emits nothing (lookalike frame).
*
* `ObjectiveData` is a discriminated union on `mode`; only SZ exists so
* far. Identifying mode from the badge between the plates awaits TC/RM/CB
* fixtures.
*
* Every successful counter read additionally emits a PlayerStatus event
* (player-status.ts) parsed off the per-player icon strip of the same
* frame — the counter parse carries the lookalike rejection for both, and
* the shared timer value pairs the two events downstream.
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
recognizeText,
scaleGlyphSet,
} from "../../glyphs";
import {
copyRoi,
maxBrightness,
maxChannel,
meanBrightness,
minChannel,
type Roi,
} from "../../image";
import { type InkRgb, meanInkColor } from "../../ink-color";
import {
type BannerScoreRead,
isBetterRead,
trailingDigitRun,
} from "../scoreboard/banner";
import type { ScoreboardResources } from "../scoreboard/index";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
type PlayerStatusData,
type PlayerStatusLayout,
parsePlayerStatus,
} from "./player-status";
import {
CONTROL_PLATE_MIN_SATURATION,
GATE_PLATE_MAX_STD,
GATE_SCORE_MIN_MAX_BRIGHTNESS,
GATE_TIMER_MAX_MEAN,
GATE_TIMER_MIN_MAX_BRIGHTNESS,
PENALTY_BIN_THRESHOLD,
PENALTY_PROBE_MAX_MEAN,
PENALTY_PROBE_MAX_STD,
PENALTY_PROBE_ROIS,
PENALTY_ROIS,
PENALTY_TEXT_HEIGHT,
PLATE_PROBE_ROIS,
SCORE_BIN_THRESHOLDS,
SCORE_EXTEND_MIN_CONF,
SCORE_ROIS,
SCORE_TEXT_HEIGHTS,
STATUS_LAYOUT_STICKY_MAX_GAP_S,
STRIP_WEAPON_SAMPLE_INTERVAL,
TIMER_BIN_THRESHOLD,
TIMER_DARK_PROBES,
TIMER_DIGIT_MIN_CONF,
TIMER_DIGIT_MIN_HEIGHT_RATIO,
TIMER_DIGIT_ROI,
TIMER_TEXT_HEIGHTS,
} from "./rois";
import { parseStripWeapons, type StripWeaponsData } from "./strip-weapons";
export type ObjectiveData = SplatZonesObjectiveData;
export interface SplatZonesObjectiveData {
mode: "SZ";
/**
* seconds shown on the match timer above the plates ("3:35" = 215);
* null = unreadable. Counts down in regulation; the overtime display is
* unattested so far.
*/
time: number | null;
/** displayed count per team, [alpha, bravo]; null = unreadable */
score: [number | null, number | null];
/** penalty pill value per team; null = no pill (or unreadable) */
penalty: [number | null, number | null];
/** which team currently holds the zone (team-color plate fill) */
control: [boolean, boolean];
/**
* mean team-ink RGB per side, sampled off the plate (the team-color
* fill while in control, the digit ink otherwise — one of the two is
* always drawn in the team's color); null when too little saturated
* ink was found. The stable team identity for casted footage, where
* the plates follow the specced player's side instead of alpha/bravo.
*/
teamColor: [InkRgb | null, InkRgb | null];
}
export const OBJECTIVE_EVENT_TYPE = "Objective";
/** How often the counter is worth checking (it changes at most 1/s). */
const CHECK_INTERVAL_SECONDS = 1;
/**
* Timeline content guard: consecutive counter reads merge only when they
* show the same state, so every actual tick/penalty/control change becomes
* its own event. `time` is deliberately not compared — the timer ticks
* every second, so comparing it would keep any two reads from ever merging
* — and neither is `teamColor`, whose raw pixel means jitter per frame.
*/
export function sameObjectiveData(a: unknown, b: unknown): boolean {
const da = a as ObjectiveData;
const db = b as ObjectiveData;
return (
da.mode === db.mode &&
da.score[0] === db.score[0] &&
da.score[1] === db.score[1] &&
da.penalty[0] === db.penalty[0] &&
da.penalty[1] === db.penalty[1] &&
da.control[0] === db.control[0] &&
da.control[1] === db.control[1]
);
}
interface SideRead {
score: BannerScoreRead;
penalty: BannerScoreRead | null;
control: boolean;
fill: { mean: number; saturation: number };
teamColor: InkRgb | null;
}
export function createObjectiveDetector(
resources: ScoreboardResources,
): Detector<ObjectiveData | PlayerStatusData | StripWeaponsData> {
const cv = getCV();
let lastStatus: { layout: PlayerStatusLayout; t: number } | undefined;
// primed so the very first counter read samples — short matches and
// single-frame runs (fixtures) get evidence too
let readsSinceWeaponSample = STRIP_WEAPON_SAMPLE_INTERVAL;
const scoreSets: GlyphSet[] = resources.paintDigits
? SCORE_TEXT_HEIGHTS.map((h) =>
scaleGlyphSet(
resources.paintDigits!,
h / resources.paintDigits!.height,
),
)
: [];
const penaltySet: GlyphSet | null = resources.paintDigits
? scaleGlyphSet(
resources.paintDigits,
PENALTY_TEXT_HEIGHT / resources.paintDigits.height,
)
: null;
const timerSets: GlyphSet[] = resources.paintDigits
? TIMER_TEXT_HEIGHTS.map((h) =>
scaleGlyphSet(
resources.paintDigits!,
h / resources.paintDigits!.height,
),
)
: [];
/** Mean and standard deviation of a grayscale ROI. */
function meanStd(gray: Mat, roi: Roi): { mean: number; std: number } {
const crop = copyRoi(gray, roi);
const { data } = crop;
let sum = 0;
for (const v of data) sum += v;
const mean = sum / data.length;
let varSum = 0;
for (const v of data) varSum += (v - mean) ** 2;
crop.delete();
return { mean, std: Math.sqrt(varSum / data.length) };
}
function plateProbeOk(gray: Mat, roi: Roi): boolean {
return meanStd(gray, roi).std <= GATE_PLATE_MAX_STD;
}
function scoreInkOk(frame: Mat, roi: Roi): boolean {
const band = maxChannel(frame, roi);
const { maxVal } = minMaxLoc(band);
band.delete();
return maxVal >= GATE_SCORE_MIN_MAX_BRIGHTNESS;
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const checks = [
...TIMER_DARK_PROBES.map(
(roi) => meanBrightness(gray, roi) <= GATE_TIMER_MAX_MEAN,
),
maxBrightness(gray, TIMER_DIGIT_ROI) >= GATE_TIMER_MIN_MAX_BRIGHTNESS,
plateProbeOk(gray, PLATE_PROBE_ROIS[0]),
plateProbeOk(gray, PLATE_PROBE_ROIS[1]),
scoreInkOk(frame, SCORE_ROIS[0]),
scoreInkOk(frame, SCORE_ROIS[1]),
];
gray.delete();
const passed = checks.filter(Boolean).length;
return { pass: passed === checks.length, score: passed / checks.length };
}
/**
* Best trailing-digit read of the band across channel extractions,
* thresholds, and glyph sizes (see module header for why one pass
* cannot cover both plate styles).
*/
function readScore(frame: Mat, gray: Mat, roi: Roi): BannerScoreRead {
let best: BannerScoreRead = {
value: null,
confidence: 0,
digits: 0,
reading: "",
};
const bands = [
copyRoi(gray, roi),
minChannel(frame, roi),
maxChannel(frame, roi),
];
for (const band of bands) {
for (const set of scoreSets) {
for (const binThreshold of SCORE_BIN_THRESHOLDS) {
const raw = recognizeText(band, set, {
binThreshold,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
// the band holds nothing but the count, so a leading digit
// blurred below the extension floor voids the read instead of
// truncating it (see SCORE_EXTEND_MIN_CONF)
const read = trailingDigitRun(raw, set, {
extendMinScore: SCORE_EXTEND_MIN_CONF,
rejectTruncated: true,
});
if (isBetterRead(read, best)) best = read;
}
}
band.delete();
}
return best;
}
/**
* The match timer's M:SS over TIMER_DIGIT_ROI: white digits on the
* near-black box the gate already anchored on. The colon's two dots stack
* to well under the digit height floor, so a valid read is exactly three
* full-height digits — the minute, then the two second digits. Each glyph
* size is tried (the digits render bigger on upscaled 720p footage) and
* the valid read with the most confident digits wins.
*/
function readTimer(gray: Mat): { value: number | null; reading: string } {
const band = copyRoi(gray, TIMER_DIGIT_ROI);
let best: { value: number | null; reading: string; score: number } = {
value: null,
reading: "",
score: 0,
};
for (const timerSet of timerSets) {
const raw = recognizeText(band, timerSet, {
binThreshold: TIMER_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
if (!best.reading) best = { ...best, reading: raw.text };
const isTimerDigit = (c: RecognizedChar) =>
c.score >= TIMER_DIGIT_MIN_CONF &&
c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO;
const chars = raw.chars.filter(isTimerDigit);
const digits = chars.map((c) => Number(c.char));
if (digits.length !== 3 || digits.some(Number.isNaN)) continue;
const [minutes, secondsTens, secondsOnes] = digits as [
number,
number,
number,
];
if (secondsTens >= 6) continue;
const score = chars.reduce((sum, c) => sum + c.score, 0) / chars.length;
if (score > best.score) {
best = {
value: minutes * 60 + secondsTens * 10 + secondsOnes,
reading: raw.text,
score,
};
}
}
band.delete();
return { value: best.value, reading: best.reading };
}
/** Penalty pill: presence probes first, then the white "+N" digits. */
function readPenalty(
frame: Mat,
gray: Mat,
side: 0 | 1,
): BannerScoreRead | null {
if (!penaltySet) return null;
const pillLike = PENALTY_PROBE_ROIS[side].every((roi) => {
const { mean, std } = meanStd(gray, roi);
return mean <= PENALTY_PROBE_MAX_MEAN && std <= PENALTY_PROBE_MAX_STD;
});
if (!pillLike) return null;
const band = minChannel(frame, PENALTY_ROIS[side]);
const raw = recognizeText(band, penaltySet, {
binThreshold: PENALTY_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
band.delete();
return trailingDigitRun(raw, penaltySet);
}
/**
* Control: the plate's fill (sampled over the probe strip) is the
* saturated team-color style instead of the near-black fill shown while
* not in control (see CONTROL_PLATE_MIN_SATURATION in rois.ts).
*/
function plateFill(
frame: Mat,
side: 0 | 1,
): { mean: number; saturation: number } {
const crop = copyRoi(frame, PLATE_PROBE_ROIS[side]);
const { data } = crop;
const channels = crop.channels();
let sum = 0;
let satSum = 0;
let count = 0;
for (let i = 0; i < data.length; i += channels) {
const r = data[i]!;
const g = data[i + 1]!;
const b = data[i + 2]!;
sum += Math.max(r, g, b);
satSum += Math.max(r, g, b) - Math.min(r, g, b);
count++;
}
crop.delete();
return { mean: sum / count, saturation: satSum / count };
}
function parse(
frame: Mat,
t: number,
): DetectedEvent<ObjectiveData | PlayerStatusData | StripWeaponsData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const sides = [0 as const, 1 as const].map((side): SideRead => {
const score = readScore(frame, gray, SCORE_ROIS[side]);
const penalty = readPenalty(frame, gray, side);
const fill = plateFill(frame, side);
return {
score,
penalty,
control:
score.value !== null &&
fill.saturation >= CONTROL_PLATE_MIN_SATURATION,
fill,
// the fill while in control, the digit ink otherwise — both
// ROIs together always cover whichever carries the team color
teamColor: meanInkColor(frame, [
SCORE_ROIS[side],
PLATE_PROBE_ROIS[side],
]),
};
}) as [SideRead, SideRead];
const timer = readTimer(gray);
gray.delete();
// no readable count on either side = the gate hit a lookalike
if (sides.every((side) => side.score.value === null)) return [];
const playerStatus = parsePlayerStatus(
frame,
t,
timer.value,
lastStatus && t - lastStatus.t <= STATUS_LAYOUT_STICKY_MAX_GAP_S
? lastStatus.layout
: undefined,
);
lastStatus = { layout: playerStatus.data.layout, t };
// sampled slot-identity evidence for the strip → scoreboard-row
// assignment; every read would re-measure fixed identities at full
// template-sweep cost
let stripWeapons: DetectedEvent<StripWeaponsData> | null = null;
readsSinceWeaponSample++;
if (
resources.stripWeapons &&
readsSinceWeaponSample >= STRIP_WEAPON_SAMPLE_INTERVAL
) {
readsSinceWeaponSample = 0;
stripWeapons = parseStripWeapons(
frame,
t,
playerStatus.data,
resources.stripWeapons,
);
}
const confidences = sides.flatMap((side) => [
...(side.score.value !== null ? [side.score.confidence] : []),
...(side.penalty?.value != null ? [side.penalty.confidence] : []),
]);
return [
// the icon-strip statuses ride along with every counter read: the
// count confirmation above is the shared lookalike rejection, and
// the shared timer value is what pairs the two events downstream
{
type: OBJECTIVE_EVENT_TYPE,
t,
confidence: confidences.reduce((a, b) => a + b, 0) / confidences.length,
data: {
mode: "SZ",
time: timer.value,
score: [sides[0].score.value, sides[1].score.value],
penalty: [
sides[0].penalty?.value ?? null,
sides[1].penalty?.value ?? null,
],
control: [sides[0].control, sides[1].control],
teamColor: [sides[0].teamColor, sides[1].teamColor],
},
debug: {
timerReading: timer.reading,
scoreReadings: sides.map((side) => side.score.reading),
scoreConfidences: sides.map((side) => side.score.confidence),
penaltyReadings: sides.map((side) => side.penalty?.reading ?? null),
plateFills: sides.map((side) => side.fill),
},
},
playerStatus,
...(stripWeapons ? [stripWeapons] : []),
];
}
return {
id: "objective",
checkIntervalS: CHECK_INTERVAL_SECONDS,
attachFrame: false,
gate,
parse,
};
}

View File

@@ -0,0 +1,572 @@
/**
* PlayerStatus: the per-player state read off the eight squid/octo icons
* flanking the match timer, emitted by the ObjectiveDetector alongside each
* Objective read (same frame, same `time`, so callers can pair the two).
*
* Per slot, three pixel-class fractions decide the state (rois.ts documents
* the calibration): an alive icon's body is saturated team ink; holding
* special washes the body into a bright pale glow that PULSES — bright
* frames light the shoulder probe past the glow floor, trough frames only
* read as pale — and a splatted icon is an unsaturated grey/dark X with
* none of the three. The casted spectator HUD draws the same strip at its
* own geometry — two of them, in fact: the pitches mirror when the specced
* POV sits on the other team ("cast-mirror", whose right column nearly
* coincides with POV's). White camera badges under the right team prove
* either cast arrangement, but broadcasts can hide them while keeping cast
* geometry, so a badge-less frame picks whichever geometry reads more
* decisively (bodies far from the dead threshold on either side) instead
* of assuming POV — with two positional refinements: a decisive slot-comb
* win proves the badge-less mirror arrangement outright, and a
* history-less near-tie stays with the cast family (see pickLayout). The
* cast wash glow is pale, so on cast layouts only the unsaturated glow
* fraction counts toward ready (and against dead) — saturated backdrop
* leaking over a dead icon's shoulder cannot fake or suppress a state.
*/
import type { Mat } from "../../cv";
import { copyRoi, type Roi } from "../../image";
import type { DetectedEvent } from "../types";
import {
STATUS_BODY_BOX_CAST,
STATUS_BODY_BOX_POV,
STATUS_CAST_MIN_DPAD_WHITE,
STATUS_COMB_BAND_H,
STATUS_COMB_BAND_Y,
STATUS_COMB_CENTER_HALF_WIDTH,
STATUS_COMB_GAP_HALF_WIDTH,
STATUS_COMB_MAX_SHIFT,
STATUS_COMB_SIDE_SPANS,
STATUS_DEAD_MAX_BODY_INK,
STATUS_DEAD_MAX_BODY_PALE,
STATUS_DEAD_MAX_SHOULDER_GLOW,
STATUS_DPAD_PROBES,
STATUS_DPAD_PROBES_MIRROR,
STATUS_FRESH_CAST_MIN_DECISIVENESS,
STATUS_FRESH_POV_MIN_LEAD,
STATUS_GLOW_MAX_SPREAD,
STATUS_GLOW_MIN_VALUE,
STATUS_INK_MIN_SPREAD,
STATUS_INK_MIN_VALUE,
STATUS_LAYOUT_SCORE_CAP,
STATUS_LAYOUT_STICKY_MARGIN,
STATUS_MIRROR_COMB_LEAD,
STATUS_MIRROR_COMB_MIN,
STATUS_PALE_MAX_SPREAD,
STATUS_PALE_MIN_VALUE,
STATUS_READY_CLEAN_WASH_MAX_BODY_INK,
STATUS_READY_INKY_WASH_MIN_BODY_PALE,
STATUS_READY_MIN_BODY_PALE,
STATUS_READY_MIN_SHOULDER_GLOW,
STATUS_READY_WASH_MAX_BODY_INK,
STATUS_SHOULDER_BOX_CAST,
STATUS_SHOULDER_BOX_POV,
STATUS_SLOT_CENTERS_CAST,
STATUS_SLOT_CENTERS_CAST_MIRROR,
STATUS_SLOT_CENTERS_POV,
STATUS_STICKY_FLIP_COMB_MIN,
STATUS_WHITE_MAX_SPREAD,
STATUS_WHITE_MIN_VALUE,
} from "./rois";
export const PLAYER_STATUS_EVENT_TYPE = "PlayerStatus";
export type PlayerStatusFlags = [boolean, boolean, boolean, boolean];
export type PlayerStatusLayout = "pov" | "cast" | "cast-mirror";
export interface PlayerStatusData {
/**
* seconds shown on the match timer at the read, same value as the
* Objective event from the same frame — the key for pairing the two
*/
time: number | null;
/** special held per slot, [left team, right team], slots left-to-right */
special: [PlayerStatusFlags, PlayerStatusFlags];
/** splatted per slot, same arrangement */
dead: [PlayerStatusFlags, PlayerStatusFlags];
/**
* which icon-strip geometry the frame showed. S3 first-person POV
* footage draws the same steady-state geometry as the casted spectator
* HUD (measured identical on the 2026-08-11 Um'ami POV VoD), so "cast"
* here means the geometry, not the footage type — only `castProven`
* reads and the mirror arrangement are evidence of an actual broadcast
*/
layout: PlayerStatusLayout;
/** the white camera badges proved a cast arrangement on this frame */
castProven: boolean;
}
/**
* Timeline content guard: reads merge only while every slot state matches,
* so each death/respawn/special flip becomes its own event. `time` is not
* compared (it ticks every second) and neither is `layout` (a camera-style
* change with identical states is the same state).
*/
export function samePlayerStatusData(a: unknown, b: unknown): boolean {
const da = a as PlayerStatusData;
const db = b as PlayerStatusData;
for (const side of [0, 1] as const) {
for (let slot = 0; slot < 4; slot++) {
if (da.special[side][slot] !== db.special[side][slot]) return false;
if (da.dead[side][slot] !== db.dead[side][slot]) return false;
}
}
return true;
}
interface SlotRead {
dead: boolean;
special: boolean;
confidence: number;
bodyInk: number;
bodyPale: number;
shoulderGlow: number;
shoulderPaleGlow: number;
}
/**
* Parse the icon strip of a frame the objective gate already anchored as
* the in-match counter HUD. Callers emit the event only alongside a
* successful Objective read — the counter parse carries the lookalike
* rejection for both. `prevLayout` is the layout of the caller's previous
* read (sticky: a badge-less frame only switches geometry when the other
* layout wins the decisiveness score by a clear margin — the footage type
* does not flip frame to frame, but a busy scene can nudge the score).
*/
export function parsePlayerStatus(
frame: Mat,
t: number,
time: number | null,
prevLayout?: PlayerStatusLayout,
): DetectedEvent<PlayerStatusData> {
const { layout, scores } = pickLayout(frame, prevLayout);
const sides = readSlots(frame, layout);
const reads = sides.flat();
return {
type: PLAYER_STATUS_EVENT_TYPE,
t,
confidence:
reads.reduce((sum, read) => sum + read.confidence, 0) / reads.length,
data: {
time,
special: sides.map((side) =>
side.map((read) => read.special),
) as PlayerStatusData["special"],
dead: sides.map((side) =>
side.map((read) => read.dead),
) as PlayerStatusData["dead"],
layout,
castProven: scores === null,
},
debug: {
layout,
layoutScores: scores
? Object.fromEntries(
Object.entries(scores).map(([name, score]) => [
name,
Number(score.toFixed(3)),
]),
)
: "badges",
bodyInk: reads.map((read) => Number(read.bodyInk.toFixed(2))),
bodyPale: reads.map((read) => Number(read.bodyPale.toFixed(2))),
shoulderGlow: reads.map((read) => Number(read.shoulderGlow.toFixed(2))),
shoulderPaleGlow: reads.map((read) =>
Number(read.shoulderPaleGlow.toFixed(2)),
),
},
};
}
function readSlots(
frame: Mat,
layout: PlayerStatusLayout,
): [SlotRead[], SlotRead[]] {
const centers =
layout === "pov"
? STATUS_SLOT_CENTERS_POV
: layout === "cast"
? STATUS_SLOT_CENTERS_CAST
: STATUS_SLOT_CENTERS_CAST_MIRROR;
const shoulderBox =
layout === "pov" ? STATUS_SHOULDER_BOX_POV : STATUS_SHOULDER_BOX_CAST;
const bodyBox = layout === "pov" ? STATUS_BODY_BOX_POV : STATUS_BODY_BOX_CAST;
return centers.map((sideCenters) =>
sideCenters.map((cx): SlotRead => {
const shoulder = classFractions(frame, {
x: cx + shoulderBox.dx,
y: shoulderBox.y,
w: shoulderBox.w,
h: shoulderBox.h,
});
const body = classFractions(frame, {
x: cx + bodyBox.dx,
y: bodyBox.y,
w: bodyBox.w,
h: bodyBox.h,
});
return classifySlot(
body.ink,
body.pale,
shoulder.glow,
shoulder.paleGlow,
layout,
);
}),
) as [SlotRead[], SlotRead[]];
}
const ALL_LAYOUTS: readonly PlayerStatusLayout[] = [
"pov",
"cast",
"cast-mirror",
];
/**
* Which layouts a badge-less frame may flip to from an established one on
* score alone. The cast-mirror right column nearly coincides with POV's, so
* decisiveness cannot tell those two apart — and a wrong POV pick on cast
* footage self-heals (the next badge frame proves the arrangement) while a
* wrong mirror pick on POV footage never would (POV shows no badges). So
* the mirror is only reachable via badges or from an established cast
* layout (the specced POV switching teams mid-game, attested in the AREA
* CUP VoD's badge-less overhead stretches).
*/
const SCORED_FLIPS: Record<PlayerStatusLayout, readonly PlayerStatusLayout[]> =
{
pov: ["cast"],
cast: ["pov", "cast-mirror"],
"cast-mirror": ["cast"],
};
/**
* Camera badges prove a cast arrangement outright (each arrangement has its
* own badge columns). Badge-less frames are NOT proven POV — broadcasts can
* hide the badges while keeping cast icon geometry — so the candidate
* geometries are scored and the one whose body reads land decisively on
* either side of the dead threshold wins. A mispicked geometry puts
* outer-slot boxes between icons or on backdrop, which reads mid-range ink —
* exactly what the score punishes; boxes on featureless dark backdrop still
* read "decisively dead" though, so a busy scene can nudge a frame's score
* across — the sticky margin keeps a single noisy frame from flipping an
* established layout, and SCORED_FLIPS keeps the POV/mirror false friends
* from ever trading places without badge proof.
*
* Two decisions decisiveness cannot make on its own:
* - The badge-less MIRROR arrangement (attested in the sendou-triton VoD)
* scores below cast even on true mirror frames, so a decisive slot-comb
* win (see combContrast) overrides everything but badges — comb evidence
* is positional and cannot confuse the POV/mirror false friends because
* only their differing left columns can produce a decisive lead.
* - A history-less pov-vs-cast near-tie (busy backdrops mis-rank cast
* footage): the fresh pick stays cast unless cast reads under the floor
* or pov leads decisively (STATUS_FRESH_* in rois.ts).
*/
function pickLayout(
frame: Mat,
prevLayout: PlayerStatusLayout | undefined,
): {
layout: PlayerStatusLayout;
scores: Record<PlayerStatusLayout, number> | null;
} {
if (badgesVisible(frame, STATUS_DPAD_PROBES))
return { layout: "cast", scores: null };
if (badgesVisible(frame, STATUS_DPAD_PROBES_MIRROR))
return { layout: "cast-mirror", scores: null };
const scores = Object.fromEntries(
ALL_LAYOUTS.map((layout) => [layout, layoutDecisiveness(frame, layout)]),
) as Record<PlayerStatusLayout, number>;
const combs = combScores(frame);
if (
combs["cast-mirror"] >= STATUS_MIRROR_COMB_MIN &&
combs["cast-mirror"] >= combs.cast + STATUS_MIRROR_COMB_LEAD &&
combs["cast-mirror"] >= combs.pov + STATUS_MIRROR_COMB_LEAD
) {
return { layout: "cast-mirror", scores };
}
if (prevLayout) {
// flips away from cast additionally need positional corroboration:
// S3 POV footage draws cast geometry, but while the POV player is
// dead the strip shrinks toward the timer and those transient frames
// spike the challenger decisiveness past the sticky margin (the
// 2026-08-11 POV VoD locked into mirror that way) — a real
// arrangement change moves the slot comb with it, a shrink does not
const challengers = SCORED_FLIPS[prevLayout].filter(
(layout) =>
prevLayout !== "cast" ||
(combs[layout] >= STATUS_STICKY_FLIP_COMB_MIN &&
combs[layout] >= combs.cast + STATUS_MIRROR_COMB_LEAD),
);
const challenger =
challengers.length > 0
? challengers.reduce((a, b) => (scores[b] > scores[a] ? b : a))
: null;
return {
layout:
challenger !== null &&
scores[challenger] > scores[prevLayout] + STATUS_LAYOUT_STICKY_MARGIN
? challenger
: prevLayout,
scores,
};
}
if (
scores.cast >= STATUS_FRESH_CAST_MIN_DECISIVENESS &&
scores.pov < scores.cast + STATUS_FRESH_POV_MIN_LEAD
) {
return { layout: "cast", scores };
}
return { layout: scores.pov >= scores.cast ? "pov" : "cast", scores };
}
function layoutDecisiveness(frame: Mat, layout: PlayerStatusLayout): number {
const reads = readSlots(frame, layout).flat();
return (
reads.reduce(
(sum, read) =>
sum +
Math.min(
Math.abs(read.bodyInk - STATUS_DEAD_MAX_BODY_INK),
STATUS_LAYOUT_SCORE_CAP,
),
0,
) / reads.length
);
}
/**
* State from the pixel-class fractions, with a confidence scaled by the
* distance to the nearest decision boundary (1 at twice the threshold / at
* zero). On the cast layouts a ready icon is always the wash, which
* replaces the body's team ink — an ink-heavy body there means the bright
* read is backdrop leaking past the icon edge, not a held special, unless
* the body also reads strongly pale (the graded STATUS_READY_*WASH*
* guards) — and the wash glow is pale, so only the
* unsaturated glow fraction counts (a saturated bright leak, like sky over
* a dead icon's shoulder, is not a wash — see STATUS_GLOW_MAX_SPREAD).
*/
function classifySlot(
bodyInk: number,
bodyPale: number,
shoulderGlow: number,
shoulderPaleGlow: number,
layout: PlayerStatusLayout,
): SlotRead {
const washGlow = layout === "pov" ? shoulderGlow : shoulderPaleGlow;
const dead =
bodyInk <= STATUS_DEAD_MAX_BODY_INK &&
washGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW &&
bodyPale <= STATUS_DEAD_MAX_BODY_PALE;
const washedBody =
bodyInk <= STATUS_READY_CLEAN_WASH_MAX_BODY_INK ||
(bodyInk <= STATUS_READY_WASH_MAX_BODY_INK &&
bodyPale >= STATUS_READY_INKY_WASH_MIN_BODY_PALE);
const special =
!dead &&
(washGlow >= STATUS_READY_MIN_SHOULDER_GLOW ||
bodyPale >= STATUS_READY_MIN_BODY_PALE) &&
(layout === "pov" || washedBody);
const confidence = dead
? Math.min(
1,
(STATUS_DEAD_MAX_BODY_INK - bodyInk) / STATUS_DEAD_MAX_BODY_INK,
)
: special
? Math.min(
1,
Math.max(
washGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2),
bodyPale / (STATUS_READY_MIN_BODY_PALE * 2),
),
)
: Math.min(1, bodyInk / (STATUS_DEAD_MAX_BODY_INK * 2));
return {
dead,
special,
confidence,
bodyInk,
bodyPale,
shoulderGlow,
shoulderPaleGlow,
};
}
/** Ink, glow, and pale pixel fractions of a ROI (see rois.ts for the classes). */
function classFractions(
frame: Mat,
roi: Roi,
): { ink: number; glow: number; paleGlow: number; pale: number } {
const crop = copyRoi(frame, roi);
const { data } = crop;
const channels = crop.channels();
let ink = 0;
let glow = 0;
let paleGlow = 0;
let pale = 0;
let count = 0;
for (let i = 0; i < data.length; i += channels) {
const r = data[i]!;
const g = data[i + 1]!;
const b = data[i + 2]!;
const value = Math.max(r, g, b);
const spread = value - Math.min(r, g, b);
if (spread >= STATUS_INK_MIN_SPREAD && value >= STATUS_INK_MIN_VALUE) ink++;
if (value >= STATUS_GLOW_MIN_VALUE) {
glow++;
if (spread <= STATUS_GLOW_MAX_SPREAD) paleGlow++;
}
if (value >= STATUS_PALE_MIN_VALUE && spread <= STATUS_PALE_MAX_SPREAD)
pale++;
count++;
}
crop.delete();
return {
ink: ink / count,
glow: glow / count,
paleGlow: paleGlow / count,
pale: pale / count,
};
}
/**
* Slot-comb contrast per layout: mean iconness (ink-or-pale column
* fraction over the strip's body band) at the layout's slot centers minus
* the mean at its between-slot gap midpoints, maximized over a small
* global shift. Icon bodies concentrate iconness at true centers while the
* V-notches between kites drop it, so a rigid comb at the wrong pitch
* cannot score all four slots at once — positional evidence orthogonal to
* body decisiveness, strong enough to prove the badge-less mirror
* arrangement (see STATUS_MIRROR_COMB_* in rois.ts).
*/
function combScores(frame: Mat): Record<PlayerStatusLayout, number> {
const profiles = STATUS_COMB_SIDE_SPANS.map(([x0, x1]) =>
iconnessProfile(frame, x0, x1),
);
const centersOf = (layout: PlayerStatusLayout) =>
layout === "pov"
? STATUS_SLOT_CENTERS_POV
: layout === "cast"
? STATUS_SLOT_CENTERS_CAST
: STATUS_SLOT_CENTERS_CAST_MIRROR;
return Object.fromEntries(
ALL_LAYOUTS.map((layout) => [
layout,
centersOf(layout).reduce(
(sum, sideCenters, side) =>
sum +
combContrast(
profiles[side]!,
STATUS_COMB_SIDE_SPANS[side]![0],
sideCenters,
),
0,
),
]),
) as Record<PlayerStatusLayout, number>;
}
function iconnessProfile(frame: Mat, x0: number, x1: number): number[] {
const crop = copyRoi(frame, {
x: x0,
y: STATUS_COMB_BAND_Y,
w: x1 - x0,
h: STATUS_COMB_BAND_H,
});
const { data } = crop;
const channels = crop.channels();
const width = crop.cols;
const height = crop.rows;
const profile = new Array<number>(width).fill(0);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * channels;
const r = data[i]!;
const g = data[i + 1]!;
const b = data[i + 2]!;
const value = Math.max(r, g, b);
const spread = value - Math.min(r, g, b);
const isInk =
spread >= STATUS_INK_MIN_SPREAD && value >= STATUS_INK_MIN_VALUE;
const isPale =
value >= STATUS_PALE_MIN_VALUE && spread <= STATUS_PALE_MAX_SPREAD;
if (isInk || isPale) profile[x]! += 1 / height;
}
}
crop.delete();
return profile;
}
function combContrast(
profile: number[],
x0: number,
centers: readonly number[],
): number {
let best = -1;
for (
let shift = -STATUS_COMB_MAX_SHIFT;
shift <= STATUS_COMB_MAX_SHIFT;
shift += 2
) {
let onCenters = 0;
for (const cx of centers)
onCenters += bandMean(
profile,
x0,
cx + shift,
STATUS_COMB_CENTER_HALF_WIDTH,
);
onCenters /= centers.length;
let onGaps = 0;
for (let i = 0; i < centers.length - 1; i++) {
const mid = Math.round((centers[i]! + centers[i + 1]!) / 2);
onGaps += bandMean(profile, x0, mid + shift, STATUS_COMB_GAP_HALF_WIDTH);
}
onGaps /= centers.length - 1;
best = Math.max(best, onCenters - onGaps);
}
return best;
}
function bandMean(
profile: number[],
x0: number,
center: number,
halfWidth: number,
): number {
let sum = 0;
let count = 0;
for (let x = center - halfWidth; x <= center + halfWidth; x++) {
const i = x - x0;
if (i < 0 || i >= profile.length) continue;
sum += profile[i]!;
count++;
}
return count ? sum / count : 0;
}
/** All four badge probes reading white = that casted spectator arrangement. */
function badgesVisible(frame: Mat, probes: readonly Roi[]): boolean {
return probes.every((roi) => {
const crop = copyRoi(frame, roi);
const { data } = crop;
const channels = crop.channels();
let white = 0;
let count = 0;
for (let i = 0; i < data.length; i += channels) {
const r = data[i]!;
const g = data[i + 1]!;
const b = data[i + 2]!;
const value = Math.max(r, g, b);
if (
value >= STATUS_WHITE_MIN_VALUE &&
value - Math.min(r, g, b) <= STATUS_WHITE_MAX_SPREAD
) {
white++;
}
count++;
}
crop.delete();
return white / count >= STATUS_CAST_MIN_DPAD_WHITE;
});
}

View File

@@ -0,0 +1,428 @@
/**
* ALL objective-counter ROI coordinates, in canonical 1920x1080 space.
* Calibrated against the objective/ fixtures (pixel maps over the
* top-center band). Layout is only roughly mirror-symmetric around x=960
* (widest digit run "100" measures x816..888 left, x1039..1112 right), so
* score/plate-probe ROIs are measured per side, not mirrored.
*
* One counter plate per team flanks the mode's objective badge under the
* timer strip: small localized label line over big BlitzBold count
* digits (~41px). The controlling team's plate fills team-color; a
* non-controlling team (or both while neutral) is near-black with digits
* in team ink — the fill swap, not digit values, identifies control. A
* penalty adds a dark pill under the plate with a white "+N" (~29px).
*/
import { CANONICAL_WIDTH, type Roi } from "../../canonical";
/** Mirror a left-side ROI across the frame's vertical center line. */
function mirrorRoi(roi: Roi): Roi {
return { ...roi, x: CANONICAL_WIDTH - roi.x - roi.w };
}
/**
* Count digit band inside the plate below the label line. Wide enough
* for the full "100" run each side (x816..888 left, x1039..1112 right),
* clear of the plate rims (edge glow against a bright scene reads as ink).
*/
export const SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 810, y: 156, w: 90, h: 46 },
{ x: 1034, y: 156, w: 84, h: 46 },
];
/**
* Penalty "+N" band inside the pill. The pill measures x 826..920,
* y 218..256 under the left plate; the band leaves the rounded ends out.
*/
export const PENALTY_ROIS: readonly [Roi, Roi] = (() => {
const left: Roi = { x: 832, y: 220, w: 82, h: 36 };
return [left, mirrorRoi(left)];
})();
/**
* Plate-fill probe strips inside each plate's LEFT rim, clear of the
* widest digit run (right strip isn't mirrored since that digit run
* reaches x1112). Both plate styles are a flat fill there (team color in
* control, near-black otherwise), so the gate accepts flatness (std)
* alone — fill brightness spans near-black to bright yellow.
*/
export const PLATE_PROBE_ROIS: readonly [Roi, Roi] = [
{ x: 800, y: 162, w: 10, h: 30 },
{ x: 1021, y: 162, w: 10, h: 30 },
];
/**
* Penalty-pill presence probes at the pill's rounded ends, clear of the
* widest "+100": translucent fill reads mid-dark and flat over any scene.
*/
export const PENALTY_PROBE_ROIS: readonly [[Roi, Roi], [Roi, Roi]] = (() => {
const leftPill: [Roi, Roi] = [
{ x: 828, y: 226, w: 8, h: 24 },
{ x: 910, y: 226, w: 8, h: 24 },
];
return [leftPill, [mirrorRoi(leftPill[1]), mirrorRoi(leftPill[0])]];
})();
export const GATE_PLATE_MAX_STD = 30;
/** A count digit's brightest channel clears this on either plate style. */
export const GATE_SCORE_MIN_MAX_BRIGHTNESS = 200;
/**
* Gate anchor: the match timer above the plates — white M:SS digits in a
* near-black box only the in-match HUD draws. In-match HUD reads <=32 on
* each dark probe; other screens light one past this ceiling — the closest
* lookalike is the replay-browser header, whose black stage tag reaches
* under the side probes when the stage name is long ("Banlieue Balibot"
* reads ~48 there). Turf War and the death cam (one centered pill) also
* show a timer — there the plate probes and no-readable-count parse
* confirmation carry the rejection.
*/
export const TIMER_DIGIT_ROI: Roi = { x: 908, y: 54, w: 100, h: 40 };
export const TIMER_DARK_PROBES: readonly Roi[] = [
{ x: 915, y: 45, w: 80, h: 7 },
{ x: 897, y: 57, w: 8, h: 26 },
{ x: 1012, y: 57, w: 7, h: 26 },
];
export const GATE_TIMER_MAX_MEAN = 40;
export const GATE_TIMER_MIN_MAX_BRIGHTNESS = 240;
/**
* Timer's white M:SS digits measure 34px on native 1080p footage; upscaled
* 720p captures draw them at ~40px. Every height is tried and the best
* valid read wins — the wrong-scale set scores well under a clean read's
* confidence. The colon's dots stack under the digit height floor at
* either size, so a plain height filter drops the colon.
*/
export const TIMER_TEXT_HEIGHTS = [34, 40] as const;
export const TIMER_BIN_THRESHOLD = 160;
export const TIMER_DIGIT_MIN_CONF = 0.75;
export const TIMER_DIGIT_MIN_HEIGHT_RATIO = 0.82;
/**
* Count digits measure ~40-44px across attested plates; both scaled sets
* are tried, best trailing read wins (banner's settled/mid-pop pattern).
*/
export const SCORE_TEXT_HEIGHTS = [40, 44] as const;
export const PENALTY_TEXT_HEIGHT = 29;
/**
* Binarization thresholds vary with plate style (team-ink ~200+ on ~45
* black; white ~250 on team fills up to ~130 gray) — each read keeps its best.
*/
export const SCORE_BIN_THRESHOLDS = [160, 190] as const;
/**
* Extension floor for count digits joining a run anchored by a confident
* one: white digits on a bright control fill blur into the plate on
* compressed cast footage, eroding a leading digit to 0.64-0.76 while its
* neighbor still clears the main floor ("50" read as trailing "0" — the
* Splat World Series lime plates). Noise chars on the same plates matched
* digit templates at up to 0.66 but never alongside an anchor digit.
*/
export const SCORE_EXTEND_MIN_CONF = 0.6;
/** Penalty pill: white digits on the translucent dark fill (~100 gray). */
export const PENALTY_BIN_THRESHOLD = 170;
export const PENALTY_PROBE_MAX_MEAN = 165;
export const PENALTY_PROBE_MAX_STD = 30;
/**
* Control = plate fill is team-color style (measured over the probe
* strip): even dark inks (deep blue ~130 spread at ~50 brightness) stay
* saturated, while a non-controlling/neutral plate is unsaturated
* (attested fills >=112 vs <=19).
*/
export const CONTROL_PLATE_MIN_SATURATION = 60;
// ---- player-status icon strips (the PlayerStatus event) ----
//
// Eight per-player squid/octo icons flank the timer. An alive icon's body
// is drawn in team ink; holding special washes the upper body out into a
// bright pale glow; a splatted player's icon turns an unsaturated grey/dark
// X'd shape. Two layouts share the band: POV (small icons) and the casted
// spectator HUD (bigger icons, gauge digits hanging over each icon's
// top-RIGHT and white camera-button badges below) — slot centers are
// measured per side off the fixtures; neither layout is mirror-symmetric
// (POV inner icons sit 108px left / 130px right of screen center) and the
// cast sides don't even share a pitch (~98 left vs ~76 right). Cast icons
// ride their badge columns ~20px left of each badge center; the outer-right
// center sits a few px past the measured icon (~1313) because splat X's
// there lean into inked backdrop on their left while alive bodies extend
// right (attested dead <=0.16 vs alive >=0.33 at 1320).
//
// The cast strip additionally MIRRORS its pitches when the broadcast specs
// a player on the other team (attested mid-game in the AREA CUP VoD): the
// narrow ~76 pitch swaps to the left column and the wide ~97 pitch to the
// right, badges and all — the "cast-mirror" layout. Its right column lands
// within a few px of the POV right column, so a mirrored frame scores
// deceptively well as POV; the mirror's own geometry must be a scored
// candidate (and badge-probed) or the left column misreads ready/dead.
//
// Broadcasts can hide the camera badges while keeping the cast icon
// geometry (attested in the AREA CUP VoD), so badge absence alone cannot
// pick the POV layout — player-status.ts scores both geometries and keeps
// the one whose body reads sit decisively on either side of the dead
// threshold; a mispicked layout puts outer-slot boxes between icons and
// flickers phantom deaths on the outermost players.
/** Per-side slot center x's, slots left-to-right. */
export const STATUS_SLOT_CENTERS_POV: readonly [
readonly number[],
readonly number[],
] = [
[554, 653, 752, 852],
[1090, 1190, 1288, 1388],
];
export const STATUS_SLOT_CENTERS_CAST: readonly [
readonly number[],
readonly number[],
] = [
[543, 642, 741, 837],
[1085, 1161, 1237, 1320],
];
export const STATUS_SLOT_CENTERS_CAST_MIRROR: readonly [
readonly number[],
readonly number[],
] = [
[605, 681, 757, 833],
[1090, 1187, 1283, 1381],
];
/**
* Shoulder probe: the icon's upper-left body, clear of the weapon
* silhouette (drawn center/lower), the cast gauge digits (hanging top-right)
* and the POV sub/special trinkets (bottom). The special-ready glow is
* detected here. Boxes are relative to a slot center.
*/
export const STATUS_SHOULDER_BOX_POV = { dx: -30, y: 38, w: 24, h: 20 };
export const STATUS_SHOULDER_BOX_CAST = { dx: -30, y: 35, w: 24, h: 30 };
/**
* Body probe: the widest band of the icon that dodges the cast camera
* badges below (y>=100) and the POV coin trinkets (y>=95). Team ink
* presence here separates alive icons from the grey/dark splatted ones.
* Sized to cover most of the icon: a slimmer box left alive icons whose
* body is largely weapon silhouette/badges reading ink 0.24 while stage
* ink bleeding around a translucent dead icon read 0.23 (AREA CUP VoD) —
* this footprint separates them at 0.26 vs 0.20.
*/
export const STATUS_BODY_BOX_POV = { dx: -40, y: 40, w: 80, h: 50 };
export const STATUS_BODY_BOX_CAST = { dx: -40, y: 45, w: 80, h: 50 };
/**
* An ink pixel: saturated and bright enough to be team color. The value
* floor keeps dark saturated stage backdrops (deep blue arena walls behind
* the translucent dead icons measure v<=90) from counting as ink.
*/
export const STATUS_INK_MIN_SPREAD = 70;
export const STATUS_INK_MIN_VALUE = 105;
/**
* A glow pixel of the special-ready wash. 225 splits the attested ready
* shoulders (fractions >=0.40) from the brightest alive team color — POV
* lime peaks between 215 and 225 (glow fraction 0.97 at 215, 0.00 at 225).
*/
export const STATUS_GLOW_MIN_VALUE = 225;
/**
* Cast-family glow must also be UNSATURATED: the cast wash is pale even at
* its brightest, while backdrop leaking past an icon edge is colored —
* bright sky over a dead icon's shoulder reads glow 0.30 raw but 0.00 under
* this cap (sky spread >130), and the lilac/pink washes sit at spread
* 70-90 (attested capped fractions 0.46-0.61 vs raw 0.46-0.62). POV ready
* icons light up IN team color (attested glow 0.94 fully saturated), so
* the cap is cast/mirror-only, like the wash-ink guard.
*/
export const STATUS_GLOW_MAX_SPREAD = 90;
/**
* A pale pixel: bright but unsaturated, the ready wash across its whole
* pulse cycle. The wash PULSES — its trough dims below the glow floor
* (~190-220) while staying pale, so a trough frame reads no ink and no
* glow; without the pale class it is indistinguishable from a splat.
*/
export const STATUS_PALE_MIN_VALUE = 185;
export const STATUS_PALE_MAX_SPREAD = 70;
/**
* Splatted: body ink under the floor (attested dead <=0.20 vs alive
* >=0.26 with the current body box) with two guards: shoulder glow keeps
* the bright ready wash out (glow >=0.40 vs dead <=0.03), body pale keeps
* the wash's dim pulse trough out (pale >=0.27 vs dead <=0.07).
*/
export const STATUS_DEAD_MAX_BODY_INK = 0.23;
export const STATUS_DEAD_MAX_SHOULDER_GLOW = 0.2;
export const STATUS_DEAD_MAX_BODY_PALE = 0.15;
/** Special ready: shoulder glow past this (attested >=0.40 vs <=0.06). */
export const STATUS_READY_MIN_SHOULDER_GLOW = 0.25;
/**
* Special ready off the body when the shoulder misses the wash (pulse
* trough, or wash dimmed on compressed footage): pale-dominant body
* (attested ready >=0.31 — the AREA CUP mirror wash after a respawn — vs
* alive <=0.25 — plain alive bodies show some pale from weapon-silhouette
* whites).
*/
export const STATUS_READY_MIN_BODY_PALE = 0.3;
/**
* Cast-layout ready guard: the cast wash REPLACES the body's team ink, so
* an ink-heavy body proves a bright shoulder/pale read is backdrop leaking
* past the icon edge — the spectator overhead view draws a badge-less
* strip whose left column sits ~12px off the cast centers, sliding the
* probes onto pale buildings and the team-color lead banner (attested
* leaks read body ink >=0.44). The guard is graded: clean washes read ink
* <=0.28, and the attested inky washes (stage ink bleeding around the
* washed icon, 0.316/0.344) still show a strongly pale body (>=0.399) —
* while the S3 POV Um'ami backdrop leak that faked a star read ink 0.36
* with pale only 0.269, so the mid band demands the pale corroboration.
* POV ready icons instead light up IN team color (attested ink up to
* 0.68), so the guard is cast-only. Margins are THIN (true ink 0.344 vs
* hard ceiling 0.4; true pale 0.399 vs floor 0.35) — re-measure before
* moving any of the three.
*/
export const STATUS_READY_WASH_MAX_BODY_INK = 0.4;
export const STATUS_READY_CLEAN_WASH_MAX_BODY_INK = 0.3;
export const STATUS_READY_INKY_WASH_MIN_BODY_PALE = 0.35;
/**
* Layout scoring (see player-status.ts): per-slot decisiveness is the
* body-ink distance from the dead threshold, capped so one saturated slot
* cannot carry a misaligned geometry. The sticky margin is what the
* non-established layout must win the score by before a badge-less frame
* switches an established geometry.
*/
export const STATUS_LAYOUT_SCORE_CAP = 0.3;
export const STATUS_LAYOUT_STICKY_MARGIN = 0.04;
/**
* History-less badge-less picks prefer the cast family: every attested
* badge-less strip is a cast broadcast (S3 first-person POV footage is
* unattested), and decisiveness alone mis-ranks busy backdrops — the
* sendou-triton match-start frame scores pov 0.278 / cast 0.273 yet is
* cast. POV wins a fresh pick only when cast geometry reads poorly
* (below the floor — the S2 POV fixture reads cast at 0.198 vs attested
* cast footage >=0.212) or pov beats it by a decisive lead (attested cast
* frames mis-lead pov by at most 0.036).
*/
export const STATUS_FRESH_CAST_MIN_DECISIVENESS = 0.21;
export const STATUS_FRESH_POV_MIN_LEAD = 0.05;
/**
* Slot-comb contrast (see combContrast in player-status.ts): mean iconness
* at a layout's slot centers minus mean at its between-slot gaps, maximized
* over a small global shift. Rigid comb positions expose the arrangement's
* pitch — the badge-less mirror strip scores 0.81 while cast reads it at
* -0.07 (sendou-triton MakoMart) — so a decisive comb win proves the
* mirror arrangement even though its right column nearly coincides with
* POV's. Both gates are needed: the worst attested false mirror comb is
* 0.44 total (lead-banner frame) with a 0.24 lead.
*/
export const STATUS_COMB_BAND_Y = 35;
export const STATUS_COMB_BAND_H = 61;
export const STATUS_COMB_SIDE_SPANS: readonly [
[number, number],
[number, number],
] = [
[440, 960],
[960, 1480],
];
export const STATUS_COMB_CENTER_HALF_WIDTH = 16;
export const STATUS_COMB_GAP_HALF_WIDTH = 7;
export const STATUS_COMB_MAX_SHIFT = 10;
export const STATUS_MIRROR_COMB_MIN = 0.5;
export const STATUS_MIRROR_COMB_LEAD = 0.25;
/**
* Sticky flips away from an established cast layout also need comb
* corroboration (challenger comb past this floor AND leading cast's comb
* by STATUS_MIRROR_COMB_LEAD): S3 POV footage draws the cast icon
* geometry, but while the POV player is dead the whole strip shrinks
* toward the timer (~0.77 scale, left column landing near the mirror
* pitches), and those transient frames spike the mirror decisiveness past
* the sticky margin — the 2026-08-11 Um'ami POV VoD locked 107 of 136
* reads into mirror that way. Attested true mirror stretches comb
* 0.41-0.81 with >=0.31 lead over cast; the worst POV-footage mirror comb
* is 0.20 with a negative lead.
*/
export const STATUS_STICKY_FLIP_COMB_MIN = 0.3;
/**
* An established layout only carries forward across reads this close in
* time — in-match reads land ~1s apart, while a longer silence means a new
* match (possibly new footage type) and the next frame picks fresh.
*/
export const STATUS_LAYOUT_STICKY_MAX_GAP_S = 30;
// ---- strip weapon-icon evidence (the StripWeapons event) ----
//
// Each slot draws the player's weapon render over its squid plate; the
// match builder aggregates sampled per-slot candidate rankings across a
// match to solve the strip-slot → scoreboard-row assignment
// (strip-weapons.ts). Calibrated on the sendou-triton VoD (cast geometry
// on 720p footage upscaled to canonical space).
/**
* Weapon search window relative to a slot center: generous enough to hold
* the render at either strip geometry (the render measures ~65px on the
* cast strip, smaller on POV) without swallowing a neighbor slot's art.
*/
export const STRIP_WEAPON_BOX = { dx: -50, y: 20, w: 100, h: 100 };
/**
* Template render heights to try inside the window; the attested cast
* strip draws renders at ~55-70px depending on the weapon's aspect.
*/
export const STRIP_WEAPON_TEMPLATE_SIZES = [44, 52, 60, 68, 76] as const;
/**
* The flat grey the knocked-out plate pixels become and templates are
* composited over — mid-grey, so both dark barrels and white bodies keep
* contrast against it.
*/
export const STRIP_WEAPON_TEMPLATE_BACKGROUND = 90;
/** Ink floor for the NCC coverage penalty over that background. */
export const STRIP_WEAPON_INK_THRESHOLD = 140;
/**
* A plate pixel: saturated and bright (the plate is drawn in team ink),
* within the hue band of the region's modal saturated hue. The spread and
* value floors sit under the modal-vote floors (+15 in strip-weapons.ts)
* so the knockout reaches the plate's dimmer edge pixels the vote skips.
*/
export const STRIP_WEAPON_KNOCKOUT_MIN_SPREAD = 55;
export const STRIP_WEAPON_KNOCKOUT_MIN_VALUE = 90;
export const STRIP_WEAPON_MAX_PLATE_HUE_DIST = 30;
/**
* Candidates kept per slot: single reads only rank the true weapon top-1
* about half the time on attested footage, but it lands in the top 8 in
* enough reads for the cross-match aggregate to decide.
*/
export const STRIP_WEAPON_TOP_K = 8;
/**
* Every Nth successful counter read samples the strip weapons: identities
* are fixed per match, ~1 read/s makes ~20 samples over a short match —
* attested to assign correctly — and the full-atlas NCC sweep is too
* heavy to run on every read.
*/
export const STRIP_WEAPON_SAMPLE_INTERVAL = 5;
/**
* Cast-layout discriminator: the spectator HUD always draws white camera
* badges under the right team's icons; nothing fixed sits there on POV
* footage. All four probes must read white (bright AND unsaturated —
* bright sky is saturated cyan) to call the frame cast. The mirror set
* covers the cast-mirror arrangement's wide right badge pitch.
*/
export const STATUS_DPAD_PROBES: readonly Roi[] = [1105, 1180, 1256, 1332].map(
(cx) => ({ x: cx - 8, y: 102, w: 16, h: 16 }),
);
export const STATUS_DPAD_PROBES_MIRROR: readonly Roi[] = [
1110, 1207, 1303, 1401,
].map((cx) => ({ x: cx - 8, y: 102, w: 16, h: 16 }));
export const STATUS_WHITE_MIN_VALUE = 215;
export const STATUS_WHITE_MAX_SPREAD = 40;
export const STATUS_CAST_MIN_DPAD_WHITE = 0.35;

View File

@@ -0,0 +1,178 @@
/**
* StripWeapons: per-slot weapon-icon evidence off the same icon strip the
* PlayerStatus read classifies, emitted by the ObjectiveDetector on a
* sampled cadence (identities are fixed for a match, so every read would be
* waste). The results scoreboard re-sorts each team per game while the
* strip keeps the lobby seating (attested in the sendou-triton VoD: strip
* [Planetz, .52, Neo Splash, Snipewriter] vs scoreboard rows
* [.52, Neo Splash, Snipewriter, Planetz]), so status samples cannot be
* paired with scoreboard rows by position alone — the match builder
* aggregates these candidate lists across the match and solves the
* slot→row assignment against the scoreboard's weapons
* (slot-row-assignment.ts).
*
* A slot's icon is the weapon render over a team-ink squid plate; the
* plate (and scene bleeding through it — the plates are translucent) is
* what drowns template matching, so saturated pixels near the plate's
* modal hue are flattened to the template background before the NCC
* ranking. One read's top-1 is only right about half the time on attested
* footage — the value is in the aggregate, so the event carries a ranked
* candidate list per slot. Splatted slots grey the render out and are
* skipped rather than guessed.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { getCV, type Mat } from "../../cv";
import { copyRoi } from "../../image";
import { hueDistance, hueOf } from "../../ink-color";
import { matchWeapon, type WeaponTemplate } from "../scoreboard/weapons";
import type { DetectedEvent } from "../types";
import type { PlayerStatusData, PlayerStatusLayout } from "./player-status";
import {
STATUS_SLOT_CENTERS_CAST,
STATUS_SLOT_CENTERS_CAST_MIRROR,
STATUS_SLOT_CENTERS_POV,
STRIP_WEAPON_BOX,
STRIP_WEAPON_INK_THRESHOLD,
STRIP_WEAPON_KNOCKOUT_MIN_SPREAD,
STRIP_WEAPON_KNOCKOUT_MIN_VALUE,
STRIP_WEAPON_MAX_PLATE_HUE_DIST,
STRIP_WEAPON_TEMPLATE_BACKGROUND,
STRIP_WEAPON_TOP_K,
} from "./rois";
export const STRIP_WEAPONS_EVENT_TYPE = "StripWeapons";
export interface StripWeaponCandidate {
weaponId: MainWeaponId;
score: number;
}
export interface StripWeaponsData {
/** match timer at the read, pairing it with the Objective/PlayerStatus events */
time: number | null;
/** the icon-strip geometry the paired PlayerStatus read picked */
layout: PlayerStatusLayout;
/**
* ranked weapon candidates per slot, [left team, right team], slots
* left-to-right as drawn; null = slot skipped (splatted icons grey the
* weapon render out)
*/
slots: [(StripWeaponCandidate[] | null)[], (StripWeaponCandidate[] | null)[]];
}
/**
* Match every alive slot's icon against the strip weapon templates.
* `status` is the PlayerStatus read off the same frame — its layout picks
* the slot centers and its dead flags pick which slots are worth reading.
*/
export function parseStripWeapons(
frame: Mat,
t: number,
status: PlayerStatusData,
templates: WeaponTemplate[],
): DetectedEvent<StripWeaponsData> {
const centers = slotCenters(status.layout);
const scores: number[] = [];
const slots = centers.map((sideCenters, side) =>
sideCenters.map((cx, slot): StripWeaponCandidate[] | null => {
if (status.dead[side as 0 | 1][slot]) return null;
const candidates = matchSlot(frame, cx, templates);
if (candidates.length > 0) scores.push(candidates[0]!.score);
return candidates;
}),
) as StripWeaponsData["slots"];
return {
type: STRIP_WEAPONS_EVENT_TYPE,
t,
// raw NCC peaks on attested footage sit ~0.4-0.6 even for correct
// reads; the aggregate assignment carries the reliability, so the
// event's own confidence only reflects that something matched at all
confidence: scores.length > 0 ? Math.max(...scores) : 0,
data: {
time: status.time,
layout: status.layout,
slots,
},
};
}
function slotCenters(
layout: PlayerStatusLayout,
): readonly [readonly number[], readonly number[]] {
return layout === "pov"
? STATUS_SLOT_CENTERS_POV
: layout === "cast"
? STATUS_SLOT_CENTERS_CAST
: STATUS_SLOT_CENTERS_CAST_MIRROR;
}
function matchSlot(
frame: Mat,
cx: number,
templates: WeaponTemplate[],
): StripWeaponCandidate[] {
const cv = getCV();
const crop = copyRoi(frame, {
x: cx + STRIP_WEAPON_BOX.dx,
y: STRIP_WEAPON_BOX.y,
w: STRIP_WEAPON_BOX.w,
h: STRIP_WEAPON_BOX.h,
});
const search = new cv.Mat();
cv.cvtColor(crop, search, cv.COLOR_RGBA2RGB);
crop.delete();
knockoutPlate(search);
const match = matchWeapon(search, templates, {
inkThreshold: STRIP_WEAPON_INK_THRESHOLD,
topN: STRIP_WEAPON_TOP_K,
});
search.delete();
return match.top.map((candidate) => ({
weaponId: Number(candidate.id) as MainWeaponId,
score: candidate.score,
}));
}
/**
* Flatten the squid plate out of the search region: the modal hue of the
* region's saturated pixels is the plate's team ink, and every pixel near
* that hue is replaced with the flat template background, leaving the
* weapon render (grey/white bodies and off-hue accents) to carry the NCC.
*/
function knockoutPlate(search: Mat): void {
const { data } = search;
const n = search.rows * search.cols;
const bins = new Array<number>(36).fill(0);
for (let i = 0; i < n; i++) {
const r = data[i * 3]!;
const g = data[i * 3 + 1]!;
const b = data[i * 3 + 2]!;
const value = Math.max(r, g, b);
const spread = value - Math.min(r, g, b);
if (
spread >= STRIP_WEAPON_KNOCKOUT_MIN_SPREAD + 15 &&
value >= STRIP_WEAPON_KNOCKOUT_MIN_VALUE + 15
) {
bins[Math.floor(hueOf({ r, g, b }) / 10)]!++;
}
}
const plateHue = bins.indexOf(Math.max(...bins)) * 10 + 5;
for (let i = 0; i < n; i++) {
const r = data[i * 3]!;
const g = data[i * 3 + 1]!;
const b = data[i * 3 + 2]!;
const value = Math.max(r, g, b);
const spread = value - Math.min(r, g, b);
if (
spread >= STRIP_WEAPON_KNOCKOUT_MIN_SPREAD &&
value >= STRIP_WEAPON_KNOCKOUT_MIN_VALUE &&
hueDistance(hueOf({ r, g, b }), plateHue) <=
STRIP_WEAPON_MAX_PLATE_HUE_DIST
) {
data[i * 3] = STRIP_WEAPON_TEMPLATE_BACKGROUND;
data[i * 3 + 1] = STRIP_WEAPON_TEMPLATE_BACKGROUND;
data[i * 3 + 2] = STRIP_WEAPON_TEMPLATE_BACKGROUND;
}
}
}

View File

@@ -0,0 +1,51 @@
/**
* The full detector registry — the single source of truth for "every
* detector that runs on a frame". New event types get added here and are
* picked up by the analyzer worker.
*/
import { createDeathDetector } from "./death/index";
import { createMapStartDetector } from "./map-start/index";
import { createMinimapDetector } from "./minimap/index";
import { createObjectiveDetector } from "./objective/index";
import {
createScoreboardDetector,
SCOREBOARD_EVENT_TYPE,
type ScoreboardResources,
} from "./scoreboard/index";
import {
createScoreboardBattleLogDetector,
SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
} from "./scoreboard-battle-log/index";
import {
createScoreboardBattleLogReplayDetector,
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
} from "./scoreboard-battle-log-replay/index";
import { createScoreboardOwnDetector } from "./scoreboard-own/index";
import type { Detector } from "./types";
/**
* Event types whose data is the full 8-player scoreboard shape
* (ScoreboardData): the results screen, the replay-browser detail, and the
* scoreboard-battle-log detail.
*/
export const SCOREBOARD_EVENT_TYPES: readonly string[] = [
SCOREBOARD_EVENT_TYPE,
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
];
export function createAllDetectors(
resources: ScoreboardResources,
): Detector<unknown>[] {
return [
createScoreboardDetector(resources) as Detector<unknown>,
createScoreboardBattleLogReplayDetector(resources) as Detector<unknown>,
createScoreboardBattleLogDetector(resources) as Detector<unknown>,
createScoreboardOwnDetector(resources) as Detector<unknown>,
createDeathDetector(resources) as Detector<unknown>,
createMapStartDetector(resources) as Detector<unknown>,
createMinimapDetector(resources) as Detector<unknown>,
createObjectiveDetector(resources) as Detector<unknown>,
];
}

View File

@@ -0,0 +1,341 @@
/**
* DetectorScheduler: decides which detectors check frame t, and which pay
* for a parse. Three concerns:
*
* - Cadence: `searchIntervalS` while a gate fails (default 0.25s — VoDs cut
* screens to ~1s, below raw-gameplay lifetimes), dense `refineIntervalS`
* once it passes, `checkIntervalS` as a hard cap exempt from suppression.
* `nextDueT()` lets the caller skip a frame's readback when nothing's due.
* - Suppression: stops paying once a streak stagnates (`maxStagnantParses`
* + a time floor, since a gate can fire during a screen's entry animation
* before it's readable). `sufficientConfidence` suppresses immediately; a
* gate `signature` moving past `signatureTolerance` ends the streak
* (distinct browsed entries never drop those gates); `rearmCooldownS`
* adds a cooldown on top (death: animated-background flicker).
* - Activity: tracks last gate pass + open-match state for the VoD scanner
* to skip dead air.
*
* State keys to the scan's clock; `t` jumping backwards resets everything.
*/
import type { DetectedEvent } from "./types";
export interface SchedulingInfo {
id: string;
checkIntervalS?: number;
searchIntervalS?: number;
refineIntervalS?: number;
sufficientConfidence?: number;
rearmCooldownS?: number;
maxStagnantParses?: number;
}
export interface SchedulerOptions {
/**
* false = one-shot harness mode: every detector is due on every frame and
* parses are never suppressed
*/
suppressSteadyFrames: boolean;
/** check cadence while a detector's gate is passing (per-detector
* refineIntervalS overrides — for detectors whose parse is expensive
* enough that the dense default multiplies real cost) */
refineIntervalS: number;
/** check cadence while a detector's gate is failing (per-detector
* searchIntervalS overrides) */
searchIntervalS: number;
/** consecutive non-improving parses tolerated before suppression
* (per-detector maxStagnantParses overrides) */
maxStagnantParses: number;
/** seconds without improvement tolerated before suppression */
stagnantAfterS: number;
/** minimum confidence gain that counts as an improvement */
minImprovement: number;
/**
* a gate signature cell moving more than this since the streak's last
* parse means the screen's content changed and the streak resets
* (measured on battle log browsing: static-screen noise ≤2 per cell,
* entry flips ≥57)
*/
signatureTolerance: number;
/** seconds without any gate pass before the scan counts as calm */
quietAfterS: number;
/** a match opened this long ago without closing is assumed abandoned */
matchOpenMaxS: number;
/** event types that open a match (map-start) */
matchOpeningTypes: readonly string[];
/** event types that close a match (the scoreboard family) */
matchClosingTypes: readonly string[];
}
const DEFAULT_SCHEDULER_OPTIONS: SchedulerOptions = {
suppressSteadyFrames: true,
refineIntervalS: 0.15,
searchIntervalS: 0.25,
maxStagnantParses: 6,
// give a screen ~3s to animate in and produce its best read, whatever
// the sampling cadence
stagnantAfterS: 3,
minImprovement: 0.001,
signatureTolerance: 12,
quietAfterS: 15,
matchOpenMaxS: 8 * 60,
matchOpeningTypes: [],
matchClosingTypes: [],
};
/** events below this are too dubious to drive match-open/close state */
const MATCH_STATE_MIN_CONFIDENCE = 0.6;
/** tolerated backwards jitter in t before the session counts as restarted */
const RESET_TOLERANCE_S = 5;
const INTERVAL_EPSILON_S = 1e-6;
interface StreakState {
best: number;
stagnant: number;
lastImprovementT: number;
suppressed: boolean;
/** gate signature of the streak's last parsed frame */
signature: readonly number[] | undefined;
}
interface DetectorState {
info: SchedulingInfo;
lastCheckT: number | undefined;
gatePassing: boolean;
streak: StreakState | null;
/** signature reported by the latest passing gate */
lastGateSignature: readonly number[] | undefined;
/** parses skipped until this t after a sufficient read (rearmCooldownS) */
parseHoldUntilT: number;
}
export class DetectorScheduler {
#options: SchedulerOptions;
#states = new Map<string, DetectorState>();
#maxT = Number.NEGATIVE_INFINITY;
#lastActivityT = Number.NEGATIVE_INFINITY;
#matchOpenUntilT = Number.NEGATIVE_INFINITY;
constructor(
detectors: readonly SchedulingInfo[],
options: Partial<SchedulerOptions> = {},
) {
this.#options = { ...DEFAULT_SCHEDULER_OPTIONS, ...options };
for (const info of detectors) {
this.#states.set(info.id, freshState(info));
}
}
/** Drop all session state; `t` seeds the activity clock (chunk start). */
reset(t = Number.NEGATIVE_INFINITY): void {
for (const [id, state] of this.#states) {
this.#states.set(id, freshState(state.info));
}
this.#maxT = t;
this.#lastActivityT = t;
this.#matchOpenUntilT = Number.NEGATIVE_INFINITY;
}
/**
* Earliest t at which any detector wants a check — frames before it can
* skip analysis (and its canvas readback) entirely.
*/
nextDueT(): number {
let next = Number.POSITIVE_INFINITY;
for (const state of this.#states.values()) {
if (state.lastCheckT === undefined) return Number.NEGATIVE_INFINITY;
next = Math.min(next, state.lastCheckT + this.#interval(state));
}
return next;
}
/** Detector ids that should gate the frame at `t`. */
dueDetectors(t: number): string[] {
if (t + RESET_TOLERANCE_S < this.#maxT) this.reset(t);
// the first frame seeds the activity clock so a fresh session is never
// instantly calm — it has to earn its quiet period first
if (this.#lastActivityT === Number.NEGATIVE_INFINITY) {
this.#lastActivityT = t;
}
this.#maxT = Math.max(this.#maxT, t);
const due: string[] = [];
for (const [id, state] of this.#states) {
if (state.lastCheckT === undefined) {
due.push(id);
continue;
}
if (t - state.lastCheckT >= this.#interval(state) - INTERVAL_EPSILON_S) {
due.push(id);
}
}
return due;
}
/** Report a gate outcome for a detector this scheduler marked due. */
recordGate(
id: string,
t: number,
pass: boolean,
signature?: readonly number[],
): void {
const state = this.#states.get(id);
if (!state) return;
state.lastCheckT = t;
state.gatePassing = pass;
if (!pass) {
state.streak = null;
return;
}
this.#lastActivityT = Math.max(this.#lastActivityT, t);
if (
signature &&
state.streak?.signature &&
signaturesDiffer(
signature,
state.streak.signature,
this.#options.signatureTolerance,
)
) {
state.streak = null;
}
state.lastGateSignature = signature;
}
/** Whether the (passed) gate should be followed by a parse at `t`. */
shouldParse(id: string, t: number): boolean {
if (!this.#options.suppressSteadyFrames) return true;
const state = this.#states.get(id);
if (!state) return true;
if (state.info.checkIntervalS !== undefined) return true;
if (t < state.parseHoldUntilT) return false;
return !state.streak?.suppressed;
}
/** Report the outcome of a parse this scheduler approved. */
recordParse(
id: string,
t: number,
events: readonly Pick<DetectedEvent, "type" | "confidence">[],
): void {
this.#recordMatchState(t, events);
const state = this.#states.get(id);
if (!state || state.info.checkIntervalS !== undefined) return;
// no events counts as confidence 0: a false-firing gate on a static
// screen stagnates and gets suppressed just like a parsed one
const confidence = events.reduce(
(max, e) => Math.max(max, e.confidence),
0,
);
const { sufficientConfidence, rearmCooldownS } = state.info;
if (
sufficientConfidence !== undefined &&
confidence >= sufficientConfidence
) {
state.streak = {
best: confidence,
stagnant: 0,
lastImprovementT: t,
suppressed: true,
signature: state.lastGateSignature,
};
if (rearmCooldownS !== undefined) {
state.parseHoldUntilT = t + rearmCooldownS;
}
return;
}
if (!state.streak) {
state.streak = {
best: confidence,
stagnant: 0,
lastImprovementT: t,
suppressed: false,
signature: state.lastGateSignature,
};
return;
}
const streak = state.streak;
streak.signature = state.lastGateSignature;
if (confidence > streak.best + this.#options.minImprovement) {
streak.best = confidence;
streak.stagnant = 0;
streak.lastImprovementT = t;
return;
}
streak.stagnant += 1;
const maxStagnant =
state.info.maxStagnantParses ?? this.#options.maxStagnantParses;
if (
streak.stagnant >= maxStagnant &&
t - streak.lastImprovementT >= this.#options.stagnantAfterS
) {
streak.suppressed = true;
}
}
/**
* True when the footage at `t` is dead air as far as detection goes: no
* gate has passed for quietAfterS and no match is open — the VoD scanner
* may skim by keyframes instead of decoding densely.
*/
calm(t: number): boolean {
if (!this.#options.suppressSteadyFrames) return false;
return (
t - this.#lastActivityT >= this.#options.quietAfterS &&
t >= this.#matchOpenUntilT
);
}
#interval(state: DetectorState): number {
if (!this.#options.suppressSteadyFrames) return 0;
const { info } = state;
if (info.checkIntervalS !== undefined) return info.checkIntervalS;
const search = info.searchIntervalS ?? this.#options.searchIntervalS;
// while suppressed only the gate keeps running (to spot the screen
// changing), which the sparser search cadence covers
if (state.streak?.suppressed) return search;
if (!state.gatePassing) return search;
return info.refineIntervalS ?? this.#options.refineIntervalS;
}
#recordMatchState(
t: number,
events: readonly Pick<DetectedEvent, "type" | "confidence">[],
): void {
for (const event of events) {
if (event.confidence < MATCH_STATE_MIN_CONFIDENCE) continue;
if (this.#options.matchOpeningTypes.includes(event.type)) {
this.#matchOpenUntilT = Math.max(
this.#matchOpenUntilT,
t + this.#options.matchOpenMaxS,
);
} else if (this.#options.matchClosingTypes.includes(event.type)) {
this.#matchOpenUntilT = Math.min(this.#matchOpenUntilT, t);
}
}
}
}
function freshState(info: SchedulingInfo): DetectorState {
return {
info,
lastCheckT: undefined,
gatePassing: false,
streak: null,
lastGateSignature: undefined,
parseHoldUntilT: Number.NEGATIVE_INFINITY,
};
}
function signaturesDiffer(
a: readonly number[],
b: readonly number[],
tolerance: number,
): boolean {
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i]! - b[i]!) > tolerance) return true;
}
return false;
}

View File

@@ -0,0 +1,136 @@
/**
* Replay code recognition ("R6KE-DO64-3CXD-XVKL"): bright green text under
* the team panels. Green on the dark background lands below the default
* grayscale binarization threshold, so recognition runs on the green
* channel, where the glyphs are near-white.
*/
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
} from "../../glyphs";
import { cropRoi } from "../../image";
import { REPLAY_CODE_ROI } from "./rois";
/**
* FOT-RowdyStd's 'Q' is a '0' bowl with a small tail below the baseline;
* the bowl dominates template correlation, so a real Q ranks as '0' by a
* hair. Like the P/p rule in scoreboard/names.ts, the segment geometry
* decides what the templates cannot: a 0/O read whose ink reaches well
* below the line's baseline (the median ink bottom of the other
* alphanumerics) is a Q.
*/
const Q_TWINS = new Set(["0", "O"]);
const Q_DESCENT_MIN_PX = 4;
function resolveQsByDescent(raw: RecognizedText): string {
const anchors = raw.chars
.filter((c) => !Q_TWINS.has(c.char) && c.char !== "-")
.map((c) => c.y1)
.sort((a, b) => a - b);
if (anchors.length === 0) return raw.text;
const baseline = anchors[Math.floor(anchors.length / 2)]!;
return raw.chars
.map((c) =>
Q_TWINS.has(c.char) && c.y1 - baseline >= Q_DESCENT_MIN_PX ? "Q" : c.char,
)
.join("");
}
/**
* On blurry captures a narrow 'L' template can edge out the true 'U' by a
* hair. Segment geometry decides what templates can't: a U's right stroke
* fills the segment's top-right quadrant, where an L has no ink (measured
* 0.00-0.15 leak on true Ls vs 0.27+ on Us across fixtures). The margin only
* skips confident reads and stays generous, since no true L on the fixtures
* carries a 'U' candidate at all.
*/
const LU_SCORE_MARGIN = 0.12;
const LU_INK_THRESHOLD = 150;
const LU_TOP_RIGHT_MIN_FRACTION = 0.2;
function resolveUsByTopRightInk(
raw: RecognizedText,
green: Mat,
): RecognizedText {
const { cols, data } = green;
const chars = raw.chars.map((c) => {
if (c.char !== "L") return c;
const u = c.candidates?.find((k) => k.char === "U");
if (!u || c.score - u.score > LU_SCORE_MARGIN) return c;
const xMid = Math.ceil((c.x0 + c.x1) / 2);
const yMid = Math.floor((c.y0 + c.y1) / 2);
let ink = 0;
let total = 0;
for (let y = c.y0; y < yMid; y++) {
for (let x = xMid; x < c.x1; x++) {
total++;
if (data[y * cols + x]! > LU_INK_THRESHOLD) ink++;
}
}
return total > 0 && ink / total >= LU_TOP_RIGHT_MIN_FRACTION
? { ...c, char: "U" }
: c;
});
return { ...raw, chars, text: chars.map((c) => c.char).join("") };
}
export interface ParsedReplayCode {
/** normalized "XXXX-XXXX-XXXX-XXXX", or null when the shape is wrong */
code: string | null;
/** min glyph score across recognized characters */
confidence: number;
raw: RecognizedText;
}
const CODE_RE = /^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$/;
/**
* Restrict a glyph set to the characters codes can contain — a shallow
* view over the same template mats, so dispose only the source set.
*/
export function codeCharsetOf(set: GlyphSet): GlyphSet {
const glyphs = set.glyphs.filter((g) => /^[0-9A-Z-]$/.test(g.char));
const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b);
return {
glyphs,
height: set.height,
medianWidth: widths[Math.floor(widths.length / 2)] ?? set.medianWidth,
};
}
/** rgb: full normalized frame in RGB (not RGBA). */
export function parseReplayCode(rgb: Mat, glyphs: GlyphSet): ParsedReplayCode {
const cv = getCV();
const view = cropRoi(rgb, REPLAY_CODE_ROI);
const channels = new cv.MatVector();
cv.split(view, channels);
const g = channels.get(1);
const green = new cv.Mat();
g.copyTo(green);
g.delete();
channels.delete();
view.delete();
const raw = recognizeText(green, glyphs, {
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const resolved = resolveUsByTopRightInk(raw, green);
green.delete();
let text = resolveQsByDescent(resolved).toUpperCase();
// Dashes are thin and can drop out of segmentation (any subset of the
// three); the 16 alphanumerics are unambiguous on their own, so re-derive
// the grouping from them alone.
const compact = text.replaceAll("-", "");
if (/^[0-9A-Z]{16}$/.test(compact)) {
text = compact.replace(/(.{4})(?=.)/g, "$1-");
}
return {
code: CODE_RE.test(text) ? text : null,
confidence: raw.confidence,
raw,
};
}

View File

@@ -0,0 +1,189 @@
/**
* Replay-browser header parsing. Same black auto-sized tag style as the
* live scoreboard header, but different content: the top line holds the
* recording timestamp ("3/7/2026 22:28") followed by the stage, the bottom
* line the lobby (bold) followed by the mode.
*
* The timestamp is locale-formatted and open-ended, so it is validated by
* shape and kept as a raw string; stage and lobby+mode snap to the closed
* sets shared with the live header.
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../../../scanner-types";
import type { Roi } from "../../canonical";
import type { Mat } from "../../cv";
import type { GlyphSet } from "../../glyphs";
import { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
import { closestBy } from "../../text";
import { readTagBand } from "../scoreboard/header";
import { HEADER_BOTTOM_BAND, HEADER_TOP_BAND } from "./rois";
export interface ParsedReplayHeader {
timestamp: string | null;
stage: StageId | null;
lobby: ScannerLobby | null;
mode: ModeShort | null;
/** min of the closed-set match scores that were attempted */
confidence: number;
debug: {
topReading: string;
bottomReading: string;
stageScore: number;
bottomScore: number;
};
}
const MIN_MATCH_SCORE = 0.62;
/**
* Lifted-blacks captures (720p streams upscaled and re-encoded) raise the
* tag background to ~80-115 gray, above readTagBand's default dark ceiling,
* so the tag-extent trim truncates the band to a sliver and the read comes
* back empty. A band whose closed-set snap fails is re-read with this
* ceiling; the retry is adopted only when it snaps at least as well.
*/
const TAG_DARK_MAX_LIFTED = 120;
/**
* "3/7/2026 22:28" and friends; capture the rest of the line (the stage).
* The console formats the date per locale — "7.3.2026" (de), "2026/3/7"
* (ja) — so any . / - separated triple followed by a time is accepted.
* Adjacent skinny time digits can read with a spurious gap on compressed
* captures ("14:1 1"), so a lone space is tolerated between them and
* stripped when the timestamp is assembled. Not left-anchored: the
* battle log line leads with a rank icon on ranked lobbies, which reads as
* a junk glyph before the date.
*/
const TIMESTAMP_RE =
/(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})\s+(\d(?: ?\d)?: ?\d ?\d)\s*(.*)$/;
interface TopBandParse {
reading: string;
timestamp: string | null;
stage: StageId | null;
stageScore: number;
}
/**
* In the BlitzMain glyphs the digit forms are near-identical to their letter
* lookalikes (0/O, the 1/I/l/| bars), so a stage read can surface the digit
* ("R0M-en") or the wrong bar ("WnrId") and burn an edit the snap threshold
* cannot spare. Stage names are digit-free in every language (sole exception
* carries a '9'), so digits and bars fold to letters before snapping — on
* both the reading and the entry, so an entry's own 'I' ("Inkblot") lands in
* the same folded space.
*/
function foldDigitLookalikes(s: string): string {
return s.replace(/0/g, "o").replace(/[1|I]/g, "l");
}
function parseTopBand(reading: string): TopBandParse {
let timestamp: string | null = null;
let stage: StageId | null = null;
let stageScore = 0;
// The top band reads with the BlitzMain name glyphs, where 1/I/l/| are
// identical bars ("I9:04"), O/o ride a hair off 0, and 8 can surface as
// S ("O9/OS/2Oo6"); in the digits-only timestamp every lookalike folds
// to its digit. Match on the normalized line, keep the stage part's
// original reading (the replacements are 1:1, so offsets line up).
const normalized = reading
.replace(/[Il|]/g, "1")
.replace(/[Oo]/g, "0")
.replace(/S/g, "5");
const m = TIMESTAMP_RE.exec(normalized);
const stageReading = m
? reading.slice(reading.length - m[3]!.length)
: reading;
if (m) timestamp = `${m[1]!} ${m[2]!.replace(/ /g, "")}`;
if (stageReading) {
const match = closestBy(
foldDigitLookalikes(stageReading),
ALL_STAGE_ENTRIES,
(e) => foldDigitLookalikes(e.text),
);
if (match) {
stageScore = match.score;
if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId;
}
}
return { reading, timestamp, stage, stageScore };
}
/** The two header tag bands; the battle log passes its own coordinates. */
export interface ReplayHeaderBands {
top: Roi;
bottom: Roi;
/**
* see TagBandOptions.tagLeadInMax; the battle log tags are not
* left-anchored
*/
tagLeadInMax?: number;
/** see TagBandOptions.tagColumnFraction; the battle log tags are tilted */
tagColumnFraction?: number;
}
const REPLAY_BANDS: ReplayHeaderBands = {
top: HEADER_TOP_BAND,
bottom: HEADER_BOTTOM_BAND,
};
export function parseReplayHeader(
gray: Mat,
topGlyphs: GlyphSet,
bottomGlyphs: GlyphSet,
bands: ReplayHeaderBands = REPLAY_BANDS,
): ParsedReplayHeader {
const leadIn = {
tagLeadInMax: bands.tagLeadInMax,
tagColumnFraction: bands.tagColumnFraction,
};
let top = parseTopBand(readTagBand(gray, bands.top, topGlyphs, leadIn));
if (top.stage === null) {
const retry = parseTopBand(
readTagBand(gray, bands.top, topGlyphs, {
...leadIn,
tagDarkMax: TAG_DARK_MAX_LIFTED,
}),
);
if (retry.stageScore >= top.stageScore) top = retry;
}
let bottomReading = readTagBand(gray, bands.bottom, bottomGlyphs, leadIn);
let bottomMatch = bottomReading
? closestBy(bottomReading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if (!bottomMatch || bottomMatch.score < MIN_MATCH_SCORE) {
const reading = readTagBand(gray, bands.bottom, bottomGlyphs, {
...leadIn,
tagDarkMax: TAG_DARK_MAX_LIFTED,
});
const match = reading
? closestBy(reading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if ((match?.score ?? 0) >= (bottomMatch?.score ?? 0)) {
bottomReading = reading;
bottomMatch = match;
}
}
let lobby: ScannerLobby | null = null;
let mode: ModeShort | null = null;
if (bottomMatch && bottomMatch.score >= MIN_MATCH_SCORE) {
lobby = bottomMatch.entry.lobby;
mode = bottomMatch.entry.mode;
}
return {
timestamp: top.timestamp,
stage: top.stage,
lobby,
mode,
confidence: Math.min(top.stageScore, bottomMatch?.score ?? 0),
debug: {
topReading: top.reading,
bottomReading,
stageScore: top.stageScore,
bottomScore: bottomMatch?.score ?? 0,
},
};
}

View File

@@ -0,0 +1,460 @@
/**
* ScoreboardBattleLogReplayDetector: parses the replay-browser detail
* screen — the same match data as the live scoreboard (header, team scores,
* 8 player rows) plus the recording timestamp and the replay code.
*
* Layout differs from the live scoreboard: the two team panels sit side by
* side and the replay owner's team may be on either side, so the
* VICTORY/DEFEAT panel tags are read to keep `players`/`matchScores`
* ordered winners-first like the live event. Field parsing reuses the
* scoreboard helpers with glyph sets rescaled to this screen's text sizes.
*/
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import {
cropRoi,
maxBrightness,
maxChannel,
meanBrightness,
type Roi,
roiSignature,
} from "../../image";
import { RESULT_TAG_ENTRIES } from "../../localized";
import { closestBy } from "../../text";
import {
FULL_COUNT_TEAM_SCORE,
KO_MATCH_SCORE,
MATCH_SCORE_MIN_CONF,
} from "../scoreboard/banner";
import { type ParsedNumber, parseNumber } from "../scoreboard/digits";
import type {
ScoreboardData,
ScoreboardPlayer,
ScoreboardResources,
ScoreboardRowDebug,
} from "../scoreboard/index";
import { findPovIndex } from "../scoreboard/pov";
import { parseScoreboardRow, type RowRois } from "../scoreboard/row";
import type { DetectedEvent, Detector, GateResult } from "../types";
import { codeCharsetOf, type ParsedReplayCode, parseReplayCode } from "./code";
import { type ParsedReplayHeader, parseReplayHeader } from "./header";
import {
CODE_TEXT_HEIGHT,
GATE_CODE_BLUE_MAX,
GATE_CODE_GREEN_MIN,
GATE_CODE_MIN_FRACTION,
GATE_FLAT_MAX_MEAN,
GATE_FLAT_MIN_MEAN,
GATE_GAP_MAX_MEAN,
GATE_GAP_PROBES,
GATE_TEXT_MIN_MAX,
gateFlatProbe,
HEADER_LINE_HEIGHT,
HEADER_TIMESTAMP_HEIGHT,
HEADER_TOP_BAND,
MATCH_SCORE_DIGIT_HEIGHT,
MATCH_SCORE_ROIS,
NAME_TEXT_HEIGHT,
nameRoi,
PAINT_DIGIT_HEIGHT,
PANEL_XS,
paintRoi,
paintSuffixRoi,
povArrowRoi,
REPLAY_CODE_ROI,
RESULT_TAG_TEXT_HEIGHT,
ROW_CENTERS,
resultTagRoi,
STAT_DIGIT_HEIGHT,
specialIconRoi,
statRoi,
TEAM_DIGIT_HEIGHT,
teamScoreRoi,
weaponRoi,
} from "./rois";
export interface ScoreboardBattleLogReplayData extends ScoreboardData {
/** recording timestamp as shown, e.g. "3/7/2026 22:28"; locale-formatted */
timestamp: string | null;
/** "XXXX-XXXX-XXXX-XXXX" */
replayCode: string | null;
}
export const SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE =
"ScoreboardBattleLogReplay";
/** Replay pills are mid-gray (~61), not near-black; see matchWeapon docs. */
const REPLAY_INK_THRESHOLD = 90;
/**
* White banner digits on saturated team color: applies to the "Score:"
* banners AND the team totals — a green DEFEAT panel weighs in at ~184
* on the green-heavy grayscale, above the default 150.
*/
const BANNER_BIN_THRESHOLD = 190;
/** Canonical results the localized VICTORY/DEFEAT panel tags snap to. */
type PanelResult = "VICTORY" | "DEFEAT";
const RESULT_MIN_SCORE = 0.6;
/**
* The chunky outlined tag letters bridge at the default 150 on the
* max-channel image; 190 keeps the cores separated (and drops the gray
* gear/signal icons trailing the text).
*/
const RESULT_TAG_BIN_THRESHOLD = 190;
interface PanelParse {
players: ScoreboardPlayer[];
rows: ScoreboardRowDebug[];
teamScore: ParsedNumber | null;
matchScore: ParsedNumber | null;
result: PanelResult | null;
resultReading: string;
resultScore: number;
confidences: number[];
}
/** Fraction of ROI pixels matching the replay code's green (RGBA frame). */
function greenFraction(frame: Mat, roi: Roi): number {
const cv = getCV();
const view = cropRoi(frame, roi);
const cont = new cv.Mat();
view.copyTo(cont);
view.delete();
const d = cont.data;
const n = cont.rows * cont.cols;
let green = 0;
for (let i = 0; i < n; i++) {
if (
d[i * 4 + 1]! > GATE_CODE_GREEN_MIN &&
d[i * 4 + 2]! < GATE_CODE_BLUE_MAX
)
green++;
}
cont.delete();
return n > 0 ? green / n : 0;
}
export function createScoreboardBattleLogReplayDetector(
resources: ScoreboardResources,
): Detector<ScoreboardBattleLogReplayData> {
const cv = getCV();
const scaled = (set: GlyphSet | null, height: number): GlyphSet | null =>
set ? scaleGlyphSet(set, height / set.height) : null;
const nameGlyphs = scaled(resources.nameGlyphs, NAME_TEXT_HEIGHT);
const paintDigits = scaled(resources.paintDigits, PAINT_DIGIT_HEIGHT);
const statDigits = scaled(resources.statDigits, STAT_DIGIT_HEIGHT);
const teamBase = resources.teamDigits ?? resources.paintDigits;
const teamDigits = scaled(teamBase, TEAM_DIGIT_HEIGHT);
const matchScoreDigits = scaled(teamBase, MATCH_SCORE_DIGIT_HEIGHT);
/** Timestamp needs digits + '/' + ':' — only the names atlas has them. */
const headerTopGlyphs = scaled(resources.nameGlyphs, HEADER_TIMESTAMP_HEIGHT);
const headerBottomGlyphs = scaled(
resources.headerLineGlyphs,
HEADER_LINE_HEIGHT,
);
// Code and result tags render in FOT-RowdyStd — use the dedicated atlases
// when present; the BlitzMain-based fallbacks read them only roughly.
const resultGlyphs =
scaled(resources.replayResultGlyphs ?? null, RESULT_TAG_TEXT_HEIGHT) ??
scaled(resources.headerLineGlyphs, RESULT_TAG_TEXT_HEIGHT);
const codeGlyphs = resources.replayCodeGlyphs
? scaled(resources.replayCodeGlyphs, CODE_TEXT_HEIGHT)
: resources.nameGlyphs
? scaleGlyphSet(
codeCharsetOf(resources.nameGlyphs),
CODE_TEXT_HEIGHT / resources.nameGlyphs.height,
)
: null;
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
let flatOk = 0;
let suffixOk = 0;
for (const dx of PANEL_XS) {
for (const cy of ROW_CENTERS) {
const flat = meanBrightness(frame, gateFlatProbe(cy, dx));
if (flat >= GATE_FLAT_MIN_MEAN && flat <= GATE_FLAT_MAX_MEAN) flatOk++;
if (maxBrightness(gray, paintSuffixRoi(cy, dx)) > GATE_TEXT_MIN_MAX)
suffixOk++;
}
}
let gapOk = 0;
for (const roi of GATE_GAP_PROBES) {
if (meanBrightness(frame, roi) < GATE_GAP_MAX_MEAN) gapOk++;
}
const codeFraction = greenFraction(frame, REPLAY_CODE_ROI);
const rowCount = PANEL_XS.length * ROW_CENTERS.length;
const score =
(flatOk / rowCount +
suffixOk / rowCount +
gapOk / GATE_GAP_PROBES.length +
Math.min(1, codeFraction / (2 * GATE_CODE_MIN_FRACTION))) /
4;
const pass =
flatOk >= 7 &&
suffixOk >= 7 &&
gapOk === 2 &&
codeFraction >= GATE_CODE_MIN_FRACTION;
// browsing flips between replays never drop this gate, so it
// fingerprints the content that always differs between two battles
// (recording timestamp band + replay code) plus the name columns —
// the scheduler re-arms suppression when the fingerprint moves
const signature = pass ? contentSignature(gray) : undefined;
gray.delete();
return { pass, score, signature };
}
function contentSignature(gray: Mat): number[] {
const signature = roiSignature(gray, HEADER_TOP_BAND, 32, 2);
signature.push(...roiSignature(gray, REPLAY_CODE_ROI, 32, 1));
for (const dx of PANEL_XS) {
for (const cy of ROW_CENTERS) {
signature.push(...roiSignature(gray, nameRoi(cy, dx), 8, 1));
}
}
return signature;
}
function parsePanel(gray: Mat, rgb: Mat, dx: number): PanelParse {
const players: ScoreboardPlayer[] = [];
const rows: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
const rowRois: RowRois = {
weapon: (cy) => weaponRoi(cy, dx),
specialIcon: (cy) => specialIconRoi(cy, dx),
paint: (cy) => paintRoi(cy, dx),
name: (cy) => nameRoi(cy, dx),
stat: (cy, i) => statRoi(cy, dx, i),
povArrow: (cy) => povArrowRoi(cy, dx),
};
const rowResources = {
weapons: resources.weapons,
specials: resources.specials,
paintDigits,
statDigits,
nameGlyphs,
};
for (const cy of ROW_CENTERS) {
// A short team (e.g. a 7-player private battle) renders no pill for
// the unused bottom row — just near-black panel background where the
// flat probe expects the mid-gray pill (the gate's flatOk >= 7 already
// tolerates the missing row). Skip it: no phantom player.
const flat = meanBrightness(rgb, gateFlatProbe(cy, dx));
if (flat < GATE_FLAT_MIN_MEAN || flat > GATE_FLAT_MAX_MEAN) continue;
// replay rows render smaller (icons ~26px, inside the live template
// set's slide range) on a lighter panel; the paint number is
// left-aligned so the "p" suffix lands inside the ROI on short paints
const row = parseScoreboardRow(
gray,
rgb,
cy,
rowRois,
rowResources,
confidences,
{
weaponInkThreshold: REPLAY_INK_THRESHOLD,
paintDropLoweredTrailing: true,
},
);
players.push(row.player);
rows.push(row.debug);
}
// The panel's point total is read only to recognize a knockout below
// (the count times five: only a knockout's full count reaches 500);
// it is never emitted as a score.
let teamScore: ParsedNumber | null = null;
if (teamDigits) {
const crop = cropRoi(gray, teamScoreRoi(dx));
teamScore = parseNumber(crop, teamDigits, {
binThreshold: BANNER_BIN_THRESHOLD,
});
crop.delete();
confidences.push(teamScore.confidence);
}
let matchScore: ParsedNumber | null = null;
if (matchScoreDigits) {
const crop = cropRoi(gray, MATCH_SCORE_ROIS[dx === 0 ? 0 : 1]!);
matchScore = parseNumber(crop, matchScoreDigits, {
binThreshold: BANNER_BIN_THRESHOLD,
});
if (
matchScore.confidence < MATCH_SCORE_MIN_CONF ||
(matchScore.value !== null && matchScore.value > KO_MATCH_SCORE)
) {
matchScore = { ...matchScore, value: null };
}
crop.delete();
confidences.push(matchScore.confidence);
// No number under the floor + a full team count = the KNOCKOUT! burst
// sitting where the banner's score would be. Report the count it won at
// rather than a hole; an unreadable banner on a lesser total stays null.
if (
matchScore.value === null &&
teamScore?.value === FULL_COUNT_TEAM_SCORE
) {
matchScore = { ...matchScore, value: KO_MATCH_SCORE };
}
}
let result: PanelParse["result"] = null;
let resultReading = "";
let resultScore = 0;
if (resultGlyphs) {
const bright = maxChannel(rgb, resultTagRoi(dx));
const raw = recognizeText(bright, resultGlyphs, {
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
});
bright.delete();
resultReading = raw.text;
if (resultReading) {
const match = closestBy(
resultReading,
RESULT_TAG_ENTRIES,
(e) => e.text,
);
if (match) {
resultScore = match.score;
if (match.score >= RESULT_MIN_SCORE)
result = match.entry.canonical as PanelResult;
}
}
}
return {
players,
rows,
teamScore,
matchScore,
result,
resultReading,
resultScore,
confidences,
};
}
function parse(
frame: Mat,
t: number,
): DetectedEvent<ScoreboardBattleLogReplayData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const [left, right] = PANEL_XS.map((dx) => parsePanel(gray, rgb, dx)) as [
PanelParse,
PanelParse,
];
// Winners first. Trust a confident VICTORY/DEFEAT tag read; when the
// tags are inconclusive, the higher "Score:" banner marks the winner
// (the shown match score decides the game). Default to left otherwise.
let swapped = false;
if (left.result !== null || right.result !== null) {
swapped = left.result === "DEFEAT" || right.result === "VICTORY";
} else if (
left.matchScore?.value != null &&
right.matchScore?.value != null
) {
swapped = right.matchScore.value > left.matchScore.value;
}
const [winner, loser] = swapped ? [right, left] : [left, right];
// POV arrow row, indexed into the winners-first players ordering
const povIndex = findPovIndex(
[...winner.rows, ...loser.rows].map((r) => r.povFraction),
);
let header: ParsedReplayHeader | null = null;
if (headerTopGlyphs && headerBottomGlyphs) {
header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs);
}
let code: ParsedReplayCode | null = null;
if (codeGlyphs) {
code = parseReplayCode(rgb, codeGlyphs);
}
gray.delete();
rgb.delete();
const confidences = [
...winner.confidences,
...loser.confidences,
...(header ? [header.confidence] : []),
...(code ? [code.confidence] : []),
];
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
t,
confidence,
data: {
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
timestamp: header?.timestamp ?? null,
replayCode: code?.code ?? null,
matchScores: [
winner.matchScore?.value ?? null,
loser.matchScore?.value ?? null,
],
players: [...winner.players, ...loser.players],
povIndex,
},
debug: {
rows: [...winner.rows, ...loser.rows],
teamScoreConf: [
winner.teamScore?.confidence ?? 0,
loser.teamScore?.confidence ?? 0,
],
matchScoreConf: [
winner.matchScore?.confidence ?? 0,
loser.matchScore?.confidence ?? 0,
],
header: header?.debug,
codeRaw: code?.raw.text,
winnerSide: swapped ? "right" : "left",
resultTags: {
left: {
reading: left.resultReading,
score: left.resultScore,
result: left.result,
},
right: {
reading: right.resultReading,
score: right.resultScore,
result: right.result,
},
},
},
},
];
}
// no rearm cooldown — distinct replays browsed in quick succession are
// told apart by content. sufficientConfidence just under the measured
// clean-read floor (fixtures 0.808-0.890)
return {
id: "scoreboard-battle-log-replay",
sufficientConfidence: 0.8,
gate,
parse,
};
}

View File

@@ -0,0 +1,155 @@
/**
* ALL scoreboard-battle-log-replay ROI coordinates, in canonical 1920x1080
* space. Calibrated against scoreboard-battle-log-replay/private-battle-
* splat-zones-hagglefish via tools/overlay-rois.ts and column-projection
* measurement.
*
* The replay-browser detail screen shows two team panels SIDE BY SIDE
* (right = left shifted by PANEL_DX), four gray pill rows each — mid-gray
* (~61) with darker gaps (~23), unlike the live scoreboard's near-black
* pills (~12). Below the panels: replay owner line + bright green replay
* code; above: two "Score:" banners and the stage-photo header (line 1 =
* timestamp + stage, line 2 = lobby + mode).
*/
import type { Roi } from "../../canonical";
/** Vertical centers of the 4 player rows within each panel. */
export const ROW_CENTERS = [573, 654, 735, 816] as const;
/** Horizontal shift from a left-panel ROI to its right-panel twin. */
export const PANEL_DX = 676;
/** dx per panel: [left (index 0), right (index 1)]. */
export const PANEL_XS = [0, PANEL_DX] as const;
/**
* Weapon icon search region — full pill height so the largest template
* (64; icons render ~60-64px) fits with slide room (matchTemplate skips
* taller templates).
*/
export function weaponRoi(cy: number, dx: number): Roi {
return { x: 522 + dx, y: cy - 34, w: 96, h: 68 };
}
/**
* Special-weapon icon on a black disc above the third stat counter (~25x29
* art at x 1109-1134, y cy-29..cy). Only read to break near-tied weapon
* matches with different specials; box stays inside the disc since the
* mid-gray pill around it sits above matchSpecial's ink threshold.
*/
export function specialIconRoi(cy: number, dx: number): Roi {
return { x: 1104 + dx, y: cy - 32, w: 38, h: 33 };
}
/**
* Player name text region (white, left-aligned; descenders reach cy+18).
* Long names run into the paint column; parse paint first and trim at its
* leftmost digit. Kept narrower than the first glyph to dodge weapon-icon bleed.
*/
export function nameRoi(cy: number, dx: number): Roi {
return { x: 620 + dx, y: cy - 16, w: 226, h: 37 };
}
/**
* Paint digits, LEFT-aligned from x~843 (~18px pitch); the trailing "p"
* moves with digit count, so short (3-digit) paints put it inside this
* region — parseNumber's digit-only charset drops it.
*/
export function paintRoi(cy: number, dx: number): Roi {
return { x: 821 + dx, y: cy - 15, w: 88, h: 34 };
}
/**
* Gate anchor over the "p" after the paint number. Since it's left-aligned,
* "p" position tracks digit count (x 898-907 after 3 digits, 915-924
* after 4) — the probe spans both.
*/
export function paintSuffixRoi(cy: number, dx: number): Roi {
return { x: 896 + dx, y: cy - 13, w: 31, h: 26 };
}
/**
* Stat counter digits (zero-padded, "x" prefix excluded), cy+4..cy+23 —
* the top edge must stay below the stat icons, which bleed ink above it.
*/
export function statRoi(cy: number, dx: number, index: 0 | 1 | 2): Roi {
const x = [1000, 1057, 1114][index]!;
return { x: x + dx, y: cy + 3, w: 36, h: 24 };
}
/**
* POV arrow: smaller replay-browser arrow on the pill's left edge (x
* 487-530). Right edge stays short of the weapon-icon region (x 522+).
*/
export function povArrowRoi(cy: number, dx: number): Roi {
return { x: 480 + dx, y: cy - 32, w: 54, h: 56 };
}
/** Team totals ("440p") on the VICTORY/DEFEAT banner, digits ending x~1119. */
export function teamScoreRoi(dx: number): Roi {
return { x: 1040 + dx, y: 481, w: 86, h: 36 };
}
/** VICTORY / DEFEAT tag on each panel banner — decides which panel won (owner's team may sit either side). */
export function resultTagRoi(dx: number): Roi {
return { x: 540 + dx, y: 460, w: 220, h: 50 };
}
/** The colored "Score: NN" banners; digits after the constant label. */
export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 742, y: 340, w: 130, h: 56 },
{ x: 1620, y: 340, w: 130, h: 56 },
];
/**
* Header bands on the stage-photo banner: line 1 timestamp + stage tag,
* line 2 lobby + mode. Tags size to their text; the header parser trims
* each band to the tag extent.
*/
export const HEADER_TOP_BAND: Roi = { x: 500, y: 68, w: 560, h: 46 };
/**
* Wide lobby tags push the mode tag right ("Anarchy Battle (Open)" +
* "Rainmaker" ends x~1134), so the band runs past the longest observed pair.
*/
export const HEADER_BOTTOM_BAND: Roi = { x: 500, y: 124, w: 700, h: 58 };
/**
* Bright green replay code line ("XXXX-XXXX-XXXX-XXXX"), left-aligned
* after the magnifier icon. Width tracks the glyphs, so a wide-letter
* code can run past x=913 — box extends into the background to fit it.
*/
export const REPLAY_CODE_ROI: Roi = { x: 574, y: 960, w: 400, h: 38 };
/** Gate probe: flat pill background strip between paint "p" and first stat "x" — mid-gray here, not near-black. */
export function gateFlatProbe(cy: number, dx: number): Roi {
return { x: 930 + dx, y: cy - 10, w: 42, h: 20 };
}
/** Dark gap between row 1 and row 2 pills, one strip per panel. */
export const GATE_GAP_PROBES: readonly Roi[] = [
{ x: 560, y: 611, w: 540, h: 5 },
{ x: 560 + PANEL_DX, y: 611, w: 540, h: 5 },
];
/** Flat pill strips must sit in this mid-gray band. */
export const GATE_FLAT_MIN_MEAN = 45;
export const GATE_FLAT_MAX_MEAN = 78;
/** The inter-pill gap is darker than the pills. */
export const GATE_GAP_MAX_MEAN = 40;
/** The paint "p" suffix region must contain bright pixels. */
export const GATE_TEXT_MIN_MAX = 180;
/** Replay-code color probe: fraction of REPLAY_CODE_ROI pixels that are green-ish (high G, low B) — unique to this screen. */
export const GATE_CODE_GREEN_MIN = 140;
export const GATE_CODE_BLUE_MAX = 90;
export const GATE_CODE_MIN_FRACTION = 0.03;
/** Text metrics measured on the fixture, used for glyph scaling / tooling. */
export const NAME_TEXT_HEIGHT = 24;
export const PAINT_DIGIT_HEIGHT = 26;
export const STAT_DIGIT_HEIGHT = 20;
export const TEAM_DIGIT_HEIGHT = 27;
export const MATCH_SCORE_DIGIT_HEIGHT = 41;
export const HEADER_TIMESTAMP_HEIGHT = 24;
export const HEADER_LINE_HEIGHT = 29;
export const RESULT_TAG_TEXT_HEIGHT = 27;
export const CODE_TEXT_HEIGHT = 25;

View File

@@ -0,0 +1,417 @@
/**
* ScoreboardBattleLogDetector: parses the Recent Battles detail screen
* (battle log) — the same match data as the live scoreboard (header, team
* scores, 8 player rows) plus the recording timestamp, but no replay code.
*
* The two team panels sit STACKED (observed winner on top, confirmed by the
* VICTORY/DEFEAT tags) and the row text renders at the live scoreboard's
* sizes, so field parsing reuses the scoreboard helpers with the shared
* glyph sets unscaled — only the ROI geometry is this screen's own. The
* header is the replay browser's (timestamp + stage / lobby + mode tags),
* parsed with battle log bands.
*/
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import {
cropRoi,
maxBrightness,
maxChannel,
meanBrightness,
roiSignature,
} from "../../image";
import { RESULT_TAG_ENTRIES } from "../../localized";
import { closestBy } from "../../text";
import {
type BannerScoreRead,
FULL_COUNT_TEAM_SCORE,
parseBannerScore,
resolveMatchScores,
} from "../scoreboard/banner";
import { type ParsedNumber, parseNumber } from "../scoreboard/digits";
import type {
ScoreboardData,
ScoreboardPlayer,
ScoreboardResources,
ScoreboardRowDebug,
} from "../scoreboard/index";
import { findPovIndex } from "../scoreboard/pov";
import { parseScoreboardRow, type RowRois } from "../scoreboard/row";
import {
type ParsedReplayHeader,
parseReplayHeader,
} from "../scoreboard-battle-log-replay/header";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
GATE_COLOR_MIN_SATURATION,
GATE_COLOR_PROBES,
GATE_DARK_MAX_MEAN,
GATE_TEXT_MIN_MAX,
gateDarkProbe,
HEADER_BOTTOM_BAND,
HEADER_LINE_HEIGHT,
HEADER_TAG_COLUMN_FRACTION,
HEADER_TAG_LEAD_IN_MAX,
HEADER_TIMESTAMP_HEIGHT,
HEADER_TOP_BAND,
MATCH_SCORE_DIGIT_HEIGHT,
MATCH_SCORE_ROIS,
nameRoi,
PANEL_DYS,
paintRoi,
paintSuffixRoi,
povArrowRoi,
RESULT_TAG_TEXT_HEIGHT,
ROW_CENTERS,
resultTagRoi,
specialIconRoi,
statRoi,
teamScoreRoi,
weaponRoi,
} from "./rois";
export interface ScoreboardBattleLogData extends ScoreboardData {
/** recording timestamp as shown, e.g. "5/8/2026 19:16"; locale-formatted */
timestamp: string | null;
}
export const SCOREBOARD_BATTLE_LOG_EVENT_TYPE = "ScoreboardBattleLog";
/**
* White outlined team totals on the panel's saturated color band — a yellow
* band grays at ~190, so binarize just above it (the digit cores are ~250).
*/
const TEAM_SCORE_BIN_THRESHOLD = 205;
/** Canonical results the localized VICTORY/DEFEAT panel tags snap to. */
type PanelResult = "VICTORY" | "DEFEAT";
const RESULT_MIN_SCORE = 0.6;
/**
* The tag letters render in the team's ink color on the gray stamp box
* (~75 gray, trailing status icons ~120), so binarize the max-channel
* image just above the icons — every ink color's brightest channel
* clears this.
*/
const RESULT_TAG_BIN_THRESHOLD = 140;
interface PanelParse {
players: ScoreboardPlayer[];
rows: ScoreboardRowDebug[];
teamScore: ParsedNumber | null;
result: PanelResult | null;
resultReading: string;
resultScore: number;
confidences: number[];
}
export function createScoreboardBattleLogDetector(
resources: ScoreboardResources,
): Detector<ScoreboardBattleLogData> {
const cv = getCV();
const scaled = (set: GlyphSet | null, height: number): GlyphSet | null =>
set ? scaleGlyphSet(set, height / set.height) : null;
const teamDigits = resources.teamDigits ?? resources.paintDigits;
const matchScoreSets = teamDigits
? [scaleGlyphSet(teamDigits, MATCH_SCORE_DIGIT_HEIGHT / teamDigits.height)]
: [];
/** Timestamp needs digits + '/' + ':' — only the names atlas has them. */
const headerTopGlyphs = scaled(resources.nameGlyphs, HEADER_TIMESTAMP_HEIGHT);
const headerBottomGlyphs = scaled(
resources.headerLineGlyphs,
HEADER_LINE_HEIGHT,
);
// The tags render in FOT-RowdyStd — use the dedicated atlas when present;
// the BlitzMain-based fallback reads them only roughly.
const resultGlyphs =
scaled(resources.replayResultGlyphs ?? null, RESULT_TAG_TEXT_HEIGHT) ??
scaled(resources.headerLineGlyphs, RESULT_TAG_TEXT_HEIGHT);
/** Mean-RGB saturation (max minus min channel) of a probe ROI. */
function probeSaturation(frame: Mat, roi: (typeof GATE_COLOR_PROBES)[0]) {
const view = cropRoi(frame, roi);
const cont = new cv.Mat();
view.copyTo(cont);
view.delete();
const d = cont.data;
const n = cont.rows * cont.cols;
let r = 0;
let g = 0;
let b = 0;
for (let i = 0; i < n; i++) {
r += d[i * 4]!;
g += d[i * 4 + 1]!;
b += d[i * 4 + 2]!;
}
cont.delete();
if (n === 0) return 0;
return (Math.max(r, g, b) - Math.min(r, g, b)) / n;
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
let darkOk = 0;
let suffixOk = 0;
for (const dy of PANEL_DYS) {
for (const base of ROW_CENTERS) {
const cy = base + dy;
if (meanBrightness(frame, gateDarkProbe(cy)) < GATE_DARK_MAX_MEAN)
darkOk++;
if (maxBrightness(gray, paintSuffixRoi(cy)) > GATE_TEXT_MIN_MAX)
suffixOk++;
}
}
let colorOk = 0;
for (const roi of GATE_COLOR_PROBES) {
if (probeSaturation(frame, roi) >= GATE_COLOR_MIN_SATURATION) colorOk++;
}
const rowCount = PANEL_DYS.length * ROW_CENTERS.length;
const score =
(darkOk / rowCount +
suffixOk / rowCount +
colorOk / GATE_COLOR_PROBES.length) /
3;
const pass = darkOk >= 7 && suffixOk >= 7 && colorOk === 3;
// browsing flips between entries never drop this gate, so it
// fingerprints the content that always differs between two battles
// (recording timestamp + stage tag) plus the name column — the
// scheduler re-arms suppression when the fingerprint moves
const signature = pass ? contentSignature(gray) : undefined;
gray.delete();
return { pass, score, signature };
}
function contentSignature(gray: Mat): number[] {
const signature = roiSignature(gray, HEADER_TOP_BAND, 32, 2);
for (const dy of PANEL_DYS) {
for (const base of ROW_CENTERS) {
signature.push(...roiSignature(gray, nameRoi(base + dy), 8, 1));
}
}
return signature;
}
function parsePanel(gray: Mat, rgb: Mat, dy: number): PanelParse {
const players: ScoreboardPlayer[] = [];
const rows: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
const rowRois: RowRois = {
weapon: weaponRoi,
specialIcon: specialIconRoi,
paint: paintRoi,
name: nameRoi,
stat: statRoi,
povArrow: povArrowRoi,
};
for (const base of ROW_CENTERS) {
const row = parseScoreboardRow(
gray,
rgb,
base + dy,
rowRois,
resources,
confidences,
);
players.push(row.player);
rows.push(row.debug);
}
// The panel's point total is read only to recognize a knockout (the
// count times five: only a knockout's full count reaches 500); it is
// never emitted as a score.
let teamScore: ParsedNumber | null = null;
if (teamDigits) {
const crop = cropRoi(gray, teamScoreRoi(dy));
teamScore = parseNumber(crop, teamDigits, {
binThreshold: TEAM_SCORE_BIN_THRESHOLD,
});
crop.delete();
confidences.push(teamScore.confidence);
}
let result: PanelParse["result"] = null;
let resultReading = "";
let resultScore = 0;
if (resultGlyphs) {
const bright = maxChannel(rgb, resultTagRoi(dy));
const raw = recognizeText(bright, resultGlyphs, {
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
});
bright.delete();
resultReading = raw.text;
if (resultReading) {
const match = closestBy(
resultReading,
RESULT_TAG_ENTRIES,
(e) => e.text,
);
if (match) {
resultScore = match.score;
if (match.score >= RESULT_MIN_SCORE)
result = match.entry.canonical as PanelResult;
}
}
}
return {
players,
rows,
teamScore,
result,
resultReading,
resultScore,
confidences,
};
}
function parse(
frame: Mat,
t: number,
): DetectedEvent<ScoreboardBattleLogData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const [top, bottom] = PANEL_DYS.map((dy) => parsePanel(gray, rgb, dy)) as [
PanelParse,
PanelParse,
];
let left: BannerScoreRead | null = null;
let right: BannerScoreRead | null = null;
if (matchScoreSets.length > 0) {
left = parseBannerScore(gray, MATCH_SCORE_ROIS[0], matchScoreSets);
right = parseBannerScore(gray, MATCH_SCORE_ROIS[1], matchScoreSets);
}
const swapped = decideSwapped(top, bottom, left, right);
const [winner, loser] = swapped ? [bottom, top] : [top, bottom];
// POV arrow row, indexed into the winners-first players ordering
const povIndex = findPovIndex(
[...winner.rows, ...loser.rows].map((r) => r.povFraction),
);
const knockout = winner.teamScore?.value === FULL_COUNT_TEAM_SCORE;
let matchScores: [number | null, number | null] = [null, null];
let bannerDebug: object | undefined;
if (left && right) {
matchScores = resolveMatchScores({ left, right, knockout });
winner.confidences.push(left.confidence, right.confidence);
bannerDebug = { left, right, knockout };
}
let header: ParsedReplayHeader | null = null;
if (headerTopGlyphs && headerBottomGlyphs) {
header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs, {
top: HEADER_TOP_BAND,
bottom: HEADER_BOTTOM_BAND,
tagLeadInMax: HEADER_TAG_LEAD_IN_MAX,
tagColumnFraction: HEADER_TAG_COLUMN_FRACTION,
});
}
gray.delete();
rgb.delete();
const confidences = [
...winner.confidences,
...loser.confidences,
...(header ? [header.confidence] : []),
];
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
t,
confidence,
data: {
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
timestamp: header?.timestamp ?? null,
matchScores,
players: [...winner.players, ...loser.players],
povIndex,
},
debug: {
rows: [...winner.rows, ...loser.rows],
teamScoreConf: [
winner.teamScore?.confidence ?? 0,
loser.teamScore?.confidence ?? 0,
],
matchScore: bannerDebug,
header: header?.debug,
winnerSide: swapped ? "bottom" : "top",
resultTags: {
top: {
reading: top.resultReading,
score: top.resultScore,
result: top.result,
},
bottom: {
reading: bottom.resultReading,
score: bottom.resultScore,
result: bottom.result,
},
},
},
},
];
}
// no rearm cooldown — distinct battles browsed in quick succession are
// told apart by content: the gate signature re-arms scheduler suppression
// and the timeline merges via sameScoreboardMatch (same as the replay
// browser)
return {
id: "scoreboard-battle-log",
sufficientConfidence: 0.8,
gate,
parse,
};
}
/**
* Whether the winner sits in the bottom panel. A confident VICTORY/DEFEAT
* tag decides (the distressed tag texture usually reads below the floor);
* otherwise the panel totals are checked against the banner scores — the
* total is the count times five, and only a knockout winner's reaches 500.
* Default: winner on top, which every observed battle log screen shows.
*/
function decideSwapped(
top: PanelParse,
bottom: PanelParse,
left: BannerScoreRead | null,
right: BannerScoreRead | null,
): boolean {
if (top.result !== null || bottom.result !== null) {
return top.result === "DEFEAT" || bottom.result === "VICTORY";
}
const topTotal = top.teamScore?.value ?? null;
const bottomTotal = bottom.teamScore?.value ?? null;
if (topTotal === FULL_COUNT_TEAM_SCORE) return false;
if (bottomTotal === FULL_COUNT_TEAM_SCORE) return true;
if (
left?.value != null &&
right?.value != null &&
topTotal !== null &&
bottomTotal !== null &&
topTotal !== bottomTotal
) {
const hi = Math.max(left.value, right.value) * 5;
const lo = Math.min(left.value, right.value) * 5;
if (topTotal === lo && bottomTotal === hi) return true;
}
return false;
}

View File

@@ -0,0 +1,145 @@
/**
* ALL scoreboard-battle-log ROI coordinates, in canonical 1920x1080 space.
* Calibrated against scoreboard-battle-log/private-battle-splat-zones-
* makomart and x-battle-clam-blitz-lemuria via scripts/scanner/dump-crops.ts
* and column-projection measurement.
*
* The Recent Battles detail screen shows two team panels STACKED (bottom =
* top shifted by PANEL_DY), four near-black pill rows each on a dark panel
* whose top band/border carry the team's ink color. Text sizes match the
* live scoreboard (row glyphs reused unscaled, only columns differ). Above
* the panels: split "Score:"/KNOCKOUT! banner and stage-photo header
* (line 1 = timestamp + stage, line 2 = lobby + mode). No replay code line.
*/
import type { Roi } from "../../canonical";
/** Vertical centers of the 4 player rows within the top panel. */
export const ROW_CENTERS = [453, 519, 585, 651] as const;
/** Vertical shift from a top-panel ROI to its bottom-panel twin. */
export const PANEL_DY = 366;
/** dy per panel: [top (index 0), bottom (index 1)]. */
export const PANEL_DYS = [0, PANEL_DY] as const;
/**
* Weapon icon search region — icons render at live-scoreboard sizes; 56px
* height excludes larger replay-browser templates (matchTemplate skips them).
*/
export function weaponRoi(cy: number): Roi {
return { x: 1040, y: cy - 28, w: 76, h: 56 };
}
/**
* Special-weapon icon on the pill above the third stat counter (~x
* 1645-1692, y cy-20..cy+2). Only read to break near-tied weapon matches
* with different specials; bounded at cy+2 to keep counter digits out.
*/
export function specialIconRoi(cy: number): Roi {
return { x: 1642, y: cy - 24, w: 52, h: 26 };
}
/**
* Player name text region (white, left-aligned x=1114). Long names run
* into paint; parse paint first and trim at its leftmost digit.
*/
export function nameRoi(cy: number): Roi {
return { x: 1110, y: cy - 14, w: 208, h: 32 };
}
/** Paint amount digits, right-aligned ending at x=1396 (the "p" suffix is excluded). */
export function paintRoi(cy: number): Roi {
return { x: 1312, y: cy - 17, w: 88, h: 34 };
}
/** The constant white "p" after the paint number — used as a gate anchor. */
export function paintSuffixRoi(cy: number): Roi {
return { x: 1398, y: cy - 14, w: 18, h: 28 };
}
/** Stat counter digits (two, zero-padded; the small "x" prefix is excluded). */
export function statRoi(cy: number, index: 0 | 1 | 2): Roi {
const x = [1530, 1593, 1656][index]!;
return { x, y: cy + 3, w: 30, h: 22 };
}
/**
* POV arrow on the panel left of the pill (x 977-1024). Right edge stays
* short of the pill's rounded cap (~x 1032).
*/
export function povArrowRoi(cy: number): Roi {
return { x: 968, y: cy - 32, w: 60, h: 62 };
}
/**
* Team point totals ("500 p") on the colored top band, ending x=1704.
* Only read to recognize a knockout (winner total 500), not the score.
*/
export function teamScoreRoi(dy: number): Roi {
return { x: 1614, y: 371 + dy, w: 96, h: 38 };
}
/**
* VICTORY/DEFEAT tag on each panel's top band — confirms which panel won
* (observed always the top one; letters render in team ink on gray stamp).
*/
export function resultTagRoi(dy: number): Roi {
return { x: 990, y: 358 + dy, w: 215, h: 52 };
}
/**
* The two sides of the "Score:" banner above the panels. Left follows the
* localized label (from x~985), right ends x~1744. A knockout replaces
* the winning side's value with the KNOCKOUT! burst.
*/
export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 985, y: 278, w: 265, h: 40 },
{ x: 1648, y: 278, w: 100, h: 40 },
];
/**
* Header bands on the stage-photo banner. Line 1: timestamp + stage tag
* (rank icon on ranked lobbies shifts the left edge); line 2: lobby
* (bold, x=809) + mode. Bands hug the tag rows tightly since the photo
* below has bright pixels that would ink glyph bottoms; readTagBand scans
* for the tag start (HEADER_TAG_LEAD_IN_MAX) instead of anchoring an edge.
*/
export const HEADER_TOP_BAND: Roi = { x: 800, y: 74, w: 668, h: 32 };
export const HEADER_BOTTOM_BAND: Roi = { x: 800, y: 122, w: 728, h: 46 };
export const HEADER_TAG_LEAD_IN_MAX = 40;
/** see TagBandOptions.tagColumnFraction — the tags are subtly tilted */
export const HEADER_TAG_COLUMN_FRACTION = 0.75;
/**
* Gate probe: strip between paint "p" (ends 1413) and first stat "x"
* (starts 1517) is always empty pill background (near-black ~20).
*/
export function gateDarkProbe(cy: number): Roi {
return { x: 1422, y: cy - 10, w: 78, h: 20 };
}
/**
* Ink-color probes discriminate against two lookalike results screens:
* panels' top bands and score banner are saturated team color here (109+),
* while the live scoreboard is neutral gray and the replay browser only
* saturated at the first spot (its own banner).
*/
export const GATE_COLOR_PROBES: readonly Roi[] = [
{ x: 1300, y: 382, w: 100, h: 16 },
{ x: 1300, y: 748, w: 100, h: 16 },
{ x: 1400, y: 292, w: 60, h: 14 },
];
/** Mean-RGB saturation (max minus min channel) floor for the color probes. */
export const GATE_COLOR_MIN_SATURATION = 60;
/** The strip between p suffix and stats must stay near-black. */
export const GATE_DARK_MAX_MEAN = 45;
/** The paint "p" suffix region must contain bright (white) pixels. */
export const GATE_TEXT_MIN_MAX = 180;
/** Text metrics measured on the fixtures, used for glyph scaling / tooling. */
export const MATCH_SCORE_DIGIT_HEIGHT = 28;
export const HEADER_TIMESTAMP_HEIGHT = 21;
export const HEADER_LINE_HEIGHT = 26;
export const RESULT_TAG_TEXT_HEIGHT = 30;

View File

@@ -0,0 +1,32 @@
/**
* Ability badge templates for the personal-results gear cards. Same
* composite-and-resize pipeline as the death panel (death/abilities.ts),
* at this screen's smaller badge sizes and its gray-strip ink threshold.
*/
import type { FrameData } from "../../image";
import { type AbilityTemplates, buildAbilityRole } from "../death/abilities";
import {
OWN_ABILITY_ART_RATIO,
OWN_ABILITY_INK_THRESHOLD,
OWN_ABILITY_MAIN_SIZES,
OWN_ABILITY_SUB_SIZES,
} from "./rois";
export function prepareOwnAbilityTemplates(
icons: { id: string; image: FrameData }[],
): AbilityTemplates {
return {
mains: buildAbilityRole(
icons,
OWN_ABILITY_MAIN_SIZES,
OWN_ABILITY_ART_RATIO,
OWN_ABILITY_INK_THRESHOLD,
),
subs: buildAbilityRole(
icons,
OWN_ABILITY_SUB_SIZES,
OWN_ABILITY_ART_RATIO,
OWN_ABILITY_INK_THRESHOLD,
),
};
}

View File

@@ -0,0 +1,268 @@
/**
* ScoreboardOwnDetector: parses the personal results screen (the "your
* results" view after a match) — lobby/mode/stage from the header tags
* (identical to the live scoreboard header, parsing is shared), the
* player's main weapon from the weapon card's title tag, and the own gear
* abilities from the three gear cards' badge strips.
*
* The weapon arrives as text: the title is OCR'd with the death-weapon
* atlas (the only atlas carrying the weapon-name charset) rescaled to this
* screen's title size, then snapped against every language's main-weapon
* names at once and reported under its canonical English name.
*/
import type {
AbilityWithUnknown,
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import {
type ScannerLobby,
toAbilityWithUnknown,
toMainWeaponId,
} from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import { copyRoi, cropRoi, maxBrightness, meanBrightness } from "../../image";
import { closestBy, matchKey } from "../../text";
import { LOCALIZED_WEAPON_NAMES } from "../death/localized-messages";
import { ALL_WEAPON_ENTRIES, type WeaponEntry } from "../death/weapon-names";
import { type ParsedHeader, parseHeader } from "../scoreboard/header";
import type { ScoreboardResources } from "../scoreboard/index";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
GATE_PANEL_MAX_MEAN,
GATE_PANEL_PROBES,
GATE_STRIP_MAX_MEAN,
GATE_STRIP_MIN_MEAN,
GATE_TEXT_MIN_MAX,
GATE_TITLE_TEXT_PROBES,
GEAR_ROWS,
gateStripProbe,
gearMainRoi,
gearSubRoi,
OWN_ABILITY_INK_THRESHOLD,
WEAPON_TITLE_BAND,
WEAPON_TITLE_BIN_THRESHOLD,
WEAPON_TITLE_TEXT_HEIGHT,
} from "./rois";
export interface ScoreboardOwnData {
/** from the header tag; null when unreadable */
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** the player's main weapon; null if unreadable */
weaponId: MainWeaponId | null;
/**
* own gear abilities, [head, clothes, shoes] rows of
* [main, sub, sub, sub] ability ids
*/
abilities: AbilityWithUnknown[][];
}
export const SCOREBOARD_OWN_EVENT_TYPE = "ScoreboardOwn";
/** Snapped weapon reading below this is reported as null (kept in debug). */
const WEAPON_MIN_SCORE = 0.55;
interface WeaponCandidate {
text: string;
entry: WeaponEntry;
}
/**
* Every string the weapon card title can show: all languages' localized
* main-weapon names plus the canonical English names (localized-messages
* omits names identical to English).
*/
let weaponCandidates: WeaponCandidate[] | null = null;
function mainWeaponCandidates(): WeaponCandidate[] {
if (weaponCandidates) return weaponCandidates;
const mains = ALL_WEAPON_ENTRIES.filter((e) => e.type === "MAIN");
const byName = new Map(mains.map((e) => [e.name, e]));
const seen = new Set<string>();
weaponCandidates = [];
const push = (text: string, entry: WeaponEntry | undefined) => {
const k = matchKey(text);
if (!entry || seen.has(k)) return;
seen.add(k);
weaponCandidates!.push({ text, entry });
};
for (const entry of mains) push(entry.name, entry);
for (const names of Object.values(LOCALIZED_WEAPON_NAMES)) {
for (const { text, name } of names) push(text, byName.get(name));
}
return weaponCandidates;
}
export function createScoreboardOwnDetector(
resources: ScoreboardResources,
): Detector<ScoreboardOwnData> {
const cv = getCV();
const titleGlyphs: GlyphSet | null = resources.deathWeaponGlyphs
? scaleGlyphSet(
resources.deathWeaponGlyphs,
WEAPON_TITLE_TEXT_HEIGHT / resources.deathWeaponGlyphs.height,
)
: null;
const abilities = resources.ownAbilities ?? null;
function gate(frame: Mat): GateResult {
let panelOk = 0;
for (const roi of GATE_PANEL_PROBES) {
if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
let textOk = 0;
for (const roi of GATE_TITLE_TEXT_PROBES) {
if (maxBrightness(gray, roi) > GATE_TEXT_MIN_MAX) textOk++;
}
gray.delete();
let stripOk = 0;
for (let row = 0; row < GEAR_ROWS; row++) {
const mean = meanBrightness(frame, gateStripProbe(row));
if (mean >= GATE_STRIP_MIN_MEAN && mean <= GATE_STRIP_MAX_MEAN) stripOk++;
}
const score =
(panelOk / GATE_PANEL_PROBES.length +
textOk / GATE_TITLE_TEXT_PROBES.length +
stripOk / GEAR_ROWS) /
3;
const pass =
panelOk === GATE_PANEL_PROBES.length &&
textOk === GATE_TITLE_TEXT_PROBES.length &&
stripOk === GEAR_ROWS;
return { pass, score };
}
function parse(frame: Mat, t: number): DetectedEvent<ScoreboardOwnData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const confidences: number[] = [];
// header tags sit at the live scoreboard's positions — shared parser
let header: ParsedHeader | null = null;
if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) {
header = parseHeader(
gray,
resources.headerLobbyGlyphs,
resources.headerLineGlyphs,
);
confidences.push(header.confidence);
}
// Weapon card title, snapped to the main-weapon closed set. The band is
// recognized whole, NOT via readTagBand: the tag is fixed-width (the
// band lies entirely inside it, there is nothing to trim away), and the
// extent trim actively harms it — long names render horizontally
// condensed, whose dense antialiased columns fail the dark-or-bright
// tag-column test and truncate the read mid-name.
let weapon: string | null = null;
let weaponId: MainWeaponId | null = null;
let weaponScore = 0;
let weaponReading = "";
if (titleGlyphs) {
const band = copyRoi(gray, WEAPON_TITLE_BAND);
weaponReading = recognizeText(band, titleGlyphs, {
binThreshold: WEAPON_TITLE_BIN_THRESHOLD,
spaceGap: 9,
minCharScore: 0.3,
}).text.trim();
band.delete();
const match = weaponReading
? closestBy(weaponReading, mainWeaponCandidates(), (c) => c.text)
: null;
if (match) {
weaponScore = match.score;
if (match.score >= WEAPON_MIN_SCORE) {
weapon = match.entry.entry.name;
weaponId = toMainWeaponId(match.entry.entry.id);
}
}
confidences.push(weaponScore);
}
// gear-card ability strips: [head, clothes, shoes] x [main, sub, sub, sub]
const abilityRows: AbilityWithUnknown[][] = [];
const abilityDebug: (WeaponMatch | null)[][] = [];
if (abilities) {
for (let row = 0; row < GEAR_ROWS; row++) {
const ids: AbilityWithUnknown[] = [];
const debug: (WeaponMatch | null)[] = [];
const mainCrop = cropRoi(rgb, gearMainRoi(row));
const main = matchWeapon(mainCrop, abilities.mains, {
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
});
mainCrop.delete();
ids.push(toAbilityWithUnknown(main.id) ?? "UNKNOWN");
debug.push(main);
confidences.push(Math.max(0, main.score));
for (let slot = 0; slot < 3; slot++) {
const crop = cropRoi(rgb, gearSubRoi(row, slot));
const sub = matchWeapon(crop, abilities.subs, {
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
});
crop.delete();
ids.push(toAbilityWithUnknown(sub.id) ?? "UNKNOWN");
debug.push(sub);
confidences.push(Math.max(0, sub.score));
}
abilityRows.push(ids);
abilityDebug.push(debug);
}
}
gray.delete();
rgb.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: SCOREBOARD_OWN_EVENT_TYPE,
t,
confidence,
data: {
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
weaponId,
abilities: abilityRows,
},
debug: {
header: header?.debug,
weaponName: weapon,
weaponReading,
weaponScore,
abilityRows: abilityDebug.map((row) =>
row.map((m) => m && { top: m.top, score: m.score }),
),
},
},
];
}
// just under the measured clean-read floor (fixtures 0.562-0.669,
// confirmed scan events 0.612-0.635 — this screen's ability-grid scores
// keep the mean low even on perfect reads)
return {
id: "scoreboard-own",
sufficientConfidence: 0.55,
gate,
parse,
};
}

View File

@@ -0,0 +1,90 @@
/**
* ALL scoreboard-own ROI coordinates, in canonical 1920x1080 space.
* Calibrated against scoreboard-own/private-battle-splat-zones-museum via
* tools/dump-crops.ts and column/row projection measurement.
*
* The personal results screen ("your results") shows the same header tags
* as the live scoreboard (same positions, reuses its bands) over a dark
* panel (~35) with the player's banner, medals list, and four bottom
* cards: weapon card (yellow-bordered title tag, big render on white
* square) then one gear card per slot, each with a gray ability strip of
* one main badge (⌀~49) and three sub badges (⌀~38).
*/
import type { Roi } from "../../canonical";
/**
* Weapon card title tag interior (black, white name left-aligned). Band
* starts inside the tag so readTagBand's extent trim anchors immediately.
*/
export const WEAPON_TITLE_BAND: Roi = { x: 876, y: 766, w: 200, h: 32 };
/** Tight cap height of the title text at 1080p. */
export const WEAPON_TITLE_TEXT_HEIGHT = 18;
/** White-core title text on the black tag binarizes high, like the burst text. */
export const WEAPON_TITLE_BIN_THRESHOLD = 190;
/** Gear cards [head, clothes, shoes]: main-ability badge center x per row. */
export const GEAR_MAIN_CXS = [1142, 1372, 1602] as const;
/** Sub badge center x offsets from the row's main badge center. */
const GEAR_SUB_DXS = [48, 88, 127] as const;
/** All badges share one vertical center (the ability strip line). */
export const GEAR_BADGE_CY = 927;
export const GEAR_ROWS = 3;
/**
* Search boxes around each badge; heights double as the size filter
* (matchTemplate skips templates taller than the ROI).
*/
export function gearMainRoi(row: number): Roi {
const cx = GEAR_MAIN_CXS[row]!;
return { x: cx - 32, y: GEAR_BADGE_CY - 32, w: 64, h: 64 };
}
export function gearSubRoi(row: number, slot: number): Roi {
const cx = GEAR_MAIN_CXS[row]! + GEAR_SUB_DXS[slot]!;
return { x: cx - 26, y: GEAR_BADGE_CY - 26, w: 52, h: 52 };
}
/** Template heights (px at 1080p) per badge role (main ⌀~49, sub ⌀~38). */
export const OWN_ABILITY_MAIN_SIZES = [45, 49, 53] as const;
export const OWN_ABILITY_SUB_SIZES = [34, 38, 42] as const;
/**
* Icon art diameter as fraction of badge box: ratio peaks at 1.0 for both
* roles (art overflows the circle slightly), so the ring contributes nothing.
*/
export const OWN_ABILITY_ART_RATIO = 1.0;
/**
* Ink threshold inside a badge box. Unlike the death panel these sit on a
* light-gray strip (~140-155); 170 keeps the strip out while art still
* clears it on its max channel.
*/
export const OWN_ABILITY_INK_THRESHOLD = 170;
/**
* Gate probes. Results panel is uniform dark (~35, edge ~54) at spots
* dodging the banner/medals/cards; each title tag holds bright white
* text at its left edge; the strip's gray shows after the third sub badge.
*/
export const GATE_PANEL_PROBES: readonly Roi[] = [
{ x: 860, y: 245, w: 30, h: 20 },
{ x: 1690, y: 395, w: 30, h: 20 },
{ x: 875, y: 695, w: 30, h: 20 },
{ x: 1400, y: 985, w: 30, h: 20 },
];
export const GATE_PANEL_MAX_MEAN = 65;
/** Left edge of each card's title text (weapon card first). */
export const GATE_TITLE_TEXT_PROBES: readonly Roi[] = [
{ x: 880, y: 768, w: 70, h: 26 },
{ x: 1090, y: 768, w: 70, h: 26 },
{ x: 1319, y: 768, w: 70, h: 26 },
{ x: 1550, y: 768, w: 70, h: 26 },
];
/** Title text regions must contain bright (white) pixels. */
export const GATE_TEXT_MIN_MAX = 180;
/** Gray ability-strip gap after the third sub badge, one per gear card. */
export function gateStripProbe(row: number): Roi {
return { x: GEAR_MAIN_CXS[row]! + 148, y: 917, w: 10, h: 18 };
}
export const GATE_STRIP_MIN_MEAN = 110;
export const GATE_STRIP_MAX_MEAN = 200;

View File

@@ -0,0 +1,313 @@
/**
* "Score:" banner parsing for the results screens. Each side of the colored
* banner shows one team's game score (0-100) as white BlitzBold digits after
* a localized label — some languages render no label at all, so the digits'
* x position is not fixed. A knockout replaces the winning side's value with
* the localized KNOCKOUT! burst, whose letters only weakly match digit
* templates (real digits score 0.9+); the knockout itself is recognized from
* the winner's team total instead (the box prints the count times five, and
* only a knockout's full 100 count reaches 500). The score value bounces as
* it lands, so a frame may catch the digits settled or mid-pop — this module
* parses at every size and binarization threshold and keeps the best
* read (valid over none, longer digit run over shorter, then confidence).
*/
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
type RecognizedText,
recognizeText,
} from "../../glyphs";
import { copyRoi, type Roi } from "../../image";
/** The count a knockout wins at — the burst hides it, so it is never read. */
export const KO_MATCH_SCORE = 100;
/**
* The team box prints the count times five ("440 p" alongside a 88 banner),
* so a knockout's full 100 count shows as 500 — a total only a knockout
* reaches, which is what separates a burst-covered banner from an unread one.
*/
export const FULL_COUNT_TEAM_SCORE = KO_MATCH_SCORE * 5;
/**
* Replay-screen reads below this floor are discarded rather than trusted as
* a score — burst/label letters overlapping a score ROI match digit
* templates at ~0.4 there.
*/
export const MATCH_SCORE_MIN_CONF = 0.6;
/**
* Char floor for the live banner's trailing-digit run. KNOCKOUT! letters
* have matched digit templates at up to 0.62 (the ko-hagglefish fixture's
* "07"), while genuine banner digits score 0.79+ across every fixture —
* including 720p upscales.
*/
const DIGIT_MIN_CONF = 0.75;
/** White banner digits on saturated team color (yellow ink grays at ~170). */
const BANNER_SCORE_BIN_THRESHOLD = 205;
/**
* Second binarization pass for pale team colors: a light banner (gray ~220,
* with the wave-crest highlight brighter still) binarizes solid white at the
* base threshold, gluing label and digits into one giant unmatchable blob
* that can swallow all but the last digit. Only the ~250 digit ink survives
* this threshold. Every pass always runs; a swallowed background can only
* shorten the digit run, never lengthen it, so the longer run wins
* regardless of confidence (the truncated read's surviving digit is genuine
* ink and scores just as well).
*/
const BANNER_SCORE_BRIGHT_BIN_THRESHOLD = 240;
/**
* Third pass for the brightest banners: a yellow battle-log banner grays at
* ~245 near the wave crest, so even the bright pass keeps label ink attached
* and erodes the digits below the confidence floor. Only the ~250 digit
* cores survive this threshold.
*/
const BANNER_SCORE_BRIGHTEST_BIN_THRESHOLD = 248;
/**
* Digits of one number nearly touch; anything further apart than this
* fraction of a digit width is the label (or an unreadable glyph) ending
* the run.
*/
const DIGIT_GAP_MAX_RATIO = 0.55;
/**
* A score digit spans the set's full height; the label's lowercase letters
* top out ~0.75 of it, so they cannot pass as digits even when their shapes
* correlate.
*/
const DIGIT_MIN_HEIGHT_RATIO = 0.82;
/**
* The banner's bright wave-crest highlight can dip into the score line as a
* wide ~12px-tall streak whose columns merge into the digits' segments and
* ruin their ink extents. Every real digit is at least ~26px tall, so ink
* components shorter than this are wiped before recognition.
*/
const MIN_COMPONENT_HEIGHT = 20;
export interface BannerScoreRead {
/** the side's score; null when unread (knockout burst, blur, label-only) */
value: number | null;
/** min glyph score across the accepted digits (0 when none) */
confidence: number;
/** digits in the accepted run (0 when unread) */
digits: number;
/** best raw reading, for debugging */
reading: string;
}
const EMPTY_READ: BannerScoreRead = {
value: null,
confidence: 0,
digits: 0,
reading: "",
};
/**
* Reads one banner side's score from `roi`: recognizes with each digit set
* (one per on-screen text size) at each binarization threshold and keeps
* the best read — a valid value beats none, a longer digit run beats a
* shorter one, confidence breaks ties. The score is the trailing run of
* full-height, confidently-matched digits — everything the localized label
* or the KNOCKOUT! burst leaves in the ROI fails at least one of those
* tests.
*/
export function parseBannerScore(
gray: Mat,
roi: Roi,
sets: readonly GlyphSet[],
): BannerScoreRead {
const crop = copyRoi(gray, roi);
clearShortBlobs(crop);
let best = EMPTY_READ;
for (const binThreshold of [
BANNER_SCORE_BIN_THRESHOLD,
BANNER_SCORE_BRIGHT_BIN_THRESHOLD,
BANNER_SCORE_BRIGHTEST_BIN_THRESHOLD,
]) {
for (const set of sets) {
const raw = recognizeText(crop, set, {
binThreshold,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const read = trailingDigitRun(raw, set);
if (isBetterRead(read, best)) best = read;
}
}
crop.delete();
return best;
}
/**
* Winner-first score pair from the two banner sides. A confirmed knockout
* (winner team total = 500) dominates: the winner reports the full count no
* matter what was read off the burst-covered side, and the loser is the
* more confident read (genuine digits score well clear of burst letters
* that survive the floor). Without a knockout, ranked scores never tie, so
* when both sides read the higher value is the winner's; one unreadable
* side cannot be attributed to a team, so nothing is reported.
*/
export function resolveMatchScores({
left,
right,
knockout,
}: {
left: BannerScoreRead;
right: BannerScoreRead;
knockout: boolean;
}): [number | null, number | null] {
if (knockout) {
const loser =
left.value !== null && right.value !== null
? left.confidence >= right.confidence
? left
: right
: left.value !== null
? left
: right;
return [KO_MATCH_SCORE, loser.value];
}
if (left.value !== null && right.value !== null) {
return left.value >= right.value
? [left.value, right.value]
: [right.value, left.value];
}
return [null, null];
}
/** Zero out ink components shorter than any digit (see MIN_COMPONENT_HEIGHT). */
function clearShortBlobs(band: Mat): void {
const cv = getCV();
const bin = new cv.Mat();
cv.threshold(band, bin, BANNER_SCORE_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 short = new Uint8Array(count);
for (let i = 1; i < count; i++) {
short[i] = s[i * 5 + cv.CC_STAT_HEIGHT]! < MIN_COMPONENT_HEIGHT ? 1 : 0;
}
stats.delete();
const lab = labels.data32S;
const out = band.data;
for (let i = 0; i < out.length; i++) {
if (short[lab[i]!]!) out[i] = 0;
}
labels.delete();
}
/**
* Read preference shared by every multi-attempt digit read: a valid value
* beats none, a longer digit run beats a shorter one, confidence breaks
* ties.
*/
export function isBetterRead(
read: BannerScoreRead,
best: BannerScoreRead,
): boolean {
if ((read.value !== null) !== (best.value !== null)) {
return read.value !== null;
}
if (read.digits !== best.digits) return read.digits > best.digits;
return read.confidence > best.confidence;
}
export interface TrailingDigitOptions {
/** char floor a glyph must clear to count as a digit of the number */
minCharScore?: number;
/**
* lower floor for digits joining a run that another digit anchors at
* `minCharScore` — motion blur / compression can erode one digit of a
* genuine number below the main floor while its neighbor stays crisp.
* Defaults to `minCharScore` (no two-tier extension).
*/
extendMinScore?: number;
/** min ink height as a fraction of the set height (drops labels, '+') */
minHeightRatio?: number;
/** values above this are rejected as misreads */
maxValue?: number;
/**
* reject the read (null) when the char immediately left of the run sat
* within digit-gap distance but failed the floors — on a band that holds
* nothing but the number (objective counter plates), that char is a
* blur-mangled leading digit and the run is a truncated misread ("50"
* returning 0). Off for banner bands, where an adjacent label/burst
* letter legitimately borders the digits.
*/
rejectTruncated?: boolean;
}
/**
* The trailing run of full-height, confidently-matched digits of a
* recognized line — the number-on-a-plate read shared by the score banner
* and the objective counters, where a localized label / burst / '+' sign
* precedes the digits and must fail at least one of the floors.
*/
export function trailingDigitRun(
raw: RecognizedText,
set: GlyphSet,
options: TrailingDigitOptions = {},
): BannerScoreRead {
const {
minCharScore = DIGIT_MIN_CONF,
extendMinScore = minCharScore,
minHeightRatio = DIGIT_MIN_HEIGHT_RATIO,
maxValue = KO_MATCH_SCORE,
rejectTruncated = false,
} = options;
const maxGap = Math.max(4, Math.round(set.medianWidth * DIGIT_GAP_MAX_RATIO));
const fullHeight = (c: RecognizedChar) =>
c.y1 - c.y0 >= set.height * minHeightRatio;
const isRunDigit = (c: RecognizedChar) =>
c.score >= extendMinScore && fullHeight(c);
const run: RecognizedChar[] = [];
let i = raw.chars.length - 1;
for (; i >= 0; i--) {
const c = raw.chars[i]!;
if (!isRunDigit(c)) break;
if (run.length > 0 && run[0]!.x0 - c.x1 > maxGap) break;
run.unshift(c);
}
if (run.length === 0) return { ...EMPTY_READ, reading: raw.text };
if (!run.some((c) => c.score >= minCharScore)) {
return { ...EMPTY_READ, reading: raw.text };
}
// A further digit left of the run means an unreadable glyph split the
// number (turf war percentages read "48", ".", "7") — the tail is not
// the score.
for (let k = i; k >= 0; k--) {
const c = raw.chars[k]!;
if (c.score >= minCharScore && fullHeight(c)) {
return { ...EMPTY_READ, reading: raw.text };
}
}
if (rejectTruncated && i >= 0 && run[0]!.x0 - raw.chars[i]!.x1 <= maxGap) {
return { ...EMPTY_READ, reading: raw.text };
}
const value = Number.parseInt(run.map((c) => c.char).join(""), 10);
if (value > maxValue) return { ...EMPTY_READ, reading: raw.text };
return {
value,
confidence: Math.min(...run.map((c) => c.score)),
digits: run.length,
reading: raw.text,
};
}

View File

@@ -0,0 +1,58 @@
/**
* Number field parsing on top of glyph recognition.
*/
import type { Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
} from "../../glyphs";
export interface ParsedNumber {
value: number | null;
/** min glyph score across recognized digits */
confidence: number;
/** x of the leftmost digit, relative to the crop (null when nothing found) */
leftX: number | null;
raw: RecognizedText;
}
/**
* Digits share a cap line; a lowercase suffix ("p") starts at x-height,
* ~7px lower at the sizes we parse. A trailing char whose ink top sits at
* least this far below the other chars' top line is the suffix, not a digit.
*/
const LOWERED_TRAILING_MIN_PX = 5;
export function parseNumber(
gray: Mat,
digits: GlyphSet,
options: { binThreshold?: number; dropLoweredTrailing?: boolean } = {},
): ParsedNumber {
const raw = recognizeText(gray, digits, {
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
binThreshold: options.binThreshold,
});
// The replay paint column is left-aligned, so its "p" suffix moves with
// the digit count and can land inside the ROI, where the digit-only
// charset misreads it (a "6"). The geometry still tells it apart.
let chars = raw.chars;
if (options.dropLoweredTrailing && chars.length > 1) {
const capY0 = Math.min(...chars.slice(0, -1).map((c) => c.y0));
if (chars[chars.length - 1]!.y0 - capY0 >= LOWERED_TRAILING_MIN_PX) {
chars = chars.slice(0, -1);
}
}
const text = chars.map((c) => c.char).join("");
const isNumeric = /^[0-9]+$/.test(text);
return {
value: isNumeric ? Number.parseInt(text, 10) : null,
confidence:
chars.length > 0
? Math.min(...chars.map((c) => c.score))
: raw.confidence,
leftX: chars.length > 0 ? chars[0]!.x0 : null,
raw,
};
}

View File

@@ -0,0 +1,193 @@
/**
* Header parsing: lobby type ("X Battle"), mode ("Splat Zones") and stage
* ("Scorch Gorge") from the black tags above the team boxes.
*
* Tags auto-size to their text, so the stage's x position depends on mode
* length. Each band is trimmed to the tag extent (near-black bg + white
* text; the map thumbnail around it is mid-brightness), then OCR'd as one
* line and snapped against every language's mode × stage combos
* (core/localized.ts) — reported values are always sendou.ink ids.
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizeOptions,
recognizeText,
} from "../../glyphs";
import { copyRoi } from "../../image";
import { ALL_LOBBY_ENTRIES, MODE_STAGE_COMBOS } from "../../localized";
import { closestBy } from "../../text";
import { HEADER_LINE_BAND, HEADER_LOBBY_BAND } from "./rois";
export interface ParsedHeader {
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** min of the closed-set match scores that were attempted */
confidence: number;
debug: {
lobbyReading: string;
lineReading: string;
lobbyScore: number;
lineScore: number;
};
}
/** Accept a closed-set match only above this score (1 = exact). */
const MIN_MATCH_SCORE = 0.62;
/** A column belongs to a tag when nearly all its pixels are dark bg or bright text. */
const TAG_COLUMN_FRACTION = 0.85;
const TAG_DARK_MAX = 75;
const TAG_BRIGHT_MIN = 165;
/** Stop extending the tag after this many consecutive non-tag columns. */
const TAG_GAP_TOLERANCE = 6;
/**
* Trim a band crop to the black-tag extent: the longest run of tag columns
* starting within `maxLeadIn` of the left edge (a dark photo edge can fake
* a short run before the real tag), each run extended right until the tag
* ends. Returns a zero-width range when no tag is present at all.
*/
function tagExtent(
crop: Mat,
darkMax: number,
maxLeadIn: number,
columnFraction: number,
): { start: number; end: number } {
const { cols, rows, data } = crop;
let best = { start: 0, end: 0 };
let start = -1;
let end = 0;
let gap = 0;
const takeRun = () => {
if (start !== -1 && end - start > best.end - best.start) {
best = { start, end };
}
start = -1;
gap = 0;
};
for (let x = 0; x < cols; x++) {
let tagLike = 0;
for (let y = 0; y < rows; y++) {
const v = data[y * cols + x]!;
if (v < darkMax || v > TAG_BRIGHT_MIN) tagLike++;
}
if (tagLike / rows >= columnFraction) {
if (start === -1) {
if (x > maxLeadIn) break;
start = x;
}
end = x + 1;
gap = 0;
} else if (start !== -1 && ++gap > TAG_GAP_TOLERANCE) {
takeRun();
} else if (start === -1 && x > maxLeadIn) {
break;
}
}
takeRun();
return best;
}
export interface TagBandOptions extends RecognizeOptions {
/**
* Dark ceiling for the tag-extent trim. Lifted-blacks captures (720p
* streams upscaled and re-encoded) raise the tag background above the
* default, truncating the trim to a sliver — callers whose closed-set
* snap fails retry with a lifted ceiling.
*/
tagDarkMax?: number;
/**
* Non-tag columns tolerated before the tag begins. The battle log tags
* are not left-anchored (a leading rank icon shifts line 1 per lobby
* type), so its bands start on the stage photo and scan for the tag.
*/
tagLeadInMax?: number;
/**
* Tag-like row fraction a column must reach. The battle log tags are
* subtly tilted, so a horizontal band always catches a few photo rows
* above or below the box — those bands pass a looser fraction.
*/
tagColumnFraction?: number;
}
/**
* OCR one header band: trim the crop to the black-tag extent, then
* recognize it as a single line. Shared with the scoreboard-battle-log-replay
* header, whose tags have the same style at different positions/sizes.
*/
export function readTagBand(
gray: Mat,
band: { x: number; y: number; w: number; h: number },
glyphs: GlyphSet,
options: TagBandOptions = {},
): string {
const crop = copyRoi(gray, band);
const { start, end } = tagExtent(
crop,
options.tagDarkMax ?? TAG_DARK_MAX,
options.tagLeadInMax ?? TAG_GAP_TOLERANCE,
options.tagColumnFraction ?? TAG_COLUMN_FRACTION,
);
if (end - start < 12) {
crop.delete();
return "";
}
const cv = getCV();
const view = crop.roi(new cv.Rect(start, 0, end - start, crop.rows));
const trimmed = new cv.Mat();
view.copyTo(trimmed);
view.delete();
crop.delete();
const result = recognizeText(trimmed, glyphs, {
spaceGap: 9,
minCharScore: 0.3,
...options,
});
trimmed.delete();
return result.text.trim();
}
export function parseHeader(
gray: Mat,
lobbyGlyphs: GlyphSet,
lineGlyphs: GlyphSet,
): ParsedHeader {
const lobbyReading = readTagBand(gray, HEADER_LOBBY_BAND, lobbyGlyphs);
const lineReading = readTagBand(gray, HEADER_LINE_BAND, lineGlyphs);
const lobbyMatch = lobbyReading
? closestBy(lobbyReading, ALL_LOBBY_ENTRIES, (e) => e.text)
: null;
const lineMatch = lineReading
? closestBy(lineReading, MODE_STAGE_COMBOS, (c) => c.text)
: null;
const lobby =
lobbyMatch && lobbyMatch.score >= MIN_MATCH_SCORE
? lobbyMatch.entry.lobby
: null;
let mode: ModeShort | null = null;
let stage: StageId | null = null;
if (lineMatch && lineMatch.score >= MIN_MATCH_SCORE) {
mode = lineMatch.entry.mode;
stage = lineMatch.entry.stageId;
}
const attempted = [lobbyMatch?.score ?? 0, lineMatch?.score ?? 0];
return {
lobby,
mode,
stage,
confidence: Math.min(...attempted),
debug: {
lobbyReading,
lineReading,
lobbyScore: lobbyMatch?.score ?? 0,
lineScore: lineMatch?.score ?? 0,
},
};
}

View File

@@ -0,0 +1,338 @@
/**
* ScoreboardDetector: parses the end-of-match results scoreboard
* (team scores + 8 rows of weapon / name / paint / splats / deaths / specials).
*/
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import { cropRoi, maxBrightness, meanBrightness } from "../../image";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
FULL_COUNT_TEAM_SCORE,
parseBannerScore,
resolveMatchScores,
} from "./banner";
import { parseNumber } from "./digits";
import { type ParsedHeader, parseHeader } from "./header";
import { findPovIndex } from "./pov";
import {
GATE_DARK_MAX_MEAN,
GATE_PANEL_MAX_MEAN,
GATE_PANEL_PROBES,
GATE_TEXT_MIN_MAX,
gateDarkProbe,
MATCH_SCORE_DIGIT_HEIGHTS,
MATCH_SCORE_ROIS,
nameRoi,
PAINT_DIGIT_HEIGHT,
paintRoi,
paintSuffixRoi,
povArrowRoi,
ROW_CENTERS,
specialIconRoi,
statRoi,
TEAM_DIGIT_HEIGHT,
TEAM_SCORE_ROIS,
weaponRoi,
} from "./rois";
import { parseScoreboardRow, type RowRois } from "./row";
import type { SpecialMatch, SpecialTemplate } from "./specials";
import type { WeaponMatch, WeaponTemplate } from "./weapons";
export interface ScoreboardPlayer {
name: string;
/** sendou main-weapon id; null when the row's weapon was unreadable */
weaponId: MainWeaponId | null;
paint: number | null;
/** kills+assists (the combined counter as shown) */
ka: number | null;
d: number | null;
s: number | null;
}
export interface ScoreboardData {
/** from the header tag; null when unreadable */
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/**
* the "Score:" banner game scores, [winner, loser]; a knockout's winner
* reports 100 (the burst hides the real banner)
*/
matchScores: [number | null, number | null];
/** 8 players: rows 0-3 winning team, rows 4-7 losing team */
players: ScoreboardPlayer[];
/**
* index into `players` of the recording player's row, marked by the
* yellow arrow; null when no arrow is found (spectator/overhead footage)
*/
povIndex: number | null;
}
export interface ScoreboardRowDebug {
weapon: WeaponMatch | null;
/** row's special icon match, when it was consulted for a weapon tie */
special?: SpecialMatch;
paintScore: number;
nameScore: number;
statScores: [number, number, number];
/** fraction of the row's POV-arrow probe that is arrow-yellow */
povFraction: number;
}
export interface ScoreboardResources {
weapons: WeaponTemplate[];
/**
* Weapon renders prepared for the in-match icon strip (objective's
* StripWeapons evidence). Optional: without them the strip slot →
* scoreboard row assignment falls back to as-drawn order.
*/
stripWeapons?: WeaponTemplate[] | null;
/**
* Special-weapon silhouettes (assets/cv/specials). Optional: without
* them, near-tied weapon icons (Splash- vs Sploosh-o-matic) stay decided
* by icon score alone.
*/
specials?: SpecialTemplate[] | null;
/** digit templates at paint size (h~28); team scores reuse these, scaled */
paintDigits: GlyphSet | null;
/** digit templates at stat-counter size (h~17) */
statDigits: GlyphSet | null;
/**
* digit templates for team totals (h~33, outlined, on team-color box).
* Optional: falls back to scaled paint digits, at reduced accuracy.
*/
teamDigits: GlyphSet | null;
nameGlyphs: GlyphSet | null;
/** header tag glyphs: lobby line, and the mode+stage line */
headerLobbyGlyphs: GlyphSet | null;
headerLineGlyphs: GlyphSet | null;
/**
* Replay-browser extras (FOT-RowdyStd face, unlike everything above):
* the replay code line, and the VICTORY/DEFEAT panel tags. Optional:
* the replay detector falls back to rescaled name/header glyphs, at
* reduced accuracy.
*/
replayCodeGlyphs?: GlyphSet | null;
replayResultGlyphs?: GlyphSet | null;
/**
* Death-screen extras. Optional: the death detector skips the fields it
* has no resources for. The weapon atlas carries both Blitz faces; the
* tag-name atlas is BlitzBold + Rowdy (kana).
*/
abilities?: import("../death/abilities").AbilityTemplates | null;
deathWeaponGlyphs?: GlyphSet | null;
/**
* JA death-message glyphs (Kurokane/Rowdy condensed + fixture crops),
* built at the on-screen text's native size. Optional: without them JA
* death screens fail the constant-line confirmation and emit nothing.
*/
deathWeaponJaGlyphs?: GlyphSet | null;
deathTagNameGlyphs?: GlyphSet | null;
/**
* The main-weapon icons again, rebuilt at the death burst's icon size
* (~124px vs the rows' 40-64px). Optional: without them the death
* detector cannot recover the killer's weapon when the message text is
* unreadable (e.g. the WIPEOUT banner covering the weapon name line).
*/
deathBurstWeapons?: WeaponTemplate[] | null;
/**
* Personal-results extras: the same ability icons rebuilt at the gear
* cards' badge sizes (scoreboard-own/abilities.ts). Optional: without
* them the scoreboard-own detector skips the ability grid.
*/
ownAbilities?: import("../death/abilities").AbilityTemplates | null;
/**
* Map-start intro extras: the big BlitzBold mode title, and the BlitzMain
* stage name (whose atlas also reads the "MODE" label, rescaled).
* Optional: without them the map-start detector emits nothing.
*/
mapStartModeGlyphs?: GlyphSet | null;
mapStartStageGlyphs?: GlyphSet | null;
/**
* Minimap extras (minimap/rois.ts documents the screen): the main-weapon
* icons composited on the card-pill background for the teammate cards'
* light silhouettes, a light-background variant for highlighted enemy
* rows, and the ability icons at the cards' badge size. Optional: without
* them the minimap detector skips the corresponding fields (enemy rows
* fall back to the standard `weapons` set). Names reuse `nameGlyphs`.
*/
minimapCardWeapons?: WeaponTemplate[] | null;
minimapLightWeapons?: WeaponTemplate[] | null;
minimapAbilities?: WeaponTemplate[] | null;
/**
* Sub-weapon silhouettes (assets/cv/sub-weapons) at the minimap sub
* tile's sizes. Optional: without them, near-tied minimap weapon icons
* whose kits differ only by sub (plain vs Custom Dualie Squelchers) stay
* decided by icon score alone.
*/
minimapSubWeapons?: SpecialTemplate[] | null;
/**
* Planner-map structural signatures (assets/cv/planner) used by the
* minimap detector to identify the stage. Optional: without them the
* minimap event's `stage` stays null.
*/
plannerStages?: import("../minimap/stage").PlannerStage[] | null;
}
export const SCOREBOARD_EVENT_TYPE = "Scoreboard";
export function createScoreboardDetector(
resources: ScoreboardResources,
): Detector<ScoreboardData> {
const cv = getCV();
const teamDigits =
resources.teamDigits ??
(resources.paintDigits
? scaleGlyphSet(
resources.paintDigits,
TEAM_DIGIT_HEIGHT / PAINT_DIGIT_HEIGHT,
)
: null);
const matchScoreSets = teamDigits
? MATCH_SCORE_DIGIT_HEIGHTS.map((height) =>
scaleGlyphSet(teamDigits, height / teamDigits.height),
)
: [];
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
let darkOk = 0;
let suffixOk = 0;
for (const cy of ROW_CENTERS) {
if (meanBrightness(frame, gateDarkProbe(cy)) < GATE_DARK_MAX_MEAN)
darkOk++;
if (maxBrightness(gray, paintSuffixRoi(cy)) > GATE_TEXT_MIN_MAX)
suffixOk++;
}
let panelOk = 0;
for (const roi of GATE_PANEL_PROBES) {
if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++;
}
gray.delete();
const score =
(darkOk / ROW_CENTERS.length +
suffixOk / ROW_CENTERS.length +
panelOk / GATE_PANEL_PROBES.length) /
3;
const pass = darkOk >= 7 && suffixOk >= 6 && panelOk >= 2;
return { pass, score };
}
function parse(frame: Mat, t: number): DetectedEvent<ScoreboardData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const players: ScoreboardPlayer[] = [];
const rowDebug: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
const rowRois: RowRois = {
weapon: weaponRoi,
specialIcon: specialIconRoi,
paint: paintRoi,
name: nameRoi,
stat: statRoi,
povArrow: povArrowRoi,
};
for (const cy of ROW_CENTERS) {
const row = parseScoreboardRow(
gray,
rgb,
cy,
rowRois,
resources,
confidences,
);
players.push(row.player);
rowDebug.push(row.debug);
}
const povIndex = findPovIndex(rowDebug.map((r) => r.povFraction));
let header: ParsedHeader | null = null;
if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) {
header = parseHeader(
gray,
resources.headerLobbyGlyphs,
resources.headerLineGlyphs,
);
confidences.push(header.confidence);
}
// The winner's team total is read only to recognize a knockout: the
// box prints the count times five, and only a knockout's full 100
// count reaches 500 (the banner value it would confirm is hidden
// under the KNOCKOUT! burst).
let knockout = false;
let winnerTotalConf = 0;
if (teamDigits) {
// The total sits on the team-colored box (light swirl pattern),
// so binarize more aggressively than on the black pills.
const crop = cropRoi(gray, TEAM_SCORE_ROIS[0]);
const winnerTotal = parseNumber(crop, teamDigits, {
binThreshold: 175,
});
crop.delete();
knockout = winnerTotal.value === FULL_COUNT_TEAM_SCORE;
winnerTotalConf = winnerTotal.confidence;
}
let matchScores: [number | null, number | null] = [null, null];
let bannerDebug: object | undefined;
if (matchScoreSets.length > 0) {
const left = parseBannerScore(gray, MATCH_SCORE_ROIS[0], matchScoreSets);
const right = parseBannerScore(gray, MATCH_SCORE_ROIS[1], matchScoreSets);
matchScores = resolveMatchScores({ left, right, knockout });
confidences.push(left.confidence, right.confidence);
bannerDebug = { left, right, knockout, winnerTotalConf };
}
gray.delete();
rgb.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: SCOREBOARD_EVENT_TYPE,
t,
confidence,
data: {
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
matchScores,
players,
povIndex,
},
debug: {
rows: rowDebug,
matchScore: bannerDebug,
header: header?.debug,
},
},
];
}
// just under the measured clean-read floor (fixtures 0.865-0.899,
// confirmed scan events down to 0.799)
return {
id: "scoreboard",
sufficientConfidence: 0.79,
gate,
parse,
};
}

View File

@@ -0,0 +1,30 @@
/**
* Main-weapon kits (sub + special ids) keyed by in-game main weapon id,
* derived directly from sendou.ink's weapon params (no generated copy).
* Special ids match assets/cv/specials/<id>.png; the results scoreboard
* shows each player's special icon above the specials counter, which lets
* near-tied weapon icon matches be disambiguated by kit. The minimap's
* sub-tile disambiguation (assets/cv/sub-weapons) reads `sub` the same way.
*/
import { weaponParams } from "~/features/build-analyzer/data/weapon-params";
import type {
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
export interface WeaponKit {
/** sendou.ink sub weapon id */
sub: SubWeaponId;
/** sendou.ink special weapon id, matching assets/cv/specials */
special: SpecialWeaponId;
}
export const WEAPON_KITS: ReadonlyMap<string, WeaponKit> = new Map(
Object.entries(weaponParams.weaponKits).map(([id, kit]) => [
id,
{
sub: kit.subWeaponId as SubWeaponId,
special: kit.specialWeaponId as SpecialWeaponId,
},
]),
);

View File

@@ -0,0 +1,295 @@
/**
* Player name recognition: glyph matching over the name ROI.
*/
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
type RecognizedText,
recognizeText,
} from "../../glyphs";
export interface ParsedName {
name: string;
/** min glyph score; 0 when ink was present but nothing recognized */
confidence: number;
raw: RecognizedText;
}
/**
* BlitzMain renders 'I', 'l', '|', and '1' as near-identical bars — no pixel
* evidence separates them, so fall back to context, by decreasing weight of
* evidence: a bar next to a lowercase letter is overwhelmingly an 'l' in
* latin names ("Olise"), next to a digit it's a '1' ("Jrod_14"), next to an
* uppercase letter it's an 'I' ("SHIP"), a bare bar hanging off an
* underscore is a numbered-alt suffix ("gori_1"), and with no latin/digit
* context at all (kana, symbols, edges) 'l' is the common case in the wild.
* (This will genuinely miss e.g. "McIntosh", but so would a human reading
* the pixels.)
*/
const BAR_CHARS = new Set(["I", "l", "|", "1"]);
function normalizeBars(name: string): string {
const chars = [...name];
// Context is the nearest NON-BAR char within the word: consecutive bars
// ("ll" in "Chill") must all resolve from the same real-letter neighbor,
// not from each other's arbitrary raw reading. Underscores bound words
// like spaces do — "gori_1"'s lowercase must not leak across the
// separator onto the suffix.
const neighbor = (i: number, step: -1 | 1): string | undefined => {
for (let j = i + step; j >= 0 && j < chars.length; j += step) {
const c = chars[j]!;
if (c === " " || c === "_") return undefined;
if (!BAR_CHARS.has(c)) return c;
}
return undefined;
};
const test = (re: RegExp) => (c: string | undefined) =>
c !== undefined && re.test(c);
const isLower = test(/\p{Ll}/u);
const isDigit = test(/\d/);
const isUpper = test(/\p{Lu}/u);
return chars
.map((c, i) => {
if (!BAR_CHARS.has(c)) return c;
const left = neighbor(i, -1);
const right = neighbor(i, 1);
if (isLower(left) || isLower(right)) return "l";
if (isDigit(left) || isDigit(right)) return "1";
if (isUpper(left) || isUpper(right)) return "I";
if (chars[i - 1] === "_" || chars[i + 1] === "_") return "1";
return "l";
})
.join("");
}
/**
* The kana long-vowel bar 'ー' and the ASCII hyphen '-' are horizontal-bar
* homoglyphs the same way the vertical bars are: both are a single
* horizontal stroke whose length difference drowns in capture blur, so
* which template wins is noise ("ドラグ-ン"). Resolve by script context —
* a kana neighbor reads 'ー', a Latin/digit neighbor reads '-', and with
* no context the raw pick stands.
*/
const LONG_BAR_CHARS = new Set(["-", "ー"]);
function normalizeLongBars(name: string): string {
const chars = [...name];
const neighbor = (i: number, step: -1 | 1): string | undefined => {
for (let j = i + step; j >= 0 && j < chars.length; j += step) {
const c = chars[j]!;
if (c === " " || c === "_") return undefined;
if (!LONG_BAR_CHARS.has(c)) return c;
}
return undefined;
};
const test = (re: RegExp) => (c: string | undefined) =>
c !== undefined && re.test(c);
const isKana = test(/[ぁ-ヾ]/u);
const isLatinOrDigit = test(/[a-zA-Z0-9]/);
return chars
.map((c, i) => {
if (!LONG_BAR_CHARS.has(c)) return c;
const left = neighbor(i, -1);
const right = neighbor(i, 1);
if (isKana(left) || isKana(right)) return "ー";
if (isLatinOrDigit(left) || isLatinOrDigit(right)) return "-";
return c;
})
.join("");
}
/**
* BlitzMain's 'O' and '0' are the same rounded box at capture fidelity —
* which template wins is noise — so, like the bars, resolve by context via
* the nearest unambiguous neighbor in the word: a digit neighbor keeps '0',
* an uppercase neighbor reads 'O' ("AHOO"), after a lowercase letter it's a
* stylized digit ("y0s"), and word-initial before lowercase it's a
* capitalized name ("Olise").
*/
function normalizeOhs(name: string): string {
const chars = [...name];
const ambiguous = (c: string | undefined) => c === "O" || c === "0";
const neighbor = (i: number, step: -1 | 1): string | undefined => {
for (let j = i + step; j >= 0 && j < chars.length; j += step) {
const c = chars[j]!;
if (c === " ") return undefined;
if (!ambiguous(c)) return c;
}
return undefined;
};
const test = (re: RegExp) => (c: string | undefined) =>
c !== undefined && re.test(c);
const isDigit = test(/\d/);
const isUpper = test(/\p{Lu}/u);
const isLower = test(/\p{Ll}/u);
return chars
.map((c, i) => {
if (!ambiguous(c)) return c;
const left = neighbor(i, -1);
const right = neighbor(i, 1);
if (isDigit(left) || isDigit(right)) return "0";
if (isUpper(left) || isUpper(right)) return "O";
if (isLower(left)) return "0";
if (isLower(right)) return "O";
return c;
})
.join("");
}
/**
* The round dots '.', '・', and '·' tight-crop to near-identical blobs, so
* which template wins is noise. Vertical position decides in one direction
* only: a period sits on the baseline, so a dot floating well above it
* cannot be '.' — reread it as the best-scoring middle-dot candidate. The
* reverse does not hold: BlitzMain draws '・' ON the baseline in some names
* (scoreboard/robot row 5, "..・"), so baseline dots stay with the template
* ranking, where the exact fixture crops separate the sizes.
*/
const DOT_CHARS = new Set([".", "・", "·"]);
const DOT_BASELINE_SLACK_PX = 3;
function fixRaisedDots(raw: RecognizedText): RecognizedText {
if (!raw.chars.some((c) => c.char === ".")) return raw;
const anchors = raw.chars
.filter((c) => !DOT_CHARS.has(c.char))
.map((c) => c.y1)
.sort((a, b) => a - b);
if (anchors.length === 0) return raw;
const baseline = anchors[Math.floor(anchors.length / 2)]!;
const chars = raw.chars.map((c) => {
if (c.char !== "." || baseline - c.y1 <= DOT_BASELINE_SLACK_PX) return c;
const alt = c.candidates?.find(
(k) => DOT_CHARS.has(k.char) && k.char !== ".",
);
return { ...c, char: alt?.char ?? "・" };
});
let ci = 0;
const text = [...raw.text]
.map((ch) => (ch === " " ? ch : chars[ci++]!.char))
.join("");
return { ...raw, text, chars };
}
/**
* On soft captures BlitzMain's 'b' and 'h' come down to whether the bowl
* closes at the bottom, and template correlation weighs that stroke too
* lightly to be trusted (a font 'h' beats a fixture 'b' by ~0.003 on a true
* 'b'). The segment's own pixels decide: the bottom band between the stems
* is solid ink in a 'b' (the bowl floor) and empty in an 'h' (open legs) —
* measured 0.90 vs 0.15 across fixtures. Only near-tied twin reads are
* re-decided; confident reads keep the template pick. Small text is exempt:
* below ~20px of ink height the blur closes a true 'h' too (a 15px 'h'
* measured 0.67), so the pixels only outrank the templates at row scale.
*/
const BH_TWINS: Record<string, string> = { b: "h", h: "b" };
const BH_SCORE_MARGIN = 0.08;
const BH_INK_THRESHOLD = 150;
const BH_BOWL_MIN_FRACTION = 0.5;
const BH_MIN_INK_HEIGHT_PX = 20;
function resolveBhByBowlFloor(
raw: RecognizedText,
grayView: Mat,
): RecognizedText {
const contested = (c: RecognizedChar) => {
const twin = BH_TWINS[c.char];
if (!twin || c.y1 - c.y0 < BH_MIN_INK_HEIGHT_PX) return false;
return (
c.candidates?.some(
(k) => k.char === twin && c.score - k.score <= BH_SCORE_MARGIN,
) ?? false
);
};
if (!raw.chars.some(contested)) return raw;
// the crop is an ROI view, so copy before pixel access
const gray = new (getCV().Mat)();
grayView.copyTo(gray);
const { cols, data } = gray;
const chars = raw.chars.map((c) => {
if (!contested(c)) return c;
const w = c.x1 - c.x0;
const h = c.y1 - c.y0;
const cx0 = c.x0 + Math.round(w * 0.3);
const cx1 = c.x1 - Math.round(w * 0.3);
const by0 = c.y1 - Math.max(2, Math.round(h * 0.18));
let ink = 0;
let total = 0;
for (let y = by0; y < c.y1; y++) {
for (let x = cx0; x < cx1; x++) {
total++;
if (data[y * cols + x]! > BH_INK_THRESHOLD) ink++;
}
}
const bowlClosed = total > 0 && ink / total >= BH_BOWL_MIN_FRACTION;
return { ...c, char: bowlClosed ? "b" : "h" };
});
gray.delete();
let ci = 0;
const text = [...raw.text]
.map((ch) => (ch === " " ? ch : chars[ci++]!.char))
.join("");
return { ...raw, text, chars };
}
/**
* BlitzMain's 'P' and 'p' tight-crop to the same stem-and-bowl shape, so the
* template scores between them are noise — but unlike the bars and ohs the
* pixels do decide: 'p' hangs below the baseline while 'P' sits on it. The
* templates lose that position, the segment keeps it. Take the baseline as
* the median ink bottom of the non-twin glyphs (most chars rest exactly on
* it, so the median shrugs off real descenders and symbols) and pick the
* case by whether the segment descends past it. Skipped when no other glyph
* anchors the baseline.
*/
const DESCENDER_TWINS: Record<string, [upper: string, lower: string]> = {
P: ["P", "p"],
p: ["P", "p"],
};
const DESCENT_MIN_PX = 3;
function resolveCaseByDescent(raw: RecognizedText): string {
if (!raw.chars.some((c) => c.char in DESCENDER_TWINS)) return raw.text;
const anchors = raw.chars
.filter((c) => !(c.char in DESCENDER_TWINS))
.map((c) => c.y1)
.sort((a, b) => a - b);
if (anchors.length === 0) return raw.text;
const baseline = anchors[Math.floor(anchors.length / 2)]!;
let ci = 0;
return [...raw.text]
.map((ch) => {
if (ch === " ") return ch;
const rc: RecognizedChar = raw.chars[ci++]!;
const twin = DESCENDER_TWINS[rc.char];
if (!twin) return rc.char;
return rc.y1 - baseline >= DESCENT_MIN_PX ? twin[1] : twin[0];
})
.join("");
}
export function parseName(
gray: Mat,
glyphs: GlyphSet,
options: { spaceGap?: number; binThreshold?: number } = {},
): ParsedName {
const raw = recognizeText(gray, glyphs, {
spaceGap: options.spaceGap ?? 7,
binThreshold: options.binThreshold,
minCharScore: 0.35,
});
return {
name: normalizeLongBars(
normalizeOhs(
normalizeBars(
resolveCaseByDescent(
resolveBhByBowlFloor(fixRaisedDots(raw), gray),
).trim(),
),
),
),
confidence: raw.confidence,
raw,
};
}

View File

@@ -0,0 +1,71 @@
/**
* POV arrow detection: both results screens mark the recording player's row
* with a solid yellow arrow at the row's left edge (absent in spectator or
* overhead footage). The arrow is the only saturated pure-yellow blob in
* that strip — team-ink yellows there are patterned/duller and stay under
* the mask thresholds — so a plain color-mask pixel count decides, no
* template needed.
*/
import { getCV, type Mat } from "../../cv";
import { cropRoi, type Roi } from "../../image";
const YELLOW_R_MIN = 190;
const YELLOW_G_MIN = 180;
const YELLOW_B_MAX = 90;
/** Pure arrow yellow has R≈G; team golds/chartreuse skew one channel. */
const YELLOW_RG_MAX_DIFF = 60;
/** Arrow pixels fill ~15-30% of the probe ROI; arrow-less rows measure ~0. */
const POV_MIN_FRACTION = 0.05;
/**
* If the runner-up row reaches this share of the best row's fraction, the
* yellow is ambient (team ink leaking into the strip), not the arrow.
*/
const POV_RUNNER_UP_MAX_RATIO = 0.5;
/** Fraction of `roi` pixels that are arrow-yellow. `rgb` is a 3-channel RGB mat. */
export function povYellowFraction(rgb: Mat, roi: Roi): number {
const cv = getCV();
const view = cropRoi(rgb, roi);
const cont = new cv.Mat();
view.copyTo(cont);
view.delete();
const d = cont.data;
const n = cont.rows * cont.cols;
let yellow = 0;
for (let i = 0; i < n; i++) {
const r = d[i * 3]!;
const g = d[i * 3 + 1]!;
const b = d[i * 3 + 2]!;
if (
r > YELLOW_R_MIN &&
g > YELLOW_G_MIN &&
b < YELLOW_B_MAX &&
Math.abs(r - g) < YELLOW_RG_MAX_DIFF
) {
yellow++;
}
}
cont.delete();
return n > 0 ? yellow / n : 0;
}
/**
* Pick the arrow row from per-row yellow fractions: the best row must clear
* POV_MIN_FRACTION and stand clear of the runner-up; null means no arrow.
*/
export function findPovIndex(fractions: readonly number[]): number | null {
let best = -1;
for (let i = 0; i < fractions.length; i++) {
if (best < 0 || fractions[i]! > fractions[best]!) best = i;
}
if (best < 0 || fractions[best]! < POV_MIN_FRACTION) return null;
for (let i = 0; i < fractions.length; i++) {
if (
i !== best &&
fractions[i]! > fractions[best]! * POV_RUNNER_UP_MAX_RATIO
)
return null;
}
return best;
}

View File

@@ -0,0 +1,120 @@
/**
* ALL scoreboard ROI coordinates, in canonical 1920x1080 space.
* Calibrated against scoreboard/xbattle-splat-zones-ko via
* tools/overlay-rois.ts and column-projection measurement.
*
* The results-screen scoreboard is a fixed-layout panel on the right side:
* two team boxes (top = winner, bottom = loser), 4 dark "pill" rows each,
* white text on the pills. Per row, left to right: avatar, weapon icon,
* name (left-aligned from x=1125), paint ("842p", digits right-aligned
* ending at x=1409, constant "p" glyph at 1411-1424), three stat counters
* ("x12", zero-padded 2 digits) under the splat/death/special icons.
*/
import type { Roi } from "../../canonical";
/** Vertical centers of the 8 player rows: 4 winner rows, then 4 loser rows. */
export const ROW_CENTERS = [416, 482, 547, 613, 770, 836, 901, 967] as const;
/**
* Weapon icon search region within a row. matchTemplate skips templates
* taller than the region, so 56px fits the largest live icon while
* excluding replay-browser template sizes.
*/
export function weaponRoi(cy: number): Roi {
return { x: 1054, y: cy - 28, w: 67, h: 56 };
}
/**
* Player name text region (white, left-aligned x=1126). Long names run
* into the paint column; parse paint first and trim at its leftmost digit.
*/
export function nameRoi(cy: number): Roi {
return { x: 1122, y: cy - 14, w: 208, h: 32 };
}
/** Paint amount digits, right-aligned ending at x=1409 (the "p" suffix is excluded). */
export function paintRoi(cy: number): Roi {
return { x: 1325, y: cy - 17, w: 84, h: 34 };
}
/** The constant white "p" after the paint number — used as a gate anchor. */
export function paintSuffixRoi(cy: number): Roi {
return { x: 1409, y: cy - 14, w: 18, h: 28 };
}
/** Stat counter digits (two, zero-padded; the small "x" prefix at 1477/1540/1603 is excluded). */
export function statRoi(cy: number, index: 0 | 1 | 2): Roi {
const x = [1484, 1547, 1610][index]!;
return { x, y: cy + 1, w: 32, h: 23 };
}
/**
* Special-weapon icon above the specials counter (~22px art, team ink
* color). Bounded at cy+1 so counter digits stay out of the binarized shape.
*/
export function specialIconRoi(cy: number): Roi {
return { x: 1595, y: cy - 29, w: 40, h: 30 };
}
/**
* POV arrow: yellow arrow marking the recording player's row, left of the
* avatar (x 933-985). Right edge stays short of the avatar circle (~x 995).
*/
export function povArrowRoi(cy: number): Roi {
return { x: 930, y: cy - 32, w: 58, h: 56 };
}
/**
* Team point totals ("500 p"), right-aligned ending x=1658. Only read to
* recognize a knockout (winner total 500) — not the match score.
*/
export const TEAM_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 1530, y: 330, w: 132, h: 44 },
{ x: 1530, y: 684, w: 132, h: 44 },
];
/**
* The two sides of the "Score:" banner. Left is left-aligned from x~946,
* right right-aligned ending x~1691 (x~1712 mid-pop) — digits bounce
* ~28-39px as the value lands (MATCH_SCORE_DIGIT_HEIGHTS).
*/
export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 935, y: 231, w: 230, h: 50 },
{ x: 1600, y: 231, w: 125, h: 50 },
];
/** Banner score digit sizes: settled, and the landing bounce's peak. */
export const MATCH_SCORE_DIGIT_HEIGHTS = [28, 39] as const;
/**
* Gate probe: the strip between the paint "p" suffix (ends 1424) and the
* first stat "x" (starts 1477) is always empty pill background (near-black).
*/
export function gateDarkProbe(cy: number): Roi {
return { x: 1434, y: cy - 10, w: 38, h: 20 };
}
/** Panel background probes (dark gray ~35) outside the team boxes. */
export const GATE_PANEL_PROBES: readonly Roi[] = [
{ x: 800, y: 500, w: 30, h: 30 },
{ x: 800, y: 900, w: 30, h: 30 },
{ x: 1770, y: 930, w: 30, h: 30 },
];
export const GATE_DARK_MAX_MEAN = 60;
export const GATE_PANEL_MAX_MEAN = 85;
/** The paint "p" suffix region must contain bright (white) pixels. */
export const GATE_TEXT_MIN_MAX = 180;
/**
* Header bands: lobby tag ("X Battle") on its own line, mode+stage share
* the next. Tags size to their text, so bands are generous — header.ts
* trims each to the actual tag extent.
*/
export const HEADER_LOBBY_BAND: Roi = { x: 828, y: 42, w: 330, h: 30 };
export const HEADER_LINE_BAND: Roi = { x: 828, y: 88, w: 580, h: 40 };
/** Text metrics measured on the fixture, used by atlas tooling. */
export const PAINT_DIGIT_HEIGHT = 28;
/** Same tight height as paint digits, but rendered in the bold face (BlitzBold). */
export const TEAM_DIGIT_HEIGHT = 28;

View File

@@ -0,0 +1,146 @@
/**
* The shared scoreboard row parse — weapon (with special-icon tie-break),
* paint, name trimmed at the leftmost paint digit, stat counters — used by
* both the results scoreboard and the replay-browser detail screen. The
* callers differ only in ROI geometry (the replay panels are dx-shifted),
* glyph sets (replay rows are smaller, so it passes rescaled sets), and two
* match options.
*/
import { toMainWeaponId } from "../../../scanner-types";
import type { Mat } from "../../cv";
import type { GlyphSet } from "../../glyphs";
import { cropRoi, type Roi } from "../../image";
import { type ParsedNumber, parseNumber } from "./digits";
import type { ScoreboardPlayer, ScoreboardRowDebug } from "./index";
import { parseName } from "./names";
import { povYellowFraction } from "./pov";
import {
disambiguateWeaponBySpecial,
matchSpecial,
type SpecialMatch,
type SpecialTemplate,
tiedWeaponsWithDistinctSpecials,
} from "./specials";
import { matchWeapon, type WeaponMatch, type WeaponTemplate } from "./weapons";
/** Per-row ROI geometry; the replay detector closes these over its panel dx. */
export interface RowRois {
weapon(cy: number): Roi;
specialIcon(cy: number): Roi;
paint(cy: number): Roi;
name(cy: number): Roi;
stat(cy: number, i: 0 | 1 | 2): Roi;
povArrow(cy: number): Roi;
}
export interface RowResources {
weapons: WeaponTemplate[];
specials?: SpecialTemplate[] | null;
paintDigits: GlyphSet | null;
statDigits: GlyphSet | null;
nameGlyphs: GlyphSet | null;
}
export interface RowOptions {
/** passed through to matchWeapon (replay rows sit on a lighter panel) */
weaponInkThreshold?: number;
/**
* replay only: the left-aligned paint number puts the "p" suffix inside
* the ROI when the paint has fewer than 4 digits
*/
paintDropLoweredTrailing?: boolean;
}
/** Parses one player row; per-field confidences append to `confidences`. */
export function parseScoreboardRow(
gray: Mat,
rgb: Mat,
cy: number,
rois: RowRois,
resources: RowResources,
confidences: number[],
options: RowOptions = {},
): { player: ScoreboardPlayer; debug: ScoreboardRowDebug } {
let weapon: WeaponMatch | null = null;
let special: SpecialMatch | undefined;
if (resources.weapons.length > 0) {
const crop = cropRoi(rgb, rois.weapon(cy));
weapon = matchWeapon(
crop,
resources.weapons,
options.weaponInkThreshold !== undefined
? { inkThreshold: options.weaponInkThreshold }
: {},
);
crop.delete();
// near-tied icons with different kit specials: let the row's special
// icon break the tie
if (resources.specials?.length && tiedWeaponsWithDistinctSpecials(weapon)) {
const spCrop = cropRoi(rgb, rois.specialIcon(cy));
special = matchSpecial(spCrop, resources.specials);
spCrop.delete();
weapon = disambiguateWeaponBySpecial(weapon, special);
}
confidences.push(Math.max(0, weapon.score));
}
// paint (parse first so the name region can be trimmed at the digits)
let paint: ParsedNumber | null = null;
const pRoi = rois.paint(cy);
if (resources.paintDigits) {
const crop = cropRoi(gray, pRoi);
paint = parseNumber(crop, resources.paintDigits, {
dropLoweredTrailing: options.paintDropLoweredTrailing,
});
crop.delete();
confidences.push(paint.confidence);
}
// name, trimmed at the leftmost paint digit
let name: ReturnType<typeof parseName> | null = null;
if (resources.nameGlyphs) {
const base = rois.name(cy);
const paintLeftAbs =
paint && paint.leftX !== null ? pRoi.x + paint.leftX : pRoi.x + pRoi.w;
const w = Math.min(base.w, Math.max(0, paintLeftAbs - 6 - base.x));
if (w > 8) {
const crop = cropRoi(gray, { ...base, w });
name = parseName(crop, resources.nameGlyphs);
crop.delete();
confidences.push(name.confidence);
}
}
// stat counters
const statValues: (number | null)[] = [null, null, null];
const statScores: [number, number, number] = [0, 0, 0];
if (resources.statDigits) {
for (const i of [0, 1, 2] as const) {
const crop = cropRoi(gray, rois.stat(cy, i));
const parsed = parseNumber(crop, resources.statDigits);
crop.delete();
statValues[i] = parsed.value;
statScores[i] = parsed.confidence;
confidences.push(parsed.confidence);
}
}
return {
player: {
name: name?.name ?? "",
weaponId: weapon ? toMainWeaponId(weapon.id) : null,
paint: paint?.value ?? null,
ka: statValues[0] ?? null,
d: statValues[1] ?? null,
s: statValues[2] ?? null,
},
debug: {
weapon,
special,
paintScore: paint?.confidence ?? 0,
nameScore: name?.confidence ?? 0,
statScores,
povFraction: povYellowFraction(rgb, rois.povArrow(cy)),
},
};
}

View File

@@ -0,0 +1,272 @@
/**
* Kit-icon identification: the team-tinted sub/special tiles next to a
* player's main weapon (scoreboard: special icon above the specials
* counter; minimap: sub-weapon tile). Tiles render the same art in each
* team's ink hue, so unlike weapon icons the match is shape-only: alpha
* silhouette vs the binarized search region, NCC + ink-coverage penalty.
*
* Low pixel budget (~22px specials, ~30px minimap subs) is enough because
* it only splits main weapons whose *icons* are near-ties (Splash- vs
* Sploosh-o-matic) — near-tie icon twins ship with far-apart kit
* silhouettes (stamp vs crab, bomb vs beakon).
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import type { FrameData } from "../../image";
import { WEAPON_KITS } from "./kits";
import type { WeaponMatch } from "./weapons";
/** Icon heights (px at 1080p) to try; the row renders it at ~22px. */
const SPECIAL_TEMPLATE_SIZES = [19, 21, 23, 25] as const;
/** Colored icon ink vs the near-black pill, on max(r,g,b). */
const SPECIAL_INK_THRESHOLD = 48;
export interface SpecialTemplate {
id: string;
/** binary silhouette + ink pixel count at each templateSizes entry */
sizes: { mat: Mat; ink: number }[];
}
export interface SpecialMatch {
id: string;
score: number;
top: { id: string; score: number }[];
}
/**
* Build binary silhouette templates from raw RGBA icon images (128x128 with
* alpha): tight-crop the alpha extent, binarize, downscale to each height.
* `templateSizes` defaults to the scoreboard's special-icon sizes; the
* minimap builds its sub-weapon set at the sub tile's larger sizes.
*/
export function prepareSpecialTemplates(
icons: { id: string; image: FrameData }[],
templateSizes: readonly number[] = SPECIAL_TEMPLATE_SIZES,
): SpecialTemplate[] {
const cv = getCV();
return icons.map(({ id, image }) => {
const { width, height, data } = image;
let xMin = width;
let xMax = -1;
let yMin = height;
let yMax = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3]! > 128) {
if (x < xMin) xMin = x;
if (x > xMax) xMax = x;
if (y < yMin) yMin = y;
if (y > yMax) yMax = y;
}
}
}
const w = Math.max(1, xMax - xMin + 1);
const h = Math.max(1, yMax - yMin + 1);
const silhouette = new cv.Mat(h, w, cv.CV_8UC1, new cv.Scalar(0));
const dst = silhouette.data;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (data[((y + yMin) * width + x + xMin) * 4 + 3]! > 128) {
dst[y * w + x] = 255;
}
}
}
const sizes = templateSizes.map((size) => {
const scale = size / Math.max(w, h);
const resized = new cv.Mat();
cv.resize(
silhouette,
resized,
new cv.Size(
Math.max(1, Math.round(w * scale)),
Math.max(1, Math.round(h * scale)),
),
0,
0,
cv.INTER_AREA,
);
// re-binarize the interpolated edges so template and search region
// live on the same two-level scale
const mat = new cv.Mat();
cv.threshold(resized, mat, 127, 255, cv.THRESH_BINARY);
resized.delete();
let ink = 0;
for (const v of mat.data) if (v > 0) ink++;
return { mat, ink };
});
silhouette.delete();
return { id, sizes };
});
}
/**
* searchRgb: RGB crop of the row's special-icon ROI (view is fine).
* The region is binarized on max(r,g,b) so any ink tint reads as shape.
*/
export function matchSpecial(
searchRgb: Mat,
templates: SpecialTemplate[],
): SpecialMatch {
const cv = getCV();
// binarized copy of the search region (pixel access needs a copy)
const cont = new cv.Mat();
searchRgb.copyTo(cont);
const { rows, cols } = cont;
const binary = new cv.Mat(rows, cols, cv.CV_8UC1, new cv.Scalar(0));
const src = cont.data;
const dst = binary.data;
let searchInk = 0;
for (let i = 0; i < rows * cols; i++) {
const v = Math.max(src[i * 3]!, src[i * 3 + 1]!, src[i * 3 + 2]!);
if (v > SPECIAL_INK_THRESHOLD) {
dst[i] = 255;
searchInk++;
}
}
cont.delete();
const result = new cv.Mat();
const ranked: { id: string; score: number }[] = [];
for (const template of templates) {
let score = -1;
for (const { mat, ink } of template.sizes) {
if (mat.rows > binary.rows || mat.cols > binary.cols) continue;
cv.matchTemplate(binary, mat, result, cv.TM_CCOEFF_NORMED);
const { maxVal } = minMaxLoc(result);
const r =
Math.min(ink, searchInk) / Math.max(Math.max(ink, searchInk), 1);
const adjusted = maxVal * (0.75 + 0.25 * r);
if (adjusted > score) score = adjusted;
}
ranked.push({ id: template.id, score });
}
result.delete();
binary.delete();
ranked.sort((a, b) => b.score - a.score);
return {
id: ranked[0]?.id ?? "unknown",
score: ranked[0]?.score ?? -1,
top: ranked,
};
}
/** Weapon-icon score gap under which two candidates count as a tie. */
const WEAPON_TIE_MARGIN = 0.04;
/**
* Icon twins whose art differs only by the nozzle (Splash- vs
* Sploosh-o-matic share the body, gauge and handle). On the replay
* browser's dim, drop-shadowed rendering the wrong twin can beat the right
* one by far more than WEAPON_TIE_MARGIN (0.15 observed on the
* brinewater-1411 fixture), so whenever both appear among the top
* candidates the kit evidence is consulted regardless of the icon-score
* gap — the KIT_DECISION_MARGIN still guards the actual re-rank.
* Symmetric pairs, extended per attested fixture need only.
*/
const ICON_TWINS: ReadonlyMap<string, string> = new Map([
["0", "20"], // Sploosh-o-matic <-> Splash-o-matic
["20", "0"],
]);
/**
* The kit-icon evidence must separate the tied kits' entries by at least
* this much to override the icon ranking: the shape matcher's own confusions
* (Wave Breaker vs Ink Vac/Reef Slider on ~22px of silhouette) all land
* inside this band, while true silhouette splits (stamp vs crab) clear it.
*/
const KIT_DECISION_MARGIN = 0.06;
type KitPart = "sub" | "special";
/**
* Weapon-icon candidates tied within WEAPON_TIE_MARGIN (plus the leader's
* ICON_TWIN at any gap) whose kits carry different subs/specials — the only
* case the kit icon can help with. Returns null when the icon match already
* decided (or the kit part can't).
*/
function tiedWeaponsWithDistinctKit(
match: WeaponMatch,
part: KitPart,
): { id: string; score: number }[] | null {
const leader = match.top[0];
if (!leader) return null;
const tied = match.top.filter(
(t) => leader.score - t.score < WEAPON_TIE_MARGIN,
);
const twinId = ICON_TWINS.get(leader.id);
if (twinId && !tied.some((t) => t.id === twinId)) {
const twin = match.top.find((t) => t.id === twinId);
if (twin) tied.push(twin);
}
if (tied.length < 2) return null;
const parts = new Set(
tied
.map((t) => WEAPON_KITS.get(t.id)?.[part])
.filter((s) => s !== undefined),
);
return parts.size >= 2 ? tied : null;
}
export function tiedWeaponsWithDistinctSpecials(
match: WeaponMatch,
): { id: string; score: number }[] | null {
return tiedWeaponsWithDistinctKit(match, "special");
}
export function tiedWeaponsWithDistinctSubs(
match: WeaponMatch,
): { id: string; score: number }[] | null {
return tiedWeaponsWithDistinctKit(match, "sub");
}
/**
* Re-rank tied weapon candidates by how well their kits' sub/special
* matches the tile evidence. Only reorders on decisive evidence; ties whose
* kit entries all score alike keep the icon-match order.
*/
function disambiguateWeaponByKit(
match: WeaponMatch,
evidence: SpecialMatch,
part: KitPart,
): WeaponMatch {
const tied = tiedWeaponsWithDistinctKit(match, part);
if (!tied) return match;
// worst-possible floor: adjusted NCC never drops below -1
const kitScore = (weaponId: string): number => {
const kit = WEAPON_KITS.get(weaponId);
if (!kit) return -1;
return evidence.top.find((t) => t.id === String(kit[part]))?.score ?? -1;
};
const ranked = [...tied].sort((a, b) => kitScore(b.id) - kitScore(a.id));
const winner = ranked[0]!;
if (winner.id === match.id) return match;
const runnerUp = ranked.find((t) => kitScore(t.id) < kitScore(winner.id));
if (
!runnerUp ||
kitScore(winner.id) - kitScore(runnerUp.id) < KIT_DECISION_MARGIN
) {
return match;
}
const top = [winner, ...match.top.filter((t) => t.id !== winner.id)].slice(
0,
3,
);
const flag: Partial<WeaponMatch> =
part === "special" ? { specialResolved: true } : { subResolved: true };
return { ...match, id: winner.id, score: winner.score, top, ...flag };
}
export function disambiguateWeaponBySpecial(
match: WeaponMatch,
special: SpecialMatch,
): WeaponMatch {
return disambiguateWeaponByKit(match, special, "special");
}
export function disambiguateWeaponBySub(
match: WeaponMatch,
sub: SpecialMatch,
): WeaponMatch {
return disambiguateWeaponByKit(match, sub, "sub");
}

View File

@@ -0,0 +1,369 @@
/**
* Weapon icon identification: NCC of every candidate icon (pre-scaled to a
* few sizes) against the row's weapon ROI, with an ink-coverage penalty so a
* small template can't win by matching a lucky sub-window of a bigger icon
* (the template's opaque area should match the ink actually present).
*
* Large template sets run coarse-to-fine: every icon first ranked at quarter
* resolution (same scoring), then only the shortlist re-matched at full res.
* Small sets (ability badges) skip the coarse pass — not worth it below ~2x
* the shortlist size.
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import type { FrameData } from "../../image";
/**
* Icon heights (px at 1080p) to try. The live scoreboard renders in-row
* icons at ~44-56px; the replay browser at ~60-64px. The oversized entries
* are skipped inside the live detector's 56px-tall weapon ROI at match
* time, so they only ever compete on the replay screen.
*/
const WEAPON_TEMPLATE_SIZES = [40, 44, 48, 52, 56, 60, 64] as const;
/** Row pill background the icons sit on (near-black). */
const PILL_BACKGROUND = 12;
/** Pixels brighter than this count as icon ink (pill is ~10-15). */
const INK_THRESHOLD = 40;
/**
* Scoped chargers' icons differ from their unscoped twins only by the scope
* tube, which is near-black and sits on the near-black pill — measurably
* invisible at ~50px (a synthetic scoped row probes *darker* in the scope
* band than a real unscoped row does from barrel bleed). When the two
* variants score within noise of each other, resolve to the unscoped one
* (matches labeled fixtures and typical usage) and flag the ambiguity.
* Map: scoped id -> unscoped id.
*/
const SCOPED_TWINS: ReadonlyMap<string, string> = new Map([
["2040", "2030"], // Splat Scope -> Splat Charger
["2041", "2031"], // Z+F variants
["2070", "2060"], // E-liter 4K Scope -> E-liter 4K
["2071", "2061"], // Custom variants
]);
const TWIN_MARGIN = 0.05;
/** Coarse-pass resolution, relative to full templates. */
const COARSE_SCALE = 0.25;
/** How many coarse-ranked ids survive into the full-resolution pass. */
const COARSE_SHORTLIST = 16;
export interface TemplateSize {
mat: Mat;
ink: number;
/** the same template at COARSE_SCALE, for the coarse ranking pass */
coarse: { mat: Mat; ink: number };
}
export interface WeaponTemplate {
id: string;
/** RGB template + ink pixel count at each candidate size */
sizes: TemplateSize[];
}
export interface WeaponMatch {
id: string;
score: number;
/** best candidates, most likely first (3 unless options.topN says more) */
top: { id: string; score: number }[];
/** true when a scoped/unscoped twin tie was resolved by the unscoped prior */
twinAmbiguous?: boolean;
/** true when a near-tie was re-ranked by the row's special icon (specials.ts) */
specialResolved?: boolean;
/** true when a near-tie was re-ranked by the minimap sub tile (specials.ts) */
subResolved?: boolean;
}
/** Icon-ink pixel count of a continuous RGB mat (NOT an ROI view). */
function countInkRgb(mat: Mat, threshold: number): number {
let ink = 0;
const d = mat.data;
const n = mat.rows * mat.cols;
for (let i = 0; i < n; i++) {
const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!);
if (v > threshold) ink++;
}
return ink;
}
/**
* Downscale a composited RGB icon to each candidate size, with the coarse
* variant and ink counts matchWeapon needs. Ink is counted on the resized
* pixels, not scaled from source alpha (which undercounts antialiased edges
* and can flip the coverage ratio toward a wrong icon).
*
* `referenceSize` scales by size/referenceSize instead of resizing to a
* size×size square — "the art as it renders inside a size-sized padded
* square" — letting a template whose icon square is taller than the ROI
* still compete when its art fits (see prepareWeaponTemplates cropToArt).
*/
export function buildTemplateSizes(
composited: Mat,
sizes: readonly number[],
inkThreshold: number,
referenceSize?: number,
): TemplateSize[] {
const cv = getCV();
return sizes.map((size) => {
const mat = new cv.Mat();
if (referenceSize) {
const scale = size / referenceSize;
cv.resize(
composited,
mat,
new cv.Size(
Math.max(1, Math.round(composited.cols * scale)),
Math.max(1, Math.round(composited.rows * scale)),
),
0,
0,
cv.INTER_AREA,
);
} else {
cv.resize(composited, mat, new cv.Size(size, size), 0, 0, cv.INTER_AREA);
}
const coarseMat = new cv.Mat();
cv.resize(
mat,
coarseMat,
new cv.Size(0, 0),
COARSE_SCALE,
COARSE_SCALE,
cv.INTER_AREA,
);
return {
mat,
ink: countInkRgb(mat, inkThreshold),
coarse: { mat: coarseMat, ink: countInkRgb(coarseMat, inkThreshold) },
};
});
}
/**
* Build match-ready templates from raw RGBA icon images (256x256 with
* alpha): composite over the pill background, then downscale to each size.
* Screens whose pills aren't near-black (minimap cards, camo surfaces)
* override `background` and `inkThreshold` (must clear the new background
* or ink counts saturate).
*
* `cropToArt` trims each template to the icon's alpha bbox (source icons
* carry generous transparent padding) while keeping the padded-square scale
* semantics — without it, a screen rendering icons near the ROI height
* (minimap cards, ~56-60px in a 54px box) can never match, since matchWeapon
* skips templates taller/wider than the ROI.
*/
export function prepareWeaponTemplates(
icons: { id: string; image: FrameData }[],
templateSizes: readonly number[] = WEAPON_TEMPLATE_SIZES,
options: {
background?: number;
inkThreshold?: number;
cropToArt?: boolean;
} = {},
): WeaponTemplate[] {
const cv = getCV();
const background = options.background ?? PILL_BACKGROUND;
const inkThreshold = options.inkThreshold ?? INK_THRESHOLD;
return icons.map(({ id, image }) => {
const rgba = cv.matFromImageData(image as unknown as ImageData);
const source = options.cropToArt ? cropToAlphaBbox(rgba) : rgba;
const composited = compositeOnBackground(source, background);
if (source !== rgba) source.delete();
const referenceSize = options.cropToArt ? rgba.cols : undefined;
rgba.delete();
const sizes = buildTemplateSizes(
composited,
templateSizes,
inkThreshold,
referenceSize,
);
composited.delete();
return { id, sizes };
});
}
/** Alpha threshold and margin (source px) for the cropToArt bounding box. */
const ART_ALPHA_MIN = 32;
const ART_BBOX_MARGIN = 6;
function cropToAlphaBbox(rgba: Mat): Mat {
const cv = getCV();
const d = rgba.data;
const cols = rgba.cols;
const rows = rgba.rows;
let x0 = cols;
let y0 = rows;
let x1 = -1;
let y1 = -1;
for (let y = 0; y < rows; y++) {
const rowBase = y * cols * 4 + 3;
for (let x = 0; x < cols; x++) {
if (d[rowBase + x * 4]! >= ART_ALPHA_MIN) {
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
}
}
if (x1 < 0) {
const copy = new cv.Mat();
rgba.copyTo(copy);
return copy;
}
x0 = Math.max(0, x0 - ART_BBOX_MARGIN);
y0 = Math.max(0, y0 - ART_BBOX_MARGIN);
x1 = Math.min(cols - 1, x1 + ART_BBOX_MARGIN);
y1 = Math.min(rows - 1, y1 + ART_BBOX_MARGIN);
const view = rgba.roi(new cv.Rect(x0, y0, x1 - x0 + 1, y1 - y0 + 1));
const out = new cv.Mat();
view.copyTo(out); // ROI views: .data/.clone broken in this build, copy out
view.delete();
return out;
}
function compositeOnBackground(rgba: Mat, background: number): Mat {
const cv = getCV();
const out = new cv.Mat(rgba.rows, rgba.cols, cv.CV_8UC3);
const src = rgba.data;
const dst = out.data;
const n = rgba.rows * rgba.cols;
for (let i = 0; i < n; i++) {
const a = src[i * 4 + 3]! / 255;
dst[i * 3] = Math.round(src[i * 4]! * a + background * (1 - a));
dst[i * 3 + 1] = Math.round(src[i * 4 + 1]! * a + background * (1 - a));
dst[i * 3 + 2] = Math.round(src[i * 4 + 2]! * a + background * (1 - a));
}
return out;
}
/**
* Coarse pass: rank every template by its best quarter-resolution score
* (same NCC + ink-coverage formula as the full pass) and return the
* COARSE_SHORTLIST ids worth full-resolution matching. Scoped ids drag
* their unscoped twin along so the twin tie-break always has both scores.
* Returns null when no coarse template fits (caller falls back to the
* full set, which then skips the same templates and reports unknown).
*/
function coarseShortlist(
searchRgb: Mat,
templates: WeaponTemplate[],
inkThreshold: number,
): Set<string> | null {
const cv = getCV();
const region = new cv.Mat();
cv.resize(
searchRgb,
region,
new cv.Size(0, 0),
COARSE_SCALE,
COARSE_SCALE,
cv.INTER_AREA,
);
const searchInk = countInkRgb(region, inkThreshold);
const result = new cv.Mat();
const scored: { id: string; score: number }[] = [];
const searchRows = searchRgb.rows;
const searchCols = searchRgb.cols;
const regionRows = region.rows;
const regionCols = region.cols;
for (const template of templates) {
let score = -1;
for (const { mat, coarse } of template.sizes) {
// gate on the *full-res* dims so a size competes here iff it competes
// in the full pass, then also require the coarse pair to fit
if (mat.rows > searchRows || mat.cols > searchCols) continue;
if (coarse.mat.rows > regionRows || coarse.mat.cols > regionCols)
continue;
cv.matchTemplate(region, coarse.mat, result, cv.TM_CCOEFF_NORMED);
const { maxVal } = minMaxLoc(result);
const r =
Math.min(coarse.ink, searchInk) /
Math.max(Math.max(coarse.ink, searchInk), 1);
const adjusted = maxVal * (0.75 + 0.25 * r);
if (adjusted > score) score = adjusted;
}
if (score > -1) scored.push({ id: template.id, score });
}
result.delete();
region.delete();
if (scored.length === 0) return null;
scored.sort((a, b) => b.score - a.score);
const ids = new Set(scored.slice(0, COARSE_SHORTLIST).map((s) => s.id));
for (const id of [...ids]) {
const twin = SCOPED_TWINS.get(id);
if (twin) ids.add(twin);
}
return ids;
}
/**
* searchRgb: RGB crop of the row's weapon ROI (view is fine).
* inkThreshold separates icon ink from the pill background in the search
* region — raise it on screens whose pills are lighter than the live
* scoreboard's (~12), e.g. the replay browser (~61), or everything counts
* as ink and the coverage penalty collapses.
*/
export function matchWeapon(
searchRgb: Mat,
templates: WeaponTemplate[],
options: { inkThreshold?: number; topN?: number } = {},
): WeaponMatch {
const cv = getCV();
const inkThreshold = options.inkThreshold ?? INK_THRESHOLD;
const topN = options.topN ?? 3;
// icon ink present in the search region (pixel access needs a copy)
const cont = new cv.Mat();
searchRgb.copyTo(cont);
const searchInk = countInkRgb(cont, inkThreshold);
cont.delete();
// shortlist large sets at coarse resolution first; below ~2x the shortlist
// size the coarse pass costs more calls than it saves
let pool = templates;
if (templates.length > COARSE_SHORTLIST * 2) {
const ids = coarseShortlist(searchRgb, templates, inkThreshold);
if (ids) pool = templates.filter((t) => ids.has(t.id));
}
const result = new cv.Mat();
const best = new Map<string, number>();
const searchRows = searchRgb.rows;
const searchCols = searchRgb.cols;
for (const template of pool) {
let score = -1;
for (const { mat, ink } of template.sizes) {
if (mat.rows > searchRows || mat.cols > searchCols) continue;
cv.matchTemplate(searchRgb, mat, result, cv.TM_CCOEFF_NORMED);
const { maxVal } = minMaxLoc(result);
const r =
Math.min(ink, searchInk) / Math.max(Math.max(ink, searchInk), 1);
const adjusted = maxVal * (0.75 + 0.25 * r);
if (adjusted > score) score = adjusted;
}
best.set(template.id, score);
}
result.delete();
const ranked = [...best.entries()]
.map(([id, score]) => ({ id, score }))
.sort((a, b) => b.score - a.score);
const top = ranked.slice(0, topN);
let first = top[0] ?? { id: "unknown", score: -1 };
let twinAmbiguous = false;
const unscopedId = SCOPED_TWINS.get(first.id);
if (unscopedId) {
const twin = ranked.find((r) => r.id === unscopedId);
if (twin && first.score - twin.score < TWIN_MARGIN) {
twinAmbiguous = true;
first = twin;
const i = top.findIndex((t) => t.id === twin.id);
if (i >= 0) top.splice(i, 1);
top.unshift(twin);
top.length = Math.min(top.length, topN);
}
}
return { id: first.id, score: first.score, top, twinAmbiguous };
}

View File

@@ -0,0 +1,85 @@
/**
* Scan telemetry: counters the analyzer accumulates so scan performance is
* measurable instead of guessed at — how many frames were decoded vs.
* actually analyzed, where detector time goes (gate vs. parse), how much
* work scheduling saved, and how much of a VoD was covered in skim mode.
* Plain JSON so it travels over the worker boundary as-is.
*/
export interface DetectorTelemetry {
/** frames on which the detector's gate ran */
checks: number;
gatePasses: number;
gateMs: number;
parses: number;
parseMs: number;
/** gate passed but parse() was skipped (suppression / cooldown) */
suppressedParses: number;
}
export interface ScanTelemetry {
decodedFrames: number;
/** frames that went through canvas readback + normalize + detectors */
analyzedFrames: number;
/** video seconds covered by dense sequential decode (chunk scan) */
activeVideoS: number;
/** video seconds covered by keyframe-hop skimming (chunk scan) */
skimVideoS: number;
wallMs: number;
detectors: Record<string, DetectorTelemetry>;
}
/** Fresh all-zero telemetry for one scan/session. */
export function createScanTelemetry(): ScanTelemetry {
return {
decodedFrames: 0,
analyzedFrames: 0,
activeVideoS: 0,
skimVideoS: 0,
wallMs: 0,
detectors: {},
};
}
/** Get-or-create the per-detector counters bucket. */
export function detectorTelemetry(
telemetry: ScanTelemetry,
id: string,
): DetectorTelemetry {
const existing = telemetry.detectors[id];
if (existing) return existing;
const created: DetectorTelemetry = {
checks: 0,
gatePasses: 0,
gateMs: 0,
parses: 0,
parseMs: 0,
suppressedParses: 0,
};
telemetry.detectors[id] = created;
return created;
}
/** Sum telemetry across parallel chunk scans into one report. */
export function mergeScanTelemetry(
parts: readonly ScanTelemetry[],
): ScanTelemetry {
const out = createScanTelemetry();
for (const part of parts) {
out.decodedFrames += part.decodedFrames;
out.analyzedFrames += part.analyzedFrames;
out.activeVideoS += part.activeVideoS;
out.skimVideoS += part.skimVideoS;
out.wallMs = Math.max(out.wallMs, part.wallMs);
for (const [id, d] of Object.entries(part.detectors)) {
const bucket = detectorTelemetry(out, id);
bucket.checks += d.checks;
bucket.gatePasses += d.gatePasses;
bucket.gateMs += d.gateMs;
bucket.parses += d.parses;
bucket.parseMs += d.parseMs;
bucket.suppressedParses += d.suppressedParses;
}
}
return out;
}

View File

@@ -0,0 +1,101 @@
import type { Mat } from "../cv";
export interface DetectedEvent<TData = unknown> {
type: string;
/** seconds into the stream/video */
t: number;
/** 0..1 aggregate confidence; TimelineBuilder drops events below threshold */
confidence: number;
data: TData;
/** per-field match scores etc. for the harness/debugging; not persisted upstream */
debug?: Record<string, unknown>;
}
export interface GateResult {
pass: boolean;
/** raw score of the gate check, for tuning */
score: number;
/**
* which screen variant the gate recognized, for detectors that gate more
* than one (minimap: "overlay" | "spectator") — parse() branches on it
* instead of re-running the gate probes
*/
variant?: string;
/**
* coarse content fingerprint (grid-cell means over content ROIs, see
* image.ts roiSignature) — the scheduler re-arms a suppressed streak
* when it moves, for screens whose distinct real occurrences keep the
* gate passing (browsing battle log / replay entries)
*/
signature?: number[];
}
/**
* A detector recognizes one event type on a canonical 1920x1080 RGBA frame.
* gate() must be cheap (probes / tiny template match); parse() may be expensive
* and only runs when gate() passes. Callers pass the gate result they already
* computed into parse(); a detector must still cope without it (re-deriving
* whatever it needs) so one-shot tools can call parse() directly.
*/
export interface Detector<TData = unknown> {
id: string;
/**
* Minimum seconds between checks in *both* scheduling phases: frames
* inside the interval skip gate and parse entirely
* (core/detectors/scheduler.ts, applied by the analyzer worker). Also
* exempts the detector from steady-frame suppression: it marks a screen
* that stays up for minutes with *changing* content, which the
* stagnating-confidence heuristic would wrongly silence.
*/
checkIntervalS?: number;
/**
* Check cadence while the gate keeps failing (the search phase); unset =
* the scheduler's default (0.25s). Don't reason from raw-gameplay screen
* lifetimes here: produced/casted VoDs cut screens (results, intros) to
* as little as ~1s with transition flicker eating frames inside that
* window, so the search cadence must sample it several times. Gates
* cost ~1.6ms, so searching at the analysis floor is effectively free;
* only override upward with strong evidence.
*/
searchIntervalS?: number;
/**
* Check cadence while the gate is passing (the refine phase); unset =
* the scheduler's dense default (0.15s). Only for detectors whose parse
* is expensive enough that refining at the dense cadence multiplies
* real cost — the sparser cadence still has to sample the screen's
* lifetime several times.
*/
refineIntervalS?: number;
/**
* Consecutive non-improving parses tolerated before stagnation
* suppression; unset = the scheduler's default (6). Lower for expensive
* parses to cap the worst-case cost of a stagnant streak.
*/
maxStagnantParses?: number;
/**
* A parse reaching this confidence ends the streak's refinement
* immediately: the timeline keeps the best read per merge window, so a
* read this good cannot be improved upon in any way that matters.
* Set just under the detector's measured clean-read confidence floor
* (fixture-suite reads + confirmed scan events), so a streak's first
* full read suppresses the rest; degraded-footage reads below the
* floor fall back to stagnation-based suppression.
*/
sufficientConfidence?: number;
/**
* After a sufficient read, skip parses for this long even across gate
* drops — for overlays whose animated background flickers the gate
* (death). Only safe when the event type's timeline merge is purely
* time-based and the cooldown fits inside the merge window, so every
* parse the cooldown skips would have merged into the same event anyway.
*/
rearmCooldownS?: number;
/**
* false = worker results for this detector's events ship without the
* analyzed-frame PNG — for detectors whose events fire continuously,
* where a stored frame per event would balloon IndexedDB.
*/
attachFrame?: boolean;
gate(frame: Mat): GateResult;
parse(frame: Mat, t: number, gate?: GateResult): DetectedEvent<TData>[];
}

View File

@@ -0,0 +1,670 @@
/**
* Glyph-template text recognition: font/size/position are known, so instead
* of OCR we segment the crop by column projection and classify each segment
* via sliding NCC against grayscale glyph templates (native scale;
* scaleGlyphSet pre-scales for larger text like team scores). Accuracy
* details: matching runs on the *masked* grayscale crop (background zeroed
* via a dilated binary mask) so colored backgrounds behave like black pills
* while antialiasing survives; scores carry an ink-coverage penalty (else a
* narrow template inside a wider glyph wins, e.g. 'c' outscoring 'o');
* segments wider than the median glyph split at deep projection dips ('T'
* overhangs merge into the next letter); multi-stroke glyphs with a stroke
* gap (katakana パ/ハ/リ) segment as fragments no template matches, so close
* pairs are re-classified merged and kept only when the merge decisively
* outscores both fragments.
*/
import { getCV, type Mat } from "./cv";
import type { FrameData } from "./image";
interface AtlasGlyphMeta {
char: string;
x: number;
y: number;
w: number;
h: number;
/**
* Template origin: "fixture" = exact pixel crop from a labeled frame
* (high fidelity, sparse coverage); "font" = rendered from game fonts
* (full coverage, imperfect). Recognition takes the max score, so
* fixture glyphs win where they exist.
*/
source?: "fixture" | "font";
}
export interface AtlasMeta {
/** nominal glyph height in canonical-1080p pixels */
height: number;
glyphs: AtlasGlyphMeta[];
}
interface Glyph {
char: string;
/** grayscale white-on-black, tight box */
mat: Mat;
/** count of pixels above the binarization threshold */
ink: number;
/** exact fixture crop vs font-rendered approximation */
source: "fixture" | "font";
}
export interface GlyphSet {
glyphs: Glyph[];
height: number;
/** median tight-box glyph width, used for split/space heuristics */
medianWidth: number;
}
const TEMPLATE_BIN_THRESHOLD = 128;
/** Slice glyph templates out of an atlas image using its metadata. */
export function loadGlyphSet(atlas: FrameData, meta: AtlasMeta): GlyphSet {
const cv = getCV();
const full = cv.matFromImageData(atlas as unknown as ImageData);
const gray = new cv.Mat();
cv.cvtColor(full, gray, cv.COLOR_RGBA2GRAY);
full.delete();
const glyphs: Glyph[] = meta.glyphs.map((g) => {
// NB: .data/.clone() are broken on ROI views in this opencv.js build;
// always copyTo into a fresh mat before pixel access.
const view = gray.roi(new cv.Rect(g.x, g.y, g.w, g.h));
const cell = new cv.Mat();
view.copyTo(cell);
view.delete();
const mat = tightCropGray(cell, TEMPLATE_BIN_THRESHOLD);
cell.delete();
let ink = 0;
for (const v of mat.data) if (v > TEMPLATE_BIN_THRESHOLD) ink++;
// untagged glyphs predate hybrid atlases and were all fixture crops
return { char: g.char, mat, ink, source: g.source ?? "fixture" };
});
gray.delete();
const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b);
const medianWidth = widths[Math.floor(widths.length / 2)] ?? 8;
return { glyphs, height: meta.height, medianWidth };
}
/** Resize every glyph by `factor` (e.g. to reuse paint digits for team scores). */
export function scaleGlyphSet(set: GlyphSet, factor: number): GlyphSet {
const cv = getCV();
const glyphs = set.glyphs.map((g) => {
const mat = new cv.Mat();
cv.resize(g.mat, mat, new cv.Size(0, 0), factor, factor, cv.INTER_CUBIC);
let ink = 0;
for (const v of mat.data) if (v > TEMPLATE_BIN_THRESHOLD) ink++;
return { char: g.char, mat, ink, source: g.source };
});
return {
glyphs,
height: Math.round(set.height * factor),
medianWidth: Math.round(set.medianWidth * factor),
};
}
/** Crop a grayscale mat to the tight bounds of its above-threshold pixels. */
function tightCropGray(gray: Mat, threshold: number): Mat {
const cv = getCV();
const { cols, rows, data } = gray;
let xMin = cols;
let xMax = -1;
let yMin = rows;
let yMax = -1;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (data[y * cols + x]! > threshold) {
if (x < xMin) xMin = x;
if (x > xMax) xMax = x;
if (y < yMin) yMin = y;
if (y > yMax) yMax = y;
}
}
}
const out = new cv.Mat();
if (xMax < 0) {
gray.copyTo(out);
return out;
}
const view = gray.roi(
new cv.Rect(xMin, yMin, xMax - xMin + 1, yMax - yMin + 1),
);
view.copyTo(out);
view.delete();
return out;
}
export interface RecognizedChar {
char: string;
score: number;
/** segment bounds relative to the crop */
x0: number;
x1: number;
/** segment ink extent relative to the crop (y1 exclusive), for baseline checks */
y0: number;
y1: number;
/** runner-up candidates for this segment (debugging/tuning aid) */
candidates?: {
char: string;
score: number;
ncc: number;
source: "fixture" | "font";
}[];
}
export interface RecognizedText {
text: string;
chars: RecognizedChar[];
/** min char score (0 when ink was present but nothing was recognized) */
confidence: number;
}
export interface RecognizeOptions {
/** binarization threshold for segmentation/masking (white text) */
binThreshold?: number;
/** min white pixels for a column to count as ink */
minColumnPixels?: number;
/** gaps wider than this emit a space; Infinity disables spaces */
spaceGap?: number;
/** discard chars scoring below this */
minCharScore?: number;
}
interface Segment {
x0: number;
x1: number;
}
function columnProfile(binary: Mat): number[] {
const { cols, rows, data } = binary;
const profile = new Array<number>(cols).fill(0);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (data[y * cols + x]! > 0) profile[x]!++;
}
}
return profile;
}
function segmentColumns(profile: number[], minColumnPixels: number): Segment[] {
const segments: Segment[] = [];
let start = -1;
for (let x = 0; x < profile.length; x++) {
const on = profile[x]! > minColumnPixels;
if (on && start < 0) start = x;
if (!on && start >= 0) {
if (x - start >= 2) segments.push({ x0: start, x1: x });
start = -1;
}
}
if (start >= 0 && profile.length - start >= 2) {
segments.push({ x0: start, x1: profile.length });
}
return segments;
}
/** Distance from `w` to the nearest positive multiple of `unit`. */
function widthError(w: number, unit: number): number {
const n = Math.max(1, Math.round(w / unit));
return Math.abs(w - n * unit);
}
function splitWideSegment(
profile: number[],
seg: Segment,
medianWidth: number,
): Segment[] {
const maxCharWidth = Math.round(medianWidth * 1.5);
if (seg.x1 - seg.x0 <= maxCharWidth) return [seg];
// Split at the dip deepest relative to its neighboring peaks, not raw
// minimum: the raw min can land inside a thin-but-real stroke (a 'T' top
// bar profiles as low as the blurred gap to the next glyph).
const leftMax = new Array<number>(seg.x1).fill(0);
const rightMax = new Array<number>(seg.x1).fill(0);
let running = 0;
for (let i = seg.x0; i < seg.x1; i++) {
leftMax[i] = running;
running = Math.max(running, profile[i]!);
}
running = 0;
for (let i = seg.x1 - 1; i >= seg.x0; i--) {
rightMax[i] = running;
running = Math.max(running, profile[i]!);
}
let best = -1;
let bestRatio = Number.POSITIVE_INFINITY;
for (let i = seg.x0 + 3; i < seg.x1 - 3; i++) {
const ratio =
profile[i]! / Math.max(1, Math.min(leftMax[i]!, rightMax[i]!));
if (ratio < bestRatio) {
bestRatio = ratio;
best = i;
}
}
if (best < 0 || bestRatio > 0.45) return [seg];
// The dip is often a plateau of equally-low columns whose ink ownership
// is ambiguous; cut at whichever edge leaves the left part closest to a
// whole glyph width.
let lo = best;
let hi = best;
while (lo - 1 >= seg.x0 + 3 && profile[lo - 1]! <= profile[best]!) lo--;
while (hi + 1 < seg.x1 - 3 && profile[hi + 1]! <= profile[best]!) hi++;
const cut =
widthError(lo - seg.x0, medianWidth) <=
widthError(hi + 1 - seg.x0, medianWidth)
? lo
: hi + 1;
return [
...splitWideSegment(profile, { x0: seg.x0, x1: cut }, medianWidth),
...splitWideSegment(profile, { x0: cut, x1: seg.x1 }, medianWidth),
];
}
interface SegmentInfo extends Segment {
ink: number;
height: number;
/** ink extent rows (y1 exclusive) */
y0: number;
y1: number;
}
function measureSegment(binary: Mat, seg: Segment): SegmentInfo {
const { cols, data, rows } = binary;
let ink = 0;
let yMin = rows;
let yMax = -1;
for (let y = 0; y < rows; y++) {
for (let x = seg.x0; x < seg.x1; x++) {
if (data[y * cols + x]! > 0) {
ink++;
if (y < yMin) yMin = y;
if (y > yMax) yMax = y;
}
}
}
return {
...seg,
ink,
height: Math.max(0, yMax - yMin + 1),
y0: Math.min(yMin, rows),
y1: yMax + 1,
};
}
/**
* Fixture crops are ground truth; a font glyph must beat one by this margin
* to win (stops 'I' noise-edging out an exact 'l' crop). Keep tight — at
* 0.04 a fixture crop of a genuinely different char ('h' vs 'b') can
* displace a correct font match.
*/
const FIXTURE_TIEBREAK = 0.02;
function classifySegment(
masked: Mat,
seg: SegmentInfo,
set: GlyphSet,
/**
* Exact early-reject floor: a score can never exceed its precomputed
* bound, so a caller that only needs to know whether any glyph can beat
* `scoreFloor` (mergeSplitGlyphs) lets the bound-sorted loop stop as soon
* as none can. Scores that ARE computed come out identical — but the
* returned list omits sub-floor candidates, so pass it only for probes.
*/
scoreFloor = Number.NEGATIVE_INFINITY,
): { char: string; score: number; ncc: number; source: "fixture" | "font" }[] {
const cv = getCV();
const segWidth = seg.x1 - seg.x0;
const pad = 5;
// NB: Mat dimension/data accessors go through embind (validateThis etc.)
// and are expensive — hoist them out of the per-glyph/per-pixel loops.
const maskedRows = masked.rows;
const x0 = Math.max(0, seg.x0 - pad);
const x1 = Math.min(masked.cols, seg.x1 + pad);
const regionCols = x1 - x0;
// Crop the search region vertically to the segment's ink rows: line crops
// can run far taller than the text (the death tag band is ~2x its glyphs),
// and every extra row multiplies matchTemplate work while only offering
// placements away from the ink no correct match can occupy. The slack
// keeps every hRatio-eligible template (tRows <= 1.3 * seg.height)
// placeable: seg.height + 2 * (0.15 * seg.height + 2) >= 1.3 * seg.height.
const vSlack = Math.ceil(0.15 * seg.height) + 2;
const y0 = Math.max(0, seg.y0 - vSlack);
const y1 = Math.min(maskedRows, seg.y1 + vSlack);
const regionRows = y1 - y0;
const region = masked.roi(new cv.Rect(x0, y0, regionCols, regionRows));
// Both penalty factors below depend only on the glyph and the segment, and
// NCC <= 1, so their product bounds the score a glyph can reach before any
// matching runs. Matching in descending-bound order lets the loop stop as
// soon as no remaining glyph could come within FIXTURE_TIEBREAK of the
// best — those can neither win nor take part in the fixture tie-break.
const eligible: {
glyph: Glyph;
tRows: number;
tCols: number;
r: number;
hr: number;
bound: number;
}[] = [];
for (const glyph of set.glyphs) {
const t = glyph.mat;
const tRows = t.rows;
const tCols = t.cols;
if (tRows > regionRows || tCols > regionCols) continue;
const wRatio = tCols / Math.max(segWidth, 1);
if (wRatio < 0.4 || wRatio > 2.5) continue;
const hRatio = tRows / Math.max(seg.height, 1);
// a template sliding freely inside a taller region can score high on a
// fragment of the segment (an 'l' bar inside a 'c'), so reject templates
// much taller or shorter than the segment's ink extent
if (hRatio < 0.5 || hRatio > 1.3) continue;
// ink-coverage penalty: templates should explain the segment's ink
const r =
Math.min(glyph.ink, seg.ink) / Math.max(Math.max(glyph.ink, seg.ink), 1);
// height-mismatch penalty: an x-height template sliding on an ascender/
// descender glyph ('o' on 'b' or 'g') can correlate well on the bowl
// alone; matching heights should outrank it
const hr =
Math.min(tRows, seg.height) / Math.max(tRows, Math.max(seg.height, 1));
eligible.push({
glyph,
tRows,
tCols,
r,
hr,
bound: (0.7 + 0.3 * r) * (0.85 + 0.15 * hr),
});
}
eligible.sort((a, b) => b.bound - a.bound);
const result = new cv.Mat();
const candidates: {
char: string;
score: number;
ncc: number;
source: "fixture" | "font";
}[] = [];
// Only consider placements that cover most of the segment: the region
// is padded, so a freely-sliding template can otherwise score a perfect
// match on the *neighboring* glyph inside the pad (an 'l' template next
// to a narrow 'i' segment matches the adjacent real 'l' at 0.97+), or
// on a fragment of the segment (a 't' stem on the left bar of a 'b').
const minOverlap = 0.7 * segWidth;
// In probe mode only the boolean "can any glyph beat the floor" matters,
// so the loop can stop as soon as either answer is certain: no remaining
// bound reaches the floor, or a computed score already cleared it.
const probeMode = scoreFloor !== Number.NEGATIVE_INFINITY;
let bestScore = scoreFloor;
for (const { glyph, tRows, tCols, r, hr, bound } of eligible) {
if (bound < bestScore - FIXTURE_TIEBREAK) break;
if (probeMode && (bound <= scoreFloor || bestScore > scoreFloor)) break;
cv.matchTemplate(region, glyph.mat, result, cv.TM_CCOEFF_NORMED);
const rCols = regionCols - tCols + 1;
const rRows = regionRows - tRows + 1;
// overlap(sx) is concave in sx, so the valid placements form one
// contiguous rx interval — find its edges, then scan row-major
const overlapAt = (rx: number) =>
Math.min(x0 + rx + tCols, seg.x1) - Math.max(x0 + rx, seg.x0);
let lo = 0;
while (lo < rCols && overlapAt(lo) < minOverlap) lo++;
let hi = rCols - 1;
while (hi >= lo && overlapAt(hi) < minOverlap) hi--;
if (hi < lo) continue;
let maxVal = Number.NEGATIVE_INFINITY;
const scores = result.data32F;
for (let ry = 0, rowBase = 0; ry < rRows; ry++, rowBase += rCols) {
for (let rx = lo; rx <= hi; rx++) {
const v = scores[rowBase + rx]!;
if (v > maxVal) maxVal = v;
}
}
const score = maxVal * (0.7 + 0.3 * r) * (0.85 + 0.15 * hr);
if (Number.isFinite(score)) {
if (score > bestScore) bestScore = score;
candidates.push({
char: glyph.char,
score,
ncc: maxVal,
source: glyph.source,
});
}
}
result.delete();
region.delete();
candidates.sort((a, b) => b.score - a.score);
const top = candidates[0];
if (top && top.source === "font") {
const fixture = candidates.find(
(c) =>
c.source === "fixture" &&
top.score - c.score < FIXTURE_TIEBREAK &&
// ...but only when the fixture's raw correlation is also competitive;
// a fixture crop of a *different* char that merely lands near the top
// score via the ink penalty must not displace a well-matching font glyph
top.ncc - c.ncc < FIXTURE_TIEBREAK,
);
if (fixture && fixture !== top) {
candidates.splice(candidates.indexOf(fixture), 1);
candidates.unshift(fixture);
}
}
return candidates.slice(0, 5);
}
interface ClassifiedSegment {
seg: SegmentInfo;
ranked: ReturnType<typeof classifySegment>;
}
/**
* Multi-stroke glyphs whose strokes never connect (katakana ハ/パ/リ and kin)
* segment as two fragments no full-glyph template can match (width-ratio
* filter rejects a wide template on a narrow fragment). Close neighbor pairs
* are re-classified merged and kept only when the merge beats both fragments
* by a clear margin — genuine pairs ("rn", "VV") already read well alone, so
* a lookalike merge ('m', 'W') can't clear it. Kana gaps run wide (い's
* strokes sit ~0.4 medianWidth apart at tag-name scale), so
* MERGE_MAX_GAP_RATIO reaches past that while staying below the space gap
* (0.55), so merges never bridge a real word break.
*/
const MERGE_MAX_GAP_RATIO = 0.45;
const MERGE_MARGIN = 0.02;
function mergeSplitGlyphs(
items: ClassifiedSegment[],
binary: Mat,
masked: Mat,
set: GlyphSet,
): void {
const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO));
const maxCharWidth = Math.round(set.medianWidth * 1.5);
for (let i = 0; i + 1 < items.length; ) {
const a = items[i]!;
const b = items[i + 1]!;
const gap = b.seg.x0 - a.seg.x1;
const width = b.seg.x1 - a.seg.x0;
if (gap > maxGap || width > maxCharWidth) {
i++;
continue;
}
const seg = measureSegment(binary, { x0: a.seg.x0, x1: b.seg.x1 });
const fragmentBest = Math.max(
a.ranked[0]?.score ?? 0,
b.ranked[0]?.score ?? 0,
);
const floor = fragmentBest + MERGE_MARGIN;
// Probe with the floor first: most neighbor pairs are genuine letter
// pairs whose merge can't win, and the floor lets the bound-sorted
// matching stop almost immediately. The probe's computed scores are
// exact, so "no candidate beats the floor" is a definitive reject.
const probe = classifySegment(masked, seg, set, floor);
let merged = false;
if (probe.some((c) => c.score > floor)) {
// full run (rare): the winning merge's ranked list must also carry
// the sub-floor runner-up candidates downstream consumers see
const ranked = classifySegment(masked, seg, set);
if ((ranked[0]?.score ?? 0) > floor) {
// stay at i: the merged segment may absorb yet another stroke
items.splice(i, 2, { seg, ranked });
merged = true;
}
}
if (!merged) i++;
}
}
/**
* A wide-segment split can land inside a glyph instead of at the true
* boundary: capture blur can leave the gap between two glyphs shallower in
* the column profile than the hollows of their bowls, so the deepest-dip cut
* severs a bowl edge ("do" reading as "′k"). Both halves of such a miscut
* classify poorly, so adjacent low-scoring segments are re-cut: each profile
* dip across their joint span is tried as the boundary and the cut whose
* weaker half classifies best wins. Template scores carry real noise at the
* blur levels where miscuts happen (a wrong "l|U" outscored a true "d|u" by
* 0.07), so a re-cut is adopted only when it is both a decent read in
* absolute terms and decisively better than the original pair — otherwise
* the read stays as segmented.
*/
const RECUT_MAX_SCORE = 0.75;
const RECUT_MARGIN = 0.08;
const RECUT_MIN_SCORE = 0.7;
function dipCuts(profile: number[], x0: number, x1: number): number[] {
const cuts = new Set<number>();
for (let x = x0 + 3; x <= x1 - 3; x++) {
const v = profile[x]!;
if (v <= profile[x - 1]! && v <= profile[x + 1]!) {
cuts.add(x);
// ink ownership over the dip is ambiguous; try its far edge too
if (x + 1 <= x1 - 3) cuts.add(x + 1);
}
}
return [...cuts];
}
function recutMiscutPairs(
items: ClassifiedSegment[],
profile: number[],
binary: Mat,
masked: Mat,
set: GlyphSet,
): void {
const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO));
for (let i = 0; i + 1 < items.length; i++) {
const a = items[i]!;
const b = items[i + 1]!;
const aScore = a.ranked[0]?.score ?? 0;
const bScore = b.ranked[0]?.score ?? 0;
if (aScore >= RECUT_MAX_SCORE || bScore >= RECUT_MAX_SCORE) continue;
if (b.seg.x0 - a.seg.x1 > maxGap) continue;
let floor = Math.max(
RECUT_MIN_SCORE,
Math.max(aScore, bScore) + RECUT_MARGIN,
);
let best: [ClassifiedSegment, ClassifiedSegment] | null = null;
for (const cut of dipCuts(profile, a.seg.x0, b.seg.x1)) {
// a cut inside the existing gap reproduces the original pair
if (cut >= a.seg.x1 && cut <= b.seg.x0) continue;
const left = measureSegment(binary, { x0: a.seg.x0, x1: cut });
const right = measureSegment(binary, { x0: cut, x1: b.seg.x1 });
// probe with the floor first (see mergeSplitGlyphs): most candidate
// cuts can't beat it and the probes early-stop almost immediately
const canWin = (seg: SegmentInfo) =>
classifySegment(masked, seg, set, floor).some((c) => c.score > floor);
if (!canWin(left) || !canWin(right)) continue;
const leftRanked = classifySegment(masked, left, set);
const rightRanked = classifySegment(masked, right, set);
const weaker = Math.min(
leftRanked[0]?.score ?? 0,
rightRanked[0]?.score ?? 0,
);
if (weaker <= floor) continue;
floor = weaker;
best = [
{ seg: left, ranked: leftRanked },
{ seg: right, ranked: rightRanked },
];
}
if (best) items.splice(i, 2, ...best);
}
}
/**
* Recognize white-on-dark text in a grayscale crop.
* The crop must be tight vertically (one text line).
*/
export function recognizeText(
gray: Mat,
set: GlyphSet,
options: RecognizeOptions = {},
): RecognizedText {
const cv = getCV();
const {
binThreshold = 150,
minColumnPixels = 1,
spaceGap = Math.max(5, Math.round(set.medianWidth * 0.55)),
minCharScore = 0.4,
} = options;
const binary = new cv.Mat();
cv.threshold(gray, binary, binThreshold, 255, cv.THRESH_BINARY);
// mask the gray crop so background patterns don't take part in matching,
// dilated so antialiased glyph edges survive
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();
const masked = new cv.Mat(gray.rows, gray.cols, cv.CV_8UC1, new cv.Scalar(0));
gray.copyTo(masked, mask);
mask.delete();
const profile = columnProfile(binary);
const segments = segmentColumns(profile, minColumnPixels)
.flatMap((s) => splitWideSegment(profile, s, set.medianWidth))
.map((s) => measureSegment(binary, s));
const items: ClassifiedSegment[] = segments.map((seg) => ({
seg,
ranked: classifySegment(masked, seg, set),
}));
mergeSplitGlyphs(items, binary, masked, set);
recutMiscutPairs(items, profile, binary, masked, set);
const chars: RecognizedChar[] = [];
let text = "";
let prevEnd: number | null = null;
for (const { seg, ranked } of items) {
if (prevEnd !== null && seg.x0 - prevEnd > spaceGap && text.length > 0) {
text += " ";
}
const top = ranked[0];
if (top && top.score >= minCharScore) {
chars.push({
char: top.char,
score: top.score,
x0: seg.x0,
x1: seg.x1,
y0: seg.y0,
y1: seg.y1,
candidates: ranked.map((c) => ({
char: c.char,
score: c.score,
ncc: c.ncc,
source: c.source,
})),
});
text += top.char;
}
prevEnd = seg.x1;
}
binary.delete();
masked.delete();
const confidence =
chars.length > 0
? Math.min(...chars.map((c) => c.score))
: segments.length > 0
? 0
: 1;
return { text, chars, confidence };
}

View File

@@ -0,0 +1,186 @@
/**
* Environment-agnostic frame representation and helpers.
* A FrameData is RGBA, same layout as browser ImageData — Node code builds it
* from @napi-rs/canvas, browser code from a canvas or VideoFrame.
*/
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "./canonical";
import { getCV, type Mat, meanOf, minMaxLoc } from "./cv";
export type { Roi };
export interface FrameData {
width: number;
height: number;
/** RGBA, 4 bytes per pixel */
data: Uint8ClampedArray;
}
export function toMat(frame: FrameData): Mat {
const cv = getCV();
// matFromImageData only reads width/height/data, so FrameData is compatible
return cv.matFromImageData(frame as unknown as ImageData);
}
/**
* Normalize any input frame to the canonical 1920x1080 RGBA mat that all ROI
* constants are defined against. Returns a new mat; caller owns both.
*/
export function normalizeFrame(src: Mat): Mat {
const cv = getCV();
const dst = new cv.Mat();
if (src.cols === CANONICAL_WIDTH && src.rows === CANONICAL_HEIGHT) {
src.copyTo(dst);
return dst;
}
const interpolation =
src.cols > CANONICAL_WIDTH ? cv.INTER_AREA : cv.INTER_CUBIC;
cv.resize(
src,
dst,
new cv.Size(CANONICAL_WIDTH, CANONICAL_HEIGHT),
0,
0,
interpolation,
);
return dst;
}
/**
* Crop a rect out of a mat. Returns a view: fine as *input* to OpenCV calls
* (matchTemplate, mean, resize, ...) but NEVER read `.data` off it — in this
* opencv.js build both `.data` and `.clone()` mishandle non-continuous views.
* Use copyRoi when pixel access is needed.
*/
export function cropRoi(src: Mat, roi: Roi): Mat {
const cv = getCV();
return src.roi(new cv.Rect(roi.x, roi.y, roi.w, roi.h));
}
/** Crop a rect into a fresh continuous mat (safe for `.data` access). */
export function copyRoi(src: Mat, roi: Roi): Mat {
const view = cropRoi(src, roi);
const out = new (getCV().Mat)();
view.copyTo(out);
view.delete();
return out;
}
/**
* Mean brightness of a ROI: the average of the first three channels on a
* color mat, the single channel's mean on a grayscale (or |Laplacian|) mat.
* The shared probe primitive of every detector gate.
*/
export function meanBrightness(mat: Mat, roi: Roi): number {
const view = cropRoi(mat, roi);
const m = meanOf(view);
view.delete();
return mat.channels() >= 3 ? (m[0]! + m[1]! + m[2]!) / 3 : m[0]!;
}
/** Brightest pixel of a grayscale ROI. */
export function maxBrightness(gray: Mat, roi: Roi): number {
const view = cropRoi(gray, roi);
const { maxVal } = minMaxLoc(view);
view.delete();
return maxVal;
}
function channelExtreme(
mat: Mat,
roi: Roi | undefined,
op: "min" | "max",
): Mat {
const cv = getCV();
const view = roi ? cropRoi(mat, roi) : null;
const src = view ?? mat;
const channels = new cv.MatVector();
cv.split(src, channels);
const r = channels.get(0);
const g = channels.get(1);
const b = channels.get(2);
const rg = new cv.Mat();
const out = new cv.Mat();
if (op === "max") {
cv.max(r, g, rg);
cv.max(rg, b, out);
} else {
cv.min(r, g, rg);
cv.min(rg, b, out);
}
rg.delete();
r.delete();
g.delete();
b.delete();
if (mat.channels() === 4) channels.get(3).delete();
channels.delete();
view?.delete();
return out;
}
/** Brightest channel per pixel, so colored text binarizes like white. */
export function maxChannel(mat: Mat, roi?: Roi): Mat {
return channelExtreme(mat, roi, "max");
}
/** Per-pixel min of R/G/B — drops color-tinted brightness, keeps white. */
export function minChannel(mat: Mat, roi?: Roi): Mat {
return channelExtreme(mat, roi, "min");
}
/**
* Coarse content fingerprint of a grayscale ROI: the mean brightness of each
* cell in a cols x rows grid over the region. Cheap enough for gates.
* Consecutive frames of one static screen move a cell by ≤~2 while different
* text/content moves cells by tens (measured on battle log browsing footage)
* — the scheduler compares fingerprints to re-arm suppression when a passing
* gate's screen flips to a new real occurrence (GateResult.signature).
*/
export function roiSignature(
gray: Mat,
roi: Roi,
cols: number,
rows: number,
): number[] {
const cv = getCV();
const view = cropRoi(gray, roi);
const small = new cv.Mat();
cv.resize(view, small, new cv.Size(cols, rows), 0, 0, cv.INTER_AREA);
view.delete();
const cells = Array.from(small.data as Uint8Array, Number);
small.delete();
return cells;
}
/** |Laplacian| response of a grayscale mat; caller owns the result. */
export function laplacianAbs(gray: Mat): Mat {
const cv = getCV();
const lap = new cv.Mat();
cv.Laplacian(gray, lap, cv.CV_16S, 3, 1, 0, cv.BORDER_DEFAULT);
const abs8 = new cv.Mat();
cv.convertScaleAbs(lap, abs8);
lap.delete();
return abs8;
}
export function matToFrameData(mat: Mat): FrameData {
const cv = getCV();
const rgba = new cv.Mat();
if (mat.type() === cv.CV_8UC4) {
mat.copyTo(rgba);
} else if (mat.type() === cv.CV_8UC3) {
cv.cvtColor(mat, rgba, cv.COLOR_RGB2RGBA);
} else if (mat.type() === cv.CV_8UC1) {
cv.cvtColor(mat, rgba, cv.COLOR_GRAY2RGBA);
} else {
rgba.delete();
throw new Error(`unsupported mat type ${mat.type()}`);
}
const out: FrameData = {
width: rgba.cols,
height: rgba.rows,
data: new Uint8ClampedArray(rgba.data),
};
rgba.delete();
return out;
}

View File

@@ -0,0 +1,85 @@
/**
* Team ink color sampling and comparison. Splatoon renders each team's UI
* accents — counter plate fills and digits, minimap sub tiles — in the
* team's ink color, fixed for the whole game and picked to contrast with
* the opponent's. That makes ink hue a per-frame team identity signal
* where screen position is not one: casted footage reorders HUD sides to
* follow the currently specced player.
*/
import type { Mat } from "./cv";
import { copyRoi, type Roi } from "./image";
export interface InkRgb {
r: number;
g: number;
b: number;
}
/** Channel spread (max-min) a pixel needs to count as ink, not chrome. */
const INK_MIN_SATURATION = 60;
/** Fewer qualifying pixels than this = no reliable ink in the ROIs. */
const MIN_INK_PIXELS = 30;
/**
* Mean RGB of the ink-saturated pixels across the ROIs (channel spread at
* least `minSaturation`); null when fewer than MIN_INK_PIXELS qualify.
* Averaging only saturated pixels keeps dark/white surroundings (plate
* fills, digit cores, tile backgrounds) from washing the hue out.
*/
export function meanInkColor(
frame: Mat,
rois: readonly Roi[],
minSaturation: number = INK_MIN_SATURATION,
): InkRgb | null {
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (const roi of rois) {
const crop = copyRoi(frame, roi);
const { data } = crop;
const channels = crop.channels();
for (let i = 0; i < data.length; i += channels) {
const pr = data[i]!;
const pg = data[i + 1]!;
const pb = data[i + 2]!;
const spread = Math.max(pr, pg, pb) - Math.min(pr, pg, pb);
if (spread < minSaturation) continue;
r += pr;
g += pg;
b += pb;
count++;
}
crop.delete();
}
if (count < MIN_INK_PIXELS) return null;
return {
r: Math.round(r / count),
g: Math.round(g / count),
b: Math.round(b / count),
};
}
/** Hue angle of an ink color, degrees on the 0-360 color wheel. */
export function hueOf(color: InkRgb): number {
const max = Math.max(color.r, color.g, color.b);
const min = Math.min(color.r, color.g, color.b);
if (max === min) return 0;
const d = max - min;
let h: number;
if (max === color.r) {
h = ((color.g - color.b) / d) % 6;
} else if (max === color.g) {
h = (color.b - color.r) / d + 2;
} else {
h = (color.r - color.g) / d + 4;
}
return (h * 60 + 360) % 360;
}
/** Shortest angular distance between two hues, 0-180 degrees. */
export function hueDistance(a: number, b: number): number {
const d = Math.abs(a - b) % 360;
return d > 180 ? 360 - d : d;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
/**
* Flattened localized match sets derived from the generated
* localized-entries.ts: detectors snap OCR output against every language's
* strings at once and report the sendou.ink id, so ingestion works no
* matter which language the player runs the game in. Combos (mode+stage,
* lobby+mode) stay within one language — on-screen text never mixes
* languages — which keeps the cross product from inventing pairings no UI
* would show.
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../scanner-types";
import {
LANGUAGE_ENTRIES,
type LocalizedLobby,
type LocalizedMode,
type LocalizedStage,
} from "./localized-entries";
import { matchKey } from "./text";
function dedupe<T>(items: T[], keyOf: (item: T) => string): T[] {
const seen = new Set<string>();
return items.filter((item) => {
const k = keyOf(item);
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
const byText = (e: { text: string }) => matchKey(e.text);
export const ALL_LOBBY_ENTRIES: readonly LocalizedLobby[] = dedupe(
LANGUAGE_ENTRIES.flatMap((l) => l.lobbies),
byText,
);
/** Single-line mode names plus the intro splash's two-line wrap variants. */
export const ALL_MODE_ENTRIES: readonly LocalizedMode[] = dedupe(
LANGUAGE_ENTRIES.flatMap((l) => [...l.modes, ...l.modeWraps]),
byText,
);
export const ALL_STAGE_ENTRIES: readonly LocalizedStage[] = dedupe(
LANGUAGE_ENTRIES.flatMap((l) => l.stages),
byText,
);
/** Every language's constant "MODE" intro-splash label. */
export const ALL_MODE_LABELS: readonly string[] = dedupe(
LANGUAGE_ENTRIES.map((l) => l.modeLabel),
matchKey,
);
/** Replay-browser panel tags; canonical is "VICTORY" or "DEFEAT". */
export const RESULT_TAG_ENTRIES: readonly {
text: string;
canonical: string;
}[] = dedupe(
LANGUAGE_ENTRIES.flatMap((l) => [
{ text: l.victory, canonical: "VICTORY" },
{ text: l.defeat, canonical: "DEFEAT" },
]),
byText,
);
export interface ModeStageCombo {
text: string;
mode: ModeShort;
stageId: StageId;
}
/** The scoreboard header's "<mode> <stage>" line, per language. */
export const MODE_STAGE_COMBOS: readonly ModeStageCombo[] = dedupe(
LANGUAGE_ENTRIES.flatMap((l) =>
l.modes.flatMap((mode) =>
l.stages.map((stage) => ({
text: `${mode.text} ${stage.text}`,
mode: mode.mode,
stageId: stage.stageId,
})),
),
),
byText,
);
export interface LobbyModeCombo {
text: string;
lobby: ScannerLobby;
mode: ModeShort;
}
/** The replay-browser header's "<lobby> <mode>" line, per language. */
export const LOBBY_MODE_COMBOS: readonly LobbyModeCombo[] = dedupe(
LANGUAGE_ENTRIES.flatMap((l) =>
l.lobbies.flatMap((lobby) =>
l.modes.map((mode) => ({
text: `${lobby.text} ${mode.text}`,
lobby: lobby.lobby,
mode: mode.mode,
})),
),
),
byText,
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
/**
* Divide a chronological run of ScannerMatches into sets — consecutive games
* played by the same eight players (a Bo3/Bo5 between two teams). Rosters
* are compared by in-game names pooled across both teams (scoreboards list
* winners first, so team sides swap between games): names match fuzzily
* because two OCR reads of the same name can differ by a glyph or two, and
* one differing player is tolerated as a sub between games. A match with no
* readable names is inconclusive and never opens a new set.
*/
import type { ScannerMatch } from "./scanner-match";
import { matchKey, rankBy } from "./text";
/**
* closestBy score two reads of the same name must reach — 0.7 forgives one
* bad glyph on a four-letter name, two on longer ones.
*/
const SAME_NAME_SCORE = 0.7;
/** Players allowed to differ between consecutive games of one set. */
const MAX_SUBS_PER_GAME = 1;
/**
* For each match its 1-based set number, aligned by index with the input;
* matches must be in chronological order. Numbers only ever step up by one:
* a match whose roster disagrees with the current set's opens the next set.
*/
export function assignMatchSets(matches: readonly ScannerMatch[]): number[] {
const setNumbers: number[] = [];
let setNumber = 1;
let roster: string[] | null = null;
for (const match of matches) {
const names = rosterNames(match);
if (names.length > 0) {
if (roster !== null && !sameRoster(roster, names)) setNumber++;
roster = names;
}
setNumbers.push(setNumber);
}
return setNumbers;
}
function rosterNames(match: ScannerMatch): string[] {
return match.teams
.flatMap((team) => team.players)
.map((player) => player.name)
.filter((name): name is string => name !== null);
}
/**
* Whether two rosters read as the same eight players: every name of the
* smaller roster must find a partner in the other, short of
* MAX_SUBS_PER_GAME misses. Partial reads compare only what both saw, so a
* minimap-sourced half-roster still chains a set together.
*/
function sameRoster(a: readonly string[], b: readonly string[]): boolean {
const remaining = [...b];
let matched = 0;
for (const name of a) {
const partner = rankBy(name, remaining, (entry) => entry).find(
({ entry, score }) =>
score >= SAME_NAME_SCORE || sharedPrefixRead(name, entry),
);
if (partner) {
matched++;
remaining.splice(remaining.indexOf(partner.entry), 1);
}
}
return Math.min(a.length, b.length) - matched <= MAX_SUBS_PER_GAME;
}
/**
* Whether two reads of a name agree on a leading prefix long enough that the
* difference reads as tail damage, not another player. CJK names run 2-4
* glyphs, so a single truncated (れた → れ) or garbled (ほった → ほっ′`)
* tail glyph sinks the edit-distance score below any usable threshold while
* the OCR damage sits, as it nearly always does, at the end of the row.
*/
function sharedPrefixRead(a: string, b: string): boolean {
const keyA = matchKey(a);
const keyB = matchKey(b);
const shorter = Math.min(keyA.length, keyB.length);
if (shorter === 0) return false;
let prefix = 0;
while (prefix < shorter && keyA[prefix] === keyB[prefix]) prefix++;
// a whole-read prefix is a truncation; a 2+ glyph prefix covering two
// thirds of the shorter read is a solid read with a garbled tail
return prefix === shorter || (prefix >= 2 && prefix * 3 >= shorter * 2);
}

View File

@@ -0,0 +1,143 @@
/**
* Turns the replay browser's on-screen recording timestamp (locale-formatted
* by the console, e.g. "3/7/2026 22:28", "7.3.2026 22:28", "2026/3/7 22:28")
* into a UTC epoch, read in the local timezone. The string carries no
* timezone or day/month-order marker, so ambiguous day-vs-month (both ≤ 12)
* resolves in three steps: hour 0 or ≥13 proves a 24h clock (12h consoles
* render AM/PM) and 24h locales are near-universally day-first, except
* en-US which is 12h; failing that, the browser locale's date-part order
* decides; finally, since the console's locale and the browser's can still
* disagree, a recency check swaps day/month when that reading lands near now
* while the un-swapped one doesn't (replays are near-always ingested soon
* after recording).
*/
const TIMESTAMP_RE =
/^(\d{1,4})[./-](\d{1,4})[./-](\d{1,4})\s+(\d{1,2}):(\d{2})$/;
/** How far in the past a reading may land and still count as "recent". */
const RECENT_PAST_MS = 30 * 24 * 60 * 60 * 1000;
/** Forward tolerance for console-vs-browser clock and timezone skew. */
const FUTURE_SLACK_MS = 24 * 60 * 60 * 1000;
type Ymd = { year: number; month: number; day: number };
/**
* Parses a replay timestamp into UTC epoch milliseconds, or null when the
* string doesn't form a valid date. `locale` defaults to the environment's;
* `now` (default `Date.now()`) anchors the recency disambiguation — pass the
* moment the replay screen was on-screen, not the send time.
*/
export function parseReplayTimestamp(
raw: string,
{ locale, now }: { locale?: string; now?: number } = {},
): number | null {
const m = TIMESTAMP_RE.exec(raw.trim());
if (!m) return null;
const dateParts = [Number(m[1]!), Number(m[2]!), Number(m[3]!)];
const hours = Number(m[4]!);
const minutes = Number(m[5]!);
if (hours > 23 || minutes > 59) return null;
// hour 0 or ≥13 only occurs on a 24h clock, and 24h locales are
// near-universally day-first; only an ambiguous hour falls back to the
// browser locale, which may not match the console's
const is24hClock = hours === 0 || hours >= 13;
const resolved = resolveDateParts(
dateParts,
is24hClock ? true : dayBeforeMonth(locale),
);
if (!resolved) return null;
const preferred = toEpoch(resolved.preferred, hours, minutes);
if (preferred === null) return null;
if (resolved.swapped) {
const swapped = toEpoch(resolved.swapped, hours, minutes);
const ref = now ?? Date.now();
if (
swapped !== null &&
isRecent(swapped, ref) &&
!isRecent(preferred, ref)
) {
return swapped;
}
}
return preferred;
}
function isRecent(t: number, ref: number): boolean {
return t >= ref - RECENT_PAST_MS && t <= ref + FUTURE_SLACK_MS;
}
function toEpoch(
{ year, month, day }: Ymd,
hours: number,
minutes: number,
): number | null {
const date = new Date(year, month - 1, day, hours, minutes);
// the Date constructor rolls invalid dates over (31/2 → 2/3); reject those
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return date.getTime();
}
/**
* Splits the three date segments into year/month/day. The year is the
* segment with ≥3 digits (leading for ja-style "2026/3/7", trailing
* otherwise); with no such segment the last one is a 2-digit year. Day vs
* month resolves by magnitude when one exceeds 12, else by the caller's
* order guess (year-first formats are month-first — no locale writes Y/D/M);
* a guessed split also reports the swapped reading for the recency check.
*/
function resolveDateParts(
parts: number[],
guessDayFirst: boolean,
): { preferred: Ymd; swapped: Ymd | null } | null {
let year: number;
let rest: [number, number];
let dayFirst: boolean;
let orderKnown = false;
if (parts[0]! >= 100) {
year = parts[0]!;
rest = [parts[1]!, parts[2]!];
dayFirst = false;
orderKnown = true;
} else {
year = parts[2]! >= 100 ? parts[2]! : 2000 + parts[2]!;
rest = [parts[0]!, parts[1]!];
dayFirst = guessDayFirst;
}
const [a, b] = rest;
let day: number;
let month: number;
let guessed = false;
if (a > 12 && b > 12) return null;
if (a > 12) [day, month] = [a, b];
else if (b > 12) [month, day] = [a, b];
else {
[day, month] = dayFirst ? [a, b] : [b, a];
guessed = !orderKnown && a !== b;
}
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
return {
preferred: { year, month, day },
swapped: guessed ? { year, month: day, day: month } : null,
};
}
function dayBeforeMonth(locale: string | undefined): boolean {
const parts = new Intl.DateTimeFormat(locale).formatToParts(
new Date(2000, 0, 2),
);
const dayIndex = parts.findIndex((p) => p.type === "day");
const monthIndex = parts.findIndex((p) => p.type === "month");
return dayIndex !== -1 && monthIndex !== -1 && dayIndex < monthIndex;
}

View File

@@ -0,0 +1,231 @@
/**
* Environment-agnostic assembly of ScoreboardResources. Node (filesystem)
* and the worker (HTTP) inject only the four IO primitives; every resource
* key, icon directory, template option set, and atlas name lives here once,
* so adding a resource cannot desync the two loaders. Icon/atlas decoding is
* eager (cheap), but template prep and atlas slicing are deferred behind
* memoized getters — a test process loads the full bundle while its one
* detector touches only a slice, and unused sets (four weapon-template
* builds alone are ~7k resizes) dominated startup.
*/
import { abilities as abilityList } from "~/modules/in-game-lists/abilities";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import { prepareAbilityTemplates } from "./detectors/death/abilities";
import { BURST_ICON_TEMPLATE_SIZES } from "./detectors/death/rois";
import { prepareMinimapAbilityTemplates } from "./detectors/minimap/abilities";
import {
CARD_WEAPON_BACKGROUND,
MINIMAP_WEAPON_INK_THRESHOLD,
MINIMAP_WEAPON_TEMPLATE_SIZES,
SPECIAL_READY_BACKGROUND,
SPECIAL_READY_INK_THRESHOLD,
SUB_TILE_TEMPLATE_SIZES,
} from "./detectors/minimap/rois";
import type { PlannerStage } from "./detectors/minimap/stage";
import {
STRIP_WEAPON_INK_THRESHOLD,
STRIP_WEAPON_TEMPLATE_BACKGROUND,
STRIP_WEAPON_TEMPLATE_SIZES,
} from "./detectors/objective/rois";
import type { ScoreboardResources } from "./detectors/scoreboard/index";
import { prepareSpecialTemplates } from "./detectors/scoreboard/specials";
import { prepareWeaponTemplates } from "./detectors/scoreboard/weapons";
import { prepareOwnAbilityTemplates } from "./detectors/scoreboard-own/abilities";
import type { GlyphSet } from "./glyphs";
import type { FrameData } from "./image";
export interface ResourceIO {
/** decoded RGBA of the shared game icon img/<dir>/<id>.avif */
readIcon(dir: string, id: string): Promise<FrameData>;
/** glyph atlas by name as a (possibly lazy) getter; () => null when absent */
loadAtlas(name: string): Promise<() => GlyphSet | null>;
/** planner signature atlas as a getter; () => null when absent */
loadPlannerStages(): Promise<() => PlannerStage[] | null>;
}
/** resource key → atlas name under assets/cv/glyphs/ */
const ATLASES = {
paintDigits: "scoreboard-paint-digits",
statDigits: "scoreboard-stat-digits",
teamDigits: "scoreboard-team-digits",
nameGlyphs: "scoreboard-names",
headerLobbyGlyphs: "scoreboard-header-lobby",
headerLineGlyphs: "scoreboard-header-line",
replayCodeGlyphs: "scoreboard-replay-code",
replayResultGlyphs: "scoreboard-replay-result",
deathWeaponGlyphs: "death-weapon",
deathWeaponJaGlyphs: "death-weapon-ja",
deathTagNameGlyphs: "death-tag-name",
mapStartModeGlyphs: "map-start-mode",
mapStartStageGlyphs: "map-start-stage",
} as const;
/** Memoize an expensive template/atlas build for the lazy resource getters. */
function lazy<T>(build: () => T): () => T {
let value: T;
let built = false;
return () => {
if (!built) {
value = build();
built = true;
}
return value;
};
}
/** Requires loadOpenCV() to have resolved. */
export async function assembleScoreboardResources(
io: ResourceIO,
): Promise<ScoreboardResources> {
const icons = (dir: string, ids: readonly (number | string)[]) =>
Promise.all(
ids.map(async (id) => ({
id: String(id),
image: await io.readIcon(dir, String(id)),
})),
);
const [
weaponIcons,
specialIcons,
subIcons,
abilityIcons,
plannerStages,
atlasEntries,
] = await Promise.all([
icons("main-weapons", mainWeaponIds),
icons("special-weapons", specialWeaponIds),
icons("sub-weapons", subWeaponIds),
// UNKNOWN is the garbled-badge template: it competes in matching and
// wins on unreadable slots (see toAbilityWithUnknown in scanner-types.ts)
icons("abilities", [
...abilityList.map((ability) => ability.name),
"UNKNOWN",
]),
io.loadPlannerStages(),
Promise.all(
(Object.entries(ATLASES) as [keyof typeof ATLASES, string][]).map(
async ([key, name]) => [key, await io.loadAtlas(name)] as const,
),
),
]);
const atlas = Object.fromEntries(atlasEntries) as Record<
keyof typeof ATLASES,
() => GlyphSet | null
>;
const weapons = lazy(() => prepareWeaponTemplates(weaponIcons));
const deathBurstWeapons = lazy(() =>
prepareWeaponTemplates(weaponIcons, BURST_ICON_TEMPLATE_SIZES),
);
const minimapCardWeapons = lazy(() =>
prepareWeaponTemplates(weaponIcons, MINIMAP_WEAPON_TEMPLATE_SIZES, {
background: CARD_WEAPON_BACKGROUND,
inkThreshold: MINIMAP_WEAPON_INK_THRESHOLD,
cropToArt: true,
}),
);
const minimapLightWeapons = lazy(() =>
prepareWeaponTemplates(weaponIcons, MINIMAP_WEAPON_TEMPLATE_SIZES, {
background: SPECIAL_READY_BACKGROUND,
inkThreshold: SPECIAL_READY_INK_THRESHOLD,
cropToArt: true,
}),
);
const stripWeapons = lazy(() =>
prepareWeaponTemplates(weaponIcons, STRIP_WEAPON_TEMPLATE_SIZES, {
background: STRIP_WEAPON_TEMPLATE_BACKGROUND,
inkThreshold: STRIP_WEAPON_INK_THRESHOLD,
cropToArt: true,
}),
);
const specials = lazy(() => prepareSpecialTemplates(specialIcons));
const minimapSubWeapons = lazy(() =>
prepareSpecialTemplates(subIcons, SUB_TILE_TEMPLATE_SIZES),
);
const abilities = lazy(() => prepareAbilityTemplates(abilityIcons));
const ownAbilities = lazy(() => prepareOwnAbilityTemplates(abilityIcons));
const minimapAbilities = lazy(() =>
prepareMinimapAbilityTemplates(abilityIcons),
);
return {
get weapons() {
return weapons();
},
get deathBurstWeapons() {
return deathBurstWeapons();
},
get minimapCardWeapons() {
return minimapCardWeapons();
},
get minimapLightWeapons() {
return minimapLightWeapons();
},
get stripWeapons() {
return stripWeapons();
},
get specials() {
return specials();
},
get minimapSubWeapons() {
return minimapSubWeapons();
},
get abilities() {
return abilities();
},
get ownAbilities() {
return ownAbilities();
},
get minimapAbilities() {
return minimapAbilities();
},
get plannerStages() {
return plannerStages();
},
get paintDigits() {
return atlas.paintDigits();
},
get statDigits() {
return atlas.statDigits();
},
get teamDigits() {
return atlas.teamDigits();
},
get nameGlyphs() {
return atlas.nameGlyphs();
},
get headerLobbyGlyphs() {
return atlas.headerLobbyGlyphs();
},
get headerLineGlyphs() {
return atlas.headerLineGlyphs();
},
get replayCodeGlyphs() {
return atlas.replayCodeGlyphs();
},
get replayResultGlyphs() {
return atlas.replayResultGlyphs();
},
get deathWeaponGlyphs() {
return atlas.deathWeaponGlyphs();
},
get deathWeaponJaGlyphs() {
return atlas.deathWeaponJaGlyphs();
},
get deathTagNameGlyphs() {
return atlas.deathTagNameGlyphs();
},
get mapStartModeGlyphs() {
return atlas.mapStartModeGlyphs();
},
get mapStartStageGlyphs() {
return atlas.mapStartStageGlyphs();
},
};
}

View File

@@ -0,0 +1,134 @@
/**
* ScannerMatch — the unit the scanner hands to the rest of sendou.ink: one
* detected game with everything the scan could read. Built from the event
* timeline by core/match-builder.ts; validated at the boundaries by
* scannerMatchSchema (../scanner-schemas.ts). Every field is nullable —
* a match may be partial (features/scanner-ingest merges partials).
*/
import type {
AbilityWithUnknown,
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../scanner-types";
export interface ScannerMatchPlayer {
/** in-game name; null when unread (e.g. minimap POV enemy rows show none) */
name: string | null;
/** sendou main-weapon id; null when no frame read the slot */
weaponId: MainWeaponId | null;
paint: number | null;
ka: number | null;
d: number | null;
s: number | null;
/** [head, clothes, shoes] ability rows harvested from death screens */
abilities?: AbilityWithUnknown[][];
}
export interface ScannerMatchTeam {
/** up to 4 players; slots the scan never saw are absent */
players: ScannerMatchPlayer[];
}
export interface ScannerMatchObjectiveSample {
/** whole seconds into the video/stream the counter was read at */
t: number;
/**
* seconds shown on the match timer at the read ("3:35" = 215); null =
* unreadable. The game clock is the axis to graph progress on — score
* moves at most 1/s against it.
*/
time: number | null;
/** displayed count per team, in `teams` order; null = unreadable */
score: [number | null, number | null];
/** penalty pill value per team; null = no pill (or unreadable) */
penalty: [number | null, number | null];
/** which team held the objective at the read */
control: [boolean, boolean];
}
/**
* Objective-counter progress over the match. Samples are chronological and
* deduped to state changes, with an unchanged state re-confirmed every ~10s
* while the counter stays on screen — so consecutive samples further apart
* than that were not observed in between (capture gap, covered HUD): render
* such stretches as unknown instead of interpolating.
*/
export interface ScannerMatchObjective {
mode: "SZ";
samples: ScannerMatchObjectiveSample[];
}
export type ScannerMatchPlayerFlags = [boolean, boolean, boolean, boolean];
export interface ScannerMatchPlayerStatusSample {
/** whole seconds into the video/stream the icon strip was read at */
t: number;
/**
* seconds shown on the match timer at the read — the same key the
* objective samples carry, for charting both on one clock axis
*/
time: number | null;
/** special held per player, teams in `teams` order, slots in row order */
special: [ScannerMatchPlayerFlags, ScannerMatchPlayerFlags];
/** splatted per player, same arrangement */
dead: [ScannerMatchPlayerFlags, ScannerMatchPlayerFlags];
}
/**
* Per-player special/death states over the match, read off the icon strip
* next to the objective counter. Chronological and deduped to state
* changes like the objective samples, with an unchanged state re-confirmed
* every ~6s — render longer sample gaps as unknown, not as a continued
* state.
*/
export interface ScannerMatchPlayerStatus {
samples: ScannerMatchPlayerStatusSample[];
}
export interface ScannerMatch {
/** whole seconds into the video/stream the match starts at */
startsAt: number | null;
/** whole seconds into the video/stream the match was last seen at */
endsAt: number | null;
/**
* wall-clock ms the game was played: a replay scoreboard's recording
* time, else the closing scoreboard's detection time; null on VoD scans
*/
playedAt: number | null;
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/**
* the "Score:" banner game scores (0-100) in `teams` order, from a
* results/replay screen; a knockout's winner reports 100. Null when no
* such screen was seen.
*/
matchScores: [number | null, number | null] | null;
replayCode: string | null;
/** spectator/casted footage (the 8-player spectator map screen was seen) */
cast: boolean;
/**
* counter progress samples in `teams` order — the builder tracks each
* side's team ink color, so casted footage's plate swaps (the plates
* follow the specced player) can't scramble the series; null when no
* counter was read
*/
objective: ScannerMatchObjective | null;
/**
* per-player special/death samples in `teams` order, oriented alongside
* the objective samples (a status read pairs with the counter read of
* its frame); null when the icon strip was never read
*/
playerStatus: ScannerMatchPlayerStatus | null;
/**
* on-screen order: scoreboard rows 0-3 are teams[0] (the winners),
* minimap alpha/own side is teams[0]
*/
teams: [ScannerMatchTeam, ScannerMatchTeam];
/** scoreboard-sourced matches know it (0); minimap-only matches don't */
winner: 0 | 1 | null;
/** the POV player's seat, when a scoreboard identified it */
pov: { team: 0 | 1; index: number } | null;
}

View File

@@ -0,0 +1,130 @@
/**
* Strip-slot → scoreboard-row assignment. The in-match icon strip (and the
* minimap's card columns, which mirror it) keeps the lobby seating for the
* whole set, while the results scoreboard re-sorts each team per game — so
* per-slot status series can only be paired with scoreboard rows through
* identity evidence. Two kinds are combined:
*
* - weapon votes: per-slot candidate scores accumulated across a match's
* sampled StripWeapons reads (strip-weapons.ts) plus the minimap cards'
* parsed weapons. The best-scoring of the 24 possible slot→row
* assignments against the scoreboard's four weapons wins — the global
* constraint corrects slots whose own evidence is wrong or missing
* (attested: a slot with zero readable votes still lands right by
* elimination).
* - card names (the POV minimap's teammate diamond): matched directly
* against scoreboard row names.
*
* Ties resolve toward the fewest moved slots, so two rows sharing a weapon
* keep their as-drawn relative order, and thin evidence degrades to the
* as-drawn arrangement rather than a coin flip.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
/** A slot→row permutation: `perm[slot]` is the scoreboard row the slot feeds. */
export type SlotRowPermutation = readonly [number, number, number, number];
export const IDENTITY_PERMUTATION: SlotRowPermutation = [0, 1, 2, 3];
/**
* Total accumulated vote score the winning assignment needs before it may
* reorder anything, and the lead it needs over the best differing
* assignment. Calibrated on the sendou-triton VoD, where correct
* assignments scored 10-33 with margins 2.2-5.3 over ~20 sampled reads;
* junk evidence (a strip geometry mispick, non-Splatoon lookalikes)
* spreads flat and fails the margin.
*/
const MIN_ASSIGNMENT_SCORE = 1.5;
const MIN_ASSIGNMENT_MARGIN = 0.75;
/** All 24 permutations, fewest-moved-slots first (ties resolve to earlier). */
const PERMUTATIONS: SlotRowPermutation[] = (() => {
const all: SlotRowPermutation[] = [];
for (const a of [0, 1, 2, 3])
for (const b of [0, 1, 2, 3])
for (const c of [0, 1, 2, 3])
for (const d of [0, 1, 2, 3]) {
if (new Set([a, b, c, d]).size === 4) all.push([a, b, c, d]);
}
const displaced = (perm: SlotRowPermutation) =>
perm.filter((row, slot) => row !== slot).length;
return all.sort((x, y) => displaced(x) - displaced(y));
})();
/**
* The slot→row assignment best supported by one side's accumulated weapon
* votes, against that side's scoreboard row weapons. Falls back to the
* as-drawn order when the evidence is too thin or too close to call (see
* MIN_ASSIGNMENT_SCORE/MARGIN).
*/
export function weaponSlotRowPermutation(
votes: readonly ReadonlyMap<MainWeaponId, number>[],
rowWeapons: readonly (MainWeaponId | null)[],
): SlotRowPermutation {
const scored = PERMUTATIONS.map((perm) => ({
perm,
score: perm.reduce((sum, row, slot) => {
const weapon = rowWeapons[row];
return sum + (weapon === null ? 0 : (votes[slot]?.get(weapon!) ?? 0));
}, 0),
}));
let best = scored[0]!;
for (const candidate of scored) {
if (candidate.score > best.score) best = candidate;
}
if (best.score < MIN_ASSIGNMENT_SCORE) return IDENTITY_PERMUTATION;
const runnerUp = Math.max(
...scored
.filter((candidate) => candidate.score < best.score)
.map((candidate) => candidate.score),
0,
);
if (best.score - runnerUp < MIN_ASSIGNMENT_MARGIN) {
return IDENTITY_PERMUTATION;
}
return best.perm;
}
/**
* A card→row assignment from card names (the POV minimap's teammate
* diamond, whose order matches neither the strip nor the scoreboard):
* unique case-insensitive name matches place their cards, the leftovers
* keep their relative as-drawn order. Null — keep the as-drawn order —
* when fewer than two cards resolve, since a single hit cannot attest the
* arrangement is worth disturbing.
*/
export function nameSlotRowPermutation(
cardNames: readonly (string | null)[],
rowNames: readonly (string | null)[],
): SlotRowPermutation | null {
const normalized = (name: string | null) =>
name?.trim().toLowerCase() || null;
const rows = rowNames.map(normalized);
const assignment: (number | null)[] = [null, null, null, null];
const takenRows = new Set<number>();
let resolved = 0;
for (const [slot, cardName] of cardNames.map(normalized).entries()) {
if (cardName === null) continue;
const matches = rows.flatMap((row, i) => (row === cardName ? [i] : []));
if (matches.length !== 1 || takenRows.has(matches[0]!)) continue;
assignment[slot] = matches[0]!;
takenRows.add(matches[0]!);
resolved++;
}
if (resolved < 2) return null;
const freeRows = [0, 1, 2, 3].filter((row) => !takenRows.has(row));
for (const [slot, row] of assignment.entries()) {
if (row === null) assignment[slot] = freeRows.shift()!;
}
return assignment as unknown as SlotRowPermutation;
}
/** `flags` rearranged so slot `i`'s value lands at `perm[i]`. */
export function applyPermutation<T>(
flags: readonly T[],
perm: SlotRowPermutation,
): T[] {
const out = [...flags] as T[];
for (const [slot, row] of perm.entries()) out[row] = flags[slot]!;
return out;
}

View File

@@ -0,0 +1,143 @@
/**
* Small text utilities: edit distance and closed-set snapping for OCR output.
*/
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]!;
}
/**
* Case-, space- and diacritic-insensitive comparison key (é≈e), so OCR
* confusing an accented glyph with its base form stays a near-match across
* the localized closed sets.
*/
export function matchKey(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, "");
}
export interface ClosestMatch<T> {
entry: T;
/** 1 = exact (ignoring case/spaces/diacritics), 0 = nothing in common */
score: number;
}
/** Snap an OCR reading to the closest of a closed set of arbitrary entries. */
export function closestBy<T>(
reading: string,
entries: readonly T[],
textOf: (entry: T) => string,
): ClosestMatch<T> | null {
const r = matchKey(reading);
let best: ClosestMatch<T> | null = null;
for (const entry of entries) {
const e = matchKey(textOf(entry));
const d = editDistance(r, e);
const score = 1 - d / Math.max(r.length, e.length, 1);
if (!best || score > best.score) best = { entry, score };
}
return best;
}
/** Snap an OCR reading to the closest entry of a closed set of strings. */
export function closestEntry<T extends string>(
reading: string,
entries: readonly T[],
): ClosestMatch<T> | null {
return closestBy(reading, entries, (e) => e);
}
/** Rank every entry of a closed set against an OCR reading, best first. */
export function rankBy<T>(
reading: string,
entries: readonly T[],
textOf: (entry: T) => string,
): ClosestMatch<T>[] {
const r = matchKey(reading);
return entries
.map((entry) => {
const e = matchKey(textOf(entry));
const d = editDistance(r, e);
return { entry, score: 1 - d / Math.max(r.length, e.length, 1) };
})
.sort((a, b) => b.score - a.score);
}
/**
* One recognized text segment with its ranked alternatives — structurally
* compatible with glyphs.ts RecognizedChar, kept minimal here so string
* utilities stay decoupled from the recognition module.
*/
export interface ReadSegment {
candidates?: readonly { char: string; score: number }[];
}
/**
* Rank a closed set against the segment *candidate lists* of a recognized
* line instead of its greedy top-1-per-segment string. On low-fidelity
* captures (720p upscaled) the right glyph often sits at rank 2-3 of a
* segment while the top-1 string is garbage; plain edit distance can't see
* it, this can. Alignment is a weighted edit distance: matching a segment
* to a target char costs 1 minus that char's score in the segment's
* candidate list (1 when absent), insert/delete cost 1. Scores land well
* below rankBy's for the same quality of match (a correct char still costs
* 1 - templateScore), so the two scales must not share thresholds.
*/
export function rankByRead<T>(
segments: readonly ReadSegment[],
entries: readonly T[],
textOf: (entry: T) => string,
): ClosestMatch<T>[] {
// per-segment candidate score by match key (max wins when keys collide,
// e.g. dakuten variants folding onto one base kana)
const segScores = segments.map((seg) => {
const m = new Map<string, number>();
for (const c of seg.candidates ?? []) {
const k = matchKey(c.char);
const s = Math.max(0, Math.min(1, c.score));
if (s > (m.get(k) ?? 0)) m.set(k, s);
}
return m;
});
const n = segScores.length;
return entries
.map((entry) => {
const t = matchKey(textOf(entry));
const m = t.length;
const dp = Array.from({ length: n + 1 }, () =>
new Array<number>(m + 1).fill(0),
);
for (let i = 1; i <= n; i++) dp[i]![0] = i;
for (let j = 1; j <= m; j++) dp[0]![j] = j;
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
const sub = 1 - (segScores[i - 1]!.get(t[j - 1]!) ?? 0);
dp[i]![j] = Math.min(
dp[i - 1]![j]! + 1,
dp[i]![j - 1]! + 1,
dp[i - 1]![j - 1]! + sub,
);
}
}
return { entry, score: 1 - dp[n]![m]! / Math.max(n, m, 1) };
})
.sort((a, b) => b.score - a.score);
}

Some files were not shown because too many files have changed in this diff Show More