/** * QUIXZOOM Urban Object Identity Engine * * Kärnkomponent: Avgör om två observationer avser samma fysiska objekt. * * Väger samman: * - GPS (med accuracy) * - Kameravinkel (kompass, pitch) * - 3D-position (triangulering) * - Visuell signatur (perceptual hash) * - Storlek * - Närliggande objekt (spatial context) * - Historik * - Sannolikhetsmodell * * Output: Samma objekt eller nytt objekt? */ const crypto = require('crypto'); class ObjectIdentityEngine { constructor(options = {}) { // Trösklar this.gpsThreshold = options.gpsThreshold || 5; // meter this.visualThreshold = options.visualThreshold || 0.85; // similarity this.combinedThreshold = options.combinedThreshold || 0.75; // overall match // Vikter för olika signaler this.weights = { gps: options.gpsWeight || 0.30, visual: options.visualWeight || 0.25, angle: options.angleWeight || 0.15, size: options.sizeWeight || 0.10, context: options.contextWeight || 0.10, temporal: options.temporalWeight || 0.10, }; // Kända objekt this.knownObjects = new Map(); // objectId -> ObjectSignature // Spatialt index för snabb sökning this.spatialIndex = new Map(); this.gridSize = 10; // meter } /** * ============================================================ * HUVUDMETOD: Matcha observation mot kända objekt * ============================================================ */ async matchObservation(observation) { const { location, visualSignature, cameraAngle, size, timestamp, nearbyObjects } = observation; // 1. Hitta kandidater i närheten (spatial filter) const candidates = this.findNearbyCandidates(location); if (candidates.length === 0) { // Ingen kandidat — nytt objekt return { isNew: true, matchedObject: null, confidence: 1.0, reason: 'No nearby candidates', }; } // 2. Beräkna matchningsscore för varje kandidat const matches = []; for (const candidate of candidates) { const score = this.calculateMatchScore(observation, candidate); matches.push({ objectId: candidate.id, score: score.overall, breakdown: score.breakdown, confidence: score.confidence, }); } // 3. Sortera efter score matches.sort((a, b) => b.score - a.score); const bestMatch = matches[0]; // 4. Avgör om det är samma objekt if (bestMatch.score >= this.combinedThreshold) { // Samma objekt — uppdatera signature await this.updateObjectSignature(bestMatch.objectId, observation); return { isNew: false, matchedObject: bestMatch.objectId, confidence: bestMatch.confidence, score: bestMatch.score, breakdown: bestMatch.breakdown, reason: `Matched with confidence ${(bestMatch.confidence * 100).toFixed(1)}%`, }; } else { // Nytt objekt return { isNew: true, matchedObject: null, confidence: 1 - bestMatch.score, bestCandidate: bestMatch.objectId, bestScore: bestMatch.score, reason: `Best match ${(bestMatch.score * 100).toFixed(1)}% below threshold`, }; } } /** * ============================================================ * BERÄKNA MATCHNINGSSCORE * ============================================================ */ calculateMatchScore(observation, candidate) { const breakdown = {}; // 1. GPS-matchning breakdown.gps = this.calculateGPSMatch( observation.location, candidate.location, observation.location.accuracy, candidate.location.accuracy ); // 2. Visuell matchning breakdown.visual = observation.visualSignature && candidate.visualSignature ? this.calculateVisualMatch(observation.visualSignature, candidate.visualSignature) : 0.5; // 3. Kameravinkel-matchning breakdown.angle = observation.cameraAngle && candidate.cameraAngle ? this.calculateAngleMatch(observation.cameraAngle, candidate.cameraAngle) : 0.5; // 4. Storleks-matchning breakdown.size = observation.size && candidate.size ? this.calculateSizeMatch(observation.size, candidate.size) : 0.5; // 5. Kontext-matchning (närliggande objekt) breakdown.context = observation.nearbyObjects && candidate.nearbyObjects ? this.calculateContextMatch(observation.nearbyObjects, candidate.nearbyObjects) : 0.5; // 6. Temporal matchning breakdown.temporal = observation.timestamp && candidate.lastSeen ? this.calculateTemporalMatch(observation.timestamp, candidate.lastSeen) : 0.5; // Beräkna viktat medelvärde let overall = 0; for (const [key, score] of Object.entries(breakdown)) { overall += score * this.weights[key]; } // Beräkna konfidens (hur säker är vi?) const confidence = this.calculateConfidence(breakdown); return { overall, breakdown, confidence, }; } /** * ============================================================ * GPS-MATCHNING * ============================================================ */ calculateGPSMatch(loc1, loc2, accuracy1 = 5, accuracy2 = 5) { const distance = this.calculateDistance(loc1, loc2); // Kombinerad accuracy const combinedAccuracy = Math.sqrt(accuracy1 ** 2 + accuracy2 ** 2); // Score: 1.0 vid distance=0, 0.0 vid distance=combinedAccuracy*3 const score = Math.max(0, 1 - distance / (combinedAccuracy * 3)); return score; } /** * ============================================================ * VISUELL MATCHNING (Perceptual Hash) * ============================================================ */ calculateVisualMatch(sig1, sig2) { // Jämför perceptual hashes if (sig1.hash && sig2.hash) { const hammingDistance = this.calculateHammingDistance(sig1.hash, sig2.hash); // Max 64 bitar (för 64-bit hash) const maxDistance = 64; return Math.max(0, 1 - hammingDistance / maxDistance); } // Jämför färgprofiler if (sig1.colorProfile && sig2.colorProfile) { return this.compareColorProfiles(sig1.colorProfile, sig2.colorProfile); } // Jämför feature vectors if (sig1.features && sig2.features) { return this.cosineSimilarity(sig1.features, sig2.features); } return 0.5; } calculateHammingDistance(hash1, hash2) { let distance = 0; for (let i = 0; i < hash1.length; i++) { if (hash1[i] !== hash2[i]) distance++; } return distance; } compareColorProfiles(profile1, profile2) { // Jämför färghistogram let similarity = 0; const bins = Math.min(profile1.length, profile2.length); for (let i = 0; i < bins; i++) { similarity += Math.min(profile1[i], profile2[i]); } return similarity; } cosineSimilarity(a, b) { let dotProduct = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dotProduct += a[i] * b[i]; normA += a[i] ** 2; normB += b[i] ** 2; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); } /** * ============================================================ * KAMERAVINKEL-MATCHNING * ============================================================ */ calculateAngleMatch(angle1, angle2) { // Jämför kompassriktning const compassDiff = Math.abs(angle1.compass - angle2.compass); const normalizedCompassDiff = Math.min(compassDiff, 360 - compassDiff); const compassScore = Math.max(0, 1 - normalizedCompassDiff / 180); // Jämför pitch (upp/ned) const pitchDiff = Math.abs((angle1.pitch || 0) - (angle2.pitch || 0)); const pitchScore = Math.max(0, 1 - pitchDiff / 90); // Jämför roll (rotation) const rollDiff = Math.abs((angle1.roll || 0) - (angle2.roll || 0)); const rollScore = Math.max(0, 1 - rollDiff / 90); return (compassScore * 0.5 + pitchScore * 0.3 + rollScore * 0.2); } /** * ============================================================ * STORLEKS-MATCHNING * ============================================================ */ calculateSizeMatch(size1, size2) { // Jämför dimensioner const widthRatio = Math.min(size1.width, size2.width) / Math.max(size1.width, size2.width); const heightRatio = Math.min(size1.height, size2.height) / Math.max(size1.height, size2.height); return (widthRatio + heightRatio) / 2; } /** * ============================================================ * KONTEXT-MATCHNING (Närliggande objekt) * ============================================================ */ calculateContextMatch(nearby1, nearby2) { // Jämför uppsättningen av närliggande objekt const set1 = new Set(nearby1.map(o => o.type)); const set2 = new Set(nearby2.map(o => o.type)); const intersection = new Set([...set1].filter(x => set2.has(x))); const union = new Set([...set1, ...set2]); return intersection.size / union.size; } /** * ============================================================ * TEMPORAL MATCHNING * ============================================================ */ calculateTemporalMatch(timestamp1, timestamp2) { const diff = Math.abs(timestamp1 - timestamp2); const oneDay = 24 * 60 * 60 * 1000; // Samma objekt bör observeras vid olika tider // Men för nära i tid kan indikera samma observation if (diff < 60000) return 0.3; // Mindre än 1 minut — troligen samma observation if (diff < oneDay) return 0.7; // Mindre än 1 dag — troligen samma objekt return 0.9; // Olika dagar — stöd för samma objekt } /** * ============================================================ * KONFIDENS-BERÄKNING * ============================================================ */ calculateConfidence(breakdown) { // Konfidens baserat på: // 1. Hur många signaler som är tillgängliga // 2. Hur starka signalerna är let availableSignals = 0; let strongSignals = 0; for (const [key, score] of Object.entries(breakdown)) { if (score !== 0.5) { // 0.5 = default/unknown availableSignals++; if (score > 0.8) strongSignals++; } } const availabilityScore = availableSignals / 6; // 6 signaler totalt const strengthScore = strongSignals / Math.max(availableSignals, 1); return availabilityScore * 0.4 + strengthScore * 0.6; } /** * ============================================================ * HANTERA KÄNDA OBJEKT * ============================================================ */ async registerObject(observation) { const objectId = this.generateObjectId(observation); const signature = { id: objectId, type: observation.objectType, location: observation.location, visualSignature: observation.visualSignature, cameraAngle: observation.cameraAngle, size: observation.size, nearbyObjects: observation.nearbyObjects, firstSeen: observation.timestamp, lastSeen: observation.timestamp, observationCount: 1, observationIds: [observation.id], }; this.knownObjects.set(objectId, signature); this.addToSpatialIndex(objectId, observation.location); console.log(`[IDENTITY] New object registered: ${objectId} (${observation.objectType})`); return objectId; } async updateObjectSignature(objectId, observation) { const signature = this.knownObjects.get(objectId); if (!signature) return; // Uppdatera plats (rullande medelvärde) signature.location = { lat: (signature.location.lat * signature.observationCount + observation.location.lat) / (signature.observationCount + 1), lng: (signature.location.lng * signature.observationCount + observation.location.lng) / (signature.observationCount + 1), accuracy: Math.min(signature.location.accuracy, observation.location.accuracy), }; // Uppdatera visuell signatur (om bättre kvalitet) if (observation.visualSignature && observation.visualSignature.quality > (signature.visualSignature?.quality || 0)) { signature.visualSignature = observation.visualSignature; } // Uppdatera storlek if (observation.size) { signature.size = { width: (signature.size?.width || observation.size.width), height: (signature.size?.height || observation.size.height), }; } // Uppdatera kontext if (observation.nearbyObjects) { signature.nearbyObjects = this.mergeNearbyObjects(signature.nearbyObjects, observation.nearbyObjects); } signature.lastSeen = observation.timestamp; signature.observationCount++; signature.observationIds.push(observation.id); console.log(`[IDENTITY] Object updated: ${objectId} (observations: ${signature.observationCount})`); } /** * ============================================================ * SPATIALT INDEX * ============================================================ */ findNearbyCandidates(location, radius = 50) { 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.knownObjects.get(objectId); if (obj) { const distance = this.calculateDistance(location, obj.location); if (distance <= radius) { candidates.push(obj); } } } } } return candidates; } 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); } getGridKey(location) { const x = Math.floor(location.lng * 1000 / this.gridSize); const y = Math.floor(location.lat * 1000 / this.gridSize); return `${x},${y}`; } getGridKeysInRadius(location, radius) { const keys = []; const radiusInGrid = Math.ceil(radius / this.gridSize); const centerX = Math.floor(location.lng * 1000 / this.gridSize); const centerY = Math.floor(location.lat * 1000 / this.gridSize); for (let dx = -radiusInGrid; dx <= radiusInGrid; dx++) { for (let dy = -radiusInGrid; dy <= radiusInGrid; dy++) { keys.push(`${centerX + dx},${centerY + dy}`); } } return keys; } /** * ============================================================ * HJÄLPMETODER * ============================================================ */ generateObjectId(observation) { // Deterministiskt ID baserat på plats och typ const lat = observation.location.lat.toFixed(6); const lng = observation.location.lng.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) { const R = 6371e3; const φ1 = a.lat * Math.PI / 180; const φ2 = b.lat * Math.PI / 180; const Δφ = (b.lat - a.lat) * Math.PI / 180; const Δλ = (b.lng - a.lng) * 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; } mergeNearbyObjects(existing, new_) { const merged = [...existing]; const existingTypes = new Set(existing.map(o => o.type)); for (const obj of new_) { if (!existingTypes.has(obj.type)) { merged.push(obj); } } return merged; } /** * ============================================================ * DEMONSTRATION * ============================================================ */ async demonstrate() { console.log('=== OBJECT IDENTITY ENGINE DEMO ===\n'); // Scenario 1: Zoomer A fotograferar gatlykta const observation1 = { id: 'obs_001', objectType: 'street_lamp', location: { lat: 13.725, lng: 100.555, accuracy: 3 }, visualSignature: { hash: '1010101010101010', colorProfile: [0.2, 0.3, 0.5], quality: 0.9, }, cameraAngle: { compass: 90, pitch: 10, roll: 0 }, size: { width: 0.5, height: 7.8 }, timestamp: Date.now(), nearbyObjects: [ { type: 'crosswalk', distance: 5 }, { type: 'sign', distance: 3 }, ], }; console.log('Observation 1: Zoomer A fotograferar gatlykta'); const result1 = await this.matchObservation(observation1); console.log(` Result: ${result1.isNew ? 'NEW OBJECT' : 'MATCHED'}`); if (result1.isNew) { await this.registerObject(observation1); } console.log(); // Scenario 2: Zoomer B fotograferar SAMMA gatlykta från annan vinkel const observation2 = { id: 'obs_002', objectType: 'street_lamp', location: { lat: 13.7251, lng: 100.5551, accuracy: 4 }, visualSignature: { hash: '1010101010101011', // Lite annorlunda colorProfile: [0.2, 0.3, 0.5], quality: 0.85, }, cameraAngle: { compass: 180, pitch: 15, roll: 2 }, // Motsatt riktning size: { width: 0.48, height: 7.7 }, timestamp: Date.now() + 3600000, // 1 timme senare nearbyObjects: [ { type: 'crosswalk', distance: 5 }, { type: 'sign', distance: 3 }, ], }; console.log('Observation 2: Zoomer B fotograferar samma gatlykta (annan vinkel)'); const result2 = await this.matchObservation(observation2); console.log(` Result: ${result2.isNew ? 'NEW OBJECT' : 'MATCHED'}`); console.log(` Confidence: ${(result2.confidence * 100).toFixed(1)}%`); if (result2.breakdown) { console.log(` Breakdown:`, result2.breakdown); } console.log(); // Scenario 3: Zoomer C fotograferar ANNAN gatlykta 50m bort const observation3 = { id: 'obs_003', objectType: 'street_lamp', location: { lat: 13.7255, lng: 100.5555, accuracy: 3 }, visualSignature: { hash: '1100110011001100', // Helt annorlunda colorProfile: [0.3, 0.4, 0.3], quality: 0.9, }, cameraAngle: { compass: 90, pitch: 10, roll: 0 }, size: { width: 0.6, height: 8.0 }, timestamp: Date.now() + 7200000, nearbyObjects: [ { type: 'tree', distance: 2 }, { type: 'bench', distance: 4 }, ], }; console.log('Observation 3: Zoomer C fotograferar annan gatlykta (50m bort)'); const result3 = await this.matchObservation(observation3); console.log(` Result: ${result3.isNew ? 'NEW OBJECT' : 'MATCHED'}`); console.log(` Confidence: ${(result3.confidence * 100).toFixed(1)}%`); if (result3.breakdown) { console.log(` Breakdown:`, result3.breakdown); } console.log(); // Statistik console.log('=== STATISTICS ==='); console.log(`Known objects: ${this.knownObjects.size}`); for (const [id, obj] of this.knownObjects) { console.log(` ${id}: ${obj.observationCount} observations`); } } } // Exportera module.exports = ObjectIdentityEngine; // Demo if (require.main === module) { const engine = new ObjectIdentityEngine(); engine.demonstrate(); }