/** * QUIXZOOM Object Identity Engine v3 * * Anpassad för video-data: * - Video-ID som kontext (observationer från samma video är nära) * - Spatial search med större radie för video-walks * - Tidsbaserad gruppering för samma session */ const crypto = require('crypto'); class ObjectIdentityPipelineV3 { constructor(config = {}) { this.config = { spatialWeight: config.spatialWeight || 0.45, visualWeight: config.visualWeight || 0.20, contextWeight: config.contextWeight || 0.25, // Högre för video-kontext temporalWeight: config.temporalWeight || 0.10, autoMergeThreshold: config.autoMergeThreshold || 0.60, waitThreshold: config.waitThreshold || 0.40, gridSize: config.gridSize || 100, // Större för video-walks maxSearchRadius: config.maxSearchRadius || 200, // Större radie ...config, }; this.objects = new Map(); this.spatialIndex = new Map(); this.stats = { processed: 0, merged: 0, created: 0, uncertain: 0 }; } async process(observation) { this.stats.processed++; const candidates = this.findCandidates(observation); if (candidates.length === 0) { return this.createObject(observation); } const matches = candidates.map(candidate => ({ object: candidate, confidence: this.calculateConfidence(observation, candidate), })); const bestMatch = matches.reduce((best, current) => current.confidence > best.confidence ? current : best ); if (bestMatch.confidence >= this.config.autoMergeThreshold) { return this.mergeObservation(bestMatch.object, observation, bestMatch.confidence); } else if (bestMatch.confidence >= this.config.waitThreshold) { this.stats.uncertain++; return { action: 'wait', objectId: bestMatch.object.id, confidence: bestMatch.confidence, }; } else { return this.createObject(observation); } } findCandidates(observation) { const radius = this.calculateSearchRadius(observation); const gridKeys = this.getGridKeysInRadius(observation.location, radius); const candidates = []; const seen = new Set(); for (const key of gridKeys) { const objectsInGrid = this.spatialIndex.get(key); if (!objectsInGrid) continue; for (const objectId of objectsInGrid) { if (seen.has(objectId)) continue; seen.add(objectId); const object = this.objects.get(objectId); if (!object) continue; const distance = this.calculateDistance(observation.location, object.location); if (distance <= radius) { candidates.push(object); } } } return candidates; } calculateConfidence(observation, object) { const spatial = this.calculateSpatialConfidence(observation, object); const visual = this.calculateVisualConfidence(observation, object); const context = this.calculateContextConfidence(observation, object); const temporal = this.calculateTemporalConfidence(observation, object); return spatial * this.config.spatialWeight + visual * this.config.visualWeight + context * this.config.contextWeight + temporal * this.config.temporalWeight; } calculateSpatialConfidence(observation, object) { const distance = this.calculateDistance(observation.location, object.location); const accuracy = Math.max(observation.gpsAccuracy || 5, object.gpsAccuracy || 5); // Större max-avstånd för video-walks const maxDistance = Math.max(accuracy * 5, 50); // Minst 50m const confidence = Math.max(0, 1 - (distance / maxDistance)); return confidence; } calculateVisualConfidence(observation, object) { const obsAttrs = observation.attributes || {}; const objAttrs = object.attributes || {}; const keys = Object.keys(obsAttrs); if (keys.length === 0) return 0.5; let matches = 0; let totalWeight = 0; for (const key of keys) { const weight = this.getAttributeWeight(key); totalWeight += weight; if (obsAttrs[key] === objAttrs[key]) { matches += weight; } else if (this.areAttributesSimilar(key, obsAttrs[key], objAttrs[key])) { matches += weight * 0.5; } } return totalWeight > 0 ? matches / totalWeight : 0.5; } calculateContextConfidence(observation, object) { let confidence = 0.5; // Samma objekttyp if (observation.objectType === object.type) { confidence += 0.3; } // Samma video-session (närliggande i tid) if (observation.source && object.lastSource) { const sameVideo = observation.source.videoId === object.lastSource.videoId; if (sameVideo) { confidence += 0.2; // Samma video = sannolikt samma område } } return Math.min(1, confidence); } calculateTemporalConfidence(observation, object) { if (!observation.timestamp || !object.lastSeen) return 0.5; const timeDiff = Math.abs(new Date(observation.timestamp) - new Date(object.lastSeen)); const minutesDiff = timeDiff / (1000 * 60); // Inom samma video-session (< 30 min) if (minutesDiff < 30) return 1.0; if (minutesDiff < 60) return 0.9; if (minutesDiff < 120) return 0.8; return 0.7; } calculateDistance(loc1, loc2) { const R = 6371000; const dLat = (loc2.lat - loc1.lat) * Math.PI / 180; const dLon = (loc2.lng - loc1.lng) * Math.PI / 180; const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(loc1.lat * Math.PI / 180) * Math.cos(loc2.lat * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2); return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } calculateSearchRadius(observation) { return Math.min( this.config.maxSearchRadius, Math.max((observation.gpsAccuracy || 5) * 10, 100) // Minst 100m för video ); } getGridKey(location) { const lat = Math.floor(location.lat * 1000 / this.config.gridSize); const lng = Math.floor(location.lng * 1000 / this.config.gridSize); return `${lat},${lng}`; } getGridKeysInRadius(location, radius) { const keys = []; const latDelta = radius / 111000; const lngDelta = radius / (111000 * Math.cos(location.lat * Math.PI / 180)); const latSteps = Math.ceil(latDelta * 1000 / this.config.gridSize); const lngSteps = Math.ceil(lngDelta * 1000 / this.config.gridSize); const centerLat = Math.floor(location.lat * 1000 / this.config.gridSize); const centerLng = Math.floor(location.lng * 1000 / this.config.gridSize); for (let lat = centerLat - latSteps; lat <= centerLat + latSteps; lat++) { for (let lng = centerLng - lngSteps; lng <= centerLng + lngSteps; lng++) { keys.push(`${lat},${lng}`); } } return keys; } getAttributeWeight(key) { const weights = { height: 0.8, material: 0.7, paint: 0.5, light: 0.9, rust: 0.6, lean: 0.7, signType: 0.9, reflective: 0.6, damaged: 0.8, species: 0.7, health: 0.6, diameter: 0.5, type: 0.8, condition: 0.7, }; return weights[key] || 0.5; } areAttributesSimilar(key, val1, val2) { if (typeof val1 === 'number' && typeof val2 === 'number') { return Math.abs(val1 - val2) / Math.max(val1, val2) < 0.2; } return false; } createObject(observation) { const id = `obj_${crypto.randomBytes(6).toString('hex')}`; const object = { id, type: observation.objectType, location: { ...observation.location }, gpsAccuracy: observation.gpsAccuracy || 5, attributes: { ...observation.attributes }, firstSeen: observation.timestamp || new Date(), lastSeen: observation.timestamp || new Date(), lastSource: observation.source, evidence: [observation.id], confidence: 0.5, }; this.objects.set(id, object); this.addToSpatialIndex(object); this.stats.created++; return { action: 'new_object', objectId: id, confidence: 0.5 }; } mergeObservation(object, observation, confidence) { object.evidence.push(observation.id); object.lastSeen = observation.timestamp || new Date(); object.lastSource = observation.source; object.confidence = Math.max(object.confidence, confidence); if (observation.attributes) { for (const [key, value] of Object.entries(observation.attributes)) { if (typeof value === 'number' && typeof object.attributes[key] === 'number') { object.attributes[key] = (object.attributes[key] + value) / 2; } else if (!object.attributes[key]) { object.attributes[key] = value; } } } this.stats.merged++; return { action: 'merge', objectId: object.id, confidence }; } addToSpatialIndex(object) { const key = this.getGridKey(object.location); if (!this.spatialIndex.has(key)) { this.spatialIndex.set(key, new Set()); } this.spatialIndex.get(key).add(object.id); } getStats() { return { ...this.stats, objectCount: this.objects.size }; } } module.exports = ObjectIdentityPipelineV3;