diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 8b85997df..0112ba4c7 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -269,7 +269,9 @@ sequenceDiagram a grey X, which a blown-out backdrop turns near-white while a wash stays 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 + which a tinted splat never does; a pale backdrop showing through a + splat's translucent plate tints it like a wash, but the X's grey strokes + over the dark squid still give it away), 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/minimap/index.ts b/app/features/scanner/core/detectors/minimap/index.ts index 112d05003..8671ba40f 100644 --- a/app/features/scanner/core/detectors/minimap/index.ts +++ b/app/features/scanner/core/detectors/minimap/index.ts @@ -46,6 +46,8 @@ import type { DetectedEvent, Detector, GateResult } from "../types"; import { badgeRoi, CARD_LAYOUTS, + CROSS_BLEED_MIN_FRACTION, + CROSS_BLEED_MIN_LAPLACIAN, CROSS_MIN_FRACTION, CROSS_MIN_LAPLACIAN, CROSS_SATURATION_MIN, @@ -182,6 +184,17 @@ function minTopCorner( return { mean: Math.min(...means), saturation: satSum / n }; } +/** the cross probe's saturated strokes are crisp enough to be the X, not scene bleed */ +function crossedOut(crossFraction: number, crossLap: number): boolean { + if (crossFraction < CROSS_MIN_FRACTION) return false; + return ( + crossLap >= + (crossFraction > CROSS_BLEED_MIN_FRACTION + ? CROSS_BLEED_MIN_LAPLACIAN + : CROSS_MIN_LAPLACIAN) + ); +} + /** fraction of the probe that is saturated-and-bright (cross-out strokes) */ function saturatedFraction(hsv: Mat, roi: Roi): number { const m = copyRoi(hsv, roi); @@ -452,9 +465,7 @@ export function createMinimapDetector( } const crossFraction = saturatedFraction(hsv, layout.cross); const crossLap = meanBrightness(lap, layout.cross); - const occluded = - crossFraction >= CROSS_MIN_FRACTION && - crossLap >= CROSS_MIN_LAPLACIAN; + const occluded = crossedOut(crossFraction, crossLap); const corner = minTopCorner(gray, hsv, layout.weapon); const lightSurface = corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN && @@ -623,8 +634,7 @@ export function createMinimapDetector( } const crossFraction = saturatedFraction(hsv, layout.cross); const crossLap = meanBrightness(lap, layout.cross); - const occluded = - crossFraction >= CROSS_MIN_FRACTION && crossLap >= CROSS_MIN_LAPLACIAN; + const occluded = crossedOut(crossFraction, crossLap); const corner = minTopCorner(gray, hsv, layout.weapon); const lightSurface = corner.mean >= SPECIAL_READY_MIN_CORNER_MEAN && @@ -643,8 +653,7 @@ export function createMinimapDetector( } const crossFraction = saturatedFraction(hsv, enemyCrossRoi(cy)); const crossLap = meanBrightness(lap, enemyCrossRoi(cy)); - const occluded = - crossFraction >= CROSS_MIN_FRACTION && crossLap >= CROSS_MIN_LAPLACIAN; + const occluded = crossedOut(crossFraction, crossLap); // light camo rows: pick template variant by corner brightness, raise ink threshold past it const corner = minTopCorner(gray, hsv, weaponRoi); const lightSurface = diff --git a/app/features/scanner/core/detectors/minimap/rois.ts b/app/features/scanner/core/detectors/minimap/rois.ts index 7a050aca6..c33a3b063 100644 --- a/app/features/scanner/core/detectors/minimap/rois.ts +++ b/app/features/scanner/core/detectors/minimap/rois.ts @@ -113,11 +113,20 @@ export const CROSS_VALUE_MIN = 110; export const CROSS_MIN_FRACTION = 0.08; /** * Saturation alone is not a cross-out: the POV map screen bleeds team ink - * through the cards (phantom fractions 0.61-0.65 on Um'ami, everyone alive). - * The X is crisp while bleed is softened: mean |Laplacian| struck >=98 vs bleed - * <=34 (clean cards read up to ~62 of line art at fraction ~0). + * through the cards (phantom fractions 0.56-0.70 at mean |Laplacian| 22-42 on + * the Um'ami and Triton cup VoDs, everyone alive), while the X is crisp + * (clean cards read up to ~62 of line art, but at fraction ~0). The X is drawn + * in the card owner's pale team tint, so over a bright team-colored scene + * (Triton cup VoD, yellow team) its strokes soften: 21 struck cards and rows + * there read 41-65 at fractions 0.11-0.34. Bleed saturates far more of the + * probe, so past CROSS_BLEED_MIN_FRACTION only the crisp floor counts — the + * two soft struck reads up there (0.56 at 55, 0.66 at 33) stay lost. Margins + * are THIN (bleed 0.56 at 41.6 vs struck 0.15 at 41.5) — re-measure before + * moving any. */ -export const CROSS_MIN_LAPLACIAN = 65; +export const CROSS_MIN_LAPLACIAN = 40; +export const CROSS_BLEED_MIN_FRACTION = 0.5; +export const CROSS_BLEED_MIN_LAPLACIAN = 65; /** Sub-tile silhouettes matched shape-only (specials.ts) to split near-tied main-weapon icons. */ export const SUB_TILE_TEMPLATE_SIZES = [24, 27, 30, 33, 36] as const; diff --git a/app/features/scanner/core/detectors/objective/player-status.ts b/app/features/scanner/core/detectors/objective/player-status.ts index 7f51be65f..bc21f138c 100644 --- a/app/features/scanner/core/detectors/objective/player-status.ts +++ b/app/features/scanner/core/detectors/objective/player-status.ts @@ -33,6 +33,8 @@ import { STATUS_COMB_GAP_HALF_WIDTH, STATUS_COMB_MAX_SHIFT, STATUS_COMB_SIDE_SPANS, + STATUS_CROSSED_MIN_BODY_DARK, + STATUS_CROSSED_MIN_BODY_GREY, STATUS_DARK_MAX_VALUE, STATUS_DEAD_MAX_BODY_INK, STATUS_DEAD_MAX_SHOULDER_GLOW, @@ -447,7 +449,8 @@ function sideDecisiveness(reads: SlotRead[]): number { * backdrop turns the plate near-white, or the trough dims the wash under both * 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 + * still tell them apart, and they veto a tinted body outright when the dark + * squid shows under them (a splat over a pale tinted backdrop). 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 @@ -471,8 +474,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 crossed = + bodyGrey >= STATUS_CROSSED_MIN_BODY_GREY && + bodyDark >= STATUS_CROSSED_MIN_BODY_DARK; const tinted = - bodyTint >= STATUS_WASH_MIN_BODY_TINT || + (bodyTint >= STATUS_WASH_MIN_BODY_TINT && !crossed) || (bodyTint >= STATUS_UNCROSSED_WASH_MIN_BODY_TINT && bodyGrey <= STATUS_UNCROSSED_WASH_MAX_BODY_GREY && bodyDark <= STATUS_UNCROSSED_WASH_MAX_BODY_DARK); diff --git a/app/features/scanner/core/detectors/objective/rois.ts b/app/features/scanner/core/detectors/objective/rois.ts index 69d8c76a9..e2fb451c8 100644 --- a/app/features/scanner/core/detectors/objective/rois.ts +++ b/app/features/scanner/core/detectors/objective/rois.ts @@ -247,6 +247,19 @@ 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; +/** + * ...and a body the X does cross is never a wash, however tinted: a pale teal + * or sky backdrop through the translucent plate lifts a splat's tint to + * 0.30-0.46 (Triton cup VoD, 21 such splats over the whole VoD). Neither + * stroke class alone splits them from washes: grey or two-tone weapon renders + * put washes at grey up to 0.26, and dark renders (Nautilus drum, frying pan) + * at dark up to 0.35. Together they do: those splats read grey >=0.21 at dark + * >=0.17, while every wash reading grey >=0.2 reads dark <=0.16. Margins are + * THIN on dark (0.16 vs 0.17) — re-measure before moving either. + */ +export const STATUS_CROSSED_MIN_BODY_GREY = 0.2; +export const STATUS_CROSSED_MIN_BODY_DARK = 0.17; + /** 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/minimap/pov-alfonsino-respawn-map-fade-in-self-cross-missed/expected.json b/app/features/scanner/tests/fixtures/minimap/pov-alfonsino-respawn-map-fade-in-self-cross-missed/expected.json new file mode 100644 index 000000000..251960713 --- /dev/null +++ b/app/features/scanner/tests/fixtures/minimap/pov-alfonsino-respawn-map-fade-in-self-cross-missed/expected.json @@ -0,0 +1,21 @@ +{ + "event": "Minimap", + "data": { + "spectator": false, + "teammates": [ + { "self": false, "dead": false }, + { "self": false, "dead": false }, + { "self": false, "dead": false }, + { "self": true, "dead": true } + ], + "enemies": [ + { "dead": false }, + { "dead": false }, + { "dead": false }, + { "dead": false } + ] + }, + "options": { + "notes": "Triton cup VoD (v2811381928, 720p S3 POV), Museum d'Alfonsino SZ at 1:21, t=1782.25. The POV player (have faith, .52 Gal) was splatted at 1:22 and this is the respawn map fading in. The self card is struck through, but the X's pale strokes over a light yellow backdrop are soft on this frame: the cross probe reads saturated 0.33, while its crispness reads 49 |Laplacian|, under the 65 floor. The card read as alive, and that one read split the player's death on the timeline into a band too short to be real, which was debounced away. Half a second later the same card reads 81. Names, weapons and abilities are soft mid-fade and are not asserted." + } +} diff --git a/app/features/scanner/tests/fixtures/minimap/pov-alfonsino-respawn-map-fade-in-self-cross-missed/frame.png b/app/features/scanner/tests/fixtures/minimap/pov-alfonsino-respawn-map-fade-in-self-cross-missed/frame.png new file mode 100644 index 000000000..723143767 Binary files /dev/null and b/app/features/scanner/tests/fixtures/minimap/pov-alfonsino-respawn-map-fade-in-self-cross-missed/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/pov-alfonsino-splat-over-teal-backdrop-read-as-ready/expected.json b/app/features/scanner/tests/fixtures/player-status/pov-alfonsino-splat-over-teal-backdrop-read-as-ready/expected.json new file mode 100644 index 000000000..078177ca2 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/pov-alfonsino-splat-over-teal-backdrop-read-as-ready/expected.json @@ -0,0 +1,20 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "narrow-right", + "cast": null, + "time": 206, + "special": [ + [true, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, true, false, false], + [false, false, false, false] + ] + }, + "options": { + "skipFields": ["layout"], + "notes": "Triton cup VoD (v2811381928, 720p S3 POV), Museum d'Alfonsino SZ at 3:26, t=1657. Left slot 1 (Custom Blaster) is splatted from 3:27 to 3:24: a dark squid under a grey X, with a pale teal backdrop showing through the translucent plate. That backdrop tints the ink-poor body to 0.33-0.45, past the 0.30 wash floor, so the splat read as special-ready on two of its four reads. The remaining lone dead read was then debounced away, and the match lost one of the player's 8 deaths. The X strokes still show: grey 0.23-0.36, where every attested wash at that tint reads <=0.20. Left slot 0 holds a real wash. The strip sits at an in-between pitch here (left apexes ~[590,670,753,833]), so the layout is not asserted." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/pov-alfonsino-splat-over-teal-backdrop-read-as-ready/frame.png b/app/features/scanner/tests/fixtures/player-status/pov-alfonsino-splat-over-teal-backdrop-read-as-ready/frame.png new file mode 100644 index 000000000..cfed616a8 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/pov-alfonsino-splat-over-teal-backdrop-read-as-ready/frame.png differ