/** * 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`); } }