mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 15:16:09 -05:00
Scanner: Handle black border better, remove slots
This commit is contained in:
@@ -194,7 +194,11 @@ sequenceDiagram
|
||||
language at once (`core/localized-entries.ts`, generated) and events carry
|
||||
sendou ids. English display names come from `components/labels.ts`.
|
||||
- ROI coordinates live in each detector's `rois.ts`, in canonical 1920×1080
|
||||
space; every frame is normalized to that size first.
|
||||
space; every frame is normalized to that size first — black bars around the
|
||||
picture (letterbox/pillarbox, or a scene drawing the game smaller than its
|
||||
canvas) are cropped away before the resize (`detectContentBox` in
|
||||
`core/canonical.ts`; a bar must be level and ≥1% deep, since the Recent
|
||||
Battles screen's own scanline-textured edge is dark but neither).
|
||||
- New event types implement `Detector` (`core/detectors/types.ts`): a cheap
|
||||
`gate(mat)` at sample rate plus `parse(mat, t)` when the gate fires.
|
||||
Register in `core/detectors/registry.ts`.
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { mainWeaponImageUrl, SCANNER_PAGE } from "~/utils/urls";
|
||||
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
|
||||
import type { Roi } from "../core/canonical";
|
||||
import type { PlayerStatusLayout } from "../core/detectors/objective/player-status";
|
||||
import * as objective from "../core/detectors/objective/rois";
|
||||
import type { FixtureListItem } from "../routes/scanner.fixtures";
|
||||
@@ -10,6 +10,7 @@ import { scannerSearchParams } from "../scanner-search-params";
|
||||
import { newInspectKey, putInspectFrame } from "../store/inspect";
|
||||
import styles from "./FixturesPage.module.css";
|
||||
import { mainWeaponLabel } from "./labels";
|
||||
import { drawNormalizedCanvas } from "./normalized-canvas";
|
||||
import { formatTimer, RoiCrop } from "./ScreenshotPage";
|
||||
|
||||
const FIXTURES_ENDPOINT = "/scanner/fixtures";
|
||||
@@ -223,13 +224,9 @@ function useNormalizedFrame(
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
if (cancelled) return;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = CANONICAL_WIDTH;
|
||||
canvas.height = CANONICAL_HEIGHT;
|
||||
canvas
|
||||
.getContext("2d")!
|
||||
.drawImage(image, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT);
|
||||
setFrame(canvas);
|
||||
setFrame(
|
||||
drawNormalizedCanvas(image, image.naturalWidth, image.naturalHeight),
|
||||
);
|
||||
};
|
||||
image.src = url;
|
||||
return () => {
|
||||
|
||||
@@ -81,16 +81,4 @@
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
&.right svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.down svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
&.left svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type MinimapEnemy,
|
||||
type MinimapTeammate,
|
||||
} from "../core/detectors/minimap/index";
|
||||
import type { CardSlot } from "../core/detectors/minimap/rois";
|
||||
import {
|
||||
EventCardMeta,
|
||||
EventCardShell,
|
||||
@@ -45,22 +44,14 @@ function AbilityRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** `up` needs no rotation, `self` renders a dot instead of a chevron */
|
||||
const SLOT_ROTATION_CLASS: Record<CardSlot, string | undefined> = {
|
||||
up: undefined,
|
||||
down: styles.down,
|
||||
left: styles.left,
|
||||
right: styles.right,
|
||||
self: undefined,
|
||||
};
|
||||
|
||||
function TeammateMarker({ slot }: { slot: CardSlot }) {
|
||||
if (slot === "self") {
|
||||
/** the POV player's own card renders a dot, allies a jump chevron */
|
||||
function TeammateMarker({ self }: { self: boolean }) {
|
||||
if (self) {
|
||||
return <span className={styles.slotMarker}>●</span>;
|
||||
}
|
||||
return (
|
||||
<span className={clsx(styles.slotMarker, SLOT_ROTATION_CLASS[slot])}>
|
||||
<ChevronUp strokeWidth={3.5} aria-label={slot} role="img" />
|
||||
<span className={styles.slotMarker}>
|
||||
<ChevronUp strokeWidth={3.5} aria-label="ally" role="img" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -143,10 +134,10 @@ export function MinimapCard(props: {
|
||||
<EventCardTeams>
|
||||
<EventCardTeam>
|
||||
<h3>Team</h3>
|
||||
{data.teammates.map((p) => (
|
||||
{data.teammates.map((p, i) => (
|
||||
<PlayerRow
|
||||
key={p.slot}
|
||||
marker={<TeammateMarker slot={p.slot} />}
|
||||
key={i}
|
||||
marker={<TeammateMarker self={p.self} />}
|
||||
player={p}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
stageLabel,
|
||||
weaponLabel,
|
||||
} from "./labels";
|
||||
import { drawNormalizedCanvas } from "./normalized-canvas";
|
||||
import { ScannerDropzone } from "./ScannerChrome";
|
||||
import styles from "./ScreenshotPage.module.css";
|
||||
|
||||
@@ -448,13 +449,7 @@ export function ScreenshotPage() {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
|
||||
// normalized frame for local crop display, same as the pipeline does
|
||||
const norm = document.createElement("canvas");
|
||||
norm.width = CANONICAL_WIDTH;
|
||||
norm.height = CANONICAL_HEIGHT;
|
||||
norm
|
||||
.getContext("2d")!
|
||||
.drawImage(bitmap, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT);
|
||||
setFrame(norm);
|
||||
setFrame(drawNormalizedCanvas(bitmap, bitmap.width, bitmap.height));
|
||||
|
||||
resultRef.current = (r) => {
|
||||
setResults((prev) => ({ ...prev, [r.detector]: r }));
|
||||
@@ -762,7 +757,7 @@ export function ScreenshotPage() {
|
||||
{data.teammates
|
||||
.map(
|
||||
(p) =>
|
||||
`${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
|
||||
`${p.self ? "self: " : ""}${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
|
||||
)
|
||||
.join(", ") || "—"}
|
||||
</Stat>
|
||||
@@ -779,10 +774,10 @@ export function ScreenshotPage() {
|
||||
</div>
|
||||
{!data.spectator ? (
|
||||
<div className={styles.detailCrops}>
|
||||
{minimap.CARD_LAYOUTS.map((card) => (
|
||||
{minimap.CARD_LAYOUTS.map((card, i) => (
|
||||
<LabeledCrop
|
||||
key={card.slot}
|
||||
label={`slot ${card.slot}`}
|
||||
key={i}
|
||||
label={card.self ? "self card" : `card ${i + 1}`}
|
||||
frame={frame}
|
||||
roi={card.name}
|
||||
/>
|
||||
|
||||
@@ -113,7 +113,7 @@ function formatMinimapPlayers(data: MinimapData): string {
|
||||
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}` +
|
||||
`${p.dead ? " · splatted" : ""}${p.specialReady ? " · special" : ""}`;
|
||||
return [
|
||||
...data.teammates.map((p) => fmt(p.slot, p)),
|
||||
...data.teammates.map((p, i) => fmt(p.self ? "self" : `ally${i + 1}`, p)),
|
||||
...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)),
|
||||
].join("; ");
|
||||
}
|
||||
@@ -234,7 +234,7 @@ function eventCells(event: CsvEvent): Cell[] {
|
||||
}
|
||||
case MINIMAP_EVENT_TYPE: {
|
||||
const d = event.data as MinimapData;
|
||||
const self = d.teammates.find((p) => p.slot === "self");
|
||||
const self = d.teammates.find((p) => p.self);
|
||||
return [
|
||||
...base,
|
||||
"", // lobby
|
||||
|
||||
@@ -122,7 +122,7 @@ function buildExpectedJson(
|
||||
}),
|
||||
...(minimap.spectator && { spectator: true }),
|
||||
teammates: minimap.teammates.map((p) => ({
|
||||
slot: p.slot,
|
||||
self: p.self,
|
||||
name: p.name,
|
||||
weaponLabel: mainWeaponLabel(p.weaponId),
|
||||
weaponId: p.weaponId,
|
||||
|
||||
47
app/features/scanner/components/normalized-canvas.ts
Normal file
47
app/features/scanner/components/normalized-canvas.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
CANONICAL_HEIGHT,
|
||||
CANONICAL_WIDTH,
|
||||
detectContentBox,
|
||||
} from "../core/canonical";
|
||||
|
||||
/**
|
||||
* Draws a frame at canonical size the way the worker normalizes it — black
|
||||
* bars around the picture cropped away, then scaled — so ROI crops shown in
|
||||
* the UI line up with what the detectors read.
|
||||
*/
|
||||
export function drawNormalizedCanvas(
|
||||
source: CanvasImageSource,
|
||||
width: number,
|
||||
height: number,
|
||||
): HTMLCanvasElement {
|
||||
const native = document.createElement("canvas");
|
||||
native.width = width;
|
||||
native.height = height;
|
||||
const nativeCtx = native.getContext("2d", { willReadFrequently: true })!;
|
||||
nativeCtx.drawImage(source, 0, 0);
|
||||
const { data } = nativeCtx.getImageData(0, 0, width, height);
|
||||
const box = detectContentBox(width, height, data) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = CANONICAL_WIDTH;
|
||||
canvas.height = CANONICAL_HEIGHT;
|
||||
canvas
|
||||
.getContext("2d")!
|
||||
.drawImage(
|
||||
native,
|
||||
box.x,
|
||||
box.y,
|
||||
box.w,
|
||||
box.h,
|
||||
0,
|
||||
0,
|
||||
CANONICAL_WIDTH,
|
||||
CANONICAL_HEIGHT,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
@@ -8,3 +8,93 @@ export interface Roi {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Mean brightness an edge row/column stays under to count as bar; true black sits at 0-5, video-range black at 16. */
|
||||
const BORDER_LINE_MAX_MEAN = 20;
|
||||
/** A bar's lines all sit at one level; the Recent Battles screen's scanline-textured edge is dark but hops several levels line to line. */
|
||||
const BORDER_LEVEL_TOLERANCE = 2;
|
||||
/** Shallower dark runs are UI, not bars (that same scanline edge runs 1-5px deep at any resolution). */
|
||||
const BORDER_MIN_FRACTION = 0.01;
|
||||
/** A dark edge deeper than this fraction of the dimension is a fade-to-black or a black screen, not a frame around the game. */
|
||||
const BORDER_MAX_FRACTION = 0.25;
|
||||
/** The game always renders 16:9: a cropped box further off than this is dark scenery, so the frame is left alone. */
|
||||
const CONTENT_ASPECT_TOLERANCE = 0.01;
|
||||
|
||||
/**
|
||||
* Finds the game picture inside a capture padded with black bars (letterbox,
|
||||
* pillarbox, or an OBS scene drawing the source slightly smaller than the
|
||||
* canvas — 1920x1080 with a 16px/29px frame of black seen live). Walks each
|
||||
* edge inward while the whole row/column stays dark at the first line's
|
||||
* level, keeps a side only when the run is deep enough to be a bar, and
|
||||
* accepts the box only when the remaining picture is 16:9 and no bar is
|
||||
* absurdly deep. Returns null when there is nothing to crop. `rgba` is the
|
||||
* frame's pixel data (ImageData layout); cost is proportional to the bars'
|
||||
* depth, since a lit outermost line ends the walk at once.
|
||||
*/
|
||||
export function detectContentBox(
|
||||
width: number,
|
||||
height: number,
|
||||
rgba: Uint8Array | Uint8ClampedArray,
|
||||
): Roi | null {
|
||||
const rowMean = (y: number) => {
|
||||
let sum = 0;
|
||||
const end = (y * width + width) * 4;
|
||||
for (let i = y * width * 4; i < end; i += 4) {
|
||||
sum += rgba[i]! + rgba[i + 1]! + rgba[i + 2]!;
|
||||
}
|
||||
return sum / (width * 3);
|
||||
};
|
||||
const colMean = (x: number) => {
|
||||
let sum = 0;
|
||||
for (let i = x * 4; i < rgba.length; i += width * 4) {
|
||||
sum += rgba[i]! + rgba[i + 1]! + rgba[i + 2]!;
|
||||
}
|
||||
return sum / (height * 3);
|
||||
};
|
||||
const barDepth = (
|
||||
lineMean: (line: number) => number,
|
||||
lineAt: (depth: number) => number,
|
||||
maxDepth: number,
|
||||
minDepth: number,
|
||||
): number => {
|
||||
const level = lineMean(lineAt(0));
|
||||
if (level > BORDER_LINE_MAX_MEAN) return 0;
|
||||
let depth = 1;
|
||||
while (
|
||||
depth < maxDepth &&
|
||||
Math.abs(lineMean(lineAt(depth)) - level) <= BORDER_LEVEL_TOLERANCE
|
||||
) {
|
||||
depth++;
|
||||
}
|
||||
return depth >= minDepth ? depth : 0;
|
||||
};
|
||||
const maxDepthY = Math.floor(height * BORDER_MAX_FRACTION);
|
||||
const maxDepthX = Math.floor(width * BORDER_MAX_FRACTION);
|
||||
const minDepthY = Math.ceil(height * BORDER_MIN_FRACTION);
|
||||
const minDepthX = Math.ceil(width * BORDER_MIN_FRACTION);
|
||||
|
||||
const top = barDepth(rowMean, (d) => d, maxDepthY, minDepthY);
|
||||
const bottom = barDepth(rowMean, (d) => height - 1 - d, maxDepthY, minDepthY);
|
||||
const left = barDepth(colMean, (d) => d, maxDepthX, minDepthX);
|
||||
const right = barDepth(colMean, (d) => width - 1 - d, maxDepthX, minDepthX);
|
||||
|
||||
if (top + bottom + left + right === 0) return null;
|
||||
if (
|
||||
top === maxDepthY ||
|
||||
bottom === maxDepthY ||
|
||||
left === maxDepthX ||
|
||||
right === maxDepthX
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const box = {
|
||||
x: left,
|
||||
y: top,
|
||||
w: width - left - right,
|
||||
h: height - top - bottom,
|
||||
};
|
||||
const canonicalAspect = CANONICAL_WIDTH / CANONICAL_HEIGHT;
|
||||
const aspectError =
|
||||
Math.abs(box.w / box.h - canonicalAspect) / canonicalAspect;
|
||||
return aspectError <= CONTENT_ASPECT_TOLERANCE ? box : null;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import type { DetectedEvent, Detector, GateResult } from "../types";
|
||||
import {
|
||||
badgeRoi,
|
||||
CARD_LAYOUTS,
|
||||
type CardSlot,
|
||||
CROSS_MIN_FRACTION,
|
||||
CROSS_MIN_LAPLACIAN,
|
||||
CROSS_SATURATION_MIN,
|
||||
@@ -61,6 +60,8 @@ import {
|
||||
GATE_SPAWN_DARK_PROBES,
|
||||
GATE_SPECTATOR_X_BRIGHT,
|
||||
GATE_SPECTATOR_X_DARK,
|
||||
GATE_SPECTATOR_X_MIRRORED_BRIGHT,
|
||||
GATE_SPECTATOR_X_MIRRORED_DARK,
|
||||
MINIMAP_ABILITY_INK_THRESHOLD,
|
||||
MINIMAP_WEAPON_INK_THRESHOLD,
|
||||
NAME_BIN_THRESHOLD,
|
||||
@@ -72,7 +73,6 @@ import {
|
||||
SPECIAL_READY_WEAPON_MIN_SCORE,
|
||||
SPECTATOR_ENEMY_DX,
|
||||
SPECTATOR_NAME_TEXT_HEIGHTS,
|
||||
SPECTATOR_SLOTS,
|
||||
spectatorCardLayout,
|
||||
WEAPON_BLEED_MIN_CORNER_MEAN,
|
||||
WEAPON_MIN_SCORE,
|
||||
@@ -80,8 +80,8 @@ import {
|
||||
import { matchStage, plannerSignature, type StageMatch } from "./stage";
|
||||
|
||||
export interface MinimapTeammate {
|
||||
/** which callout card: super-jump slot, or the POV player's own card */
|
||||
slot: CardSlot;
|
||||
/** the POV player's own card (bottom-left on the overlay); never on the spectator screen */
|
||||
self: boolean;
|
||||
/** card name; null when covered by a respawn cross-out or unreadable */
|
||||
name: string | null;
|
||||
/** sendou main-weapon id; null when unreadable/covered */
|
||||
@@ -114,7 +114,7 @@ export interface MinimapData {
|
||||
* column reported as teammates, bravo (right) as enemy rows, both with names
|
||||
*/
|
||||
spectator: boolean;
|
||||
/** own-team callout cards; a slot missing from the frame is omitted */
|
||||
/** own-team callout cards in drawn order; a card missing from the frame is omitted */
|
||||
teammates: MinimapTeammate[];
|
||||
/** enemy panel rows, top to bottom */
|
||||
enemies: MinimapEnemy[];
|
||||
@@ -264,9 +264,20 @@ export function createMinimapDetector(
|
||||
);
|
||||
}
|
||||
|
||||
/** Spectator screen: the X jump-button disc beside the 8th player card. */
|
||||
/** Spectator screen: the X jump-button disc beside the 8th player card, in whichever column carries the face buttons. */
|
||||
function spectatorGate(gray: Mat): GateResult {
|
||||
return probeGate(gray, GATE_SPECTATOR_X_DARK, GATE_SPECTATOR_X_BRIGHT);
|
||||
const right = probeGate(
|
||||
gray,
|
||||
GATE_SPECTATOR_X_DARK,
|
||||
GATE_SPECTATOR_X_BRIGHT,
|
||||
);
|
||||
if (right.pass) return right;
|
||||
const left = probeGate(
|
||||
gray,
|
||||
GATE_SPECTATOR_X_MIRRORED_DARK,
|
||||
GATE_SPECTATOR_X_MIRRORED_BRIGHT,
|
||||
);
|
||||
return left.score > right.score ? left : right;
|
||||
}
|
||||
|
||||
function gate(frame: Mat): GateResult {
|
||||
@@ -395,6 +406,7 @@ export function createMinimapDetector(
|
||||
const sideSubTiles: [Roi[], Roi[]] = [[], []];
|
||||
const cardDebug: Record<string, unknown>[] = [];
|
||||
for (const dx of [0, SPECTATOR_ENEMY_DX]) {
|
||||
const isTeammate = dx === 0;
|
||||
for (let row = 0; row < 4; row++) {
|
||||
const layout = spectatorCardLayout(row, dx);
|
||||
const presence = meanBrightness(lap, layout.name);
|
||||
@@ -402,7 +414,7 @@ export function createMinimapDetector(
|
||||
cardDebug.push({ dx, row, presence, skipped: true });
|
||||
continue;
|
||||
}
|
||||
sideSubTiles[dx === 0 ? 0 : 1].push(layout.subTile);
|
||||
sideSubTiles[isTeammate ? 0 : 1].push(layout.subTile);
|
||||
const crossFraction = saturatedFraction(hsv, layout.cross);
|
||||
const crossLap = meanBrightness(lap, layout.cross);
|
||||
const occluded =
|
||||
@@ -469,8 +481,8 @@ export function createMinimapDetector(
|
||||
dead: occluded,
|
||||
specialReady: lightSurface,
|
||||
};
|
||||
if (dx === 0) {
|
||||
teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields });
|
||||
if (isTeammate) {
|
||||
teammates.push({ self: false, ...fields });
|
||||
} else {
|
||||
enemies.push(fields);
|
||||
}
|
||||
@@ -543,10 +555,10 @@ export function createMinimapDetector(
|
||||
const sideSubTiles: [Roi[], Roi[]] = [[], []];
|
||||
const cardDebug: Record<string, unknown>[] = [];
|
||||
for (const layout of CARD_LAYOUTS) {
|
||||
// presence: the card is crisp UI, absent slots show blurred scene
|
||||
// presence: the card is crisp UI, an absent card shows blurred scene
|
||||
const presence = meanBrightness(lap, layout.name);
|
||||
if (presence < PRESENCE_MIN_LAPLACIAN) {
|
||||
cardDebug.push({ slot: layout.slot, presence, skipped: true });
|
||||
cardDebug.push({ self: layout.self, presence, skipped: true });
|
||||
continue;
|
||||
}
|
||||
const crossFraction = saturatedFraction(hsv, layout.cross);
|
||||
@@ -599,7 +611,7 @@ export function createMinimapDetector(
|
||||
);
|
||||
}
|
||||
cardDebug.push({
|
||||
slot: layout.slot,
|
||||
self: layout.self,
|
||||
presence,
|
||||
crossFraction,
|
||||
crossLap,
|
||||
@@ -623,7 +635,7 @@ export function createMinimapDetector(
|
||||
if (!hasEvidence) continue;
|
||||
sideSubTiles[0].push(layout.subTile);
|
||||
teammates.push({
|
||||
slot: layout.slot,
|
||||
self: layout.self,
|
||||
name,
|
||||
weaponId: matched ? toMainWeaponId(matched.id) : null,
|
||||
abilities,
|
||||
|
||||
@@ -8,12 +8,9 @@
|
||||
*/
|
||||
import type { Roi } from "../../canonical";
|
||||
|
||||
/** "self" and "down" never coexist: the POV overlay has no down slot (the
|
||||
* player's own card replaces it), the spectator grid has no self card. */
|
||||
export type CardSlot = "up" | "left" | "right" | "self" | "down";
|
||||
|
||||
export interface CardLayout {
|
||||
slot: CardSlot;
|
||||
/** the POV player's own card (bottom-left, replacing the down jump slot); the spectator grid has none */
|
||||
self: boolean;
|
||||
/** name text band (BlitzMain caps ~29px plus outline/descender margin) */
|
||||
name: Roi;
|
||||
/** main-weapon silhouette box (icons render ~31-48px tall) */
|
||||
@@ -32,7 +29,7 @@ export interface CardLayout {
|
||||
*/
|
||||
export const CARD_LAYOUTS: readonly CardLayout[] = [
|
||||
{
|
||||
slot: "up",
|
||||
self: false,
|
||||
name: { x: 872, y: 46, w: 300, h: 44 },
|
||||
weapon: { x: 860, y: 83, w: 84, h: 54 },
|
||||
subTile: { x: 932, y: 98, w: 38, h: 41 },
|
||||
@@ -44,7 +41,7 @@ export const CARD_LAYOUTS: readonly CardLayout[] = [
|
||||
cross: { x: 925, y: 78, w: 60, h: 32 },
|
||||
},
|
||||
{
|
||||
slot: "left",
|
||||
self: false,
|
||||
name: { x: 198, y: 492, w: 300, h: 44 },
|
||||
weapon: { x: 193, y: 529, w: 84, h: 54 },
|
||||
subTile: { x: 265, y: 544, w: 38, h: 41 },
|
||||
@@ -56,7 +53,7 @@ export const CARD_LAYOUTS: readonly CardLayout[] = [
|
||||
cross: { x: 258, y: 524, w: 60, h: 32 },
|
||||
},
|
||||
{
|
||||
slot: "right",
|
||||
self: false,
|
||||
name: { x: 1550, y: 492, w: 300, h: 44 },
|
||||
weapon: { x: 1545, y: 529, w: 84, h: 54 },
|
||||
subTile: { x: 1617, y: 544, w: 38, h: 41 },
|
||||
@@ -68,7 +65,7 @@ export const CARD_LAYOUTS: readonly CardLayout[] = [
|
||||
cross: { x: 1610, y: 524, w: 60, h: 32 },
|
||||
},
|
||||
{
|
||||
slot: "self",
|
||||
self: true,
|
||||
name: { x: 126, y: 942, w: 300, h: 46 },
|
||||
weapon: { x: 118, y: 985, w: 94, h: 55 },
|
||||
subTile: { x: 193, y: 995, w: 38, h: 36 },
|
||||
@@ -197,7 +194,10 @@ export const GATE_BRIGHT_MIN_MAX = 210;
|
||||
/**
|
||||
* Spectator gate: casts often cover the overlay's corner chrome, so gate on the
|
||||
* X jump-button disc beside the 8th card (center (1424,712) ±4px). Measured
|
||||
* bright>=249 / dark<=65 against the shared 210/85 thresholds.
|
||||
* bright>=249 / dark<=65 against the shared 210/85 thresholds. The button
|
||||
* glyphs also come mirrored — face buttons down the left column, D-pad down
|
||||
* the right — with the card grid unchanged, so the disc is probed one column
|
||||
* over as well (GATE_SPECTATOR_X_MIRRORED_*; bright 255 / dark<=56 there).
|
||||
*/
|
||||
export const GATE_SPECTATOR_X_BRIGHT: readonly Roi[] = [
|
||||
{ x: 1418, y: 706, w: 12, h: 12 },
|
||||
@@ -218,19 +218,20 @@ export const GATE_SPECTATOR_X_DARK: readonly Roi[] = [
|
||||
* left is alpha, right bravo. No struck/special-ready fixture attested yet, so
|
||||
* those probes reuse overlay thresholds untuned.
|
||||
*/
|
||||
export const SPECTATOR_SLOTS: readonly CardSlot[] = [
|
||||
"up",
|
||||
"right",
|
||||
"down",
|
||||
"left",
|
||||
];
|
||||
export const SPECTATOR_ENEMY_DX = 1348;
|
||||
export const GATE_SPECTATOR_X_MIRRORED_BRIGHT: readonly Roi[] =
|
||||
GATE_SPECTATOR_X_BRIGHT.map(mirrorToLeftColumn);
|
||||
export const GATE_SPECTATOR_X_MIRRORED_DARK: readonly Roi[] =
|
||||
GATE_SPECTATOR_X_DARK.map(mirrorToLeftColumn);
|
||||
function mirrorToLeftColumn(roi: Roi): Roi {
|
||||
return { ...roi, x: roi.x - SPECTATOR_ENEMY_DX };
|
||||
}
|
||||
const SPECTATOR_ROW_PITCH = 120;
|
||||
|
||||
export function spectatorCardLayout(
|
||||
row: number,
|
||||
dx: number,
|
||||
): Omit<CardLayout, "slot"> {
|
||||
): Omit<CardLayout, "self"> {
|
||||
const dy = SPECTATOR_ROW_PITCH * row;
|
||||
return {
|
||||
name: { x: 198 + dx, y: 306 + dy, w: 310, h: 44 },
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
* same layout as browser ImageData (Node builds it from @napi-rs/canvas).
|
||||
*/
|
||||
|
||||
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "./canonical";
|
||||
import {
|
||||
CANONICAL_HEIGHT,
|
||||
CANONICAL_WIDTH,
|
||||
detectContentBox,
|
||||
type Roi,
|
||||
} from "./canonical";
|
||||
import { getCV, type Mat, meanOf, minMaxLoc } from "./cv";
|
||||
|
||||
export type { Roi };
|
||||
@@ -21,24 +26,32 @@ export function toMat(frame: FrameData): Mat {
|
||||
return cv.matFromImageData(frame as unknown as ImageData);
|
||||
}
|
||||
|
||||
/** Normalizes any frame to the canonical 1920x1080 RGBA mat all ROI constants assume. New mat; caller owns both. */
|
||||
/**
|
||||
* Normalizes any frame to the canonical 1920x1080 RGBA mat all ROI constants
|
||||
* assume: black bars around the picture are cropped away first
|
||||
* (detectContentBox), then the picture is resized. New mat; caller owns both.
|
||||
* `src` must be continuous (a fresh mat, not a ROI view).
|
||||
*/
|
||||
export function normalizeFrame(src: Mat): Mat {
|
||||
const cv = getCV();
|
||||
const dst = new cv.Mat();
|
||||
if (src.cols === CANONICAL_WIDTH && src.rows === CANONICAL_HEIGHT) {
|
||||
src.copyTo(dst);
|
||||
return dst;
|
||||
const box = detectContentBox(src.cols, src.rows, src.data as Uint8Array);
|
||||
const picture = box ? cropRoi(src, box) : src;
|
||||
if (picture.cols === CANONICAL_WIDTH && picture.rows === CANONICAL_HEIGHT) {
|
||||
picture.copyTo(dst);
|
||||
} else {
|
||||
const interpolation =
|
||||
picture.cols > CANONICAL_WIDTH ? cv.INTER_AREA : cv.INTER_CUBIC;
|
||||
cv.resize(
|
||||
picture,
|
||||
dst,
|
||||
new cv.Size(CANONICAL_WIDTH, CANONICAL_HEIGHT),
|
||||
0,
|
||||
0,
|
||||
interpolation,
|
||||
);
|
||||
}
|
||||
const interpolation =
|
||||
src.cols > CANONICAL_WIDTH ? cv.INTER_AREA : cv.INTER_CUBIC;
|
||||
cv.resize(
|
||||
src,
|
||||
dst,
|
||||
new cv.Size(CANONICAL_WIDTH, CANONICAL_HEIGHT),
|
||||
0,
|
||||
0,
|
||||
interpolation,
|
||||
);
|
||||
if (box) picture.delete();
|
||||
return dst;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ interface ExpectedPlayer {
|
||||
}
|
||||
|
||||
interface ExpectedMinimapTeammate {
|
||||
slot?: "up" | "left" | "right" | "self" | "down";
|
||||
/** the POV player's own card */
|
||||
self?: boolean;
|
||||
name?: string | null;
|
||||
/** informational for the human corrector; tests compare weaponId */
|
||||
weaponLabel?: string | null;
|
||||
@@ -119,7 +120,7 @@ interface ExpectedScoreboard {
|
||||
weaponLabels?: [(string | null)[], (string | null)[]];
|
||||
/** Minimap only: casted 8-player spectator map screen (not parsed yet) */
|
||||
spectator?: boolean;
|
||||
/** Minimap only: own-team callout cards in slot order */
|
||||
/** Minimap only: own-team callout cards in drawn order */
|
||||
teammates?: ExpectedMinimapTeammate[];
|
||||
/** Minimap only: enemy panel rows, top to bottom */
|
||||
enemies?: ExpectedMinimapEnemy[];
|
||||
|
||||
@@ -6,36 +6,52 @@
|
||||
"spectator": false,
|
||||
"teammates": [
|
||||
{
|
||||
"slot": "up",
|
||||
"self": false,
|
||||
"name": "Bread-Chan",
|
||||
"abilities": ["LDE", "SCU", "SPU"],
|
||||
"abilities": [
|
||||
"LDE",
|
||||
"SCU",
|
||||
"SPU"
|
||||
],
|
||||
"weaponLabel": "Clawz .96 Gal",
|
||||
"weaponId": 82,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "left",
|
||||
"self": false,
|
||||
"name": "amo",
|
||||
"abilities": ["LDE", "RP", "OS"],
|
||||
"abilities": [
|
||||
"LDE",
|
||||
"RP",
|
||||
"OS"
|
||||
],
|
||||
"weaponLabel": "E-liter 4K",
|
||||
"weaponId": 2030,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "right",
|
||||
"self": false,
|
||||
"name": "mr 2",
|
||||
"abilities": ["CB", "SSU", "SJ"],
|
||||
"abilities": [
|
||||
"CB",
|
||||
"SSU",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": "Splatana Stamper",
|
||||
"weaponId": 8000,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "self",
|
||||
"self": true,
|
||||
"name": "Sendou",
|
||||
"abilities": ["CB", "QR", "SJ"],
|
||||
"abilities": [
|
||||
"CB",
|
||||
"QR",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": "Custom Blaster",
|
||||
"weaponId": 211,
|
||||
"dead": false,
|
||||
@@ -44,28 +60,44 @@
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"abilities": ["CB", "QR", "SJ"],
|
||||
"abilities": [
|
||||
"CB",
|
||||
"QR",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": "Custom Blaster",
|
||||
"weaponId": 211,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"abilities": ["LDE", "RP", "OS"],
|
||||
"abilities": [
|
||||
"LDE",
|
||||
"RP",
|
||||
"OS"
|
||||
],
|
||||
"weaponLabel": "E-liter 4K",
|
||||
"weaponId": 2030,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"abilities": ["LDE", "SCU", "SJ"],
|
||||
"abilities": [
|
||||
"LDE",
|
||||
"SCU",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": "N-ZAP '85",
|
||||
"weaponId": 60,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"abilities": ["OG", "SSU", "SJ"],
|
||||
"abilities": [
|
||||
"OG",
|
||||
"SSU",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": ".52 Gal",
|
||||
"weaponId": 50,
|
||||
"dead": false,
|
||||
@@ -74,7 +106,9 @@
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"skipFields": ["teammates.3.weapon"],
|
||||
"skipFields": [
|
||||
"teammates.3.weapon"
|
||||
],
|
||||
"notes": "Sendou POV, Um'ami Ruins SZ at ~4:55 (t=18 in the 2026-08-11 VoD) — fully rendered POV map screen, everyone alive on both teams. The scan's read off this moment claimed mr 2 (right card) and enemy rows 1–2 splatted, amo holding special, and Sendou on Octobrush — four phantoms in one read, seconds into the match. Teammate abilities transcribed from adjacent clean reads (t=12/t=40/t=42.5) that agree with the card icons. The phantom splats (bright ink bled through the translucent surfaces) are fenced by the cross probe's crispness gate, the phantom special by the camo probe's corner-saturation cap, and the bright-bleed surfaces get a dual template-set match with the camo floor. Sendou's own weapon stays skipped: the self-card render is soft and half-covered by the yellow freshness tag badge, and the blaster family trails Octobrush by 0.006 NCC — no robust margin to assert."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"stage": 3,
|
||||
"teammates": [
|
||||
{
|
||||
"slot": "up",
|
||||
"self": false,
|
||||
"name": "こむぎこをこねたユウ",
|
||||
"abilities": [
|
||||
"CB",
|
||||
@@ -17,7 +17,7 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "left",
|
||||
"self": false,
|
||||
"name": "Snix",
|
||||
"abilities": [
|
||||
"CB",
|
||||
@@ -30,7 +30,7 @@
|
||||
"specialReady": true
|
||||
},
|
||||
{
|
||||
"slot": "right",
|
||||
"self": false,
|
||||
"name": "スライムLv.13",
|
||||
"abilities": [
|
||||
"ISS",
|
||||
@@ -43,7 +43,7 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "self",
|
||||
"self": true,
|
||||
"name": "Sendou",
|
||||
"abilities": [
|
||||
"CB",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"spectator": true,
|
||||
"teammates": [
|
||||
{
|
||||
"slot": "up",
|
||||
"name": "リビア",
|
||||
"abilities": [
|
||||
"SSU",
|
||||
@@ -18,7 +17,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "right",
|
||||
"name": "がらっこ",
|
||||
"abilities": [
|
||||
"LDE",
|
||||
@@ -31,7 +29,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "down",
|
||||
"name": "ーロたべる",
|
||||
"abilities": [
|
||||
"OG",
|
||||
@@ -44,7 +41,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "left",
|
||||
"name": "ももドラグーン",
|
||||
"abilities": [
|
||||
"LDE",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"spectator": true,
|
||||
"teammates": [
|
||||
{
|
||||
"slot": "up",
|
||||
"name": "ももドラグーン",
|
||||
"abilities": [
|
||||
"LDE",
|
||||
@@ -18,7 +17,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "right",
|
||||
"name": "れいまるがんばれや!",
|
||||
"abilities": [
|
||||
"SCU",
|
||||
@@ -31,7 +29,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "down",
|
||||
"name": "ひまじん",
|
||||
"abilities": [
|
||||
"OG",
|
||||
@@ -44,7 +41,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "left",
|
||||
"name": "!のりしお!",
|
||||
"abilities": [
|
||||
"CB",
|
||||
|
||||
114
app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json
vendored
Normal file
114
app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"event": "Minimap",
|
||||
"data": {
|
||||
"stage": 21,
|
||||
"stageLabel": "Robo ROM-en",
|
||||
"spectator": true,
|
||||
"teammates": [
|
||||
{
|
||||
"name": "mukα",
|
||||
"abilities": [
|
||||
"LDE",
|
||||
"SSU",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": "Forge Splattershot Pro",
|
||||
"weaponId": 71,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": "BoogasWife",
|
||||
"abilities": [
|
||||
"CB",
|
||||
"RSU",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": "Mint Decavitator",
|
||||
"weaponId": 8020,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": "Waltsu",
|
||||
"abilities": [
|
||||
"CB",
|
||||
"SSU",
|
||||
"ISS"
|
||||
],
|
||||
"weaponLabel": "Order Splatana Replica",
|
||||
"weaponId": 8005,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": "Patfromwii",
|
||||
"abilities": [
|
||||
"ISS",
|
||||
"ISS",
|
||||
"ISS"
|
||||
],
|
||||
"weaponLabel": "Custom Splattershot Jr.",
|
||||
"weaponId": 11,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
}
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"name": "TUNAROLL",
|
||||
"abilities": [
|
||||
"CB",
|
||||
"ISM",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": "Gold Dynamo Roller",
|
||||
"weaponId": 1021,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": "vaporeon ★",
|
||||
"abilities": [
|
||||
"OG",
|
||||
"SSU",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": ".52 Gal",
|
||||
"weaponId": 50,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": ">_<",
|
||||
"abilities": [
|
||||
"ISM",
|
||||
"RP",
|
||||
"OS"
|
||||
],
|
||||
"weaponLabel": "E-liter 4K",
|
||||
"weaponId": 2030,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": "ANGEL☆CARE",
|
||||
"abilities": [
|
||||
"SCU",
|
||||
"SSU",
|
||||
"SJ"
|
||||
],
|
||||
"weaponLabel": "Splash-o-matic",
|
||||
"weaponId": 20,
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"skipFields": [
|
||||
"enemies.2.name"
|
||||
],
|
||||
"notes": "8-player spectator map screen, Robo ROM-en, captured as a 1920x1080 screenshot with the game drawn ~97% size inside a black frame (16px top/bottom, 29px left/right) — the case that added black-bar cropping to frame normalization: untouched, every ROI lands ~30px off and no gate fires. Also the first frame with the button glyphs mirrored: A/B/Y/X face buttons down the left column, D-pad down the right (every earlier spectator fixture has the reverse), so the spectator gate probes the X jump-button disc in either column. The card grid is unchanged and the left column is reported as teammates like the other spectator fixtures; whether the mirrored buttons say anything about which team is alpha is unverified. Labels transcribed from the visible cards. enemies.2.name skipped: >_< reads >ー< — the underscore is matched as a long dash at this name height."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -5,7 +5,6 @@
|
||||
"spectator": true,
|
||||
"teammates": [
|
||||
{
|
||||
"slot": "up",
|
||||
"name": "kyutpie<3",
|
||||
"abilities": [
|
||||
"SSU",
|
||||
@@ -18,7 +17,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "right",
|
||||
"name": "Grey",
|
||||
"abilities": [
|
||||
"LDE",
|
||||
@@ -31,7 +29,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "down",
|
||||
"name": "JORDANYAGI",
|
||||
"abilities": [
|
||||
"CB",
|
||||
@@ -44,7 +41,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "left",
|
||||
"name": "f(x)= x²-8",
|
||||
"abilities": [
|
||||
"OG",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"spectator": true,
|
||||
"teammates": [
|
||||
{
|
||||
"slot": "up",
|
||||
"name": "King Inate",
|
||||
"abilities": [
|
||||
"LDE",
|
||||
@@ -18,7 +17,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "right",
|
||||
"name": "todo",
|
||||
"abilities": [
|
||||
"CB",
|
||||
@@ -31,7 +29,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "down",
|
||||
"name": "tanaha",
|
||||
"abilities": [
|
||||
"RSU",
|
||||
@@ -44,7 +41,6 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "left",
|
||||
"name": "soph",
|
||||
"abilities": [
|
||||
"RSU",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"stage": 6,
|
||||
"teammates": [
|
||||
{
|
||||
"slot": "up",
|
||||
"self": false,
|
||||
"name": "STONKS",
|
||||
"abilities": [
|
||||
"IRU",
|
||||
@@ -17,7 +17,7 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "left",
|
||||
"self": false,
|
||||
"name": "Player",
|
||||
"abilities": [
|
||||
"IRU",
|
||||
@@ -30,7 +30,7 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "right",
|
||||
"self": false,
|
||||
"name": null,
|
||||
"abilities": [],
|
||||
"weaponLabel": null,
|
||||
@@ -39,7 +39,7 @@
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"slot": "self",
|
||||
"self": true,
|
||||
"name": "Edd",
|
||||
"abilities": [
|
||||
"IRU",
|
||||
|
||||
104
app/features/scanner/tests/logic/content-box.test.ts
Normal file
104
app/features/scanner/tests/logic/content-box.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for the black-bar crop that precedes frame normalization: which
|
||||
* captures count as a bordered game picture, and which dark edges are the
|
||||
* game's own UI and must stay untouched.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { detectContentBox, type Roi } from "../../core/canonical";
|
||||
import { test } from "../node-test-compat";
|
||||
|
||||
interface Band {
|
||||
rows?: (y: number) => number;
|
||||
cols?: (x: number) => number;
|
||||
}
|
||||
|
||||
/** RGBA frame of `fill` grey with everything outside `picture` painted `barLevel`; `band` overrides per-row/column levels. */
|
||||
function frame(
|
||||
width: number,
|
||||
height: number,
|
||||
picture: Roi | null,
|
||||
{
|
||||
fill = 120,
|
||||
barLevel = 0,
|
||||
band,
|
||||
}: { fill?: number; barLevel?: number; band?: Band } = {},
|
||||
): Uint8ClampedArray {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const inside =
|
||||
picture !== null &&
|
||||
x >= picture.x &&
|
||||
x < picture.x + picture.w &&
|
||||
y >= picture.y &&
|
||||
y < picture.y + picture.h;
|
||||
let level = inside ? fill : barLevel;
|
||||
if (band?.rows && !inside) level = band.rows(y);
|
||||
if (band?.cols && !inside) level = band.cols(x);
|
||||
const i = (y * width + x) * 4;
|
||||
data[i] = level;
|
||||
data[i + 1] = level;
|
||||
data[i + 2] = level;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
test("a bar-less frame is left alone", () => {
|
||||
assert.equal(
|
||||
detectContentBox(
|
||||
1920,
|
||||
1080,
|
||||
frame(1920, 1080, { x: 0, y: 0, w: 1920, h: 1080 }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("a picture drawn smaller than its 1920x1080 canvas is found inside the black frame", () => {
|
||||
const picture = { x: 28, y: 16, w: 1864, h: 1048 };
|
||||
assert.deepEqual(
|
||||
detectContentBox(1920, 1080, frame(1920, 1080, picture)),
|
||||
picture,
|
||||
);
|
||||
});
|
||||
|
||||
test("a letterboxed 4:3 capture yields the 16:9 picture", () => {
|
||||
const picture = { x: 0, y: 135, w: 1440, h: 810 };
|
||||
assert.deepEqual(
|
||||
detectContentBox(1440, 1080, frame(1440, 1080, picture)),
|
||||
picture,
|
||||
);
|
||||
});
|
||||
|
||||
test("video-range black bars count as bars", () => {
|
||||
const picture = { x: 28, y: 16, w: 1864, h: 1048 };
|
||||
assert.deepEqual(
|
||||
detectContentBox(1920, 1080, frame(1920, 1080, picture, { barLevel: 16 })),
|
||||
picture,
|
||||
);
|
||||
});
|
||||
|
||||
test("a black screen is not cropped", () => {
|
||||
assert.equal(detectContentBox(1920, 1080, frame(1920, 1080, null)), null);
|
||||
});
|
||||
|
||||
test("a shallow dark edge is UI, not a bar", () => {
|
||||
const picture = { x: 0, y: 5, w: 1920, h: 1075 };
|
||||
assert.equal(detectContentBox(1920, 1080, frame(1920, 1080, picture)), null);
|
||||
});
|
||||
|
||||
test("a dark edge that breaks the 16:9 picture is scenery, not a bar", () => {
|
||||
const picture = { x: 0, y: 100, w: 1920, h: 980 };
|
||||
assert.equal(detectContentBox(1920, 1080, frame(1920, 1080, picture)), null);
|
||||
});
|
||||
|
||||
test("a scanline-textured dark edge is not level enough to be a bar", () => {
|
||||
const picture = { x: 0, y: 12, w: 1920, h: 1068 };
|
||||
const scanlines = frame(1920, 1080, picture, {
|
||||
band: { rows: (y) => (y % 2 === 0 ? 1 : 12) },
|
||||
});
|
||||
assert.equal(detectContentBox(1920, 1080, scanlines), null);
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
MinimapEnemy,
|
||||
MinimapTeammate,
|
||||
} from "../../core/detectors/minimap/index";
|
||||
import { SPECTATOR_SLOTS } from "../../core/detectors/minimap/rois";
|
||||
import type { DetectedEvent } from "../../core/detectors/types";
|
||||
import { test } from "../node-test-compat";
|
||||
|
||||
@@ -20,7 +19,6 @@ const BRAVO: MainWeaponId[] = [50, 210, 4010, 8000];
|
||||
|
||||
function teammate(
|
||||
weaponId: MainWeaponId | null,
|
||||
i: number,
|
||||
{
|
||||
name = null as string | null,
|
||||
abilities = [] as (AbilityWithUnknown | null)[],
|
||||
@@ -28,7 +26,7 @@ function teammate(
|
||||
} = {},
|
||||
): MinimapTeammate {
|
||||
return {
|
||||
slot: SPECTATOR_SLOTS[i]!,
|
||||
self: false,
|
||||
name,
|
||||
weaponId,
|
||||
abilities,
|
||||
@@ -48,7 +46,7 @@ function minimap(
|
||||
t: number,
|
||||
{
|
||||
stage = 0 as StageId | null,
|
||||
teammates = ALPHA.map((id, i) => teammate(id, i)),
|
||||
teammates = ALPHA.map((id) => teammate(id)),
|
||||
enemies = BRAVO.map((id) => enemy(id)),
|
||||
} = {},
|
||||
): DetectedEvent {
|
||||
@@ -91,7 +89,7 @@ test("a changed ability read keeps both minimaps", () => {
|
||||
minimap(70),
|
||||
minimap(73, {
|
||||
teammates: ALPHA.map((id, i) =>
|
||||
teammate(id, i, { abilities: i === 0 ? ["ISM"] : [] }),
|
||||
teammate(id, { abilities: i === 0 ? ["ISM"] : [] }),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
@@ -102,7 +100,7 @@ test("a changed dead state keeps both minimaps", () => {
|
||||
const kept = withoutRepeatEvents([
|
||||
minimap(70),
|
||||
minimap(73, {
|
||||
teammates: ALPHA.map((id, i) => teammate(id, i, { dead: i === 0 })),
|
||||
teammates: ALPHA.map((id, i) => teammate(id, { dead: i === 0 })),
|
||||
}),
|
||||
]);
|
||||
assert.equal(kept.length, 2);
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
MinimapEnemy,
|
||||
MinimapTeammate,
|
||||
} from "../../core/detectors/minimap/index";
|
||||
import { SPECTATOR_SLOTS } from "../../core/detectors/minimap/rois";
|
||||
import type { ObjectiveData } from "../../core/detectors/objective/index";
|
||||
import type { PlayerStatusData } from "../../core/detectors/objective/player-status";
|
||||
import type { StripWeaponsData } from "../../core/detectors/objective/strip-weapons";
|
||||
@@ -135,9 +134,9 @@ function battleLogScoreboard(
|
||||
return { type: "ScoreboardBattleLog", t, confidence: 0.9, data };
|
||||
}
|
||||
|
||||
function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate {
|
||||
function teammate(weaponId: MainWeaponId | null): MinimapTeammate {
|
||||
return {
|
||||
slot: SPECTATOR_SLOTS[i]!,
|
||||
self: false,
|
||||
name: null,
|
||||
weaponId,
|
||||
abilities: [],
|
||||
@@ -172,7 +171,7 @@ function minimap(
|
||||
stage,
|
||||
spectator,
|
||||
teammates: alpha.map((id, i) => ({
|
||||
...teammate(id, i),
|
||||
...teammate(id),
|
||||
dead: dead[0].includes(i),
|
||||
specialReady: specialReady[0].includes(i),
|
||||
})),
|
||||
@@ -1276,10 +1275,10 @@ test("minimap enemy-card weapons vote the strip assignment too", () => {
|
||||
|
||||
test("pov diamond cards map to scoreboard rows by name", () => {
|
||||
const cards = [
|
||||
{ ...teammate(ALPHA[1]!, 0), name: "w2", dead: true },
|
||||
{ ...teammate(ALPHA[0]!, 1), name: "w1" },
|
||||
{ ...teammate(ALPHA[3]!, 2), name: "w4" },
|
||||
{ ...teammate(ALPHA[2]!, 3), name: "w3" },
|
||||
{ ...teammate(ALPHA[1]!), name: "w2", dead: true },
|
||||
{ ...teammate(ALPHA[0]!), name: "w1" },
|
||||
{ ...teammate(ALPHA[3]!), name: "w4" },
|
||||
{ ...teammate(ALPHA[2]!), name: "w3" },
|
||||
];
|
||||
const data: MinimapData = {
|
||||
stage: 0 as StageId,
|
||||
|
||||
@@ -94,7 +94,7 @@ for (const fixture of fixtures) {
|
||||
|
||||
for (const [i, want] of (expected.teammates ?? []).entries()) {
|
||||
await t.test(
|
||||
`teammate ${i} (${want.slot ?? "?"})`,
|
||||
`teammate ${i}`,
|
||||
{ skip: skip(fixture, `teammates.${i}`) },
|
||||
() => {
|
||||
const got = event.data.teammates[i];
|
||||
@@ -105,7 +105,7 @@ for (const fixture of fixtures) {
|
||||
const cardDebug = JSON.stringify(
|
||||
(event.debug?.cards as unknown[])?.[i],
|
||||
);
|
||||
if (want.slot !== undefined) assert.equal(got.slot, want.slot);
|
||||
if (want.self !== undefined) assert.equal(got.self, want.self);
|
||||
if (
|
||||
want.name !== undefined &&
|
||||
!isFieldSkipped(fixture, `teammates.${i}.name`)
|
||||
|
||||
@@ -55,10 +55,10 @@ test("weapons are padded to 4 slots per team so uneven rosters keep the team spl
|
||||
const data: MinimapData = {
|
||||
stage: 0,
|
||||
spectator: true,
|
||||
teammates: (["up", "left", "right"] as const).map((slot, i) => ({
|
||||
slot,
|
||||
teammates: ALPHA.slice(0, 3).map((weaponId) => ({
|
||||
self: false,
|
||||
name: null,
|
||||
weaponId: ALPHA[i]!,
|
||||
weaponId,
|
||||
abilities: [],
|
||||
dead: false,
|
||||
specialReady: false,
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
type MinimapEnemy,
|
||||
type MinimapTeammate,
|
||||
} from "../../app/features/scanner/core/detectors/minimap/index";
|
||||
import type { CardSlot } from "../../app/features/scanner/core/detectors/minimap/rois";
|
||||
import {
|
||||
OBJECTIVE_EVENT_TYPE,
|
||||
type ObjectiveData,
|
||||
@@ -95,8 +94,6 @@ const SCOREBOARD_TYPES = new Set([
|
||||
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
|
||||
]);
|
||||
|
||||
const CARD_SLOTS = new Set<CardSlot>(["up", "left", "right", "self", "down"]);
|
||||
|
||||
const MODE_BY_LABEL = new Map<string, ModeShort>(
|
||||
modesShort.map((mode) => [modeLabel(mode) ?? mode, mode]),
|
||||
);
|
||||
@@ -358,8 +355,8 @@ function parseMinimapCell(cell: string, stageCell: string): MinimapData {
|
||||
const rawName = spaceAt === -1 ? "" : head.slice(spaceAt + 1);
|
||||
const name = rawName === "?" || rawName === "" ? null : rawName;
|
||||
const player = { name, weaponId, abilities, dead, specialReady };
|
||||
if (CARD_SLOTS.has(label as CardSlot)) {
|
||||
teammates.push({ slot: label as CardSlot, ...player });
|
||||
if (label === "self" || /^ally[1-4]$/.test(label)) {
|
||||
teammates.push({ self: label === "self", ...player });
|
||||
} else if (/^enemy[1-4]$/.test(label)) {
|
||||
enemies.push(player);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user