mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-20 18:15:35 -05:00
Resolve cast side
This commit is contained in:
@@ -84,7 +84,12 @@ sequenceDiagram
|
||||
+ 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). Objective reads land on `ScannerMatch` as
|
||||
progress samples anchored to the game clock. Reads grouping into a match
|
||||
progress samples anchored to the game clock. 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
|
||||
|
||||
@@ -16,10 +16,7 @@ import {
|
||||
type MapStartData,
|
||||
} from "../core/detectors/map-start/index";
|
||||
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
|
||||
import {
|
||||
OBJECTIVE_EVENT_TYPE,
|
||||
type ObjectiveData,
|
||||
} from "../core/detectors/objective/index";
|
||||
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
|
||||
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
|
||||
import type { DetectedEvent, GateResult } from "../core/detectors/types";
|
||||
import type { BuiltMatch } from "../core/match-builder";
|
||||
@@ -403,13 +400,13 @@ export function LivePage({
|
||||
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;
|
||||
// a non-SZ match's reads (objective null) are never shown
|
||||
const objectiveEvents = built.match.objective
|
||||
? built.sources
|
||||
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
|
||||
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
|
||||
: [];
|
||||
// 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 cardEvents = withoutRepeatEvents(built.sources).filter(
|
||||
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
|
||||
);
|
||||
|
||||
@@ -24,10 +24,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
|
||||
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
|
||||
import { connectAbilities } from "../core/ability-harvest";
|
||||
import {
|
||||
OBJECTIVE_EVENT_TYPE,
|
||||
type ObjectiveData,
|
||||
} from "../core/detectors/objective/index";
|
||||
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
|
||||
import {
|
||||
mergeScanTelemetry,
|
||||
type ScanTelemetry,
|
||||
@@ -719,13 +716,13 @@ export function VodPage({
|
||||
ingestableBuilt.indexOf(built),
|
||||
);
|
||||
const send = skipReason ? undefined : bulkSend;
|
||||
// counter reads render as one timeline chart, not a card each;
|
||||
// a non-SZ match's reads (objective null) are never shown
|
||||
const objectiveEvents = built.match.objective
|
||||
? built.sources
|
||||
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
|
||||
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
|
||||
: [];
|
||||
// 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 cardEvents = withoutRepeatEvents(built.sources).filter(
|
||||
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
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 {
|
||||
@@ -127,6 +128,13 @@ export interface MinimapData {
|
||||
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";
|
||||
@@ -324,6 +332,7 @@ export function createMinimapDetector(
|
||||
|
||||
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++) {
|
||||
@@ -333,6 +342,7 @@ export function createMinimapDetector(
|
||||
cardDebug.push({ dx, row, presence, skipped: true });
|
||||
continue;
|
||||
}
|
||||
sideSubTiles[dx === 0 ? 0 : 1].push(layout.subTile);
|
||||
const crossFraction = saturatedFraction(hsv, layout.cross);
|
||||
const occluded = crossFraction >= CROSS_MIN_FRACTION;
|
||||
const cornerMin = minTopCornerMean(gray, layout.weapon);
|
||||
@@ -407,6 +417,11 @@ export function createMinimapDetector(
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -429,6 +444,7 @@ export function createMinimapDetector(
|
||||
spectator: true,
|
||||
teammates,
|
||||
enemies,
|
||||
teamColors,
|
||||
},
|
||||
debug,
|
||||
},
|
||||
@@ -463,6 +479,7 @@ export function createMinimapDetector(
|
||||
|
||||
// 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
|
||||
@@ -540,6 +557,7 @@ export function createMinimapDetector(
|
||||
matched !== null ||
|
||||
abilities.some((a) => a !== null);
|
||||
if (!hasEvidence) continue;
|
||||
sideSubTiles[0].push(layout.subTile);
|
||||
teammates.push({
|
||||
slot: layout.slot,
|
||||
name,
|
||||
@@ -604,6 +622,7 @@ export function createMinimapDetector(
|
||||
? SPECIAL_READY_WEAPON_MIN_SCORE
|
||||
: WEAPON_MIN_SCORE;
|
||||
const matched = weapon !== null && weapon.score >= floor ? weapon : null;
|
||||
sideSubTiles[1].push(enemySubTileRoi(cy));
|
||||
enemies.push({
|
||||
name: null,
|
||||
weaponId: matched ? toMainWeaponId(matched.id) : null,
|
||||
@@ -612,6 +631,11 @@ export function createMinimapDetector(
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -635,6 +659,7 @@ export function createMinimapDetector(
|
||||
spectator: false,
|
||||
teammates,
|
||||
enemies,
|
||||
teamColors,
|
||||
},
|
||||
debug,
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
minChannel,
|
||||
type Roi,
|
||||
} from "../../image";
|
||||
import { type InkRgb, meanInkColor } from "../../ink-color";
|
||||
import {
|
||||
type BannerScoreRead,
|
||||
isBetterRead,
|
||||
@@ -78,6 +79,14 @@ export interface SplatZonesObjectiveData {
|
||||
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";
|
||||
@@ -89,7 +98,8 @@ 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.
|
||||
* 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;
|
||||
@@ -110,6 +120,7 @@ interface SideRead {
|
||||
penalty: BannerScoreRead | null;
|
||||
control: boolean;
|
||||
fill: { mean: number; saturation: number };
|
||||
teamColor: InkRgb | null;
|
||||
}
|
||||
|
||||
export function createObjectiveDetector(
|
||||
@@ -312,6 +323,12 @@ export function createObjectiveDetector(
|
||||
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);
|
||||
@@ -338,6 +355,7 @@ export function createObjectiveDetector(
|
||||
sides[1].penalty?.value ?? null,
|
||||
],
|
||||
control: [sides[0].control, sides[1].control],
|
||||
teamColor: [sides[0].teamColor, sides[1].teamColor],
|
||||
},
|
||||
debug: {
|
||||
timerReading: timer.reading,
|
||||
|
||||
85
app/features/scanner/core/ink-color.ts
Normal file
85
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;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type ScoreboardBattleLogReplayData,
|
||||
} from "./detectors/scoreboard-battle-log-replay/index";
|
||||
import type { DetectedEvent } from "./detectors/types";
|
||||
import { hueDistance, hueOf, type InkRgb } from "./ink-color";
|
||||
import { parseReplayTimestamp } from "./replay-time";
|
||||
import type {
|
||||
ScannerMatch,
|
||||
@@ -70,6 +71,14 @@ const PLAYERS_PER_TEAM = 4;
|
||||
*/
|
||||
const EARLY_END_MARGIN_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* The two team-ink hues must be at least this far apart before color is
|
||||
* trusted to orient counter reads: a game's color pair is picked to
|
||||
* contrast (attested pairs measure >130° apart), so a closer seed pair is
|
||||
* a misread, and orientation falls back to the as-read arrangement.
|
||||
*/
|
||||
const MIN_TEAM_HUE_SEPARATION = 30;
|
||||
|
||||
export interface BuiltMatch<E extends DetectedEvent> {
|
||||
match: ScannerMatch;
|
||||
/**
|
||||
@@ -357,6 +366,7 @@ function toBuiltMatch<E extends DetectedEvent>(
|
||||
t: event.t,
|
||||
data: event.data as ObjectiveData,
|
||||
}));
|
||||
const minimaps = open.minimaps.map((event) => event.data as MinimapData);
|
||||
|
||||
const mode = board?.mode ?? start?.mode ?? null;
|
||||
|
||||
@@ -376,13 +386,12 @@ function toBuiltMatch<E extends DetectedEvent>(
|
||||
// only the SZ counter is parsed — reads on a known other-mode match
|
||||
// are misreads of a lookalike overlay, not progress data
|
||||
objective:
|
||||
mode === null || mode === "SZ" ? buildObjective(objectives, board) : null,
|
||||
mode === null || mode === "SZ"
|
||||
? buildObjective(objectives, board, minimapTeamColors(minimaps))
|
||||
: null,
|
||||
teams: board
|
||||
? teamsFromScoreboard(board, deaths)
|
||||
: teamsFromMinimaps(
|
||||
open.minimaps.map((event) => event.data as MinimapData),
|
||||
deaths,
|
||||
),
|
||||
: teamsFromMinimaps(minimaps, deaths),
|
||||
winner: board ? 0 : null,
|
||||
pov:
|
||||
board && board.povIndex !== null
|
||||
@@ -401,46 +410,167 @@ function floorOrNull(t: number | undefined): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The counter reads as `objective` samples in `teams` order. The on-screen
|
||||
* plates put the POV/alpha side left, which already is teams[0] for a
|
||||
* minimap-grouped match; a scoreboard-closed match's teams are winner-first,
|
||||
* so the sides swap when the POV seat sat on the losing team — or, with no
|
||||
* POV arrow read, when the right plate's count got lower (in SZ the winner
|
||||
* is the team whose remaining count went furthest down; ties keep the order
|
||||
* as read).
|
||||
* The counter reads as `objective` samples in `teams` order. On POV footage
|
||||
* the left plate is the POV/alpha side for the whole game, but casted
|
||||
* footage reorders the plates to follow the specced player — so each read
|
||||
* is first oriented by its sides' team ink hues (clustered against the
|
||||
* first read that saw both), making the series side-stable. The whole
|
||||
* series then goes into `teams` order: a scoreboard-closed match is
|
||||
* winner-first (POV seat when read; else in SZ the winner is the side
|
||||
* whose remaining count went furthest down), a minimap-grouped match
|
||||
* anchors on the minimap's own/alpha-vs-enemy/bravo ink colors, and with
|
||||
* no signal the first read's arrangement stands.
|
||||
*/
|
||||
function buildObjective(
|
||||
objectives: readonly { t: number; data: ObjectiveData }[],
|
||||
board: ScoreboardData | undefined,
|
||||
minimapColors: [InkRgb | null, InkRgb | null] | null,
|
||||
): ScannerMatchObjective | null {
|
||||
if (objectives.length === 0) return null;
|
||||
|
||||
const clusterHues = seedClusterHues(objectives);
|
||||
const oriented = orientByTeamColor(objectives, clusterHues);
|
||||
|
||||
const swap = board
|
||||
? board.povIndex !== null
|
||||
? board.povIndex >= PLAYERS_PER_TEAM
|
||||
: bestCount(objectives, 1) < bestCount(objectives, 0)
|
||||
: false;
|
||||
const samples = objectives.map(({ t, data }): ScannerMatchObjectiveSample => {
|
||||
: bestCount(oriented, 1) < bestCount(oriented, 0)
|
||||
: minimapAnchorSwap(clusterHues, minimapColors);
|
||||
|
||||
const samples = oriented.map((read): ScannerMatchObjectiveSample => {
|
||||
const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return {
|
||||
t: Math.max(0, Math.floor(t)),
|
||||
t: Math.max(0, Math.floor(read.t)),
|
||||
time: read.time,
|
||||
score: [read.score[a], read.score[b]],
|
||||
penalty: [read.penalty[a], read.penalty[b]],
|
||||
control: [read.control[a], read.control[b]],
|
||||
};
|
||||
});
|
||||
return { mode: "SZ", samples };
|
||||
}
|
||||
|
||||
/** A counter read with its sides in cluster (first-read) order. */
|
||||
interface OrientedObjectiveRead {
|
||||
t: number;
|
||||
time: number | null;
|
||||
score: [number | null, number | null];
|
||||
penalty: [number | null, number | null];
|
||||
control: [boolean, boolean];
|
||||
}
|
||||
|
||||
/**
|
||||
* The two team-ink cluster hues, seeded from the first read that saw both
|
||||
* sides' colors far enough apart; null when no read qualifies (color
|
||||
* orientation then stays at the as-read arrangement).
|
||||
*/
|
||||
function seedClusterHues(
|
||||
objectives: readonly { data: ObjectiveData }[],
|
||||
): [number, number] | null {
|
||||
for (const { data } of objectives) {
|
||||
const [left, right] = data.teamColor;
|
||||
if (left === null || right === null) continue;
|
||||
const hues: [number, number] = [hueOf(left), hueOf(right)];
|
||||
if (hueDistance(hues[0], hues[1]) >= MIN_TEAM_HUE_SEPARATION) return hues;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign every read's sides to the color clusters: a read whose ink hues
|
||||
* sit closer to the clusters crosswise is swapped (the cast switched the
|
||||
* specced side). Reads with no readable color inherit the previous read's
|
||||
* orientation — plate arrangement only changes with a camera change, which
|
||||
* leaves the colors readable once the plates are back.
|
||||
*/
|
||||
function orientByTeamColor(
|
||||
objectives: readonly { t: number; data: ObjectiveData }[],
|
||||
clusterHues: [number, number] | null,
|
||||
): OrientedObjectiveRead[] {
|
||||
let previousSwapped = false;
|
||||
return objectives.map(({ t, data }): OrientedObjectiveRead => {
|
||||
const swapped = clusterHues
|
||||
? readSwapped(data, clusterHues, previousSwapped)
|
||||
: false;
|
||||
previousSwapped = swapped;
|
||||
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
|
||||
return {
|
||||
t,
|
||||
time: data.time,
|
||||
score: [data.score[a], data.score[b]],
|
||||
penalty: [data.penalty[a], data.penalty[b]],
|
||||
control: [data.control[a], data.control[b]],
|
||||
};
|
||||
});
|
||||
return { mode: "SZ", samples };
|
||||
}
|
||||
|
||||
/** The lowest count a side's plate ever showed; Infinity when never read. */
|
||||
function readSwapped(
|
||||
data: ObjectiveData,
|
||||
clusterHues: [number, number],
|
||||
previousSwapped: boolean,
|
||||
): boolean {
|
||||
const [left, right] = data.teamColor;
|
||||
if (left === null && right === null) return previousSwapped;
|
||||
const identityCost =
|
||||
(left ? hueDistance(hueOf(left), clusterHues[0]) : 0) +
|
||||
(right ? hueDistance(hueOf(right), clusterHues[1]) : 0);
|
||||
const swappedCost =
|
||||
(left ? hueDistance(hueOf(left), clusterHues[1]) : 0) +
|
||||
(right ? hueDistance(hueOf(right), clusterHues[0]) : 0);
|
||||
if (identityCost === swappedCost) return previousSwapped;
|
||||
return swappedCost < identityCost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cluster order is bravo-first, judged against the minimap's
|
||||
* ink colors (own/alpha column, enemy/bravo column) — the `teams` anchor
|
||||
* for cast matches, which never see a results screen.
|
||||
*/
|
||||
function minimapAnchorSwap(
|
||||
clusterHues: [number, number] | null,
|
||||
minimapColors: [InkRgb | null, InkRgb | null] | null,
|
||||
): boolean {
|
||||
if (!clusterHues || !minimapColors) return false;
|
||||
const [own, enemy] = minimapColors;
|
||||
if (own === null && enemy === null) return false;
|
||||
const identityCost =
|
||||
(own ? hueDistance(hueOf(own), clusterHues[0]) : 0) +
|
||||
(enemy ? hueDistance(hueOf(enemy), clusterHues[1]) : 0);
|
||||
const swappedCost =
|
||||
(own ? hueDistance(hueOf(own), clusterHues[1]) : 0) +
|
||||
(enemy ? hueDistance(hueOf(enemy), clusterHues[0]) : 0);
|
||||
return swappedCost < identityCost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componentwise mean of the minimap reads' per-side ink colors; null when
|
||||
* no read got a side's color (or there were no minimaps at all).
|
||||
*/
|
||||
function minimapTeamColors(
|
||||
minimaps: readonly MinimapData[],
|
||||
): [InkRgb | null, InkRgb | null] | null {
|
||||
if (minimaps.length === 0) return null;
|
||||
const sides = [0, 1].map((side): InkRgb | null => {
|
||||
const colors = minimaps
|
||||
.map((minimap) => minimap.teamColors[side as 0 | 1])
|
||||
.filter((color): color is InkRgb => color !== null);
|
||||
if (colors.length === 0) return null;
|
||||
return {
|
||||
r: Math.round(colors.reduce((sum, c) => sum + c.r, 0) / colors.length),
|
||||
g: Math.round(colors.reduce((sum, c) => sum + c.g, 0) / colors.length),
|
||||
b: Math.round(colors.reduce((sum, c) => sum + c.b, 0) / colors.length),
|
||||
};
|
||||
}) as [InkRgb | null, InkRgb | null];
|
||||
return sides[0] === null && sides[1] === null ? null : sides;
|
||||
}
|
||||
|
||||
/** The lowest count a side ever showed; Infinity when never read. */
|
||||
function bestCount(
|
||||
objectives: readonly { data: ObjectiveData }[],
|
||||
oriented: readonly OrientedObjectiveRead[],
|
||||
side: 0 | 1,
|
||||
): number {
|
||||
return Math.min(
|
||||
...objectives.map(
|
||||
({ data }) => data.score[side] ?? Number.POSITIVE_INFINITY,
|
||||
),
|
||||
...oriented.map((read) => read.score[side] ?? Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,9 +83,10 @@ export interface ScannerMatch {
|
||||
/** spectator/casted footage (the 8-player spectator map screen was seen) */
|
||||
cast: boolean;
|
||||
/**
|
||||
* counter progress samples in `teams` order (the on-screen left plate is
|
||||
* the POV/alpha side; the builder reorients when teams[0] is the other
|
||||
* side); null when no counter was read
|
||||
* 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;
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,13 @@ function minimap(
|
||||
enemies = BRAVO.map((id) => enemy(id)),
|
||||
} = {},
|
||||
): DetectedEvent {
|
||||
const data: MinimapData = { stage, spectator: true, teammates, enemies };
|
||||
const data: MinimapData = {
|
||||
stage,
|
||||
spectator: true,
|
||||
teammates,
|
||||
enemies,
|
||||
teamColors: [null, null],
|
||||
};
|
||||
return { type: "Minimap", t, confidence: 0.8, data };
|
||||
}
|
||||
|
||||
|
||||
68
app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json
vendored
Normal file
68
app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"event": "Minimap",
|
||||
"data": {
|
||||
"stage": 1,
|
||||
"spectator": true,
|
||||
"teammates": [
|
||||
{
|
||||
"slot": "up",
|
||||
"name": "King Inate",
|
||||
"abilities": ["LDE", "RSU", "RSU"],
|
||||
"weaponLabel": "Zink Mini Splatling",
|
||||
"weaponId": 4001
|
||||
},
|
||||
{
|
||||
"slot": "right",
|
||||
"name": "todo",
|
||||
"abilities": ["CB", "SSU", "SJ"],
|
||||
"weaponLabel": "Mint Decavitator",
|
||||
"weaponId": 8020
|
||||
},
|
||||
{
|
||||
"slot": "down",
|
||||
"name": "tanaha",
|
||||
"abilities": ["RSU", "SSU", "SCU"],
|
||||
"weaponLabel": "Splat Brella",
|
||||
"weaponId": 6000
|
||||
},
|
||||
{
|
||||
"slot": "left",
|
||||
"name": "soph",
|
||||
"abilities": ["RSU", "RSU", "SJ"],
|
||||
"weaponLabel": "Heavy Splatling",
|
||||
"weaponId": 4010
|
||||
}
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"name": "Burstie",
|
||||
"abilities": ["SSU", "SSU", "SSU"],
|
||||
"weaponLabel": ".96 Gal",
|
||||
"weaponId": 80
|
||||
},
|
||||
{
|
||||
"name": "[K]yo!",
|
||||
"abilities": ["CB", "QR", "SJ"],
|
||||
"weaponLabel": "Slosher",
|
||||
"weaponId": 3000
|
||||
},
|
||||
{
|
||||
"name": "leafi !!",
|
||||
"abilities": ["OG", "SSU", "SJ"],
|
||||
"weaponLabel": "Custom Blaster",
|
||||
"weaponId": 211
|
||||
},
|
||||
{
|
||||
"name": "biscuit",
|
||||
"abilities": ["LDE", "SCU", "ISM"],
|
||||
"weaponLabel": "Snipewriter 5H",
|
||||
"weaponId": 2070
|
||||
}
|
||||
],
|
||||
"stageLabel": "Eeltail Alley"
|
||||
},
|
||||
"options": {
|
||||
"skipFields": ["teammates.0.name"],
|
||||
"notes": "8-player spectator map screen from the SWS26 cast, same game as the objective/ splat-zones-cast-* fixtures: green team left column, purple right — the cross-detector anchor pair for objective team-color tracking. teammates.0.name skipped: King Inate reads King lnate — capital I and lowercase l are pixel-identical in BlitzMain at this size (the POV nameplate in the sibling frame confirms Inate)."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"event": "Objective",
|
||||
"data": {
|
||||
"mode": "SZ",
|
||||
"time": 243,
|
||||
"score": [49, 98],
|
||||
"penalty": [39, null],
|
||||
"control": [false, true]
|
||||
},
|
||||
"options": {
|
||||
"notes": "Casted stream (SWS26) overhead camera of the same game as the specced-purple-left fixture: default arrangement puts green left (black plate, green digits, +39 penalty pill) and purple right (in control, team-color fill)."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
13
app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json
vendored
Normal file
13
app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"event": "Objective",
|
||||
"data": {
|
||||
"mode": "SZ",
|
||||
"time": 248,
|
||||
"score": [100, 49],
|
||||
"penalty": [null, null],
|
||||
"control": [false, false]
|
||||
},
|
||||
"options": {
|
||||
"notes": "Casted stream (SWS26) from a purple-team player's POV: the specced team's plate sits left, so purple (magenta digits) is side 0 and green side 1 — opposite arrangement to the overhead frame from the same game. Zone neutral: both plates near-black."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -58,9 +58,17 @@ function objective(
|
||||
score = [95, 53] as [number | null, number | null],
|
||||
penalty = [null, null] as [number | null, number | null],
|
||||
control = [true, false] as [boolean, boolean],
|
||||
teamColor = [null, null] as ObjectiveData["teamColor"],
|
||||
} = {},
|
||||
): DetectedEvent {
|
||||
const data: ObjectiveData = { mode: "SZ", time, score, penalty, control };
|
||||
const data: ObjectiveData = {
|
||||
mode: "SZ",
|
||||
time,
|
||||
score,
|
||||
penalty,
|
||||
control,
|
||||
teamColor,
|
||||
};
|
||||
return { type: "Objective", t, confidence: 0.9, data };
|
||||
}
|
||||
|
||||
@@ -147,6 +155,7 @@ function minimap(
|
||||
alpha = ALPHA as (MainWeaponId | null)[],
|
||||
bravo = BRAVO as (MainWeaponId | null)[],
|
||||
spectator = true,
|
||||
teamColors = [null, null] as MinimapData["teamColors"],
|
||||
} = {},
|
||||
): DetectedEvent {
|
||||
const data: MinimapData = {
|
||||
@@ -154,6 +163,7 @@ function minimap(
|
||||
spectator,
|
||||
teammates: alpha.map(teammate),
|
||||
enemies: bravo.map(enemy),
|
||||
teamColors,
|
||||
};
|
||||
return { type: "Minimap", t, confidence: 0.8, data };
|
||||
}
|
||||
@@ -279,6 +289,76 @@ test("an unknown-mode match keeps its objective reads", () => {
|
||||
assert.deepEqual(invalidObjectiveEvents(built), []);
|
||||
});
|
||||
|
||||
const GREEN_INK = { r: 146, g: 180, b: 96 };
|
||||
const PURPLE_INK = { r: 130, g: 43, b: 130 };
|
||||
|
||||
test("casted plate swaps are reoriented by team ink color", () => {
|
||||
const built = buildScannerMatches([
|
||||
minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }),
|
||||
objective(60, {
|
||||
score: [80, 90],
|
||||
control: [true, false],
|
||||
teamColor: [GREEN_INK, PURPLE_INK],
|
||||
}),
|
||||
// the caster specs a purple player: purple's plate moves left
|
||||
objective(120, {
|
||||
score: [90, 75],
|
||||
penalty: [4, null],
|
||||
control: [true, false],
|
||||
teamColor: [PURPLE_INK, GREEN_INK],
|
||||
}),
|
||||
// colors unreadable: the previous arrangement carries over
|
||||
objective(125, {
|
||||
score: [85, 75],
|
||||
control: [true, false],
|
||||
teamColor: [null, null],
|
||||
}),
|
||||
minimap(180),
|
||||
]);
|
||||
assert.equal(built.length, 1);
|
||||
const samples = built[0]!.match.objective!.samples;
|
||||
assert.deepEqual(
|
||||
samples.map((sample) => sample.score),
|
||||
[
|
||||
[80, 90],
|
||||
[75, 90],
|
||||
[75, 85],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
samples.map((sample) => sample.penalty),
|
||||
[
|
||||
[null, null],
|
||||
[null, 4],
|
||||
[null, null],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
samples.map((sample) => sample.control),
|
||||
[
|
||||
[true, false],
|
||||
[false, true],
|
||||
[false, true],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("minimap ink colors anchor a bravo-first cluster into teams order", () => {
|
||||
const built = buildScannerMatches([
|
||||
minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }),
|
||||
// every read had purple (bravo) on the left plate
|
||||
objective(60, {
|
||||
score: [90, 80],
|
||||
control: [false, true],
|
||||
teamColor: [PURPLE_INK, GREEN_INK],
|
||||
}),
|
||||
minimap(120),
|
||||
]);
|
||||
const samples = built[0]!.match.objective!.samples;
|
||||
assert.deepEqual(samples[0]!.score, [80, 90]);
|
||||
assert.deepEqual(samples[0]!.control, [true, false]);
|
||||
});
|
||||
|
||||
test("without a pov the side whose count got lower is the winner side", () => {
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createScoreboardDetector } from "../core/detectors/scoreboard/index";
|
||||
import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index";
|
||||
import type { Detector } from "../core/detectors/types";
|
||||
import { hueDistance, hueOf } from "../core/ink-color";
|
||||
import {
|
||||
type Fixture,
|
||||
isFieldSkipped,
|
||||
@@ -182,6 +183,28 @@ for (const fixture of fixtures) {
|
||||
});
|
||||
}
|
||||
|
||||
// The columns' sub-tile ink means anchor the objective counter's color
|
||||
// clusters to `teams` order (match-builder); the SWS26 spectator fixture
|
||||
// pairs with objective/splat-zones-cast-* from the same game, where the
|
||||
// plates read green ~78° and purple ~302°.
|
||||
test("spectator sub tiles read the two team ink colors", async () => {
|
||||
const fixture = fixtures.find((f) => f.name === "spectator-sws26-swiss");
|
||||
assert.ok(fixture, "spectator-sws26-swiss fixture missing");
|
||||
const { events } = await runDetectorOnFixture<MinimapData>(
|
||||
detector,
|
||||
fixture!,
|
||||
);
|
||||
const teamColors = events[0]?.data.teamColors;
|
||||
assert.ok(teamColors?.[0] && teamColors[1], "column ink color unreadable");
|
||||
const [alpha, bravo] = teamColors;
|
||||
assert.ok(
|
||||
hueDistance(hueOf(alpha), hueOf(bravo)) >= 90,
|
||||
"the two columns' ink hues do not separate",
|
||||
);
|
||||
assert.ok(hueDistance(hueOf(alpha), 84) <= 25, "left column is not green");
|
||||
assert.ok(hueDistance(hueOf(bravo), 300) <= 25, "right column is not purple");
|
||||
});
|
||||
|
||||
// The map overlay replaces everything else on screen; its gate may not fire
|
||||
// on any other detector's positives, nor theirs on the minimap fixtures.
|
||||
const otherPositives = [
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createScoreboardDetector } from "../core/detectors/scoreboard/index";
|
||||
import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index";
|
||||
import type { Detector } from "../core/detectors/types";
|
||||
import { hueDistance, hueOf } from "../core/ink-color";
|
||||
import {
|
||||
type Fixture,
|
||||
isFieldSkipped,
|
||||
@@ -130,6 +131,41 @@ for (const fixture of fixtures) {
|
||||
});
|
||||
}
|
||||
|
||||
// The cast fixture pair captures the same game under both camera
|
||||
// arrangements (the specced team's plate sits left, so purple is left in
|
||||
// one frame and right in the other): each frame's two ink hues must
|
||||
// separate cleanly, and cross-frame the same team's hue must land on the
|
||||
// same cluster with the sides swapped — the invariant cast score tracking
|
||||
// (match-builder's color orientation) rests on.
|
||||
test("cast fixture pair: team ink hues identify sides across camera swaps", async () => {
|
||||
const pair = [
|
||||
"splat-zones-cast-specced-purple-left",
|
||||
"splat-zones-cast-overhead-purple-right",
|
||||
].map((name) => fixtures.find((fixture) => fixture.name === name));
|
||||
assert.ok(pair[0] && pair[1], "cast fixture pair missing");
|
||||
|
||||
const colors = [];
|
||||
for (const fixture of pair) {
|
||||
const { events } = await runDetectorOnFixture<ObjectiveData>(
|
||||
detector,
|
||||
fixture!,
|
||||
);
|
||||
const teamColor = events[0]?.data.teamColor;
|
||||
assert.ok(teamColor?.[0] && teamColor[1], "side ink color unreadable");
|
||||
colors.push([teamColor[0], teamColor[1]] as const);
|
||||
}
|
||||
|
||||
for (const [left, right] of colors) {
|
||||
assert.ok(
|
||||
hueDistance(hueOf(left), hueOf(right)) >= 90,
|
||||
"the two teams' ink hues do not separate",
|
||||
);
|
||||
}
|
||||
const [specced, overhead] = colors;
|
||||
assert.ok(hueDistance(hueOf(specced![0]), hueOf(overhead![1])) <= 20);
|
||||
assert.ok(hueDistance(hueOf(specced![1]), hueOf(overhead![0])) <= 20);
|
||||
});
|
||||
|
||||
// Screens that replace gameplay can never show the counters — the gate must
|
||||
// stay quiet on their positives.
|
||||
const otherPositiveSets = [
|
||||
|
||||
@@ -62,6 +62,7 @@ test("weapons are padded to 4 slots per team so uneven rosters keep the team spl
|
||||
abilities: [],
|
||||
})),
|
||||
enemies: BRAVO.map((weaponId) => ({ name: null, weaponId, abilities: [] })),
|
||||
teamColors: [null, null],
|
||||
};
|
||||
const matches = prefilledMatches([
|
||||
{ type: "Minimap", t: 300, confidence: 0.9, data },
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"test:e2e:flaky-detect": "playwright test --repeat-each=10 --max-failures=1",
|
||||
"check-plural-collapse": "node --experimental-strip-types scripts/collapse-single-plural-keys.ts --check",
|
||||
"checks": "pnpm run biome:fix && pnpm run test:unit:browser && pnpm run check-translation-jsons && pnpm run check-plural-collapse && pnpm run typecheck && pnpm run knip",
|
||||
"checks:scanner": "pnpm checks && pnpm test:scanner",
|
||||
"seed": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/seed.ts",
|
||||
"setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts",
|
||||
"i18n:sync": "node --experimental-strip-types scripts/collapse-single-plural-keys.ts && i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && pnpm run biome:fix",
|
||||
|
||||
Reference in New Issue
Block a user