diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 37e0ddc90..8eef0403d 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -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, diff --git a/app/features/scanner/core/detectors/objective/player-status.ts b/app/features/scanner/core/detectors/objective/player-status.ts index 82bcdb050..7f51be65f 100644 --- a/app/features/scanner/core/detectors/objective/player-status.ts +++ b/app/features/scanner/core/detectors/objective/player-status.ts @@ -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 = * 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; - 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 | 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 { const profiles = STATUS_COMB_SIDE_SPANS.map(([x0, x1]) => diff --git a/app/features/scanner/core/detectors/objective/rois.ts b/app/features/scanner/core/detectors/objective/rois.ts index 6fd51d62d..69d8c76a9 100644 --- a/app/features/scanner/core/detectors/objective/rois.ts +++ b/app/features/scanner/core/detectors/objective/rois.ts @@ -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. diff --git a/app/features/scanner/tests/fixtures/player-status/pov-brinewater-even-pitch-read-as-narrow-right/expected.json b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-even-pitch-read-as-narrow-right/expected.json new file mode 100644 index 000000000..825664649 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-even-pitch-read-as-narrow-right/expected.json @@ -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_*)." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/pov-brinewater-even-pitch-read-as-narrow-right/frame.png b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-even-pitch-read-as-narrow-right/frame.png new file mode 100644 index 000000000..22f99fb14 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-even-pitch-read-as-narrow-right/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat-beside-real-splat/expected.json b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat-beside-real-splat/expected.json new file mode 100644 index 000000000..9648a17cb --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat-beside-real-splat/expected.json @@ -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." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat-beside-real-splat/frame.png b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat-beside-real-splat/frame.png new file mode 100644 index 000000000..639c211a7 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat-beside-real-splat/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat/expected.json b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat/expected.json new file mode 100644 index 000000000..88c36cb97 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat/expected.json @@ -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])." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat/frame.png b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat/frame.png new file mode 100644 index 000000000..b208b8b01 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/pov-brinewater-nautilus-wash-read-as-splat/frame.png differ