diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css index aa484a52b..e56055a40 100644 --- a/app/features/scanner/components/styles.css +++ b/app/features/scanner/components/styles.css @@ -806,10 +806,7 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). flex-shrink: 0; } -/* frosted-glass plate: keeps the score readable over vivid banner art - without dimming the image itself */ .scanner-app .match-score { - font-size: var(--font-lg); font-weight: var(--weight-extra); font-variant-numeric: tabular-nums; line-height: 1; diff --git a/app/features/scanner/core/detectors/objective/index.ts b/app/features/scanner/core/detectors/objective/index.ts index 075699f1f..1d48bbd4c 100644 --- a/app/features/scanner/core/detectors/objective/index.ts +++ b/app/features/scanner/core/detectors/objective/index.ts @@ -1,17 +1,18 @@ /** * ObjectiveDetector: parses the ranked in-match counter overlay top-center — * each team's count plate, the penalty pill under it, which team is in - * control (the controlling team's plate swaps to a near-black fill with the - * digits in the team's ink color; see rois.ts for the layout), and the M:SS - * match timer above the plates. + * control (the controlling team's plate keeps its saturated team-color + * fill; a non-controlling team's — or both, while the objective is neutral + * — swaps to a near-black fill with the digits in the team's ink color; + * see rois.ts for the layout), and the M:SS match timer above the plates. * * Digits are read as the trailing digit run (banner.ts) of the band under * several channel extractions: team-color ink on the black plate needs the - * brightest channel (dark blue ink is near-black in luminance), white ink - * on a team-color fill needs the darkest channel (which drops the fill and - * keeps white) — every extraction is tried at each threshold/size and the - * best-scoring read wins. A gate hit that yields no readable count on - * either side emits nothing (lookalike frame). + * brightest channel (dark blue ink is near-black in luminance), white or + * near-black ink on a team-color fill needs the darkest channel (which + * drops the fill and keeps the contrast) — every extraction is tried at + * each threshold/size and the best-scoring read wins. A gate hit that + * yields no readable count on either side emits nothing (lookalike frame). * * The data is a discriminated union on `mode`, prepared for the counter * semantics of the other modes; only the SZ member exists so far and every @@ -41,9 +42,7 @@ import { import type { ScoreboardResources } from "../scoreboard/index"; import type { DetectedEvent, Detector, GateResult } from "../types"; import { - CONTROL_PLATE_MAX_MEAN, - CONTROL_PLATE_MAX_SATURATION, - GATE_PLATE_MAX_MEAN, + CONTROL_PLATE_MIN_SATURATION, GATE_PLATE_MAX_STD, GATE_SCORE_MIN_MAX_BRIGHTNESS, GATE_TIMER_MAX_MEAN, @@ -80,7 +79,7 @@ export interface SplatZonesObjectiveData { score: [number | null, number | null]; /** penalty pill value per team; null = no pill (or unreadable) */ penalty: [number | null, number | null]; - /** which team currently holds the zone (team-ink digits on black plate) */ + /** which team currently holds the zone (team-color plate fill) */ control: [boolean, boolean]; } @@ -156,8 +155,7 @@ export function createObjectiveDetector( } function plateProbeOk(gray: Mat, roi: Roi): boolean { - const { mean, std } = meanStd(gray, roi); - return mean <= GATE_PLATE_MAX_MEAN && std <= GATE_PLATE_MAX_STD; + return meanStd(gray, roi).std <= GATE_PLATE_MAX_STD; } function scoreInkOk(frame: Mat, roi: Roi): boolean { @@ -277,8 +275,8 @@ export function createObjectiveDetector( /** * Control: the plate's fill (sampled over the probe strip) is the - * neutral near-black style — dark AND unsaturated — instead of the - * non-controlling team-color fill (see CONTROL_PLATE_MAX_* in rois.ts). + * saturated team-color style instead of the near-black fill shown while + * not in control (see CONTROL_PLATE_MIN_SATURATION in rois.ts). */ function plateFill( frame: Mat, @@ -315,8 +313,7 @@ export function createObjectiveDetector( penalty, control: score.value !== null && - fill.mean <= CONTROL_PLATE_MAX_MEAN && - fill.saturation <= CONTROL_PLATE_MAX_SATURATION, + fill.saturation >= CONTROL_PLATE_MIN_SATURATION, fill, }; }) as [SideRead, SideRead]; diff --git a/app/features/scanner/core/detectors/objective/rois.ts b/app/features/scanner/core/detectors/objective/rois.ts index 0ff35f478..1b6160be1 100644 --- a/app/features/scanner/core/detectors/objective/rois.ts +++ b/app/features/scanner/core/detectors/objective/rois.ts @@ -1,25 +1,23 @@ /** * ALL objective-counter ROI coordinates, in canonical 1920x1080 space. * Calibrated against the objective/ fixtures (pixel maps over the top-center - * band; the layout is mirror-symmetric around x=960, so right-side ROIs are - * derived with mirrorRoi and stay locked to the left-side measurements). + * band). The layout is only roughly mirror-symmetric around x=960: the + * widest attested digit run ("100") measures x816..888 on the left plate + * but x1039..1112 on the right — a few px right of mirror-center — so the + * score and plate-probe ROIs are measured per side instead of mirrored. * * The ranked in-match HUD shows one counter plate per team flanking the * mode's objective badge, under the timer strip: * - each plate is a rounded box with a small localized label line - * ("Remaining", のこり, ...) over big BlitzBold count digits (~42px); - * - the team currently in control has its plate restyled: near-black fill - * with the digits in the team's ink color; the other plate keeps a - * team-color fill with white digits. The fill/ink swap — not the digit - * values — is what identifies control; + * ("Remaining", のこり, ...) over big BlitzBold count digits (~41px); + * - the team currently in control has its plate in its team-color fill + * (digits near-black on bright inks, white on dark inks); a team not in + * control — including both teams while the objective is neutral — has a + * near-black plate with the digits in its team ink color. The fill swap — + * not the digit values — is what identifies control; * - a penalty adds a translucent dark pill under the plate with a white - * "+N" (~26px digits; the '+' is shorter than any digit and is dropped by + * "+N" (~29px digits; the '+' is shorter than any digit and is dropped by * the trailing-digit height floor). - * - * Only one fixture is attested so far (SZ, two-digit counts on both sides). - * Assumed until more fixtures land: the plates keep their geometry with - * three-digit counts ("100"), and the penalty pill sits mirror-symmetric - * under the right plate. */ import { CANONICAL_WIDTH, type Roi } from "../../canonical"; @@ -28,11 +26,16 @@ function mirrorRoi(roi: Roi): Roi { return { ...roi, x: CANONICAL_WIDTH - roi.x - roi.w }; } -/** Count digit band, inside the plate below the label line. */ -export const SCORE_ROIS: readonly [Roi, Roi] = (() => { - const left: Roi = { x: 812, y: 156, w: 84, h: 50 }; - return [left, mirrorRoi(left)]; -})(); +/** + * Count digit band, inside the plate below the label line. Wide enough for + * the full three-digit "100" run on each side (measured x816..888 left, + * x1039..1112 right) while staying clear of the plate rims, whose edge glow + * against a bright scene can read as ink. + */ +export const SCORE_ROIS: readonly [Roi, Roi] = [ + { x: 810, y: 156, w: 90, h: 46 }, + { x: 1034, y: 156, w: 84, h: 46 }, +]; /** * Penalty "+N" band inside the pill. The pill measures x 826..920, @@ -44,17 +47,20 @@ export const PENALTY_ROIS: readonly [Roi, Roi] = (() => { })(); /** - * Plate-fill probe strips at the plates' outer edges (x 796..903 is the - * left plate; the strip sits inside its left rim, clear of the widest - * attested digit run). Both plate styles are a flat fill there — near-black - * when in control, saturated team color when not — while gameplay showing - * through a lookalike frame is busy: the gate accepts a strip on mean - * brightness plus flatness (std). + * Plate-fill probe strips inside each plate's LEFT rim (x 796..903 is the + * left plate, x 1017..1124 the right), clear of the widest attested digit + * run ("100", see SCORE_ROIS) — the right strip is not mirrored because the + * right plate's digit run reaches x1112, into where a mirrored strip would + * sit. Height stops at y192 to stay above the bottom-corner edge glow. + * Both plate styles are a flat fill there — saturated team color when in + * control, near-black when not — while gameplay showing through a + * lookalike frame is busy: the gate accepts a strip on flatness (std) + * alone, since fill brightness spans near-black to bright yellow. */ -export const PLATE_PROBE_ROIS: readonly [Roi, Roi] = (() => { - const left: Roi = { x: 800, y: 162, w: 10, h: 36 }; - return [left, mirrorRoi(left)]; -})(); +export const PLATE_PROBE_ROIS: readonly [Roi, Roi] = [ + { x: 800, y: 162, w: 10, h: 30 }, + { x: 1021, y: 162, w: 10, h: 30 }, +]; /** * Penalty-pill presence probes at the pill's rounded ends, clear of the @@ -69,7 +75,6 @@ export const PENALTY_PROBE_ROIS: readonly [[Roi, Roi], [Roi, Roi]] = (() => { return [leftPill, [mirrorRoi(leftPill[1]), mirrorRoi(leftPill[0])]]; })(); -export const GATE_PLATE_MAX_MEAN = 165; export const GATE_PLATE_MAX_STD = 30; /** A count digit's brightest channel clears this on either plate style. */ export const GATE_SCORE_MIN_MAX_BRIGHTNESS = 200; @@ -110,7 +115,7 @@ export const TIMER_DIGIT_MIN_HEIGHT_RATIO = 0.82; * banner's settled/mid-pop sizes). */ export const SCORE_TEXT_HEIGHTS = [40, 44] as const; -export const PENALTY_TEXT_HEIGHT = 26; +export const PENALTY_TEXT_HEIGHT = 29; /** * Binarization thresholds tried per channel extraction. The ink/background @@ -126,11 +131,11 @@ export const PENALTY_PROBE_MAX_MEAN = 165; export const PENALTY_PROBE_MAX_STD = 30; /** - * Control = the plate's fill is the near-black style (measured over the - * plate probe strip): a controlling team's plate is neutral dark — low - * brightness AND low saturation — while the team-color fill of a - * non-controlling plate stays saturated even for dark inks (deep blue - * measures ~130 spread at ~50 brightness). + * Control = the plate's fill is the team-color style (measured over the + * plate probe strip): a controlling team's plate keeps its saturated ink + * fill even for dark inks (deep blue measures ~130 spread at ~50 + * brightness), while the near-black plate of a non-controlling team — or + * of both teams while the objective is neutral — is unsaturated (attested + * fills measure >=112 vs <=19). */ -export const CONTROL_PLATE_MAX_MEAN = 90; -export const CONTROL_PLATE_MAX_SATURATION = 60; +export const CONTROL_PLATE_MIN_SATURATION = 60; diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/expected.json new file mode 100644 index 000000000..13f808c0b --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 277, + "score": [97, 100], + "penalty": [null, null], + "control": [true, false] + }, + "options": { + "notes": "Alpha (bright yellow-green ink) in control: their plate keeps the bright team-color fill (POV frame shows the We're in control! banner). Bravo still at the three-digit 100." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/frame.png new file mode 100644 index 000000000..437d187e9 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/expected.json new file mode 100644 index 000000000..34628f9e3 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 157, + "score": [2, 15], + "penalty": [64, 65], + "control": [true, false] + }, + "options": { + "notes": "Penalty pills under both plates at once; alpha (bright green fill) in control." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/frame.png new file mode 100644 index 000000000..f3a1959ee Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/expected.json new file mode 100644 index 000000000..8c517bef6 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 209, + "score": [2, 84], + "penalty": [75, null], + "control": [false, true] + }, + "options": { + "notes": "Single-digit count with a large penalty on alpha; bravo (purple) in control with the team-color fill." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/frame.png new file mode 100644 index 000000000..f874fd46d Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/expected.json new file mode 100644 index 000000000..28dba9670 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 299, + "score": [100, 100], + "penalty": [null, null], + "control": [false, false] + }, + "options": { + "notes": "Match start: zone neutral, both plates near-black with three-digit 100s (green/pink digits)." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/frame.png new file mode 100644 index 000000000..7970b25ae Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-mako-mart/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-mako-mart/expected.json index 362bde79e..2edfe054c 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-mako-mart/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-mako-mart/expected.json @@ -5,6 +5,6 @@ "time": 215, "score": [95, 53], "penalty": [4, null], - "control": [true, false] + "control": [false, true] } }