gridstamp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursorrules +74 -0
- package/CLAUDE.md +61 -0
- package/LICENSE +190 -0
- package/README.md +107 -0
- package/dist/index.js +194 -0
- package/package.json +84 -0
- package/src/antispoofing/detector.ts +509 -0
- package/src/antispoofing/index.ts +7 -0
- package/src/gamification/badges.ts +429 -0
- package/src/gamification/fleet-leaderboard.ts +293 -0
- package/src/gamification/index.ts +44 -0
- package/src/gamification/streaks.ts +243 -0
- package/src/gamification/trust-tiers.ts +393 -0
- package/src/gamification/zone-mastery.ts +256 -0
- package/src/index.ts +341 -0
- package/src/memory/index.ts +9 -0
- package/src/memory/place-cells.ts +279 -0
- package/src/memory/spatial-memory.ts +375 -0
- package/src/navigation/index.ts +1 -0
- package/src/navigation/pathfinding.ts +403 -0
- package/src/perception/camera.ts +249 -0
- package/src/perception/index.ts +2 -0
- package/src/types/index.ts +416 -0
- package/src/utils/crypto.ts +94 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/math.ts +204 -0
- package/src/verification/index.ts +9 -0
- package/src/verification/spatial-proof.ts +442 -0
- package/tests/antispoofing/detector.test.ts +196 -0
- package/tests/gamification/badges.test.ts +163 -0
- package/tests/gamification/fleet-leaderboard.test.ts +181 -0
- package/tests/gamification/streaks.test.ts +158 -0
- package/tests/gamification/trust-tiers.test.ts +165 -0
- package/tests/gamification/zone-mastery.test.ts +143 -0
- package/tests/memory/place-cells.test.ts +128 -0
- package/tests/stress/load.test.ts +499 -0
- package/tests/stress/security.test.ts +378 -0
- package/tests/stress/simulation.test.ts +361 -0
- package/tests/utils/crypto.test.ts +115 -0
- package/tests/utils/math.test.ts +195 -0
- package/tests/verification/spatial-proof.test.ts +299 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation — A* and RRT* pathfinding in 3D space
|
|
3
|
+
*
|
|
4
|
+
* A*: Optimal for grid-based environments (warehouses, structured spaces)
|
|
5
|
+
* RRT*: Optimal for continuous spaces with complex obstacles (outdoor, unstructured)
|
|
6
|
+
*
|
|
7
|
+
* Both integrate with place cell activations for biologically-plausible navigation.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
Vec3,
|
|
11
|
+
Path,
|
|
12
|
+
Waypoint,
|
|
13
|
+
PathAlgorithm,
|
|
14
|
+
AABB,
|
|
15
|
+
} from '../types/index.js';
|
|
16
|
+
import { vec3Distance, vec3Sub, vec3Add, vec3Scale, vec3Normalize } from '../utils/math.js';
|
|
17
|
+
import { generateNonce } from '../utils/crypto.js';
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// OCCUPANCY GRID (collision checking)
|
|
21
|
+
// ============================================================
|
|
22
|
+
|
|
23
|
+
/** 3D occupancy grid for collision detection */
|
|
24
|
+
export class OccupancyGrid {
|
|
25
|
+
private grid: Uint8Array;
|
|
26
|
+
readonly resolution: number; // meters per cell
|
|
27
|
+
readonly sizeX: number;
|
|
28
|
+
readonly sizeY: number;
|
|
29
|
+
readonly sizeZ: number;
|
|
30
|
+
readonly origin: Vec3;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
bounds: AABB,
|
|
34
|
+
resolution: number = 0.1,
|
|
35
|
+
) {
|
|
36
|
+
this.resolution = resolution;
|
|
37
|
+
this.origin = bounds.min;
|
|
38
|
+
this.sizeX = Math.ceil((bounds.max.x - bounds.min.x) / resolution);
|
|
39
|
+
this.sizeY = Math.ceil((bounds.max.y - bounds.min.y) / resolution);
|
|
40
|
+
this.sizeZ = Math.ceil((bounds.max.z - bounds.min.z) / resolution);
|
|
41
|
+
this.grid = new Uint8Array(this.sizeX * this.sizeY * this.sizeZ);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Convert world position to grid index */
|
|
45
|
+
private toIndex(pos: Vec3): number | null {
|
|
46
|
+
const ix = Math.floor((pos.x - this.origin.x) / this.resolution);
|
|
47
|
+
const iy = Math.floor((pos.y - this.origin.y) / this.resolution);
|
|
48
|
+
const iz = Math.floor((pos.z - this.origin.z) / this.resolution);
|
|
49
|
+
if (ix < 0 || ix >= this.sizeX || iy < 0 || iy >= this.sizeY || iz < 0 || iz >= this.sizeZ) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return ix + iy * this.sizeX + iz * this.sizeX * this.sizeY;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Mark a position as occupied */
|
|
56
|
+
setOccupied(pos: Vec3): void {
|
|
57
|
+
const idx = this.toIndex(pos);
|
|
58
|
+
if (idx !== null) this.grid[idx] = 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Check if a position is free */
|
|
62
|
+
isFree(pos: Vec3): boolean {
|
|
63
|
+
const idx = this.toIndex(pos);
|
|
64
|
+
if (idx === null) return false; // out of bounds = not free
|
|
65
|
+
return this.grid[idx] === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Check if a straight line between two points is collision-free */
|
|
69
|
+
isLineFree(from: Vec3, to: Vec3, stepSize: number = 0.05): boolean {
|
|
70
|
+
const dist = vec3Distance(from, to);
|
|
71
|
+
const steps = Math.ceil(dist / stepSize);
|
|
72
|
+
for (let i = 0; i <= steps; i++) {
|
|
73
|
+
const t = i / steps;
|
|
74
|
+
const point: Vec3 = {
|
|
75
|
+
x: from.x + (to.x - from.x) * t,
|
|
76
|
+
y: from.y + (to.y - from.y) * t,
|
|
77
|
+
z: from.z + (to.z - from.z) * t,
|
|
78
|
+
};
|
|
79
|
+
if (!this.isFree(point)) return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================
|
|
86
|
+
// A* PATHFINDING
|
|
87
|
+
// ============================================================
|
|
88
|
+
|
|
89
|
+
interface AStarNode {
|
|
90
|
+
position: Vec3;
|
|
91
|
+
g: number; // cost from start
|
|
92
|
+
h: number; // heuristic to goal
|
|
93
|
+
f: number; // g + h
|
|
94
|
+
parent: AStarNode | null;
|
|
95
|
+
key: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function positionKey(pos: Vec3, resolution: number): string {
|
|
99
|
+
const x = Math.round(pos.x / resolution);
|
|
100
|
+
const y = Math.round(pos.y / resolution);
|
|
101
|
+
const z = Math.round(pos.z / resolution);
|
|
102
|
+
return `${x},${y},${z}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A* pathfinding in 3D space
|
|
107
|
+
* Uses 26-connected grid (all diagonal neighbors)
|
|
108
|
+
*/
|
|
109
|
+
export function aStarPath(
|
|
110
|
+
start: Vec3,
|
|
111
|
+
goal: Vec3,
|
|
112
|
+
grid: OccupancyGrid,
|
|
113
|
+
maxIterations: number = 100_000,
|
|
114
|
+
): Vec3[] | null {
|
|
115
|
+
const resolution = grid.resolution;
|
|
116
|
+
const startKey = positionKey(start, resolution);
|
|
117
|
+
|
|
118
|
+
const startNode: AStarNode = {
|
|
119
|
+
position: start,
|
|
120
|
+
g: 0,
|
|
121
|
+
h: vec3Distance(start, goal),
|
|
122
|
+
f: vec3Distance(start, goal),
|
|
123
|
+
parent: null,
|
|
124
|
+
key: startKey,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const openSet = new Map<string, AStarNode>();
|
|
128
|
+
const closedSet = new Set<string>();
|
|
129
|
+
openSet.set(startKey, startNode);
|
|
130
|
+
|
|
131
|
+
// 26-connected neighbors (3D)
|
|
132
|
+
const neighbors: Vec3[] = [];
|
|
133
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
134
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
135
|
+
for (let dz = -1; dz <= 1; dz++) {
|
|
136
|
+
if (dx === 0 && dy === 0 && dz === 0) continue;
|
|
137
|
+
neighbors.push({
|
|
138
|
+
x: dx * resolution,
|
|
139
|
+
y: dy * resolution,
|
|
140
|
+
z: dz * resolution,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let iterations = 0;
|
|
147
|
+
while (openSet.size > 0 && iterations < maxIterations) {
|
|
148
|
+
iterations++;
|
|
149
|
+
|
|
150
|
+
// Find node with lowest f in open set
|
|
151
|
+
let current: AStarNode | null = null;
|
|
152
|
+
for (const node of openSet.values()) {
|
|
153
|
+
if (!current || node.f < current.f) {
|
|
154
|
+
current = node;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!current) break;
|
|
158
|
+
|
|
159
|
+
// Check if we reached the goal
|
|
160
|
+
if (vec3Distance(current.position, goal) < resolution * 1.5) {
|
|
161
|
+
return reconstructPath(current);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
openSet.delete(current.key);
|
|
165
|
+
closedSet.add(current.key);
|
|
166
|
+
|
|
167
|
+
// Explore neighbors
|
|
168
|
+
for (const offset of neighbors) {
|
|
169
|
+
const neighborPos = vec3Add(current.position, offset);
|
|
170
|
+
const neighborKey = positionKey(neighborPos, resolution);
|
|
171
|
+
|
|
172
|
+
if (closedSet.has(neighborKey)) continue;
|
|
173
|
+
if (!grid.isFree(neighborPos)) continue;
|
|
174
|
+
|
|
175
|
+
const g = current.g + vec3Distance(current.position, neighborPos);
|
|
176
|
+
const existing = openSet.get(neighborKey);
|
|
177
|
+
|
|
178
|
+
if (!existing || g < existing.g) {
|
|
179
|
+
const h = vec3Distance(neighborPos, goal);
|
|
180
|
+
const node: AStarNode = {
|
|
181
|
+
position: neighborPos,
|
|
182
|
+
g,
|
|
183
|
+
h,
|
|
184
|
+
f: g + h,
|
|
185
|
+
parent: current,
|
|
186
|
+
key: neighborKey,
|
|
187
|
+
};
|
|
188
|
+
openSet.set(neighborKey, node);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return null; // no path found
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function reconstructPath(node: AStarNode): Vec3[] {
|
|
197
|
+
const path: Vec3[] = [];
|
|
198
|
+
let current: AStarNode | null = node;
|
|
199
|
+
while (current) {
|
|
200
|
+
path.unshift(current.position);
|
|
201
|
+
current = current.parent;
|
|
202
|
+
}
|
|
203
|
+
return path;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================
|
|
207
|
+
// RRT* PATHFINDING
|
|
208
|
+
// ============================================================
|
|
209
|
+
|
|
210
|
+
interface RRTNode {
|
|
211
|
+
position: Vec3;
|
|
212
|
+
parent: RRTNode | null;
|
|
213
|
+
cost: number;
|
|
214
|
+
children: RRTNode[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* RRT* (Rapidly-exploring Random Tree Star)
|
|
219
|
+
* Asymptotically optimal for continuous spaces
|
|
220
|
+
*/
|
|
221
|
+
export function rrtStarPath(
|
|
222
|
+
start: Vec3,
|
|
223
|
+
goal: Vec3,
|
|
224
|
+
grid: OccupancyGrid,
|
|
225
|
+
bounds: AABB,
|
|
226
|
+
options: {
|
|
227
|
+
maxIterations?: number;
|
|
228
|
+
stepSize?: number;
|
|
229
|
+
goalBias?: number;
|
|
230
|
+
rewireRadius?: number;
|
|
231
|
+
} = {},
|
|
232
|
+
): Vec3[] | null {
|
|
233
|
+
const {
|
|
234
|
+
maxIterations = 5000,
|
|
235
|
+
stepSize = 0.3,
|
|
236
|
+
goalBias = 0.1,
|
|
237
|
+
rewireRadius = 0.5,
|
|
238
|
+
} = options;
|
|
239
|
+
|
|
240
|
+
const root: RRTNode = { position: start, parent: null, cost: 0, children: [] };
|
|
241
|
+
const nodes: RRTNode[] = [root];
|
|
242
|
+
let bestGoalNode: RRTNode | null = null;
|
|
243
|
+
|
|
244
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
245
|
+
// Sample random point (with goal bias)
|
|
246
|
+
const sample = Math.random() < goalBias
|
|
247
|
+
? goal
|
|
248
|
+
: randomPoint(bounds);
|
|
249
|
+
|
|
250
|
+
// Find nearest node in tree
|
|
251
|
+
const nearest = findNearest(nodes, sample);
|
|
252
|
+
if (!nearest) continue;
|
|
253
|
+
|
|
254
|
+
// Steer towards sample
|
|
255
|
+
const direction = vec3Normalize(vec3Sub(sample, nearest.position));
|
|
256
|
+
const newPos = vec3Add(nearest.position, vec3Scale(direction, stepSize));
|
|
257
|
+
|
|
258
|
+
// Check collision
|
|
259
|
+
if (!grid.isFree(newPos)) continue;
|
|
260
|
+
if (!grid.isLineFree(nearest.position, newPos)) continue;
|
|
261
|
+
|
|
262
|
+
// Find nearby nodes for potential rewiring
|
|
263
|
+
const nearby = findNearby(nodes, newPos, rewireRadius);
|
|
264
|
+
|
|
265
|
+
// Choose best parent (lowest cost path)
|
|
266
|
+
let bestParent = nearest;
|
|
267
|
+
let bestCost = nearest.cost + vec3Distance(nearest.position, newPos);
|
|
268
|
+
|
|
269
|
+
for (const candidate of nearby) {
|
|
270
|
+
const candidateCost = candidate.cost + vec3Distance(candidate.position, newPos);
|
|
271
|
+
if (candidateCost < bestCost && grid.isLineFree(candidate.position, newPos)) {
|
|
272
|
+
bestParent = candidate;
|
|
273
|
+
bestCost = candidateCost;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Add new node
|
|
278
|
+
const newNode: RRTNode = {
|
|
279
|
+
position: newPos,
|
|
280
|
+
parent: bestParent,
|
|
281
|
+
cost: bestCost,
|
|
282
|
+
children: [],
|
|
283
|
+
};
|
|
284
|
+
bestParent.children.push(newNode);
|
|
285
|
+
nodes.push(newNode);
|
|
286
|
+
|
|
287
|
+
// Rewire nearby nodes through new node if cheaper
|
|
288
|
+
for (const candidate of nearby) {
|
|
289
|
+
const newCandidateCost = newNode.cost + vec3Distance(newNode.position, candidate.position);
|
|
290
|
+
if (newCandidateCost < candidate.cost && grid.isLineFree(newNode.position, candidate.position)) {
|
|
291
|
+
// Remove from old parent's children
|
|
292
|
+
if (candidate.parent) {
|
|
293
|
+
const idx = candidate.parent.children.indexOf(candidate);
|
|
294
|
+
if (idx >= 0) candidate.parent.children.splice(idx, 1);
|
|
295
|
+
}
|
|
296
|
+
candidate.parent = newNode;
|
|
297
|
+
candidate.cost = newCandidateCost;
|
|
298
|
+
newNode.children.push(candidate);
|
|
299
|
+
propagateCostUpdate(candidate);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Check if we reached the goal
|
|
304
|
+
if (vec3Distance(newPos, goal) < stepSize * 2) {
|
|
305
|
+
if (!bestGoalNode || newNode.cost < bestGoalNode.cost) {
|
|
306
|
+
bestGoalNode = newNode;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!bestGoalNode) return null;
|
|
312
|
+
return reconstructRRTPath(bestGoalNode);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function randomPoint(bounds: AABB): Vec3 {
|
|
316
|
+
return {
|
|
317
|
+
x: bounds.min.x + Math.random() * (bounds.max.x - bounds.min.x),
|
|
318
|
+
y: bounds.min.y + Math.random() * (bounds.max.y - bounds.min.y),
|
|
319
|
+
z: bounds.min.z + Math.random() * (bounds.max.z - bounds.min.z),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function findNearest(nodes: RRTNode[], target: Vec3): RRTNode | null {
|
|
324
|
+
let best: RRTNode | null = null;
|
|
325
|
+
let bestDist = Infinity;
|
|
326
|
+
for (const node of nodes) {
|
|
327
|
+
const dist = vec3Distance(node.position, target);
|
|
328
|
+
if (dist < bestDist) {
|
|
329
|
+
bestDist = dist;
|
|
330
|
+
best = node;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return best;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function findNearby(nodes: RRTNode[], target: Vec3, radius: number): RRTNode[] {
|
|
337
|
+
return nodes.filter(n => vec3Distance(n.position, target) <= radius);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function propagateCostUpdate(node: RRTNode): void {
|
|
341
|
+
for (const child of node.children) {
|
|
342
|
+
child.cost = node.cost + vec3Distance(node.position, child.position);
|
|
343
|
+
propagateCostUpdate(child);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function reconstructRRTPath(node: RRTNode): Vec3[] {
|
|
348
|
+
const path: Vec3[] = [];
|
|
349
|
+
let current: RRTNode | null = node;
|
|
350
|
+
while (current) {
|
|
351
|
+
path.unshift(current.position);
|
|
352
|
+
current = current.parent;
|
|
353
|
+
}
|
|
354
|
+
return path;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ============================================================
|
|
358
|
+
// PATH PLANNING (unified interface)
|
|
359
|
+
// ============================================================
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Plan a path using the specified algorithm
|
|
363
|
+
*/
|
|
364
|
+
export function planPath(
|
|
365
|
+
start: Vec3,
|
|
366
|
+
goal: Vec3,
|
|
367
|
+
grid: OccupancyGrid,
|
|
368
|
+
bounds: AABB,
|
|
369
|
+
algorithm: PathAlgorithm = 'a-star' as PathAlgorithm,
|
|
370
|
+
): Path | null {
|
|
371
|
+
let points: Vec3[] | null;
|
|
372
|
+
|
|
373
|
+
if (algorithm === 'rrt-star') {
|
|
374
|
+
points = rrtStarPath(start, goal, grid, bounds);
|
|
375
|
+
} else {
|
|
376
|
+
points = aStarPath(start, goal, grid);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!points || points.length === 0) return null;
|
|
380
|
+
|
|
381
|
+
// Convert points to waypoints
|
|
382
|
+
const waypoints: Waypoint[] = points.map(pos => ({
|
|
383
|
+
position: pos,
|
|
384
|
+
tolerance: grid.resolution * 2,
|
|
385
|
+
}));
|
|
386
|
+
|
|
387
|
+
// Calculate total distance
|
|
388
|
+
let totalDistance = 0;
|
|
389
|
+
for (let i = 1; i < points.length; i++) {
|
|
390
|
+
totalDistance += vec3Distance(points[i - 1]!, points[i]!);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
id: generateNonce(16),
|
|
395
|
+
waypoints,
|
|
396
|
+
algorithm,
|
|
397
|
+
totalDistance,
|
|
398
|
+
estimatedTime: totalDistance / 0.5, // assume 0.5 m/s robot speed
|
|
399
|
+
cost: totalDistance,
|
|
400
|
+
collisionFree: true,
|
|
401
|
+
createdAt: Date.now(),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Camera abstraction layer
|
|
3
|
+
* Supports Luxonis OAK-D, RealSense, ZED, and simulated cameras
|
|
4
|
+
* Handles frame capture, depth fusion, and HMAC signing at capture time
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
CameraConfig,
|
|
8
|
+
CameraFrame,
|
|
9
|
+
CameraType,
|
|
10
|
+
DepthMap,
|
|
11
|
+
Pose,
|
|
12
|
+
StereoConfig,
|
|
13
|
+
} from '../types/index.js';
|
|
14
|
+
import { signFrame, generateNonce } from '../utils/crypto.js';
|
|
15
|
+
import { stereoDepth } from '../utils/math.js';
|
|
16
|
+
|
|
17
|
+
/** Camera driver interface — implement per hardware */
|
|
18
|
+
export interface CameraDriver {
|
|
19
|
+
readonly type: CameraType;
|
|
20
|
+
initialize(): Promise<void>;
|
|
21
|
+
captureRGB(): Promise<{ data: Uint8Array; width: number; height: number }>;
|
|
22
|
+
captureDepth(): Promise<DepthMap>;
|
|
23
|
+
getPose(): Promise<Pose | undefined>;
|
|
24
|
+
shutdown(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Frame capture pipeline with integrity guarantees
|
|
29
|
+
*
|
|
30
|
+
* Security properties:
|
|
31
|
+
* - Every frame is HMAC-SHA256 signed at capture time
|
|
32
|
+
* - Monotonic sequence numbers detect frame drops/injection
|
|
33
|
+
* - Timestamps are validated against system clock (±50ms tolerance)
|
|
34
|
+
*/
|
|
35
|
+
export class FrameCapture {
|
|
36
|
+
private sequenceNumber = 0;
|
|
37
|
+
private lastTimestamp = 0;
|
|
38
|
+
private readonly maxClockDrift = 50; // ms
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly driver: CameraDriver,
|
|
42
|
+
private readonly config: CameraConfig,
|
|
43
|
+
private readonly hmacSecret: string,
|
|
44
|
+
) {
|
|
45
|
+
if (!hmacSecret || hmacSecret.length < 32) {
|
|
46
|
+
throw new Error('HMAC secret must be at least 32 characters');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async initialize(): Promise<void> {
|
|
51
|
+
await this.driver.initialize();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Capture a signed frame with depth and pose
|
|
56
|
+
* Returns a CameraFrame with HMAC signature and sequence number
|
|
57
|
+
*/
|
|
58
|
+
async capture(): Promise<CameraFrame> {
|
|
59
|
+
const timestamp = Date.now();
|
|
60
|
+
|
|
61
|
+
// Validate timestamp monotonicity (prevents replay of old frames)
|
|
62
|
+
if (timestamp < this.lastTimestamp) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Clock went backwards: ${timestamp} < ${this.lastTimestamp}. ` +
|
|
65
|
+
'Possible clock manipulation attack.',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Capture RGB + depth in parallel
|
|
70
|
+
const [rgbResult, depthResult, pose] = await Promise.all([
|
|
71
|
+
this.driver.captureRGB(),
|
|
72
|
+
this.driver.captureDepth(),
|
|
73
|
+
this.driver.getPose(),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// Increment sequence number (monotonic, never resets)
|
|
77
|
+
this.sequenceNumber++;
|
|
78
|
+
this.lastTimestamp = timestamp;
|
|
79
|
+
|
|
80
|
+
// Sign the frame at capture time
|
|
81
|
+
const hmac = signFrame(
|
|
82
|
+
rgbResult.data,
|
|
83
|
+
timestamp,
|
|
84
|
+
this.sequenceNumber,
|
|
85
|
+
this.hmacSecret,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const frame: CameraFrame = {
|
|
89
|
+
id: generateNonce(16),
|
|
90
|
+
timestamp,
|
|
91
|
+
rgb: rgbResult.data,
|
|
92
|
+
width: rgbResult.width,
|
|
93
|
+
height: rgbResult.height,
|
|
94
|
+
depth: depthResult.data,
|
|
95
|
+
pose,
|
|
96
|
+
hmac,
|
|
97
|
+
sequenceNumber: this.sequenceNumber,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return frame;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Get current sequence number (for external monitoring) */
|
|
104
|
+
getSequenceNumber(): number {
|
|
105
|
+
return this.sequenceNumber;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async shutdown(): Promise<void> {
|
|
109
|
+
await this.driver.shutdown();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Dual-camera system (foveal + peripheral)
|
|
115
|
+
* Foveal: OAK-D Pro (20cm-8m, high detail)
|
|
116
|
+
* Peripheral: OAK-D Long Range (1-35m, wide coverage)
|
|
117
|
+
*/
|
|
118
|
+
export class DualCameraSystem {
|
|
119
|
+
private foveal: FrameCapture | undefined;
|
|
120
|
+
private peripheral: FrameCapture | undefined;
|
|
121
|
+
|
|
122
|
+
constructor(
|
|
123
|
+
private readonly fovealDriver: CameraDriver,
|
|
124
|
+
private readonly peripheralDriver: CameraDriver,
|
|
125
|
+
private readonly fovealConfig: CameraConfig,
|
|
126
|
+
private readonly peripheralConfig: CameraConfig,
|
|
127
|
+
private readonly hmacSecret: string,
|
|
128
|
+
) {}
|
|
129
|
+
|
|
130
|
+
async initialize(): Promise<void> {
|
|
131
|
+
this.foveal = new FrameCapture(this.fovealDriver, this.fovealConfig, this.hmacSecret);
|
|
132
|
+
this.peripheral = new FrameCapture(this.peripheralDriver, this.peripheralConfig, this.hmacSecret);
|
|
133
|
+
await Promise.all([
|
|
134
|
+
this.foveal.initialize(),
|
|
135
|
+
this.peripheral.initialize(),
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Capture from both cameras simultaneously */
|
|
140
|
+
async captureStereo(): Promise<{ foveal: CameraFrame; peripheral: CameraFrame }> {
|
|
141
|
+
if (!this.foveal || !this.peripheral) {
|
|
142
|
+
throw new Error('Dual camera system not initialized');
|
|
143
|
+
}
|
|
144
|
+
const [foveal, peripheral] = await Promise.all([
|
|
145
|
+
this.foveal.capture(),
|
|
146
|
+
this.peripheral.capture(),
|
|
147
|
+
]);
|
|
148
|
+
return { foveal, peripheral };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async shutdown(): Promise<void> {
|
|
152
|
+
await Promise.all([
|
|
153
|
+
this.foveal?.shutdown(),
|
|
154
|
+
this.peripheral?.shutdown(),
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Depth fusion — merge depth maps from multiple cameras
|
|
161
|
+
* Uses confidence-weighted averaging for overlapping regions
|
|
162
|
+
*/
|
|
163
|
+
export function fuseDepthMaps(
|
|
164
|
+
maps: readonly DepthMap[],
|
|
165
|
+
): DepthMap {
|
|
166
|
+
if (maps.length === 0) throw new Error('No depth maps to fuse');
|
|
167
|
+
if (maps.length === 1) return maps[0]!;
|
|
168
|
+
|
|
169
|
+
const reference = maps[0]!;
|
|
170
|
+
const width = reference.width;
|
|
171
|
+
const height = reference.height;
|
|
172
|
+
const fused = new Float32Array(width * height);
|
|
173
|
+
const totalWeight = new Float32Array(width * height);
|
|
174
|
+
let minDepth = Infinity;
|
|
175
|
+
let maxDepth = -Infinity;
|
|
176
|
+
|
|
177
|
+
for (const map of maps) {
|
|
178
|
+
if (map.width !== width || map.height !== height) {
|
|
179
|
+
throw new Error('Depth map dimensions must match for fusion');
|
|
180
|
+
}
|
|
181
|
+
for (let i = 0; i < map.data.length; i++) {
|
|
182
|
+
const depth = map.data[i]!;
|
|
183
|
+
if (!isFinite(depth) || depth <= 0) continue;
|
|
184
|
+
|
|
185
|
+
const confidence = map.confidence?.[i] ?? 0.5;
|
|
186
|
+
fused[i]! += depth * confidence;
|
|
187
|
+
totalWeight[i]! += confidence;
|
|
188
|
+
|
|
189
|
+
if (depth < minDepth) minDepth = depth;
|
|
190
|
+
if (depth > maxDepth) maxDepth = depth;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Normalize by total weight
|
|
195
|
+
for (let i = 0; i < fused.length; i++) {
|
|
196
|
+
if (totalWeight[i]! > 0) {
|
|
197
|
+
fused[i] = fused[i]! / totalWeight[i]!;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
data: fused,
|
|
203
|
+
width,
|
|
204
|
+
height,
|
|
205
|
+
minDepth: isFinite(minDepth) ? minDepth : 0,
|
|
206
|
+
maxDepth: isFinite(maxDepth) ? maxDepth : 0,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Compute depth from stereo disparity map
|
|
212
|
+
* Z = f * B / d (fundamental stereo equation)
|
|
213
|
+
*/
|
|
214
|
+
export function disparityToDepth(
|
|
215
|
+
disparity: Float32Array,
|
|
216
|
+
config: StereoConfig,
|
|
217
|
+
width: number,
|
|
218
|
+
height: number,
|
|
219
|
+
): DepthMap {
|
|
220
|
+
const depth = new Float32Array(disparity.length);
|
|
221
|
+
const confidence = new Float32Array(disparity.length);
|
|
222
|
+
|
|
223
|
+
for (let i = 0; i < disparity.length; i++) {
|
|
224
|
+
const d = disparity[i]!;
|
|
225
|
+
if (d <= 0) {
|
|
226
|
+
depth[i] = 0;
|
|
227
|
+
confidence[i] = 0;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const z = stereoDepth(config.intrinsics.fx, config.baseline, d);
|
|
231
|
+
if (z >= config.minDepth && z <= config.maxDepth) {
|
|
232
|
+
depth[i] = z;
|
|
233
|
+
// Confidence decreases with distance (depth noise ∝ z²/fB)
|
|
234
|
+
confidence[i] = Math.max(0, 1 - (z - config.minDepth) / (config.maxDepth - config.minDepth));
|
|
235
|
+
} else {
|
|
236
|
+
depth[i] = 0;
|
|
237
|
+
confidence[i] = 0;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
data: depth,
|
|
243
|
+
width,
|
|
244
|
+
height,
|
|
245
|
+
minDepth: config.minDepth,
|
|
246
|
+
maxDepth: config.maxDepth,
|
|
247
|
+
confidence,
|
|
248
|
+
};
|
|
249
|
+
}
|