mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
249
apps/web-react/app/features/scanner/README.md
Normal file
249
apps/web-react/app/features/scanner/README.md
Normal 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.
|
||||
75
apps/web-react/app/features/scanner/capture/sampler.ts
Normal file
75
apps/web-react/app/features/scanner/capture/sampler.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
95
apps/web-react/app/features/scanner/capture/vod-frames.ts
Normal file
95
apps/web-react/app/features/scanner/capture/vod-frames.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
82
apps/web-react/app/features/scanner/components/DeathCard.tsx
Normal file
82
apps/web-react/app/features/scanner/components/DeathCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
175
apps/web-react/app/features/scanner/components/EventCard.tsx
Normal file
175
apps/web-react/app/features/scanner/components/EventCard.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
109
apps/web-react/app/features/scanner/components/FrameThumb.tsx
Normal file
109
apps/web-react/app/features/scanner/components/FrameThumb.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
581
apps/web-react/app/features/scanner/components/LivePage.tsx
Normal file
581
apps/web-react/app/features/scanner/components/LivePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
352
apps/web-react/app/features/scanner/components/MatchCard.tsx
Normal file
352
apps/web-react/app/features/scanner/components/MatchCard.tsx
Normal 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}`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.matchList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
39
apps/web-react/app/features/scanner/components/MetaChips.tsx
Normal file
39
apps/web-react/app/features/scanner/components/MetaChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
159
apps/web-react/app/features/scanner/components/MinimapCard.tsx
Normal file
159
apps/web-react/app/features/scanner/components/MinimapCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
939
apps/web-react/app/features/scanner/components/VodPage.tsx
Normal file
939
apps/web-react/app/features/scanner/components/VodPage.tsx
Normal 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);
|
||||
}
|
||||
@@ -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"])),
|
||||
};
|
||||
}
|
||||
336
apps/web-react/app/features/scanner/components/events-csv.ts
Normal file
336
apps/web-react/app/features/scanner/components/events-csv.ts
Normal 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);
|
||||
}
|
||||
305
apps/web-react/app/features/scanner/components/fixture-export.ts
Normal file
305
apps/web-react/app/features/scanner/components/fixture-export.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
37
apps/web-react/app/features/scanner/components/format.ts
Normal file
37
apps/web-react/app/features/scanner/components/format.ts
Normal 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));
|
||||
}
|
||||
59
apps/web-react/app/features/scanner/components/labels.ts
Normal file
59
apps/web-react/app/features/scanner/components/labels.ts
Normal 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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
301
apps/web-react/app/features/scanner/components/sendou-ingest.ts
Normal file
301
apps/web-react/app/features/scanner/components/sendou-ingest.ts
Normal 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)}` : ""}`;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
10
apps/web-react/app/features/scanner/components/thumbnail.ts
Normal file
10
apps/web-react/app/features/scanner/components/thumbnail.ts
Normal 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);
|
||||
}
|
||||
97
apps/web-react/app/features/scanner/core/ability-harvest.ts
Normal file
97
apps/web-react/app/features/scanner/core/ability-harvest.ts
Normal 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;
|
||||
}
|
||||
14
apps/web-react/app/features/scanner/core/canonical.ts
Normal file
14
apps/web-react/app/features/scanner/core/canonical.ts
Normal 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;
|
||||
}
|
||||
90
apps/web-react/app/features/scanner/core/cv.ts
Normal file
90
apps/web-react/app/features/scanner/core/cv.ts
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
146
apps/web-react/app/features/scanner/core/detectors/death/rois.ts
Normal file
146
apps/web-react/app/features/scanner/core/detectors/death/rois.ts
Normal 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;
|
||||
@@ -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"),
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
];
|
||||
}
|
||||
341
apps/web-react/app/features/scanner/core/detectors/scheduler.ts
Normal file
341
apps/web-react/app/features/scanner/core/detectors/scheduler.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
101
apps/web-react/app/features/scanner/core/detectors/types.ts
Normal file
101
apps/web-react/app/features/scanner/core/detectors/types.ts
Normal 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>[];
|
||||
}
|
||||
670
apps/web-react/app/features/scanner/core/glyphs.ts
Normal file
670
apps/web-react/app/features/scanner/core/glyphs.ts
Normal 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 };
|
||||
}
|
||||
186
apps/web-react/app/features/scanner/core/image.ts
Normal file
186
apps/web-react/app/features/scanner/core/image.ts
Normal 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;
|
||||
}
|
||||
85
apps/web-react/app/features/scanner/core/ink-color.ts
Normal file
85
apps/web-react/app/features/scanner/core/ink-color.ts
Normal 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;
|
||||
}
|
||||
2171
apps/web-react/app/features/scanner/core/localized-entries.ts
Normal file
2171
apps/web-react/app/features/scanner/core/localized-entries.ts
Normal file
File diff suppressed because it is too large
Load Diff
104
apps/web-react/app/features/scanner/core/localized.ts
Normal file
104
apps/web-react/app/features/scanner/core/localized.ts
Normal 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,
|
||||
);
|
||||
1317
apps/web-react/app/features/scanner/core/match-builder.ts
Normal file
1317
apps/web-react/app/features/scanner/core/match-builder.ts
Normal file
File diff suppressed because it is too large
Load Diff
88
apps/web-react/app/features/scanner/core/match-sets.ts
Normal file
88
apps/web-react/app/features/scanner/core/match-sets.ts
Normal 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);
|
||||
}
|
||||
143
apps/web-react/app/features/scanner/core/replay-time.ts
Normal file
143
apps/web-react/app/features/scanner/core/replay-time.ts
Normal 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;
|
||||
}
|
||||
231
apps/web-react/app/features/scanner/core/resources.ts
Normal file
231
apps/web-react/app/features/scanner/core/resources.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
134
apps/web-react/app/features/scanner/core/scanner-match.ts
Normal file
134
apps/web-react/app/features/scanner/core/scanner-match.ts
Normal 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;
|
||||
}
|
||||
130
apps/web-react/app/features/scanner/core/slot-row-assignment.ts
Normal file
130
apps/web-react/app/features/scanner/core/slot-row-assignment.ts
Normal 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;
|
||||
}
|
||||
143
apps/web-react/app/features/scanner/core/text.ts
Normal file
143
apps/web-react/app/features/scanner/core/text.ts
Normal 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
Reference in New Issue
Block a user