mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-09 23:25:30 -05:00
Improve splatted/special detection
This commit is contained in:
parent
9e3fa42d41
commit
9bb37bb0fc
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -37,3 +37,6 @@ notepad.txt
|
|||
|
||||
# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner)
|
||||
/assets/fonts/
|
||||
|
||||
*.mp4
|
||||
*.mkv
|
||||
|
|
@ -87,9 +87,17 @@ sequenceDiagram
|
|||
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; the cast layout is picked by its D-pad camera badges), with
|
||||
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), with
|
||||
the same `time` value so the two reads pair downstream; its fixtures
|
||||
live under `tests/fixtures/player-status/`. Objective reads land on `ScannerMatch` as
|
||||
live under `tests/fixtures/player-status/`. 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). 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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
* Flatten detected events into a single CSV for download. One row per event;
|
||||
* the six event types share columns where they overlap (lobby/mode/stage,
|
||||
* 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 — are packed into one cell, matching the
|
||||
* compact per-event view of the live feed.
|
||||
* 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";
|
||||
|
|
@ -23,8 +23,19 @@ import {
|
|||
OBJECTIVE_EVENT_TYPE,
|
||||
type ObjectiveData,
|
||||
} from "../core/detectors/objective/index";
|
||||
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
|
||||
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
import {
|
||||
PLAYER_STATUS_EVENT_TYPE,
|
||||
type PlayerStatusData,
|
||||
} from "../core/detectors/objective/player-status";
|
||||
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,
|
||||
|
|
@ -105,6 +116,13 @@ function formatMinimapPlayers(data: MinimapData): string {
|
|||
].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(
|
||||
|
|
@ -220,9 +238,29 @@ function eventCells(event: CsvEvent): Cell[] {
|
|||
"",
|
||||
];
|
||||
}
|
||||
default: {
|
||||
// Scoreboard, ScoreboardBattleLogReplay and ScoreboardBattleLog share
|
||||
// the base shape
|
||||
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 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 [
|
||||
|
|
@ -241,6 +279,8 @@ function eventCells(event: CsvEvent): Cell[] {
|
|||
d.timestamp ?? "",
|
||||
];
|
||||
}
|
||||
default:
|
||||
return [...base, ...Array(HEADER.length - base.length).fill("")];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ import {
|
|||
} from "../scoreboard/banner";
|
||||
import type { ScoreboardResources } from "../scoreboard/index";
|
||||
import type { DetectedEvent, Detector, GateResult } from "../types";
|
||||
import { type PlayerStatusData, parsePlayerStatus } from "./player-status";
|
||||
import {
|
||||
type PlayerStatusData,
|
||||
type PlayerStatusLayout,
|
||||
parsePlayerStatus,
|
||||
} from "./player-status";
|
||||
import {
|
||||
CONTROL_PLATE_MIN_SATURATION,
|
||||
GATE_PLATE_MAX_STD,
|
||||
|
|
@ -62,6 +66,7 @@ import {
|
|||
SCORE_EXTEND_MIN_CONF,
|
||||
SCORE_ROIS,
|
||||
SCORE_TEXT_HEIGHTS,
|
||||
STATUS_LAYOUT_STICKY_MAX_GAP_S,
|
||||
TIMER_BIN_THRESHOLD,
|
||||
TIMER_DARK_PROBES,
|
||||
TIMER_DIGIT_MIN_CONF,
|
||||
|
|
@ -134,6 +139,7 @@ export function createObjectiveDetector(
|
|||
resources: ScoreboardResources,
|
||||
): Detector<ObjectiveData | PlayerStatusData> {
|
||||
const cv = getCV();
|
||||
let lastStatus: { layout: PlayerStatusLayout; t: number } | undefined;
|
||||
|
||||
const scoreSets: GlyphSet[] = resources.paintDigits
|
||||
? SCORE_TEXT_HEIGHTS.map((h) =>
|
||||
|
|
@ -368,6 +374,16 @@ export function createObjectiveDetector(
|
|||
// 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 };
|
||||
|
||||
const confidences = sides.flatMap((side) => [
|
||||
...(side.score.value !== null ? [side.score.confidence] : []),
|
||||
...(side.penalty?.value != null ? [side.penalty.confidence] : []),
|
||||
|
|
@ -399,7 +415,7 @@ export function createObjectiveDetector(
|
|||
plateFills: sides.map((side) => side.fill),
|
||||
},
|
||||
},
|
||||
parsePlayerStatus(frame, t, timer.value),
|
||||
playerStatus,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@
|
|||
* flanking the match timer, emitted by the ObjectiveDetector alongside each
|
||||
* Objective read (same frame, same `time`, so callers can pair the two).
|
||||
*
|
||||
* Per slot, two pixel-class fractions decide the state (rois.ts documents
|
||||
* 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 upper body into a bright pale glow (the shoulder
|
||||
* probe); a splatted icon is an unsaturated grey/dark X with neither. The
|
||||
* casted spectator HUD draws the same strip at its own geometry — the
|
||||
* white D-pad camera badges under the right team pick the layout.
|
||||
* 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. White D-pad camera badges under the right team prove the
|
||||
* cast layout, 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.
|
||||
*/
|
||||
import type { Mat } from "../../cv";
|
||||
import { copyRoi, type Roi } from "../../image";
|
||||
|
|
@ -18,11 +23,17 @@ import {
|
|||
STATUS_BODY_BOX_POV,
|
||||
STATUS_CAST_MIN_DPAD_WHITE,
|
||||
STATUS_DEAD_MAX_BODY_INK,
|
||||
STATUS_DEAD_MAX_BODY_PALE,
|
||||
STATUS_DEAD_MAX_SHOULDER_GLOW,
|
||||
STATUS_DPAD_PROBES,
|
||||
STATUS_GLOW_MIN_VALUE,
|
||||
STATUS_INK_MIN_SPREAD,
|
||||
STATUS_INK_MIN_VALUE,
|
||||
STATUS_LAYOUT_SCORE_CAP,
|
||||
STATUS_LAYOUT_STICKY_MARGIN,
|
||||
STATUS_PALE_MAX_SPREAD,
|
||||
STATUS_PALE_MIN_VALUE,
|
||||
STATUS_READY_MIN_BODY_PALE,
|
||||
STATUS_READY_MIN_SHOULDER_GLOW,
|
||||
STATUS_SHOULDER_BOX_CAST,
|
||||
STATUS_SHOULDER_BOX_POV,
|
||||
|
|
@ -75,6 +86,7 @@ interface SlotRead {
|
|||
special: boolean;
|
||||
confidence: number;
|
||||
bodyInk: number;
|
||||
bodyPale: number;
|
||||
shoulderGlow: number;
|
||||
}
|
||||
|
||||
|
|
@ -82,38 +94,19 @@ interface SlotRead {
|
|||
* 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.
|
||||
* 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: PlayerStatusLayout = isCastLayout(frame) ? "cast" : "pov";
|
||||
const centers =
|
||||
layout === "cast" ? STATUS_SLOT_CENTERS_CAST : STATUS_SLOT_CENTERS_POV;
|
||||
const shoulderBox =
|
||||
layout === "cast" ? STATUS_SHOULDER_BOX_CAST : STATUS_SHOULDER_BOX_POV;
|
||||
const bodyBox =
|
||||
layout === "cast" ? STATUS_BODY_BOX_CAST : STATUS_BODY_BOX_POV;
|
||||
|
||||
const sides = 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, shoulder.glow);
|
||||
}),
|
||||
) as [SlotRead[], SlotRead[]];
|
||||
const { layout, scores } = pickLayout(frame, prevLayout);
|
||||
const sides = readSlots(frame, layout);
|
||||
|
||||
const reads = sides.flat();
|
||||
return {
|
||||
|
|
@ -133,39 +126,145 @@ export function parsePlayerStatus(
|
|||
},
|
||||
debug: {
|
||||
layout,
|
||||
layoutScores: scores
|
||||
? {
|
||||
pov: Number(scores.pov.toFixed(3)),
|
||||
cast: Number(scores.cast.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))),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readSlots(
|
||||
frame: Mat,
|
||||
layout: PlayerStatusLayout,
|
||||
): [SlotRead[], SlotRead[]] {
|
||||
const centers =
|
||||
layout === "cast" ? STATUS_SLOT_CENTERS_CAST : STATUS_SLOT_CENTERS_POV;
|
||||
const shoulderBox =
|
||||
layout === "cast" ? STATUS_SHOULDER_BOX_CAST : STATUS_SHOULDER_BOX_POV;
|
||||
const bodyBox =
|
||||
layout === "cast" ? STATUS_BODY_BOX_CAST : STATUS_BODY_BOX_POV;
|
||||
|
||||
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);
|
||||
}),
|
||||
) as [SlotRead[], SlotRead[]];
|
||||
}
|
||||
|
||||
/**
|
||||
* State from the two fractions, with a confidence scaled by the distance
|
||||
* Camera badges prove the cast layout outright. Badge-less frames are NOT
|
||||
* proven POV — broadcasts can hide the badges while keeping the cast icon
|
||||
* geometry — so both geometries are tried 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.
|
||||
*/
|
||||
function pickLayout(
|
||||
frame: Mat,
|
||||
prevLayout: PlayerStatusLayout | undefined,
|
||||
): {
|
||||
layout: PlayerStatusLayout;
|
||||
scores: { pov: number; cast: number } | null;
|
||||
} {
|
||||
if (isCastLayout(frame)) return { layout: "cast", scores: null };
|
||||
const scores = {
|
||||
pov: layoutDecisiveness(frame, "pov"),
|
||||
cast: layoutDecisiveness(frame, "cast"),
|
||||
};
|
||||
if (prevLayout) {
|
||||
const other: PlayerStatusLayout = prevLayout === "pov" ? "cast" : "pov";
|
||||
return {
|
||||
layout:
|
||||
scores[other] > scores[prevLayout] + STATUS_LAYOUT_STICKY_MARGIN
|
||||
? other
|
||||
: prevLayout,
|
||||
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 three fractions, with a confidence scaled by the distance
|
||||
* to the nearest decision boundary (1 at twice the threshold / at zero).
|
||||
*/
|
||||
function classifySlot(bodyInk: number, shoulderGlow: number): SlotRead {
|
||||
function classifySlot(
|
||||
bodyInk: number,
|
||||
bodyPale: number,
|
||||
shoulderGlow: number,
|
||||
): SlotRead {
|
||||
const dead =
|
||||
bodyInk <= STATUS_DEAD_MAX_BODY_INK &&
|
||||
shoulderGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW;
|
||||
const special = !dead && shoulderGlow >= STATUS_READY_MIN_SHOULDER_GLOW;
|
||||
shoulderGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW &&
|
||||
bodyPale <= STATUS_DEAD_MAX_BODY_PALE;
|
||||
const special =
|
||||
!dead &&
|
||||
(shoulderGlow >= STATUS_READY_MIN_SHOULDER_GLOW ||
|
||||
bodyPale >= STATUS_READY_MIN_BODY_PALE);
|
||||
const confidence = dead
|
||||
? Math.min(
|
||||
1,
|
||||
(STATUS_DEAD_MAX_BODY_INK - bodyInk) / STATUS_DEAD_MAX_BODY_INK,
|
||||
)
|
||||
: special
|
||||
? Math.min(1, shoulderGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2))
|
||||
? Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
shoulderGlow / (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, shoulderGlow };
|
||||
return { dead, special, confidence, bodyInk, bodyPale, shoulderGlow };
|
||||
}
|
||||
|
||||
/** Ink and glow pixel fractions of a ROI (see rois.ts for the classes). */
|
||||
function classFractions(frame: Mat, roi: Roi): { ink: number; glow: number } {
|
||||
/** 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; pale: number } {
|
||||
const crop = copyRoi(frame, roi);
|
||||
const { data } = crop;
|
||||
const channels = crop.channels();
|
||||
let ink = 0;
|
||||
let glow = 0;
|
||||
let pale = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i += channels) {
|
||||
const r = data[i]!;
|
||||
|
|
@ -175,10 +274,12 @@ function classFractions(frame: Mat, roi: Roi): { ink: number; glow: number } {
|
|||
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 (value >= STATUS_PALE_MIN_VALUE && spread <= STATUS_PALE_MAX_SPREAD)
|
||||
pale++;
|
||||
count++;
|
||||
}
|
||||
crop.delete();
|
||||
return { ink: ink / count, glow: glow / count };
|
||||
return { ink: ink / count, glow: glow / count, pale: pale / count };
|
||||
}
|
||||
|
||||
/** All four D-pad probes reading white = the casted spectator layout. */
|
||||
|
|
|
|||
|
|
@ -141,6 +141,13 @@ export const CONTROL_PLATE_MIN_SATURATION = 60;
|
|||
// 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 (~103 left vs ~88 right).
|
||||
//
|
||||
// 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 [
|
||||
|
|
@ -171,9 +178,13 @@ 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: -32, y: 44, w: 64, h: 48 };
|
||||
export const STATUS_BODY_BOX_CAST = { dx: -32, y: 55, w: 64, h: 43 };
|
||||
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
|
||||
|
|
@ -191,16 +202,52 @@ export const STATUS_INK_MIN_VALUE = 105;
|
|||
export const STATUS_GLOW_MIN_VALUE = 225;
|
||||
|
||||
/**
|
||||
* Splatted: body ink under the floor (attested dead <=0.09 vs alive
|
||||
* >=0.40) with the shoulder-glow guard keeping the near-white ready wash
|
||||
* (body ink as low as 0.03, glow >=0.40 vs dead <=0.03) out of it.
|
||||
* 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_DEAD_MAX_BODY_INK = 0.22;
|
||||
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.40 vs alive <=0.25 — plain alive bodies show some
|
||||
* pale from weapon-silhouette whites).
|
||||
*/
|
||||
export const STATUS_READY_MIN_BODY_PALE = 0.32;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Cast-layout discriminator: the spectator HUD always draws white D-pad
|
||||
* camera badges under the right team's icons; nothing fixed sits there on
|
||||
|
|
|
|||
|
|
@ -95,6 +95,20 @@ const MIN_TEAM_HUE_SEPARATION = 30;
|
|||
*/
|
||||
const REPLAY_ANCHOR_TOLERANCE_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* Dead-flag runs that fit between their flanking opposite-state reads in
|
||||
* less than these spans are physically impossible and get flipped to their
|
||||
* surroundings: the fastest respawn in Splatoon is 3.5s, so no true dead
|
||||
* stretch is shorter — while a respawned player CAN be re-splatted quickly
|
||||
* (spawncamps are real), so the alive floor stays a conservative 2s and
|
||||
* only clears blips like background ink bleeding through a crossed-out
|
||||
* icon. Judging by the flank-to-flank span (the longest the state could
|
||||
* truly have held) keeps sparse sampling honest: a lone dead read between
|
||||
* far-apart reads spans wide and is left alone.
|
||||
*/
|
||||
const DEAD_RUN_MIN_SECONDS = 3.5;
|
||||
const ALIVE_RUN_MIN_SECONDS = 2;
|
||||
|
||||
export interface BuiltMatch<E extends DetectedEvent> {
|
||||
match: ScannerMatch;
|
||||
/**
|
||||
|
|
@ -552,23 +566,77 @@ function buildProgress(
|
|||
liveStatuses.length === 0
|
||||
? null
|
||||
: {
|
||||
samples: liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
|
||||
const swapped = read.fromMinimap
|
||||
? minimapSwapped
|
||||
: nearestSwapFlag(live, swapFlags, read.t) !== swap;
|
||||
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return {
|
||||
t: Math.max(0, Math.floor(read.t)),
|
||||
time: read.data.time,
|
||||
special: [read.data.special[a], read.data.special[b]],
|
||||
dead: [read.data.dead[a], read.data.dead[b]],
|
||||
};
|
||||
}),
|
||||
samples: withImpossibleDeadRunsFlipped(
|
||||
liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
|
||||
const swapped = read.fromMinimap
|
||||
? minimapSwapped
|
||||
: nearestSwapFlag(live, swapFlags, read.t) !== swap;
|
||||
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return {
|
||||
t: Math.max(0, Math.floor(read.t)),
|
||||
time: read.data.time,
|
||||
special: [read.data.special[a], read.data.special[b]],
|
||||
dead: [read.data.dead[a], read.data.dead[b]],
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
return { objective, playerStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce per-slot dead flags across a match's samples: an interior run
|
||||
* whose flanking opposite-state reads sit closer together than the state
|
||||
* could truly have held (DEAD/ALIVE_RUN_MIN_SECONDS) is a misread blip
|
||||
* (background ink bleeding through a translucent splatted icon, or a box
|
||||
* over a mid-animation icon) and takes the flanking state instead. Edge
|
||||
* runs stay — nothing attests what came before or after the match window.
|
||||
*/
|
||||
function withImpossibleDeadRunsFlipped(
|
||||
samples: ScannerMatchPlayerStatusSample[],
|
||||
): ScannerMatchPlayerStatusSample[] {
|
||||
let smoothed = samples;
|
||||
for (const side of [0, 1] as const) {
|
||||
for (let slot = 0; slot < PLAYERS_PER_TEAM; slot++) {
|
||||
// flipping one blip can expose the next (alternating flicker), so
|
||||
// each slot's series is re-swept until it settles
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
const series = smoothed.map((sample) => sample.dead[side][slot]);
|
||||
let runStart = 0;
|
||||
for (let i = 1; i <= series.length; i++) {
|
||||
if (i < series.length && series[i] === series[runStart]) continue;
|
||||
const interior = runStart > 0 && i < series.length;
|
||||
const impossiblyShort =
|
||||
interior &&
|
||||
smoothed[i]!.t - smoothed[runStart - 1]!.t <=
|
||||
(series[runStart] ? DEAD_RUN_MIN_SECONDS : ALIVE_RUN_MIN_SECONDS);
|
||||
if (impossiblyShort) {
|
||||
if (smoothed === samples) {
|
||||
smoothed = samples.map((sample) => ({
|
||||
...sample,
|
||||
dead: [[...sample.dead[0]], [...sample.dead[1]]] as [
|
||||
PlayerStatusFlags,
|
||||
PlayerStatusFlags,
|
||||
],
|
||||
}));
|
||||
}
|
||||
for (let j = runStart; j < i; j++) {
|
||||
smoothed[j]!.dead[side][slot] = !series[runStart];
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
runStart = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
/** A status read from either source, sides as read (pre-orientation). */
|
||||
interface StatusRead {
|
||||
t: number;
|
||||
|
|
|
|||
18
app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/expected.json
vendored
Normal file
18
app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/expected.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"event": "PlayerStatus",
|
||||
"data": {
|
||||
"layout": "cast",
|
||||
"time": 258,
|
||||
"special": [
|
||||
[false, false, false, false],
|
||||
[false, false, false, false]
|
||||
],
|
||||
"dead": [
|
||||
[false, false, false, false],
|
||||
[false, false, false, false]
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"notes": "JP broadcast (AREA CUP) spectator HUD with the camera-button badges hidden — the strip keeps the cast icon geometry, so the layout must resolve to cast via the geometry score, not the badge probe (reading it as POV drifts the outer-slot boxes off their icons and flickers phantom deaths). All eight players alive."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"event": "PlayerStatus",
|
||||
"data": {
|
||||
"layout": "cast",
|
||||
"time": 256,
|
||||
"special": [
|
||||
[true, false, false, false],
|
||||
[false, false, false, false]
|
||||
],
|
||||
"dead": [
|
||||
[false, false, false, false],
|
||||
[true, false, true, false]
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"notes": "Same badge-less AREA CUP spectator HUD two seconds on. Left team's outermost slot holds special caught at the dim trough of the ready wash's pulse — pale but below the glow floor, previously misread as splatted. Right team's first and third slots are splatted (grey X'd icons)."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/player-status/cast-areacup-ready-trough-two-dead/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/player-status/cast-areacup-ready-trough-two-dead/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"event": "PlayerStatus",
|
||||
"data": {
|
||||
"layout": "cast",
|
||||
"time": 248,
|
||||
"special": [
|
||||
[false, false, true, false],
|
||||
[false, false, false, false]
|
||||
],
|
||||
"dead": [
|
||||
[false, false, false, false],
|
||||
[false, false, false, true]
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"notes": "AREA CUP spectator HUD with the camera-button badges visible. Right team's outermost slot is splatted with the arena behind it covered in that team's ink color — the ink bleeding around the translucent crossed-out icon previously read as an alive body. Left team's third slot holds special (pale wash behind the icon)."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/player-status/cast-areacup-splat-on-inked-backdrop/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/player-status/cast-areacup-splat-on-inked-backdrop/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -948,6 +948,58 @@ test("status reads inherit the nearest counter read's cast orientation", () => {
|
|||
assert.equal(built[0]!.match.cast, true);
|
||||
});
|
||||
|
||||
test("sub-2s dead-flag blips between dense opposite reads get flipped", () => {
|
||||
const deadAt = (slots: number[]) =>
|
||||
[
|
||||
[false, false, false, false],
|
||||
[0, 1, 2, 3].map((slot) => slots.includes(slot)),
|
||||
] as PlayerStatusData["dead"];
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
// slot0: a real death 101-107 with a one-read false "respawn" at 104
|
||||
// (background ink bleeding through the crossed-out icon), plus a
|
||||
// one-read false death at 111 after the real respawn
|
||||
playerStatus(100, { dead: deadAt([]) }),
|
||||
playerStatus(101, { dead: deadAt([0]) }),
|
||||
playerStatus(102, { dead: deadAt([0]) }),
|
||||
playerStatus(103, { dead: deadAt([0]) }),
|
||||
playerStatus(104, { dead: deadAt([]) }),
|
||||
playerStatus(105, { dead: deadAt([0]) }),
|
||||
playerStatus(106, { dead: deadAt([0]) }),
|
||||
playerStatus(107, { dead: deadAt([0]) }),
|
||||
playerStatus(108, { dead: deadAt([]) }),
|
||||
playerStatus(109, { dead: deadAt([]) }),
|
||||
playerStatus(110, { dead: deadAt([]) }),
|
||||
playerStatus(111, { dead: deadAt([0]) }),
|
||||
playerStatus(112, { dead: deadAt([]) }),
|
||||
playerStatus(113, { dead: deadAt([]) }),
|
||||
scoreboard(300),
|
||||
]);
|
||||
const slot0Deads = built[0]!.match.playerStatus!.samples.map(
|
||||
(sample) => sample.dead[1][0],
|
||||
);
|
||||
assert.deepEqual(slot0Deads, [
|
||||
false,
|
||||
...Array.from({ length: 7 }, () => true),
|
||||
...Array.from({ length: 6 }, () => false),
|
||||
]);
|
||||
});
|
||||
|
||||
test("a lone dead read between sparse reads is kept", () => {
|
||||
const dead = [
|
||||
[false, false, false, false],
|
||||
[true, false, false, false],
|
||||
] as PlayerStatusData["dead"];
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
playerStatus(60),
|
||||
playerStatus(120, { dead }),
|
||||
playerStatus(180),
|
||||
scoreboard(300),
|
||||
]);
|
||||
assert.deepEqual(built[0]!.match.playerStatus!.samples[1]!.dead, dead);
|
||||
});
|
||||
|
||||
test("a known non-SZ match drops its player-status reads too", () => {
|
||||
const events = [
|
||||
mapStart(0, { mode: "CB" }),
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import test from "./node-test-compat";
|
|||
|
||||
await loadOpenCV();
|
||||
const resources = await loadScoreboardResources();
|
||||
const detector = createObjectiveDetector(resources);
|
||||
const fixtures = loadFixtures("player-status");
|
||||
|
||||
test("player-status fixtures exist", () => {
|
||||
|
|
@ -42,7 +41,12 @@ test("player-status fixtures exist", () => {
|
|||
|
||||
for (const fixture of fixtures) {
|
||||
test(`player-status/${fixture.name}`, async (t) => {
|
||||
const { gate, events } = await runDetectorOnFixture(detector, fixture);
|
||||
// fresh detector per fixture: the objective detector carries sticky
|
||||
// layout state across reads, and fixtures are unrelated frames
|
||||
const { gate, events } = await runDetectorOnFixture(
|
||||
createObjectiveDetector(resources),
|
||||
fixture,
|
||||
);
|
||||
const expectPositive = fixture.expected.event === "PlayerStatus";
|
||||
|
||||
await t.test("gate", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user