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
631 lines
19 KiB
JavaScript
631 lines
19 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|