mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-24 11:28:40 -05:00
Fix scanner misreading lowercase g/p in names
This commit is contained in:
@@ -57,6 +57,9 @@ import {
|
||||
gateAbilityProbe,
|
||||
JA_CONST_LINE_ROI,
|
||||
JA_WEAPON_LINE_ROI,
|
||||
REPLAY_HINT_CHIP_MIN_CHANNEL,
|
||||
REPLAY_HINT_CHIP_PROBES,
|
||||
REPLAY_HINT_L_CHIP,
|
||||
SPLAT_LINE1_ROI,
|
||||
SPLAT_TEXT_BIN_THRESHOLD,
|
||||
TAG_NAME_INNER,
|
||||
@@ -260,6 +263,12 @@ export function createDeathDetector(
|
||||
/** Crop the tilted tag, rotate it level, and return the name band crop. */
|
||||
function levelTagInner(rgb: Mat): Mat {
|
||||
const outer = copyRoi(rgb, TAG_NAME_OUTER);
|
||||
const inner = levelOuter(outer, cv.INTER_LINEAR, cv.BORDER_REPLICATE);
|
||||
outer.delete();
|
||||
return inner;
|
||||
}
|
||||
|
||||
function levelOuter(outer: Mat, interpolation: number, border: number): Mat {
|
||||
const center = new cv.Point(outer.cols / 2, outer.rows / 2);
|
||||
const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1);
|
||||
const rotated = new cv.Mat();
|
||||
@@ -268,17 +277,54 @@ export function createDeathDetector(
|
||||
rotated,
|
||||
m,
|
||||
new cv.Size(outer.cols, outer.rows),
|
||||
cv.INTER_LINEAR,
|
||||
cv.BORDER_REPLICATE,
|
||||
interpolation,
|
||||
border,
|
||||
new cv.Scalar(),
|
||||
);
|
||||
m.delete();
|
||||
outer.delete();
|
||||
const inner = copyRoi(rotated, TAG_NAME_INNER);
|
||||
rotated.delete();
|
||||
return inner;
|
||||
}
|
||||
|
||||
function showsReplayHints(rgb: Mat): boolean {
|
||||
return REPLAY_HINT_CHIP_PROBES.every((roi) => {
|
||||
const probe = copyRoi(rgb, roi);
|
||||
const d = probe.data;
|
||||
let min = 255;
|
||||
for (let i = 0; i < probe.rows * probe.cols; i++) {
|
||||
min = Math.min(min, d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!);
|
||||
}
|
||||
probe.delete();
|
||||
return min >= REPLAY_HINT_CHIP_MIN_CHANNEL;
|
||||
});
|
||||
}
|
||||
|
||||
let replayChipMask: Mat | null = null;
|
||||
/** REPLAY_HINT_L_CHIP in the leveled name band's coordinates. */
|
||||
function replayChipMaskInner(): Mat {
|
||||
if (!replayChipMask) {
|
||||
const outer = new cv.Mat(
|
||||
TAG_NAME_OUTER.h,
|
||||
TAG_NAME_OUTER.w,
|
||||
cv.CV_8UC1,
|
||||
new cv.Scalar(0),
|
||||
);
|
||||
const x = REPLAY_HINT_L_CHIP.x - TAG_NAME_OUTER.x;
|
||||
const y = REPLAY_HINT_L_CHIP.y - TAG_NAME_OUTER.y;
|
||||
cv.rectangle(
|
||||
outer,
|
||||
new cv.Point(x, y),
|
||||
new cv.Point(x + REPLAY_HINT_L_CHIP.w, y + REPLAY_HINT_L_CHIP.h),
|
||||
new cv.Scalar(255),
|
||||
-1,
|
||||
);
|
||||
replayChipMask = levelOuter(outer, cv.INTER_NEAREST, cv.BORDER_CONSTANT);
|
||||
outer.delete();
|
||||
}
|
||||
return replayChipMask;
|
||||
}
|
||||
|
||||
const tagMemo: { signature: Uint8Array; read: TagNameRead }[] = [];
|
||||
|
||||
function tagSignature(inner: Mat): Uint8Array {
|
||||
@@ -485,6 +531,7 @@ export function createDeathDetector(
|
||||
inner: Mat,
|
||||
backgrounds: readonly [number, number, number][],
|
||||
spaceGap: number,
|
||||
chipMask: Mat | null,
|
||||
speculative: boolean,
|
||||
): MatchSteps<{
|
||||
parsed: ParsedName;
|
||||
@@ -492,6 +539,7 @@ export function createDeathDetector(
|
||||
textColor: [number, number, number] | null;
|
||||
}> {
|
||||
const band = distanceBand(inner, backgrounds, false);
|
||||
if (chipMask) band.setTo(new cv.Scalar(0), chipMask);
|
||||
cv.normalize(band, band, 0, 255, cv.NORM_MINMAX);
|
||||
clearBorderBlobs(band, TAG_NAME_BIN_THRESHOLD);
|
||||
|
||||
@@ -504,6 +552,7 @@ export function createDeathDetector(
|
||||
if (inkCount >= TAG_NAME_REFINE_MIN_INK) {
|
||||
textColor = medianColor(inner, (i) => ink[i]! > TAG_NAME_BIN_THRESHOLD);
|
||||
refined = distanceBand(inner, [textColor], true);
|
||||
if (chipMask) refined.setTo(new cv.Scalar(0), chipMask);
|
||||
clearBorderBlobs(refined, TAG_NAME_REFINE_BIN_THRESHOLD);
|
||||
}
|
||||
const [bandParse, refinedParse] = yield* all([
|
||||
@@ -542,6 +591,7 @@ export function createDeathDetector(
|
||||
): MatchSteps<{ read: TagNameRead; memoHit: boolean }> {
|
||||
const spaceGap = Math.max(7, Math.round(tagNameGlyphs!.medianWidth * 0.55));
|
||||
const inner = levelTagInner(rgb);
|
||||
const chipMask = showsReplayHints(rgb) ? replayChipMaskInner() : null;
|
||||
const signature = tagSignature(inner);
|
||||
let read = tagMemoLookup(signature);
|
||||
const memoHit = read !== null;
|
||||
@@ -580,7 +630,13 @@ export function createDeathDetector(
|
||||
};
|
||||
const reads = yield* all(
|
||||
candidates.map((backgrounds) =>
|
||||
readWithBackground(inner, backgrounds, spaceGap, speculative),
|
||||
readWithBackground(
|
||||
inner,
|
||||
backgrounds,
|
||||
spaceGap,
|
||||
chipMask,
|
||||
speculative,
|
||||
),
|
||||
),
|
||||
);
|
||||
let best = reads[0]!;
|
||||
|
||||
@@ -77,9 +77,25 @@ export const TAG_TILT_DEG = 3.0;
|
||||
export const TAG_NAME_OUTER: Roi = { x: 1130, y: 770, w: 650, h: 140 };
|
||||
/**
|
||||
* Top edge must clear kana dakuten (y=34 clipped them, misreading こ for ご) yet stay below the title
|
||||
* line (ends y=17).
|
||||
* line (ends y=17); the bottom keeps a 'p' tail at the long name's sagging right end off the border.
|
||||
*/
|
||||
export const TAG_NAME_INNER: Roi = { x: 20, y: 21, w: 610, h: 87 };
|
||||
export const TAG_NAME_INNER: Roi = { x: 20, y: 21, w: 610, h: 91 };
|
||||
|
||||
/**
|
||||
* Replay playback draws button hint chips (R View Graph, L Pause, ...) over the
|
||||
* tag's right end; the L chip reads as a trailing glyph ("カニ Jokera ¬").
|
||||
* Probes: chip faces either side of the R and L letters, white (min channel
|
||||
* 255) on replays, under 200 on every other fixture.
|
||||
*/
|
||||
export const REPLAY_HINT_CHIP_PROBES: readonly Roi[] = [
|
||||
{ x: 1697, y: 752, w: 10, h: 12 },
|
||||
{ x: 1730, y: 752, w: 10, h: 12 },
|
||||
{ x: 1708, y: 809, w: 8, h: 12 },
|
||||
{ x: 1733, y: 809, w: 10, h: 12 },
|
||||
];
|
||||
export const REPLAY_HINT_CHIP_MIN_CHANNEL = 220;
|
||||
/** The L chip with its shadow, blanked out of the tag band when the hints show. */
|
||||
export const REPLAY_HINT_L_CHIP: Roi = { x: 1684, y: 800, w: 74, h: 36 };
|
||||
|
||||
/** Tight cap height of the tag name text (atlas nominal height). */
|
||||
export const TAG_NAME_TEXT_HEIGHT = 46;
|
||||
|
||||
@@ -15,8 +15,8 @@ export const MAX_ROWS = 4;
|
||||
/** Text caps sit at y1004 on the bottom row and y938 on the one above. */
|
||||
const ROW_PITCH = 66;
|
||||
|
||||
/** Bottom row's text band: from the squid icon's right edge to the pill's plain right end, with drift headroom. */
|
||||
const TEXT_ROI_BOTTOM: Roi = { x: 796, y: 997, w: 412, h: 38 };
|
||||
/** Bottom row's text band: from the squid icon's right edge to the pill's plain right end, with drift headroom; descenders reach y1035 ('g' lost its tail at h 38 and read 'A'). */
|
||||
const TEXT_ROI_BOTTOM: Roi = { x: 796, y: 997, w: 412, h: 41 };
|
||||
|
||||
/** Tight cap height of the row text (atlas nominal height). */
|
||||
export const KILL_TEXT_HEIGHT = 24;
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
MINIMAP_ABILITY_INK_THRESHOLD,
|
||||
MINIMAP_WEAPON_INK_THRESHOLD,
|
||||
NAME_BIN_THRESHOLD,
|
||||
NAME_PLAIN_TIE_MARGIN,
|
||||
NAME_TEXT_HEIGHT,
|
||||
PRESENCE_MIN_LAPLACIAN,
|
||||
SPECIAL_READY_INK_THRESHOLD,
|
||||
@@ -425,7 +426,10 @@ export function createMinimapDetector(
|
||||
parseNameSteps(
|
||||
band,
|
||||
set,
|
||||
{ binThreshold: NAME_BIN_THRESHOLD },
|
||||
{
|
||||
binThreshold: NAME_BIN_THRESHOLD,
|
||||
plainTieMargin: NAME_PLAIN_TIE_MARGIN,
|
||||
},
|
||||
speculative,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -106,6 +106,8 @@ export const MINIMAP_ABILITY_INK_THRESHOLD = 90;
|
||||
/** BlitzMain caps measure 28-29px on every card (self included). */
|
||||
export const NAME_TEXT_HEIGHT = 29;
|
||||
export const NAME_BIN_THRESHOLD = 170;
|
||||
/** A fullwidth bracket reads level with its ASCII twin ("[K]yo!" 0.015 apart). */
|
||||
export const NAME_PLAIN_TIE_MARGIN = 0.02;
|
||||
|
||||
/** Cross-out probe: fraction of saturated+bright HSV pixels. Struck 0.26-0.38, clean <=0.01. */
|
||||
export const CROSS_SATURATION_MIN = 110;
|
||||
@@ -244,8 +246,8 @@ export function spectatorCardLayout(
|
||||
const dy = SPECTATOR_ROW_PITCH * row;
|
||||
return {
|
||||
// superscripts and accents reach 2px above the old top (x² read x・); the
|
||||
// card's team stripe ends 4px above this one
|
||||
name: { x: 198 + dx, y: 303 + dy, w: 310, h: 47 },
|
||||
// card's team stripe ends 4px above this one; a 'g' tail reaches 50px down
|
||||
name: { x: 198 + dx, y: 303 + dy, w: 310, h: 51 },
|
||||
weapon: { x: 196 + dx, y: 350 + dy, w: 66, h: 54 },
|
||||
subTile: { x: 264 + dx, y: 354 + dy, w: 38, h: 42 },
|
||||
badges: [
|
||||
|
||||
@@ -135,15 +135,16 @@ function normalizeOhs(name: string): string {
|
||||
|
||||
/**
|
||||
* A long bar is '_' or a dash by height alone, which the templates weigh
|
||||
* lightly: ">_<" read ">ー<". A bar whose bottom reaches the line's baseline
|
||||
* (median ink bottom of the other glyphs) is an underscore; dashes float at
|
||||
* mid height.
|
||||
* lightly: ">_<" read ">ー<", "Jrod_14" read "Jrod¯14". A bar whose bottom
|
||||
* reaches the line's baseline (median ink bottom of the other glyphs) is an
|
||||
* underscore; dashes float at mid height, a macron at the cap line.
|
||||
*/
|
||||
const BASELINE_BAR_CHARS = new Set(["-", "ー", "¯", "_"]);
|
||||
const UNDERSCORE_LOOKALIKES = new Set(["-", "ー", "¯"]);
|
||||
const UNDERSCORE_BASELINE_SLACK_PX = 2;
|
||||
|
||||
function resolveUnderscoreByBaseline(raw: RecognizedText): RecognizedText {
|
||||
if (!raw.chars.some((c) => LONG_BAR_CHARS.has(c.char))) return raw;
|
||||
if (!raw.chars.some((c) => UNDERSCORE_LOOKALIKES.has(c.char))) return raw;
|
||||
const anchors = raw.chars
|
||||
.filter((c) => !BASELINE_BAR_CHARS.has(c.char))
|
||||
.map((c) => c.y1)
|
||||
@@ -151,7 +152,7 @@ function resolveUnderscoreByBaseline(raw: RecognizedText): RecognizedText {
|
||||
if (anchors.length === 0) return raw;
|
||||
const baseline = anchors[Math.floor(anchors.length / 2)]!;
|
||||
const chars = raw.chars.map((c) => {
|
||||
if (!LONG_BAR_CHARS.has(c.char)) return c;
|
||||
if (!UNDERSCORE_LOOKALIKES.has(c.char)) return c;
|
||||
if (baseline - c.y1 > UNDERSCORE_BASELINE_SLACK_PX) return c;
|
||||
return { ...c, char: "_" };
|
||||
});
|
||||
@@ -275,12 +276,14 @@ function resolveBhByBowlFloor(
|
||||
* profile keeps them: an O's top and bottom solid rows sit inset from its
|
||||
* mid-height edge on both sides alike, a D's only on the right. Measured
|
||||
* across fixtures at 15-29px, a D's right inset exceeds its left by 0.15+ of
|
||||
* the glyph height, an O's by under 0.08. Only an O read over a near-tied D
|
||||
* is re-decided: a D read already stands.
|
||||
* the glyph height, an O's by under 0.08. A D read over a near-tied O only
|
||||
* turns O when its corners are near level: a ~15px D can sit at 0.05 ("sDpp"),
|
||||
* the 48px splash tag O of "OguriCap" read D at 0.00.
|
||||
*/
|
||||
const ROUND_CHARS = new Set(["O", "0"]);
|
||||
const DO_SCORE_MARGIN = 0.05;
|
||||
const D_MIN_CORNER_ASYMMETRY = 0.15;
|
||||
const O_MAX_CORNER_ASYMMETRY = 0.025;
|
||||
/** solid rows reach this share of the glyph's brightest pixel */
|
||||
const SOLID_ROW_FRACTION = 0.8;
|
||||
const EDGE_LEVEL = 128;
|
||||
@@ -289,12 +292,14 @@ function resolveDoByCorners(
|
||||
raw: RecognizedText,
|
||||
grayView: Mat,
|
||||
): RecognizedText {
|
||||
const twinOf = (c: RecognizedChar) =>
|
||||
c.candidates?.find(
|
||||
(k) =>
|
||||
(ROUND_CHARS.has(c.char) ? k.char === "D" : ROUND_CHARS.has(k.char)) &&
|
||||
c.score - k.score <= DO_SCORE_MARGIN,
|
||||
);
|
||||
const contested = (c: RecognizedChar) =>
|
||||
ROUND_CHARS.has(c.char) &&
|
||||
(c.candidates?.some(
|
||||
(k) => k.char === "D" && c.score - k.score <= DO_SCORE_MARGIN,
|
||||
) ??
|
||||
false);
|
||||
(ROUND_CHARS.has(c.char) || c.char === "D") && twinOf(c) !== undefined;
|
||||
if (!raw.chars.some(contested)) return raw;
|
||||
|
||||
const gray = new (getCV().Mat)();
|
||||
@@ -302,10 +307,14 @@ function resolveDoByCorners(
|
||||
const { cols, data } = gray;
|
||||
const chars = raw.chars.map((c) => {
|
||||
if (!contested(c)) return c;
|
||||
const asymmetry = cornerAsymmetry(data, cols, c);
|
||||
if (!(asymmetry >= D_MIN_CORNER_ASYMMETRY * (c.y1 - c.y0))) return c;
|
||||
const d = c.candidates!.find((k) => k.char === "D")!;
|
||||
return { ...c, char: "D", score: d.score };
|
||||
const asymmetry = cornerAsymmetry(data, cols, c) / (c.y1 - c.y0);
|
||||
const isD =
|
||||
c.char === "D"
|
||||
? !(asymmetry < O_MAX_CORNER_ASYMMETRY)
|
||||
: asymmetry >= D_MIN_CORNER_ASYMMETRY;
|
||||
if (isD === (c.char === "D")) return c;
|
||||
const twin = twinOf(c)!;
|
||||
return { ...c, char: twin.char, score: twin.score };
|
||||
});
|
||||
gray.delete();
|
||||
return { ...raw, text: retext(raw.text, chars), chars };
|
||||
|
||||
29
app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/expected.json
vendored
Normal file
29
app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/expected.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"event": "Death",
|
||||
"data": {
|
||||
"weaponLabel": "Cometz Octobrush",
|
||||
"weaponId": 1112,
|
||||
"weaponType": "MAIN",
|
||||
"abilities": [
|
||||
[
|
||||
"CB",
|
||||
"SCU",
|
||||
"SPU",
|
||||
"SPU"
|
||||
],
|
||||
[
|
||||
"NS",
|
||||
"SCU",
|
||||
"SS",
|
||||
"SSU"
|
||||
],
|
||||
[
|
||||
"SJ",
|
||||
"QSJ",
|
||||
"RES",
|
||||
"QSJ"
|
||||
]
|
||||
],
|
||||
"name": "カニ Jokera"
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/frame.webp
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/death/kani-jokera-trailing-mark/frame.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
29
app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/expected.json
vendored
Normal file
29
app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/expected.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"event": "Death",
|
||||
"data": {
|
||||
"weaponLabel": "Splattershot Jr.",
|
||||
"weaponId": 10,
|
||||
"weaponType": "MAIN",
|
||||
"abilities": [
|
||||
[
|
||||
"ISS",
|
||||
"QSJ",
|
||||
"RES",
|
||||
"QR"
|
||||
],
|
||||
[
|
||||
"ISS",
|
||||
"ISS",
|
||||
"ISS",
|
||||
"QR"
|
||||
],
|
||||
[
|
||||
"SJ",
|
||||
"SPU",
|
||||
"BRU",
|
||||
"ISS"
|
||||
]
|
||||
],
|
||||
"name": "カニOguriCap"
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/frame.webp
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/death/kani-oguricap-lowercase-g/frame.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
29
app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/expected.json
vendored
Normal file
29
app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/expected.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"event": "Death",
|
||||
"data": {
|
||||
"weaponLabel": "N-ZAP '85",
|
||||
"weaponId": 60,
|
||||
"weaponType": "MAIN",
|
||||
"abilities": [
|
||||
[
|
||||
"RSU",
|
||||
"RSU",
|
||||
"RSU",
|
||||
"RSU"
|
||||
],
|
||||
[
|
||||
"SCU",
|
||||
"SPU",
|
||||
"SPU",
|
||||
"SPU"
|
||||
],
|
||||
[
|
||||
"SJ",
|
||||
"RES",
|
||||
"QSJ",
|
||||
"QSJ"
|
||||
]
|
||||
],
|
||||
"name": "カニ strings"
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/frame.webp
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/death/kani-strings-lowercase-g/frame.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
10
app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/expected.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"event": "Kill",
|
||||
"data": {
|
||||
"time": 256,
|
||||
"names": ["カニOguriCap"]
|
||||
},
|
||||
"options": {
|
||||
"notes": "Frame shared with death/kani-oguricap-lowercase-g (1080p POV, Rainmaker): a trade — the death overlay is up while the feed shows the splat. Lowercase g in the name."
|
||||
}
|
||||
}
|
||||
1
app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/frame.webp
vendored
Symbolic link
1
app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-death-overlay/frame.webp
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../death/kani-oguricap-lowercase-g/frame.webp
|
||||
10
app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/expected.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"event": "Kill",
|
||||
"data": {
|
||||
"time": 258,
|
||||
"names": ["カニOguriCap"]
|
||||
},
|
||||
"options": {
|
||||
"notes": "1080p POV, Rainmaker (timer 4:18). Lowercase g in the name, read as \"A\"; the same row a second earlier read correctly."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/frame.webp
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/kill/pov-kani-oguricap-lowercase-g/frame.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
112
app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/expected.json
vendored
Normal file
112
app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/expected.json
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"event": "Minimap",
|
||||
"data": {
|
||||
"stage": 16,
|
||||
"stageLabel": "Humpback Pump Track",
|
||||
"spectator": true,
|
||||
"teammates": [
|
||||
{
|
||||
"self": false,
|
||||
"name": "Deffy»95",
|
||||
"weaponLabel": "Carbon Roller Deco",
|
||||
"weaponId": 1001,
|
||||
"abilities": [
|
||||
"QSJ",
|
||||
"NS",
|
||||
"SJ"
|
||||
],
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"self": false,
|
||||
"name": "Stance☆»95",
|
||||
"weaponLabel": ".96 Gal",
|
||||
"weaponId": 80,
|
||||
"abilities": [
|
||||
"SSU",
|
||||
"SSU",
|
||||
"IRU"
|
||||
],
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"self": false,
|
||||
"name": "Kei»95",
|
||||
"weaponLabel": "Heavy Edit Splatling",
|
||||
"weaponId": 4050,
|
||||
"abilities": [
|
||||
"LDE",
|
||||
"RSU",
|
||||
"SCU"
|
||||
],
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"self": false,
|
||||
"name": "S&wich»95",
|
||||
"weaponLabel": "Splatana Wiper",
|
||||
"weaponId": 8010,
|
||||
"abilities": [
|
||||
"CB",
|
||||
"QR",
|
||||
"SJ"
|
||||
],
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
}
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"name": "カニ strings",
|
||||
"weaponLabel": "N-ZAP '85",
|
||||
"weaponId": 60,
|
||||
"abilities": [
|
||||
"RSU",
|
||||
"SCU",
|
||||
"SJ"
|
||||
],
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": "カニ glum",
|
||||
"weaponLabel": ".52 Gal",
|
||||
"weaponId": 50,
|
||||
"abilities": [
|
||||
"SSU",
|
||||
"SSU",
|
||||
"SJ"
|
||||
],
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": "カニ Jokera",
|
||||
"weaponLabel": "Cometz Octobrush",
|
||||
"weaponId": 1112,
|
||||
"abilities": [
|
||||
"CB",
|
||||
"NS",
|
||||
"SJ"
|
||||
],
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
},
|
||||
{
|
||||
"name": "カニOguriCap",
|
||||
"weaponLabel": "Splattershot Jr.",
|
||||
"weaponId": 10,
|
||||
"abilities": [
|
||||
"ISS",
|
||||
"ISS",
|
||||
"SJ"
|
||||
],
|
||||
"dead": false,
|
||||
"specialReady": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/frame.webp
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/minimap/spectator-kani-lowercase-g/frame.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
5
changelog/2026-10-03-scanner-name-reads.md
Normal file
5
changelog/2026-10-03-scanner-name-reads.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: scanner
|
||||
type: bug
|
||||
---
|
||||
Scanner reads player names with a lowercase g or p correctly, and no longer adds a stray symbol to names when watching a replay
|
||||
Reference in New Issue
Block a user