Files
boc/quixzoom-capture-pipeline/identity-engine/pipeline.js
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

878 lines
26 KiB
JavaScript

/**
* QUIXZOOM Object Identity Engine (OIE) — Pipeline
*
* Avgör om två observationer beskriver samma fysiska objekt.
*
* Pipeline:
* Observation
* ↓
* Candidate Search (spatial index)
* ↓
* Spatial Matching (35%)
* ↓
* Visual Matching (35%)
* ↓
* Context Matching (15%)
* ↓
* Temporal Matching (15%)
* ↓
* Confidence Scoring
* ↓
* Same Object?
* ┌────┴────┐
* │ │
* Yes No
* │ │
* ▼ ▼
* Merge New Object
*/
const crypto = require('crypto');
class ObjectIdentityPipeline {
constructor(options = {}) {
// Vikter för matchningslager
this.weights = {
spatial: options.spatialWeight || 0.35,
visual: options.visualWeight || 0.35,
context: options.contextWeight || 0.15,
temporal: options.temporalWeight || 0.15,
};
// Trösklar för beslut
this.thresholds = {
autoMerge: options.autoMergeThreshold || 0.95,
waitForEvidence: options.waitThreshold || 0.80,
newObject: options.newObjectThreshold || 0.80,
};
// Spatialt index
this.spatialIndex = new Map();
this.gridSize = 50; // meter - större för att fånga närliggande observationer
// Kända objekt
this.objects = new Map();
// Självinlärning: feedback-loop
this.feedbackHistory = [];
}
/**
* ============================================================
* HUVUDMETOD: Processa observation
* ============================================================
*/
async process(observation) {
console.log(`[OIE] Processing observation: ${observation.id}`);
// 1. Candidate Search
const candidates = this.candidateSearch(observation);
console.log(`[OIE] Found ${candidates.length} candidates`);
if (candidates.length === 0) {
return this.createNewObject(observation);
}
// 2. Matcha mot varje kandidat
const matches = [];
for (const candidate of candidates) {
const match = await this.match(observation, candidate);
matches.push(match);
}
// 3. Sortera efter confidence
matches.sort((a, b) => b.confidence - a.confidence);
const bestMatch = matches[0];
console.log(`[OIE] Best match: ${bestMatch.objectId} (confidence: ${bestMatch.confidence.toFixed(3)})`);
// 4. Beslut
return this.decide(observation, bestMatch, matches);
}
/**
* ============================================================
* 1. CANDIDATE SEARCH
* ============================================================
*/
candidateSearch(observation) {
const location = observation.location;
const radius = this.calculateSearchRadius(observation);
// Hitta kandidater i spatialt index
const candidates = [];
const gridKeys = this.getGridKeysInRadius(location, radius);
for (const key of gridKeys) {
const objectIds = this.spatialIndex.get(key);
if (objectIds) {
for (const objectId of objectIds) {
const obj = this.objects.get(objectId);
if (obj) {
const distance = this.calculateDistance(location, obj.location);
if (distance <= radius) {
candidates.push(obj);
}
}
}
}
}
return candidates;
}
calculateSearchRadius(observation) {
// Baseras på GPS-accuracy och objekttyp
const baseRadius = (observation.gpsAccuracy || 10) * 3; // 3x accuracy för att fånga samma objekt
const typeMultiplier = this.getTypeMultiplier(observation.objectType);
return Math.max(baseRadius * typeMultiplier, 20); // Minst 20m
}
getTypeMultiplier(type) {
// Större objekt = större sökradie
const multipliers = {
'building': 3.0,
'road': 2.5,
'bridge': 2.0,
'street_lamp': 1.5,
'sign': 1.2,
'pothole': 1.0,
'crosswalk': 1.0,
};
return multipliers[type] || 1.5;
}
/**
* ============================================================
* 2. MATCHNING
* ============================================================
*/
async match(observation, candidate) {
const spatial = this.spatialMatch(observation, candidate);
const visual = await this.visualMatch(observation, candidate);
const context = this.contextMatch(observation, candidate);
const temporal = this.temporalMatch(observation, candidate);
// Viktat medelvärde
const confidence =
spatial.score * this.weights.spatial +
visual.score * this.weights.visual +
context.score * this.weights.context +
temporal.score * this.weights.temporal;
return {
objectId: candidate.id,
confidence,
breakdown: { spatial, visual, context, temporal },
observation,
candidate,
};
}
/**
* ============================================================
* 2a. SPATIAL MATCH (35%)
* ============================================================
*/
spatialMatch(obs, candidate) {
const distance = this.calculateDistance(obs.location, candidate.location);
const accuracy = Math.max(obs.gpsAccuracy || 5, candidate.gpsAccuracy || 5);
// Distance score: 1.0 vid 0m, 0.0 vid 3x accuracy
const distanceScore = Math.max(0, 1 - distance / (accuracy * 3));
// Höjd-score
let altitudeScore = 0.5;
if (obs.altitude && candidate.altitude) {
const altDiff = Math.abs(obs.altitude - candidate.altitude);
altitudeScore = Math.max(0, 1 - altDiff / 10);
}
// Väg-geometri
let roadScore = 0.5;
if (obs.roadGeometry && candidate.roadGeometry) {
roadScore = this.compareRoadGeometry(obs.roadGeometry, candidate.roadGeometry);
}
// Relativ position
let relativeScore = 0.5;
if (obs.relativePosition && candidate.relativePosition) {
relativeScore = this.compareRelativePosition(obs.relativePosition, candidate.relativePosition);
}
const score = distanceScore * 0.5 + altitudeScore * 0.2 + roadScore * 0.15 + relativeScore * 0.15;
return {
score,
details: {
distance,
distanceScore,
altitudeScore,
roadScore,
relativeScore,
},
};
}
/**
* ============================================================
* 2b. VISUAL MATCH (35%)
* ============================================================
*/
async visualMatch(obs, candidate) {
// Färg
let colorScore = 0.5;
if (obs.colorProfile && candidate.colorProfile) {
colorScore = this.compareColorProfiles(obs.colorProfile, candidate.colorProfile);
}
// Form (embeddings)
let shapeScore = 0.5;
if (obs.shapeEmbedding && candidate.shapeEmbedding) {
shapeScore = this.cosineSimilarity(obs.shapeEmbedding, candidate.shapeEmbedding);
}
// Material
let materialScore = 0.5;
if (obs.material && candidate.material) {
materialScore = obs.material === candidate.material ? 1.0 : 0.0;
}
// Textur
let textureScore = 0.5;
if (obs.textureSignature && candidate.textureSignature) {
textureScore = this.compareTextureSignatures(obs.textureSignature, candidate.textureSignature);
}
// Dimensioner
let dimensionScore = 0.5;
if (obs.dimensions && candidate.dimensions) {
dimensionScore = this.compareDimensions(obs.dimensions, candidate.dimensions);
}
// OCR (text på objektet)
let ocrScore = 0.5;
if (obs.ocrText && candidate.ocrText) {
ocrScore = this.compareOCR(obs.ocrText, candidate.ocrText);
}
// Skador/defekter
let damageScore = 0.5;
if (obs.damageSignature && candidate.damageSignature) {
damageScore = this.compareDamageSignatures(obs.damageSignature, candidate.damageSignature);
}
const score =
colorScore * 0.15 +
shapeScore * 0.25 +
materialScore * 0.10 +
textureScore * 0.10 +
dimensionScore * 0.15 +
ocrScore * 0.10 +
damageScore * 0.15;
return {
score,
details: {
colorScore,
shapeScore,
materialScore,
textureScore,
dimensionScore,
ocrScore,
damageScore,
},
};
}
/**
* ============================================================
* 2c. CONTEXT MATCH (15%)
* ============================================================
*/
contextMatch(obs, candidate) {
// Vad finns runt objektet?
const obsContext = obs.spatialContext || [];
const candContext = candidate.spatialContext || [];
// Jämför uppsättningen av närliggande objekt
const obsTypes = new Set(obsContext.map(c => c.type));
const candTypes = new Set(candContext.map(c => c.type));
const intersection = new Set([...obsTypes].filter(x => candTypes.has(x)));
const union = new Set([...obsTypes, ...candTypes]);
const typeScore = intersection.size / Math.max(union.size, 1);
// Jämför avstånd till närliggande objekt
let distanceScore = 0.5;
if (obsContext.length > 0 && candContext.length > 0) {
distanceScore = this.compareContextDistances(obsContext, candContext);
}
// Husnummer, butiker, etc.
let addressScore = 0.5;
if (obs.address && candidate.address) {
addressScore = obs.address === candidate.address ? 1.0 : 0.0;
}
const score = typeScore * 0.5 + distanceScore * 0.3 + addressScore * 0.2;
return {
score,
details: {
typeScore,
distanceScore,
addressScore,
},
};
}
/**
* ============================================================
* 2d. TEMPORAL MATCH (15%)
* ============================================================
*/
temporalMatch(obs, candidate) {
const timeDiff = Math.abs(obs.timestamp - candidate.lastSeen);
const daysDiff = timeDiff / (1000 * 60 * 60 * 24);
// Rimlighetsmodell: Vad kan förändras över tid?
let changeScore = 0.5;
if (obs.attributes && candidate.attributes) {
const changes = this.detectChanges(obs.attributes, candidate.attributes);
changeScore = this.evaluateChanges(changes, daysDiff);
}
// Tidsscore: Samma objekt bör observeras vid olika tider
// Men för nära i tid kan indikera samma observation
let timeScore;
if (daysDiff < 1/24) { // Mindre än 1 timme
timeScore = 0.3; // Troligen samma observation
} else if (daysDiff < 1) {
timeScore = 0.7; // Samma dag
} else if (daysDiff < 30) {
timeScore = 0.9; // Inom en månad
} else {
timeScore = 0.95; // Längre tid
}
const score = changeScore * 0.6 + timeScore * 0.4;
return {
score,
details: {
daysDiff,
changeScore,
timeScore,
changes: obs.attributes && candidate.attributes ? this.detectChanges(obs.attributes, candidate.attributes) : [],
},
};
}
detectChanges(newAttrs, oldAttrs) {
const changes = [];
const allKeys = new Set([...Object.keys(newAttrs), ...Object.keys(oldAttrs)]);
for (const key of allKeys) {
if (newAttrs[key] !== oldAttrs[key]) {
changes.push({
attribute: key,
old: oldAttrs[key],
new: newAttrs[key],
});
}
}
return changes;
}
evaluateChanges(changes, daysDiff) {
// Rimlighetsbedömning
let score = 1.0;
for (const change of changes) {
const reasonableness = this.getChangeReasonableness(change, daysDiff);
score *= reasonableness;
}
return score;
}
getChangeReasonableness(change, daysDiff) {
const { attribute, old: oldVal, new: newVal } = change;
// Rimliga förändringar
const reasonableChanges = {
'rust': { from: false, to: true, rate: 'slow' },
'lean': { maxChange: 5, rate: 'slow' },
'light': { values: ['working', 'flickering', 'broken'], rate: 'fast' },
'paint': { rate: 'very_slow' },
'height': { maxChange: 0.1, rate: 'very_slow' },
};
const config = reasonableChanges[attribute];
if (!config) return 0.5; // Okänd attribut
// Kontrollera rimlighet
if (config.values) {
// Diskreta värden
const oldIdx = config.values.indexOf(oldVal);
const newIdx = config.values.indexOf(newVal);
if (oldIdx === -1 || newIdx === -1) return 0.3; // Okända värden
const stepDiff = Math.abs(newIdx - oldIdx);
return Math.max(0.3, 1 - stepDiff * 0.3);
}
if (config.maxChange !== undefined) {
// Kontinuerliga värden
const change = Math.abs(newVal - oldVal);
return Math.max(0.3, 1 - change / config.maxChange);
}
if (config.from !== undefined && config.to !== undefined) {
// Boolean
if (oldVal === config.from && newVal === config.to) {
return 0.9; // Rimlig förändring
}
if (oldVal === config.to && newVal === config.from) {
return 0.7; // Mindre vanligt men möjligt (reparation)
}
}
return 0.5;
}
/**
* ============================================================
* 3. BESLUT
* ============================================================
*/
decide(observation, bestMatch, allMatches) {
const confidence = bestMatch.confidence;
if (confidence >= this.thresholds.autoMerge) {
// Auto-merge
return {
action: 'merge',
objectId: bestMatch.objectId,
confidence,
reason: `Auto-merge: confidence ${(confidence * 100).toFixed(1)}% >= ${(this.thresholds.autoMerge * 100).toFixed(1)}%`,
observation,
};
} else if (confidence >= this.thresholds.waitForEvidence) {
// Vänta på mer evidens
return {
action: 'wait',
objectId: bestMatch.objectId,
confidence,
reason: `Wait: confidence ${(confidence * 100).toFixed(1)}% between thresholds`,
observation,
};
} else {
// Nytt objekt
return this.createNewObject(observation);
}
}
createNewObject(observation) {
const objectId = this.generateObjectId(observation);
const obj = {
id: objectId,
type: observation.objectType,
location: observation.location,
altitude: observation.altitude,
gpsAccuracy: observation.gpsAccuracy,
attributes: observation.attributes || {},
firstSeen: observation.timestamp,
lastSeen: observation.timestamp,
observationCount: 1,
observationIds: [observation.id],
spatialContext: observation.spatialContext || [],
};
this.objects.set(objectId, obj);
this.addToSpatialIndex(objectId, observation.location);
console.log(`[OIE] New object created: ${objectId}`);
return {
action: 'new',
objectId,
confidence: 1.0,
reason: 'No matching candidate found',
observation,
};
}
mergeObject(objectId, observation) {
const obj = this.objects.get(objectId);
if (!obj) return null;
// Uppdatera plats (rullande medelvärde)
const totalObs = obj.observationCount + 1;
obj.location = {
lat: (obj.location.lat * obj.observationCount + observation.location.lat) / totalObs,
lng: (obj.location.lng * obj.observationCount + observation.location.lng) / totalObs,
};
// Uppdatera attribut (senaste värden)
if (observation.attributes) {
obj.attributes = { ...obj.attributes, ...observation.attributes };
}
obj.lastSeen = observation.timestamp;
obj.observationCount++;
obj.observationIds.push(observation.id);
console.log(`[OIE] Object merged: ${objectId} (observations: ${obj.observationCount})`);
return obj;
}
/**
* ============================================================
* SJÄLVINLÄRNING
* ============================================================
*/
addFeedback(observationId, objectId, wasCorrect, correctedObjectId) {
this.feedbackHistory.push({
timestamp: Date.now(),
observationId,
objectId,
wasCorrect,
correctedObjectId,
});
// Justera vikter baserat på feedback
if (!wasCorrect) {
this.adjustWeights(observationId, objectId, correctedObjectId);
}
}
adjustWeights(observationId, predictedObjectId, correctedObjectId) {
// Förenklad justering
const obs = this.findObservation(observationId);
const predicted = this.objects.get(predictedObjectId);
const corrected = this.objects.get(correctedObjectId);
if (!obs || !predicted || !corrected) return;
// Analysera vilka signaler misslyckades
const predictedMatch = this.match(obs, predicted);
const correctedMatch = this.match(obs, corrected);
// Justera vikter
for (const layer of ['spatial', 'visual', 'context', 'temporal']) {
if (correctedMatch.breakdown[layer].score > predictedMatch.breakdown[layer].score) {
// Öka vikt för den signal som hade rätt
this.weights[layer] = Math.min(0.5, this.weights[layer] + 0.01);
} else {
// Minska vikt för den signal som hade fel
this.weights[layer] = Math.max(0.05, this.weights[layer] - 0.01);
}
}
// Normalisera vikter
const total = Object.values(this.weights).reduce((a, b) => a + b, 0);
for (const key of Object.keys(this.weights)) {
this.weights[key] /= total;
}
console.log(`[OIE] Weights adjusted:`, this.weights);
}
/**
* ============================================================
* HJÄLPMETODER
* ============================================================
*/
generateObjectId(observation) {
const lat = (observation.location.lat || observation.location.x || 0).toFixed(6);
const lng = (observation.location.lng || observation.location.y || 0).toFixed(6);
const type = observation.objectType;
const hash = crypto.createHash('md5')
.update(`${type}_${lat}_${lng}_${Date.now()}`)
.digest('hex')
.substring(0, 12);
return `${type}_${hash}`;
}
calculateDistance(a, b) {
// Hantera både lat/lng (grader) och x/y (km)
let lat1 = a.lat !== undefined ? a.lat : a.y || 0;
let lng1 = a.lng !== undefined ? a.lng : a.x || 0;
let lat2 = b.lat !== undefined ? b.lat : b.y || 0;
let lng2 = b.lng !== undefined ? b.lng : b.x || 0;
// Om koordinater är i km (stora värden = km, små = grader)
const isKm = Math.abs(lat1) > 100 || Math.abs(lng1) > 100;
if (isKm) {
// Enkel euklidisk distans i km → konvertera till meter
const dx = (lng2 - lng1) * 1000;
const dy = (lat2 - lat1) * 1000;
return Math.sqrt(dx * dx + dy * dy);
}
// Haversine för lat/lng
const R = 6371e3;
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lng2 - lng1) * Math.PI / 180;
const x = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1-x));
return R * c;
}
cosineSimilarity(a, b) {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] ** 2;
normB += b[i] ** 2;
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
compareColorProfiles(a, b) {
return a.reduce((sum, val, i) => sum + Math.min(val, b[i] || 0), 0);
}
compareTextureSignatures(a, b) {
return this.cosineSimilarity(a, b);
}
compareDimensions(a, b) {
const ratios = [
Math.min(a.width, b.width) / Math.max(a.width, b.width),
Math.min(a.height, b.height) / Math.max(a.height, b.height),
Math.min(a.depth, b.depth) / Math.max(a.depth, b.depth),
].filter(r => !isNaN(r));
return ratios.reduce((sum, r) => sum + r, 0) / ratios.length;
}
compareOCR(a, b) {
return a === b ? 1.0 : 0.0;
}
compareDamageSignatures(a, b) {
return this.cosineSimilarity(a, b);
}
compareRoadGeometry(a, b) {
return 0.5; // Förenklad
}
compareRelativePosition(a, b) {
return 0.5; // Förenklad
}
compareContextDistances(a, b) {
return 0.5; // Förenklad
}
getGridKey(location) {
// Hantera både lat/lng och x/y (km)
const lng = location.lng !== undefined ? location.lng : location.x || 0;
const lat = location.lat !== undefined ? location.lat : location.y || 0;
// Konvertera till meter (1 km = 1000m, 1 grad ≈ 111000m)
const isKm = Math.abs(lat) > 100 || Math.abs(lng) > 100;
const scale = isKm ? 1000 : 111000;
const x = Math.floor(lng * scale / this.gridSize);
const y = Math.floor(lat * scale / this.gridSize);
return `${x},${y}`;
}
getGridKeysInRadius(location, radius) {
const keys = [];
const radiusInGrid = Math.max(1, Math.ceil(radius / this.gridSize));
const lng = location.lng !== undefined ? location.lng : location.x || 0;
const lat = location.lat !== undefined ? location.lat : location.y || 0;
const isKm = Math.abs(lat) > 100 || Math.abs(lng) > 100;
const scale = isKm ? 1000 : 111000;
const centerX = Math.floor(lng * scale / this.gridSize);
const centerY = Math.floor(lat * scale / this.gridSize);
for (let dx = -radiusInGrid; dx <= radiusInGrid; dx++) {
for (let dy = -radiusInGrid; dy <= radiusInGrid; dy++) {
keys.push(`${centerX + dx},${centerY + dy}`);
}
}
return keys;
}
addToSpatialIndex(objectId, location) {
const key = this.getGridKey(location);
if (!this.spatialIndex.has(key)) {
this.spatialIndex.set(key, new Set());
}
this.spatialIndex.get(key).add(objectId);
}
findObservation(id) {
// Förenklad — i praktiken från databas
return null;
}
}
// Exportera
module.exports = ObjectIdentityPipeline;
// Demo
if (require.main === module) {
const pipeline = new ObjectIdentityPipeline();
console.log('=== OBJECT IDENTITY ENGINE DEMO ===\n');
// Scenario 1: Zoomer A observerar gatlykta
const obs1 = {
id: 'obs_001',
objectType: 'street_lamp',
location: { lat: 13.725, lng: 100.555 },
altitude: 15,
gpsAccuracy: 3,
timestamp: Date.now(),
attributes: {
material: 'steel',
height: 7.8,
paint: 'grey',
rust: false,
lean: 0,
light: 'working',
},
colorProfile: [0.2, 0.3, 0.5],
shapeEmbedding: [0.1, 0.2, 0.3, 0.4, 0.5],
dimensions: { width: 0.5, height: 7.8, depth: 0.5 },
spatialContext: [
{ type: 'crosswalk', distance: 5 },
{ type: 'sign', distance: 3 },
],
};
console.log('Observation 1: Zoomer A — Street lamp');
const result1 = pipeline.process(obs1);
console.log(` Result: ${result1.action} (${result1.objectId})`);
console.log();
// Scenario 2: Zoomer B observerar samma gatlykta (annan vinkel)
const obs2 = {
id: 'obs_002',
objectType: 'street_lamp',
location: { lat: 13.7251, lng: 100.5551 },
altitude: 15,
gpsAccuracy: 4,
timestamp: Date.now() + 3600000, // 1 timme senare
attributes: {
material: 'steel',
height: 7.8,
paint: 'grey',
rust: false,
lean: 0,
light: 'working',
},
colorProfile: [0.2, 0.3, 0.5],
shapeEmbedding: [0.1, 0.2, 0.3, 0.4, 0.5],
dimensions: { width: 0.48, height: 7.7, depth: 0.48 },
spatialContext: [
{ type: 'crosswalk', distance: 5 },
{ type: 'sign', distance: 3 },
],
};
console.log('Observation 2: Zoomer B — Same street lamp (different angle)');
const result2 = pipeline.process(obs2);
console.log(` Result: ${result2.action} (${result2.objectId})`);
if (result2.confidence) {
console.log(` Confidence: ${(result2.confidence * 100).toFixed(1)}%`);
}
console.log();
// Scenario 3: Zoomer C observerar samma gatlykta efter 3 månader (rost!)
const obs3 = {
id: 'obs_003',
objectType: 'street_lamp',
location: { lat: 13.725, lng: 100.555 },
altitude: 15,
gpsAccuracy: 3,
timestamp: Date.now() + 90 * 24 * 3600000, // 3 månader senare
attributes: {
material: 'steel',
height: 7.8,
paint: 'grey',
rust: true, // Förändring!
lean: 2, // Förändring!
light: 'flickering', // Förändring!
},
colorProfile: [0.25, 0.3, 0.45],
shapeEmbedding: [0.1, 0.2, 0.3, 0.4, 0.5],
dimensions: { width: 0.5, height: 7.8, depth: 0.5 },
spatialContext: [
{ type: 'crosswalk', distance: 5 },
{ type: 'sign', distance: 3 },
],
};
console.log('Observation 3: Zoomer C — Same street lamp after 3 months (rust, tilt, flickering)');
const result3 = pipeline.process(obs3);
console.log(` Result: ${result3.action} (${result3.objectId})`);
if (result3.confidence) {
console.log(` Confidence: ${(result3.confidence * 100).toFixed(1)}%`);
}
console.log();
// Scenario 4: Zoomer D observerar annan gatlykta 50m bort
const obs4 = {
id: 'obs_004',
objectType: 'street_lamp',
location: { lat: 13.7255, lng: 100.5555 },
altitude: 15,
gpsAccuracy: 3,
timestamp: Date.now() + 7200000,
attributes: {
material: 'aluminum', // Annat material!
height: 8.0,
paint: 'black',
rust: false,
lean: 0,
light: 'working',
},
colorProfile: [0.3, 0.4, 0.3],
shapeEmbedding: [0.5, 0.4, 0.3, 0.2, 0.1],
dimensions: { width: 0.6, height: 8.0, depth: 0.6 },
spatialContext: [
{ type: 'tree', distance: 2 },
{ type: 'bench', distance: 4 },
],
};
console.log('Observation 4: Zoomer D — Different street lamp (50m away)');
const result4 = pipeline.process(obs4);
console.log(` Result: ${result4.action} (${result4.objectId})`);
if (result4.confidence) {
console.log(` Confidence: ${(result4.confidence * 100).toFixed(1)}%`);
}
console.log();
// Statistik
console.log('=== STATISTICS ===');
console.log(`Total objects: ${pipeline.objects.size}`);
for (const [id, obj] of pipeline.objects) {
console.log(` ${id}: ${obj.observationCount} observations`);
}
}