diff --git a/.gitignore b/.gitignore index 31a272f21..ccc7efd30 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ notepad.txt /assets/fonts/ *.mp4 -*.mkv \ No newline at end of file +*.mkv +*.webm \ No newline at end of file diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 4dcfeb803..0884da87b 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -101,7 +101,9 @@ sequenceDiagram badge-less frame scores the geometries on how decisively the bodies read and sticks with the established layout unless another wins clearly — the special-ready wash also pulses, so its dim trough is told - apart from a splat by its pale body, and a narrow-layout ready read + 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 narrow-layout ready read must also see a washed (ink-poor) body: pale backdrop or the lead banner leaking past an icon edge fakes the shoulder glow on the overhead map view's badge-less strip), with diff --git a/app/features/scanner/core/detectors/objective/player-status.ts b/app/features/scanner/core/detectors/objective/player-status.ts index 6fc79c3cb..d95729fcc 100644 --- a/app/features/scanner/core/detectors/objective/player-status.ts +++ b/app/features/scanner/core/detectors/objective/player-status.ts @@ -1,10 +1,11 @@ /** * PlayerStatus: per-player state off the eight squid/octo icons flanking the * timer, emitted alongside each Objective read (same frame, same `time`). - * Three pixel-class fractions decide a slot (calibration in rois.ts): alive = - * saturated team-ink body; special held = bright pale wash that PULSES (bright - * frames light the shoulder probe, trough frames only read pale); splatted = - * unsaturated grey X with none of the three. Three geometries, named by which + * Pixel-class fractions decide a slot (calibration in rois.ts): alive = + * saturated team-ink body; special held = pale team-tinted wash that PULSES + * (bright frames light the shoulder probe, trough frames only read tinted); + * 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 @@ -27,7 +28,6 @@ import { STATUS_COMB_MAX_SHIFT, STATUS_COMB_SIDE_SPANS, STATUS_DEAD_MAX_BODY_INK, - STATUS_DEAD_MAX_BODY_PALE, STATUS_DEAD_MAX_SHOULDER_GLOW, STATUS_DPAD_PROBES_EVEN, STATUS_DPAD_PROBES_NARROW_LEFT, @@ -58,6 +58,9 @@ import { STATUS_SLOT_CENTERS_NARROW_LEFT, STATUS_SLOT_CENTERS_NARROW_RIGHT, STATUS_STICKY_FLIP_COMB_MIN, + STATUS_TINT_MIN_SPREAD, + STATUS_TINT_MIN_VALUE, + STATUS_WASH_MIN_BODY_TINT, STATUS_WHITE_MAX_SPREAD, STATUS_WHITE_MIN_VALUE, } from "./rois"; @@ -109,6 +112,7 @@ interface SlotRead { confidence: number; bodyInk: number; bodyPale: number; + bodyTint: number; shoulderGlow: number; shoulderPaleGlow: number; } @@ -157,6 +161,7 @@ export function parsePlayerStatus( : "badges", 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))), shoulderGlow: reads.map((read) => Number(read.shoulderGlow.toFixed(2))), shoulderPaleGlow: reads.map((read) => Number(read.shoulderPaleGlow.toFixed(2)), @@ -197,6 +202,7 @@ function readSlots( return classifySlot( body.ink, body.pale, + body.tint, shoulder.glow, shoulder.paleGlow, layout, @@ -347,35 +353,43 @@ function sideDecisiveness(reads: SlotRead[]): number { /** * State from the class fractions; confidence scales with distance to the - * nearest boundary (1 at twice the threshold / at zero). On narrow layouts the - * wash replaces the body's ink, so an ink-heavy body means backdrop leak unless - * strongly pale too (graded STATUS_READY_*WASH* guards), and only unsaturated - * glow counts (STATUS_GLOW_MAX_SPREAD). A pale backdrop can still light a DEAD - * icon's shoulder, so narrow ready reads also need the wash's pale body and the + * nearest boundary (1 at twice the threshold / at zero). An ink-poor body is a + * 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. On narrow layouts the wash replaces the body's ink, so an + * ink-heavy body means backdrop leak unless strongly pale too (graded + * STATUS_READY_*WASH* guards), and only unsaturated glow counts + * (STATUS_GLOW_MAX_SPREAD). A pale backdrop can still light a DEAD icon's + * shoulder, so narrow ready reads also need the wash's pale body and the * narrow dead read trusts the body classes alone. */ function classifySlot( bodyInk: number, bodyPale: number, + bodyTint: number, shoulderGlow: number, shoulderPaleGlow: number, layout: PlayerStatusLayout, ): SlotRead { const washGlow = layout === "even" ? shoulderGlow : shoulderPaleGlow; + const inkPoor = bodyInk <= STATUS_DEAD_MAX_BODY_INK; + const tinted = bodyTint >= STATUS_WASH_MIN_BODY_TINT; const dead = - bodyInk <= STATUS_DEAD_MAX_BODY_INK && - (layout !== "even" || washGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW) && - bodyPale <= STATUS_DEAD_MAX_BODY_PALE; + inkPoor && + !tinted && + (layout !== "even" || washGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW); const washedBody = bodyInk <= STATUS_READY_CLEAN_WASH_MAX_BODY_INK || (bodyInk <= STATUS_READY_WASH_MAX_BODY_INK && bodyPale >= STATUS_READY_INKY_WASH_MIN_BODY_PALE); const special = !dead && - (washGlow >= STATUS_READY_MIN_SHOULDER_GLOW || - bodyPale >= STATUS_READY_MIN_BODY_PALE) && - (layout === "even" || - (washedBody && bodyPale >= STATUS_READY_MIN_WASH_BODY_PALE)); + ((inkPoor && tinted) || + ((washGlow >= STATUS_READY_MIN_SHOULDER_GLOW || + bodyPale >= STATUS_READY_MIN_BODY_PALE) && + (layout === "even" || + (washedBody && bodyPale >= STATUS_READY_MIN_WASH_BODY_PALE)))); const confidence = dead ? Math.min( 1, @@ -387,6 +401,7 @@ function classifySlot( Math.max( washGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2), bodyPale / (STATUS_READY_MIN_BODY_PALE * 2), + inkPoor ? bodyTint / (STATUS_WASH_MIN_BODY_TINT * 2) : 0, ), ) : Math.min(1, bodyInk / (STATUS_DEAD_MAX_BODY_INK * 2)); @@ -396,16 +411,17 @@ function classifySlot( confidence, bodyInk, bodyPale, + bodyTint, shoulderGlow, shoulderPaleGlow, }; } -/** Ink, glow, and pale pixel fractions of a ROI (see rois.ts for the classes). */ +/** Ink, glow, pale, and tint 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 } { +): { ink: number; glow: number; paleGlow: number; pale: number; tint: number } { const crop = copyRoi(frame, roi); const { data } = crop; const channels = crop.channels(); @@ -413,6 +429,7 @@ function classFractions( let glow = 0; let paleGlow = 0; let pale = 0; + let tint = 0; let count = 0; for (let i = 0; i < data.length; i += channels) { const r = data[i]!; @@ -427,6 +444,12 @@ function classFractions( } if (value >= STATUS_PALE_MIN_VALUE && spread <= STATUS_PALE_MAX_SPREAD) pale++; + if ( + value >= STATUS_TINT_MIN_VALUE && + spread > STATUS_TINT_MIN_SPREAD && + spread < STATUS_INK_MIN_SPREAD + ) + tint++; count++; } crop.delete(); @@ -435,6 +458,7 @@ function classFractions( glow: glow / count, paleGlow: paleGlow / count, pale: pale / count, + tint: tint / count, }; } diff --git a/app/features/scanner/core/detectors/objective/rois.ts b/app/features/scanner/core/detectors/objective/rois.ts index 402570ac0..e509f62d2 100644 --- a/app/features/scanner/core/detectors/objective/rois.ts +++ b/app/features/scanner/core/detectors/objective/rois.ts @@ -189,15 +189,33 @@ export const STATUS_PALE_MIN_VALUE = 185; export const STATUS_PALE_MAX_SPREAD = 70; /** - * Splatted: body ink under the floor (dead <=0.20 vs alive >=0.26) and the - * body-pale guard keeping the wash out across its pulse (wash >=0.22 vs dead - * <=0.15 under the 2026-08-22 VoD's skylight). Even reads add the shoulder-glow + * Tinted pixel: bright-ish and unsaturated, but not neutral. The special-ready + * wash is a pale team tint (lavender, pink, ...) at every pulse phase, while a + * splatted icon is a neutral grey plate under a grey X — and under a blown-out + * backdrop that plate reads near-white, so brightness alone cannot tell them + * apart. Spread floor sits above chroma-subsampling noise on grey (splats + * <=0.19 tinted) and the value floor above the dimmed plate. + */ +export const STATUS_TINT_MIN_VALUE = 100; +export const STATUS_TINT_MIN_SPREAD = 12; + +/** + * Splatted: body ink under the floor (dead <=0.20 vs alive >=0.26) and a + * neutral body (STATUS_WASH_MIN_BODY_TINT). Even reads add the shoulder-glow * guard (ready >=0.40 vs dead <=0.03); on narrow layouts backdrop leak past a * shrunken X reads 0.26-0.35 there, so only the body classes decide. */ 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; + +/** + * 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 + * 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; /** Special ready: shoulder glow past this (attested >=0.40 vs <=0.06). */ export const STATUS_READY_MIN_SHOULDER_GLOW = 0.25; diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/expected.json index 1e300df5b..ef84443eb 100644 --- a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/expected.json +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/expected.json @@ -5,14 +5,14 @@ "time": 152, "special": [ [true, false, false, false], - [false, false, true, false] + [false, false, false, false] ], "dead": [ [false, false, true, true], - [true, false, false, false] + [true, false, true, false] ] }, "options": { - "notes": "Actually dead[1][2] should be true but it is not in proper view so accepted." + "notes": "dead[1][2] sits on the overhead map's pale backdrop, which turns its grey plate near-white; it used to be accepted as a ready misread until the body-tint split told the untinted plate from a wash." } } diff --git a/app/features/scanner/tests/fixtures/player-status/cast-sws26-ready-wash-inner-slot-read-as-splat/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-sws26-ready-wash-inner-slot-read-as-splat/expected.json new file mode 100644 index 000000000..25d99fbaf --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-sws26-ready-wash-inner-slot-read-as-splat/expected.json @@ -0,0 +1,19 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "even", + "time": 161, + "cast": true, + "special": [ + [false, false, false, false], + [true, false, false, false] + ], + "dead": [ + [false, false, false, false], + [false, false, false, true] + ] + }, + "options": { + "notes": "Casted stream (SWS26 REWOUND, Rush vs Let's Go Brother, Sturgeon game 1) at 2:41, yellow team left vs purple right, both strips at the even pitch with the camera badges drawn. All four yellow icons are alive (gauge 87/45/60/7). Purple's slot nearest the timer wears the special-ready wash (pale lavender body, number hidden) and its outer slot is crossed out (gauge 37 kept); the read flagged the ready slot as splatted and missed the special entirely — the washed body's grey charger silhouette on the pale plate scored like a crossed-out icon. Purple's third slot shows no gauge number while its special is active (normal purple body, not ready)." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-sws26-ready-wash-inner-slot-read-as-splat/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-sws26-ready-wash-inner-slot-read-as-splat/frame.png new file mode 100644 index 000000000..6af8d6d9f Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-sws26-ready-wash-inner-slot-read-as-splat/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-sws26-splat-on-white-backdrop-read-as-ready/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-sws26-splat-on-white-backdrop-read-as-ready/expected.json new file mode 100644 index 000000000..2a6fccd49 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-sws26-splat-on-white-backdrop-read-as-ready/expected.json @@ -0,0 +1,19 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "narrow-right", + "time": 188, + "cast": true, + "special": [ + [false, true, false, false], + [false, false, false, false] + ], + "dead": [ + [true, false, false, false], + [false, false, false, true] + ] + }, + "options": { + "notes": "Casted stream (SWS26 REWOUND, Rush vs Let's Go Brother, Hagglefish game 2) at 3:08, yellow team left vs purple right with the A/B/Y/X and D-pad camera badges drawn. Yellow's outer slot is crossed out (gauge 40 kept on screen) and its second slot wears the special-ready wash with the number hidden; both read fine. Purple's outer slot is also crossed out (gauge 41 kept) but sits on a blown-out white sky, and the read came back alive with special ready instead of splatted — the bright backdrop leaking around the grey X'd body fakes the ready wash. Gauge numbers 4/37 on yellow's inner slots, 29/57/15 on purple's alive slots." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-sws26-splat-on-white-backdrop-read-as-ready/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-sws26-splat-on-white-backdrop-read-as-ready/frame.png new file mode 100644 index 000000000..d8e31e973 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-sws26-splat-on-white-backdrop-read-as-ready/frame.png differ