Improve special/splatted parsing

This commit is contained in:
Kalle
2026-09-25 19:12:47 +03:00
parent a76dfc0ccf
commit d99c2fb40a
9 changed files with 195 additions and 35 deletions

View File

@@ -257,12 +257,17 @@ sequenceDiagram
badge-less frame scores the geometries on how decisively the bodies
read and sticks with the established layout unless another wins
clearly, or unless the slot comb — which is positional, so it sees a
pitch the body reads cannot — puts narrow-right decisively ahead of a
latched `even`, as happens when a spectator toggles between the
overhead map and a player POV mid-match — the special-ready wash also pulses, so its dim trough is told
pitch the body reads cannot — decisively picks a geometry (even needs
the widest win) or puts narrow-right decisively ahead of a latched
`even`, as happens when a spectator toggles between the overhead map and
a player POV mid-match, and in S3 POV itself, which resizes each side's
icons as the objective swings and so cycles through all three
geometries within one game — the special-ready wash also pulses, so its dim trough is told
apart from a splat by its team tint: a splat is a neutral grey plate under
a grey X, which a blown-out backdrop turns near-white while a wash stays
tinted at every pulse phase, and a ready read
tinted at every pulse phase (a big dark weapon render such as the
Nautilus drum dilutes that tint, but a diluted wash still reads pale,
which a tinted splat never does), and a ready read
must also see a washed (ink-poor) body: pale backdrop, a ship's hull or
the lead banner leaking past an icon edge fakes the shoulder glow, and a ready the shoulder glow does
not corroborate needs the body's ink gone rather than merely paled,

View File

