Files
boc/quixzoom-capture-pipeline/identity-engine/identity.js
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- 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
2026-06-29 16:24:48 +00:00

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();
}