bae705aa97
- 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
878 lines
26 KiB
JavaScript
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`);
|
|
}
|
|
}
|