@@ -7,10 +7,11 @@
* splatted = neutral grey plate under a grey X, ink-poor and untinted whatever
* the backdrop brightness. Three geometries, named by which
* side sits at the packed pitch: "even", "narrow-right" (usual spectator HUD;
* S3 POV draws it too) and "narrow-left" (right column nearly coincides with
* even's). Camera badges prove a broadcast, but broadcasts can hide them, so a
* badge-less frame picks the geometry reading more decisively, with a slot-comb
* win proving narrow-left or unseating a latched even, and a history-less
* S3 POV draws all three, resizing each side as the objective swings) and
* "narrow-left" (right column nearly coincides with even's). Camera badges
* prove a broadcast, but broadcasts can hide them, so a badge-less frame picks
* the geometry reading more decisively, with a decisive slot-comb win proving
* any geometry outright or unseating a latched even, and a history-less
* near-tie staying narrow-right (pickLayout). Only the unsaturated glow counts
* toward ready — saturated backdrop leaks and bright team ink alike must not
* fake a state — and a ready read the shoulder does not corroborate needs a
@@ -32,8 +33,13 @@ import {
STATUS_COMB_GAP_HALF_WIDTH,
STATUS_COMB_MAX_SHIFT,
STATUS_COMB_SIDE_SPANS,
STATUS_DARK_MAX_VALUE,
STATUS_DEAD_MAX_BODY_INK,
STATUS_DEAD_MAX_SHOULDER_GLOW,
STATUS_DECISIVE_COMB_LEAD,
STATUS_DECISIVE_COMB_MIN,
STATUS_DECISIVE_EVEN_COMB_LEAD,
STATUS_DECISIVE_EVEN_COMB_MIN,
STATUS_DPAD_PROBES_EVEN,
STATUS_DPAD_PROBES_NARROW_LEFT,
STATUS_DPAD_PROBES_NARROW_RIGHT,
@@ -45,12 +51,12 @@ import {
STATUS_FRESH_NARROW_RIGHT_MIN_DECISIVENESS,
STATUS_GLOW_MAX_SPREAD,
STATUS_GLOW_MIN_VALUE,
STATUS_GREY_MAX_VALUE,
STATUS_GREY_MIN_VALUE,
STATUS_INK_MIN_SPREAD,
STATUS_INK_MIN_VALUE,
STATUS_LAYOUT_SCORE_CAP,
STATUS_LAYOUT_STICKY_MARGIN,
STATUS_NARROW_LEFT_COMB_LEAD,
STATUS_NARROW_LEFT_COMB_MIN,
STATUS_PALE_MAX_SPREAD,
STATUS_PALE_MIN_VALUE,
STATUS_READY_CLEAN_WASH_MAX_BODY_INK,
@@ -68,6 +74,9 @@ import {
STATUS_STICKY_FLIP_COMB_MIN,
STATUS_TINT_MIN_SPREAD,
STATUS_TINT_MIN_VALUE,
STATUS_UNCROSSED_WASH_MAX_BODY_DARK,
STATUS_UNCROSSED_WASH_MAX_BODY_GREY,
STATUS_UNCROSSED_WASH_MIN_BODY_TINT,
STATUS_WASH_MIN_BODY_TINT,
STATUS_WHITE_MAX_SPREAD,
STATUS_WHITE_MIN_VALUE,
@@ -77,7 +86,7 @@ export const PLAYER_STATUS_EVENT_TYPE = "PlayerStatus";
/**
* classFractions' per-pixel classes by (max, min) channel: bit 0 ink, 1 glow,
* 2 pale glow, 3 pale, 4 tint.
* 2 pale glow, 3 pale, 4 tint, 5 grey, 6 dark.
*/
const PIXEL_CLASSES = (() => {
const classes = new Uint8Array(1 << 16);
@@ -99,6 +108,13 @@ const PIXEL_CLASSES = (() => {
spread < STATUS_INK_MIN_SPREAD
)
flags |= 16;
if (
value >= STATUS_GREY_MIN_VALUE &&
value <= STATUS_GREY_MAX_VALUE &&
spread <= STATUS_TINT_MIN_SPREAD
)
flags |= 32;
if (value <= STATUS_DARK_MAX_VALUE) flags |= 64;
classes[(value << 8) | low] = flags;
}
}
@@ -151,6 +167,8 @@ interface SlotRead {
bodyInk: number;
bodyPale: number;
bodyTint: number;
bodyGrey: number;
bodyDark: number;
shoulderGlow: number;
shoulderPaleGlow: number;
}
@@ -200,6 +218,8 @@ export function parsePlayerStatus(
bodyInk: reads.map((read) => Number(read.bodyInk.toFixed(2))),
bodyPale: reads.map((read) => Number(read.bodyPale.toFixed(2))),
bodyTint: reads.map((read) => Number(read.bodyTint.toFixed(2))),
bodyGrey: reads.map((read) => Number(read.bodyGrey.toFixed(2))),
bodyDark: reads.map((read) => Number(read.bodyDark.toFixed(2))),
shoulderGlow: reads.map((read) => Number(read.shoulderGlow.toFixed(2))),
shoulderPaleGlow: reads.map((read) =>
Number(read.shoulderPaleGlow.toFixed(2)),
@@ -241,6 +261,8 @@ function readSlots(
body.ink,
body.pale,
body.tint,
body.grey,
body.dark,
shoulder.glow,
shoulder.paleGlow,
layout,
@@ -279,8 +301,10 @@ const SCORED_FLIPS: Record<PlayerStatusLayout, readonly PlayerStatusLayout[]> =
* SCORED_FLIPS keeps the even/narrow-left false friends from trading places.
* Four decisions decisiveness cannot make alone:
* - badge-less narrow-left (sendou-triton VoD) scores below narrow-right even
* when true, so a decisive slot-comb win (combContrast) overrides all but
* badges — positional, so only the differing left columns can lead;
* when true, and S3 POV swaps geometries as the objective swings (Triton cup
* VoD) while SCORED_FLIPS and the sticky margin hold the old one,
* so a decisive slot-comb win (combContrast, STATUS_DECISIVE_*COMB_*)
* overrides all but badges, fresh or sticky;
* - a spectator toggling between the overhead map and a player POV swaps even
* for narrow-right mid-match, and even's columns sit between the narrow ones,
* so the two score within 0.001 of each other and the wrong pick sticks for a
@@ -325,14 +349,8 @@ function pickLayout(
sideCombs[layout][0] + sideCombs[layout][1],
]),
) as Record<PlayerStatusLayout, number>;
if (
combs["narrow-left"] >= STATUS_NARROW_LEFT_COMB_MIN &&
combs["narrow-left"] >=
combs["narrow-right"] + STATUS_NARROW_LEFT_COMB_LEAD &&
combs["narrow-left"] >= combs.even + STATUS_NARROW_LEFT_COMB_LEAD
) {
return { layout: "narrow-left", scores };
}
const combWinner = decisiveCombWinner(combs);
if (combWinner) return { layout: combWinner, scores };
const combFlipsEven =
combs["narrow-right"] >= STATUS_EVEN_FLIP_COMB_MIN &&
combs["narrow-right"] >= combs.even + STATUS_EVEN_FLIP_COMB_LEAD;
@@ -344,8 +362,7 @@ function pickLayout(
(layout) =>
prevLayout !== "narrow-right" ||
(combs[layout] >= STATUS_STICKY_FLIP_COMB_MIN &&
combs[layout] >=
combs["narrow-right"] + STATUS_NARROW_LEFT_COMB_LEAD),
combs[layout] >= combs["narrow-right"] + STATUS_DECISIVE_COMB_LEAD),
);
const challenger =
challengers.length > 0
@@ -388,6 +405,26 @@ function pickLayout(
};
}
function decisiveCombWinner(
combs: Record<PlayerStatusLayout, number>,
): PlayerStatusLayout | null {
for (const layout of ALL_LAYOUTS) {
const [min, lead] =
layout === "even"
? [STATUS_DECISIVE_EVEN_COMB_MIN, STATUS_DECISIVE_EVEN_COMB_LEAD]
: [STATUS_DECISIVE_COMB_MIN, STATUS_DECISIVE_COMB_LEAD];
if (
combs[layout] >= min &&
ALL_LAYOUTS.every(
(rival) => rival === layout || combs[layout] >= combs[rival] + lead,
)
) {
return layout;
}
}
return null;
}
function sideDecisiveness(reads: SlotRead[]): number {
return (
reads.reduce(
@@ -408,7 +445,9 @@ function sideDecisiveness(reads: SlotRead[]): number {
* splat or a wash, and its tint tells them apart: the wash is a pale team tint
* at every pulse phase while the splat is neutral grey — even when a blown-out
* backdrop turns the plate near-white, or the trough dims the wash under both
* ready floors. Only unsaturated glow counts as the wash's
* ready floors. Where a big dark weapon render dilutes the wash's tint as low
* as a splat over a bright tinted backdrop reads, the splat's grey X strokes
* still tell them apart. Only unsaturated glow counts as the wash's
* (STATUS_GLOW_MAX_SPREAD): bright team ink lights the shoulder on its own
* once the ink is light enough (orange clears the glow floor, lime does not).
* The wash also replaces the body's ink, so an ink-heavy body means backdrop
@@ -423,6 +462,8 @@ function classifySlot(
bodyInk: number,
bodyPale: number,
bodyTint: number,
bodyGrey: number,
bodyDark: number,
shoulderGlow: number,
shoulderPaleGlow: number,
layout: PlayerStatusLayout,
@@ -430,7 +471,11 @@ function classifySlot(
// the wash glows pale on every layout; raw brightness is team ink or backdrop
const washGlow = shoulderPaleGlow;
const inkPoor = bodyInk <= STATUS_DEAD_MAX_BODY_INK;
const tinted = bodyTint >= STATUS_WASH_MIN_BODY_TINT;
const tinted =
bodyTint >= STATUS_WASH_MIN_BODY_TINT ||
(bodyTint >= STATUS_UNCROSSED_WASH_MIN_BODY_TINT &&
bodyGrey <= STATUS_UNCROSSED_WASH_MAX_BODY_GREY &&
bodyDark <= STATUS_UNCROSSED_WASH_MAX_BODY_DARK);
const dead =
inkPoor &&
!tinted &&
@@ -470,16 +515,26 @@ function classifySlot(
bodyInk,
bodyPale,
bodyTint,
bodyGrey,
bodyDark,
shoulderGlow,
shoulderPaleGlow,
};
}
/** Ink, glow, pale, and tint pixel fractions of a ROI (see rois.ts for the classes). */
/** Ink, glow, pale, tint, grey, and dark 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; tint: number } {
): {
ink: number;
glow: number;
paleGlow: number;
pale: number;
tint: number;
grey: number;
dark: number;
} {
const cols = frame.cols;
const inside =
roi.x >= 0 &&
@@ -501,6 +556,8 @@ function classFractions(
let paleGlow = 0;
let pale = 0;
let tint = 0;
let grey = 0;
let dark = 0;
for (let y = 0; y < roi.h; y++) {
const rowStart = start + y * rowStride;
const rowEnd = rowStart + roi.w * channels;
@@ -515,7 +572,9 @@ function classFractions(
glow += (flags >> 1) & 1;
paleGlow += (flags >> 2) & 1;
pale += (flags >> 3) & 1;
tint += flags >> 4;
tint += (flags >> 4) & 1;
grey += (flags >> 5) & 1;
dark += flags >> 6;
}
}
crop?.delete();
@@ -526,6 +585,8 @@ function classFractions(
paleGlow: paleGlow / count,
pale: pale / count,
tint: tint / count,
grey: grey / count,
dark: dark / count,
};
}
@@ -533,7 +594,7 @@ function classFractions(
* Slot-comb contrast per layout and side: mean iconness (ink-or-pale column
* fraction) at slot centers minus at gap midpoints, maximized over a small
* shift. A rigid comb at the wrong pitch cannot score all four slots at once:
* positional evidence orthogonal to body decisiveness (STATUS_NARROW_LEFT_COMB_*).
* positional evidence orthogonal to body decisiveness (STATUS_DECISIVE_*COMB_*).
*/
function combScores(frame: Mat): Record<PlayerStatusLayout, [number, number]> {
const profiles = STATUS_COMB_SIDE_SPANS.map(([x0, x1]) =>

View File

@@ -215,12 +215,38 @@ export const STATUS_DEAD_MAX_SHOULDER_GLOW = 0.2;
/**
* Wash body: tinted past this on an ink-poor body means the special-ready
* wash at any pulse phase (bright frames and the dim trough alike), under it
* a splat. Splats read <=0.19 (SWS26 splat on a blown-out white sky: 0.07 at
* a splat. Splats read <=0.25 (SWS26 splat on a blown-out white sky: 0.07 at
* pale 0.80), ink-poor washes >=0.45 (SWS26 even-layout trough: 0.57 at pale
* 0.13, shoulder glow 0.20 — under both ready floors).
*/
export const STATUS_WASH_MIN_BODY_TINT = 0.3;
/**
* Grey pixel: neutral (the tint class's complement) at mid brightness — the
* splat's X strokes, which cross the whole body box on every splat whatever
* the backdrop does to the translucent plate under them.
*/
export const STATUS_GREY_MIN_VALUE = 90;
export const STATUS_GREY_MAX_VALUE = 200;
/** Dark pixel: the unlit plate of a splatted or vacant slot. */
export const STATUS_DARK_MAX_VALUE = 70;
/**
* ...or tinted past a lower floor on a body no X crosses: a large dark weapon
* render (Nautilus drum) fills the body box and dilutes the wash to 0.2-0.3
* tint (Triton cup VoD, S3 POV), where splats over a bright tinted backdrop
* land too (sky and sunlit walls through the translucent plate). The X strokes
* split them: splats in that band read grey >=0.17 (median ~0.35), the
* drum-diluted washes <=0.10. Grey renders (Painbrush) put other washes at
* 0.15-0.25, left to the main floor. The dark cap keeps out the unlit black
* squid a slot shows without any X (InTheZone VoD: dark >=0.57, drum-diluted
* washes 0.20-0.35).
*/
export const STATUS_UNCROSSED_WASH_MIN_BODY_TINT = 0.2;
export const STATUS_UNCROSSED_WASH_MAX_BODY_GREY = 0.15;
export const STATUS_UNCROSSED_WASH_MAX_BODY_DARK = 0.45;
/** Special ready: shoulder glow past this (attested >=0.40 vs <=0.06). */
export const STATUS_READY_MIN_SHOULDER_GLOW = 0.25;
@@ -310,8 +336,17 @@ export const STATUS_FRESH_NARROW_LEFT_RIVAL_COMB_VETO = 0.3;
/**
* Slot-comb contrast (combContrast): a rigid comb exposes the pitch — badge-less
* narrow-left scores 0.81 while narrow-right reads it at -0.07 (sendou-triton
* MakoMart), so a decisive win proves narrow-left despite the shared right
* column. Both gates needed: worst false narrow-left comb is 0.44 with a 0.24 lead.
* MakoMart), so a decisive win (past the floor AND leading both rivals) proves
* that geometry whatever came before. Both gates needed: worst false narrow-left
* comb is 0.44 with a 0.24 lead. S3 POV resizes each side's icons as the
* objective swings (there, a side both holding the zone and leading drew
* large), so one Splat Zones game cycles through all three geometries and some
* in-between pitches (Triton cup VoD, Brinewater Springs: a dozen switches),
* which only the comb follows. Even needs the stronger win: its columns sit between the narrow ones, so the
* comb's ±10px shift lets it half-fit either, and the AREA CUP overhead view
* (left column ~12px off narrow-right) combs even 0.77 with a 0.30 lead. Clean
* even frames in that Triton game comb 0.98 with a 0.40 lead at the median,
* and three in four clear both floors — enough, as the pick is sticky.
*/
export const STATUS_COMB_BAND_Y = 35;
export const STATUS_COMB_BAND_H = 61;
@@ -325,12 +360,14 @@ export const STATUS_COMB_SIDE_SPANS: readonly [
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_NARROW_LEFT_COMB_MIN = 0.5;
export const STATUS_NARROW_LEFT_COMB_LEAD = 0.25;
export const STATUS_DECISIVE_COMB_MIN = 0.5;
export const STATUS_DECISIVE_COMB_LEAD = 0.25;
export const STATUS_DECISIVE_EVEN_COMB_MIN = 0.8;
export const STATUS_DECISIVE_EVEN_COMB_LEAD = 0.35;
/**
* Sticky flips away from narrow-right need comb corroboration (past this floor
* AND leading by STATUS_NARROW_LEFT_COMB_LEAD): while the S3 POV player is dead
* AND leading by STATUS_DECISIVE_COMB_LEAD): while the S3 POV player is dead
* the strip shrinks ~0.77 toward the timer, landing near narrow-left pitches
* (2026-08-11 Um'ami VoD locked 107 of 136 reads that way). True narrow-left
* combs 0.41-0.81 with >=0.31 lead; worst POV false comb 0.20, negative lead.

View File

@@ -0,0 +1,19 @@
{
"event": "PlayerStatus",
"data": {
"layout": "even",
"cast": null,
"time": 257,
"special": [
[false, false, false, true],
[true, false, false, false]
],
"dead": [
[false, false, false, false],
[false, false, false, false]
]
},
"options": {
"notes": "Triton cup VoD (v2811381928, 720p S3 POV), Brinewater Springs SZ at 4:17, t=6087.3. S3 POV resizes each side's icons as the objective swings, and this game cycles through even, narrow-left, narrow-right and back a dozen times. Here both sides sit at the even pitch (apexes ~[570,659,748,833 | 1087,1175,1263,1351]) but decisiveness is a near-tie (even 0.257 vs narrow-right 0.246, under the 0.025 lead a fresh even pick needs), so the fresh pick stayed narrow-right, and a live scan kept whatever geometry it had latched: the Nautilus wash on right slot 0 then read as a splat under narrow-right's probes. The comb sees the pitch (even 1.07 vs 0.62/0.62), so a decisive comb win now picks even outright, fresh or sticky (STATUS_DECISIVE_EVEN_COMB_*)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,19 @@
{
"event": "PlayerStatus",
"data": {
"layout": "narrow-right",
"cast": null,
"time": 266,
"special": [
[false, false, false, true],
[true, false, false, false]
],
"dead": [
[false, false, true, false],
[false, false, false, false]
]
},
"options": {
"notes": "Triton cup VoD (v2811381928, 720p S3 POV), Brinewater Springs SZ at 4:26, t=6078.3. Same Nautilus wash as pov-brinewater-nautilus-wash-read-as-splat, this time at the narrow-right pitch (left apexes ~[543,641,737,829], right ~[1087,1162,1238,1313]) and diluted further by the drum: tint 0.26 at pale 0.15, read as a splat. Left slot 2 is a real splat on a dark backdrop (tint 0.07, pale 0.03) that must stay one, and left slot 3 holds a wash."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 KiB

View File

@@ -0,0 +1,19 @@
{
"event": "PlayerStatus",
"data": {
"layout": "narrow-left",
"cast": null,
"time": 247,
"special": [
[false, true, false, true],
[true, false, false, false]
],
"dead": [
[false, false, false, false],
[false, false, false, true]
]
},
"options": {
"notes": "Triton cup VoD (v2811381928, 720p S3 POV), Brinewater Springs SZ at 4:07, t=6097.3. Right slot 0 holds a Nautilus whose special-ready wash is plainly visible (pale lilac icon) but whose big dark drum render fills most of the body box: the body reads ink-poor (0.00) with the wash diluted to tint 0.28, just under the 0.30 wash floor, so the slot read as a splat. Over the game the slot alternated ★/✕ every second while ready (pulse phases landing either side of the floor). It still reads pale 0.16, which a tinted splat never does (tinted splats read pale <=0.01), hence STATUS_PALE_WASH_*. Right slot 3 is a real splat, left slots 1 and 3 hold washes. The strip is at the narrow-left pitch here (left apexes ~[608,683,758,834], right ~[1087,1183,1280,1377])."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB