Skip to content

Commit 6084e3f

Browse files
committed
Amongi Analyser
1 parent 8bc56de commit 6084e3f

13 files changed

Lines changed: 827 additions & 17 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,7 @@ A JSON file listing all checkpoints with their timestamps, color palette, canvas
7575
- [x] Loading state indicators
7676
- [ ] YouTube-style seekbar drag-up-to-zoom
7777
- [ ] Prefetch checkpoints ahead of playback position
78+
79+
## Special Thanks
80+
81+
- [Woutervdvelde/AmongiAnalyser](https://github.com/Woutervdvelde/AmongiAnalyser) — amongi detection algorithm

app/public/amongus-shake.gif

151 KB
Loading

app/src/amongi-detector/detect.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import type {
2+
DetectedAmongi,
3+
DetectionResult,
4+
PixelPosition,
5+
Template,
6+
VariantType,
7+
} from "./types";
8+
import { VARIANTS } from "./templates";
9+
10+
type UsedMatrix = Record<number, Record<number, true>>;
11+
12+
function getRgb(data: Uint8Array, width: number, x: number, y: number): number {
13+
const offset = (y * width + x) * 3;
14+
return (data[offset] << 16) | (data[offset + 1] << 8) | data[offset + 2];
15+
}
16+
17+
function isInBounds(
18+
x: number,
19+
y: number,
20+
width: number,
21+
height: number
22+
): boolean {
23+
return x >= 0 && x < width && y >= 0 && y < height;
24+
}
25+
26+
interface MatchResult {
27+
positions: PixelPosition[];
28+
bodyColor: number;
29+
completeness: number;
30+
}
31+
32+
const MIN_COMPLETENESS = 0.5;
33+
34+
/** Original fast bail-early exact matching */
35+
function tryExactMatch(
36+
data: Uint8Array,
37+
width: number,
38+
height: number,
39+
template: Template,
40+
startX: number,
41+
startY: number,
42+
used: UsedMatrix,
43+
): MatchResult | null {
44+
if (!isInBounds(startX, startY, width, height)) return null;
45+
46+
const slotColors = new Map<string, number>();
47+
const assignedColors = new Set<number>();
48+
const positions: PixelPosition[] = [];
49+
50+
for (const coord of template.coords) {
51+
const x = startX + coord.x;
52+
const y = startY + coord.y;
53+
54+
if (!isInBounds(x, y, width, height)) return null;
55+
if (used[x]?.[y]) return null;
56+
57+
const color = getRgb(data, width, x, y);
58+
59+
const slot = coord.c;
60+
if (!slotColors.has(slot) && !assignedColors.has(color)) {
61+
slotColors.set(slot, color);
62+
assignedColors.add(color);
63+
}
64+
if (slotColors.get(slot) !== color) return null;
65+
66+
positions.push({ x, y });
67+
}
68+
69+
return { positions, bodyColor: slotColors.get("body")!, completeness: 1.0 };
70+
}
71+
72+
/** Tolerant matching with color voting — allows partial pixel mismatches */
73+
function tryTolerantMatch(
74+
data: Uint8Array,
75+
width: number,
76+
height: number,
77+
template: Template,
78+
startX: number,
79+
startY: number,
80+
used: UsedMatrix,
81+
): MatchResult | null {
82+
if (!isInBounds(startX, startY, width, height)) return null;
83+
84+
// All pixels must be in bounds and unused
85+
for (const coord of template.coords) {
86+
const x = startX + coord.x;
87+
const y = startY + coord.y;
88+
if (!isInBounds(x, y, width, height)) return null;
89+
if (used[x]?.[y]) return null;
90+
}
91+
92+
// Vote on most frequent color per slot
93+
const slotColorCounts = new Map<string, Map<number, number>>();
94+
95+
for (const coord of template.coords) {
96+
const x = startX + coord.x;
97+
const y = startY + coord.y;
98+
const color = getRgb(data, width, x, y);
99+
const slot = coord.c;
100+
101+
let counts = slotColorCounts.get(slot);
102+
if (!counts) {
103+
counts = new Map();
104+
slotColorCounts.set(slot, counts);
105+
}
106+
counts.set(color, (counts.get(color) ?? 0) + 1);
107+
}
108+
109+
const slotColors = new Map<string, number>();
110+
const assignedColors = new Set<number>();
111+
112+
for (const [slot, counts] of slotColorCounts) {
113+
let bestColor = -1;
114+
let bestCount = 0;
115+
for (const [color, count] of counts) {
116+
if (count > bestCount) {
117+
bestColor = color;
118+
bestCount = count;
119+
}
120+
}
121+
if (bestColor === -1) continue;
122+
if (assignedColors.has(bestColor)) return null;
123+
slotColors.set(slot, bestColor);
124+
assignedColors.add(bestColor);
125+
}
126+
127+
if (!slotColors.has("body")) return null;
128+
129+
// Count color matches
130+
const positions: PixelPosition[] = [];
131+
let matched = 0;
132+
133+
for (const coord of template.coords) {
134+
const x = startX + coord.x;
135+
const y = startY + coord.y;
136+
const color = getRgb(data, width, x, y);
137+
138+
if (slotColors.get(coord.c) === color) {
139+
positions.push({ x, y });
140+
matched++;
141+
}
142+
}
143+
144+
const completeness = matched / template.coords.length;
145+
if (completeness < MIN_COMPLETENESS) return null;
146+
147+
return { positions, bodyColor: slotColors.get("body")!, completeness };
148+
}
149+
150+
function markUsed(used: UsedMatrix, positions: PixelPosition[]): void {
151+
for (const { x, y } of positions) {
152+
if (!used[x]) used[x] = {};
153+
used[x][y] = true;
154+
}
155+
}
156+
157+
function calculateCertainty(
158+
data: Uint8Array,
159+
width: number,
160+
height: number,
161+
template: Template,
162+
startX: number,
163+
startY: number,
164+
bodyColor: number
165+
): number {
166+
let total = 0;
167+
let bodyCount = 0;
168+
169+
for (const offset of template.context) {
170+
const x = startX + offset.x;
171+
const y = startY + offset.y;
172+
if (!isInBounds(x, y, width, height)) continue;
173+
174+
if (getRgb(data, width, x, y) === bodyColor) bodyCount++;
175+
total++;
176+
}
177+
178+
if (total === 0) return 0;
179+
return (total - bodyCount) / total;
180+
}
181+
182+
type MatchFn = (
183+
data: Uint8Array,
184+
width: number,
185+
height: number,
186+
template: Template,
187+
startX: number,
188+
startY: number,
189+
used: UsedMatrix,
190+
) => MatchResult | null;
191+
192+
function scanPass(
193+
data: Uint8Array,
194+
width: number,
195+
height: number,
196+
used: UsedMatrix,
197+
result: Record<VariantType, DetectedAmongi[]>,
198+
matchFn: MatchFn,
199+
): void {
200+
for (let y = 0; y < height; y++) {
201+
for (let x = 0; x < width; x++) {
202+
if (used[x]?.[y]) continue;
203+
204+
for (const template of VARIANTS) {
205+
const match = matchFn(data, width, height, template, x, y, used);
206+
if (match === null) continue;
207+
208+
markUsed(used, match.positions);
209+
210+
const certainty = calculateCertainty(
211+
data, width, height, template, x, y, match.bodyColor
212+
);
213+
214+
result[template.type].push({
215+
pixels: match.positions,
216+
certainty,
217+
completeness: match.completeness,
218+
});
219+
break;
220+
}
221+
}
222+
}
223+
}
224+
225+
export function detect(
226+
data: Uint8Array,
227+
width: number,
228+
height: number
229+
): DetectionResult {
230+
const used: UsedMatrix = {};
231+
const result: Record<VariantType, DetectedAmongi[]> = {
232+
short: [],
233+
short_backpack: [],
234+
short_backpack_flipped: [],
235+
short_flipped: [],
236+
short_glasses: [],
237+
short_glasses_flipped: [],
238+
traditional: [],
239+
traditional_backpack: [],
240+
traditional_backpack_flipped: [],
241+
traditional_flipped: [],
242+
traditional_glasses: [],
243+
traditional_glasses_flipped: [],
244+
};
245+
246+
// Pass 1: exact matches (fast bail-early) claim pixels first
247+
scanPass(data, width, height, used, result, tryExactMatch);
248+
// Pass 2: tolerant matches (color voting) on remaining pixels
249+
scanPass(data, width, height, used, result, tryTolerantMatch);
250+
251+
return { amongi: result };
252+
}

0 commit comments

Comments
 (0)