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
290 lines
9.0 KiB
JavaScript
290 lines
9.0 KiB
JavaScript
/**
|
|
* QUIXZOOM Object Identity Engine v3
|
|
*
|
|
* Anpassad för video-data:
|
|
* - Video-ID som kontext (observationer från samma video är nära)
|
|
* - Spatial search med större radie för video-walks
|
|
* - Tidsbaserad gruppering för samma session
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
|
|
class ObjectIdentityPipelineV3 {
|
|
constructor(config = {}) {
|
|
this.config = {
|
|
spatialWeight: config.spatialWeight || 0.45,
|
|
visualWeight: config.visualWeight || 0.20,
|
|
contextWeight: config.contextWeight || 0.25, // Högre för video-kontext
|
|
temporalWeight: config.temporalWeight || 0.10,
|
|
|
|
autoMergeThreshold: config.autoMergeThreshold || 0.60,
|
|
waitThreshold: config.waitThreshold || 0.40,
|
|
|
|
gridSize: config.gridSize || 100, // Större för video-walks
|
|
maxSearchRadius: config.maxSearchRadius || 200, // Större radie
|
|
|
|
...config,
|
|
};
|
|
|
|
this.objects = new Map();
|
|
this.spatialIndex = new Map();
|
|
this.stats = { processed: 0, merged: 0, created: 0, uncertain: 0 };
|
|
}
|
|
|
|
async process(observation) {
|
|
this.stats.processed++;
|
|
|
|
const candidates = this.findCandidates(observation);
|
|
|
|
if (candidates.length === 0) {
|
|
return this.createObject(observation);
|
|
}
|
|
|
|
const matches = candidates.map(candidate => ({
|
|
object: candidate,
|
|
confidence: this.calculateConfidence(observation, candidate),
|
|
}));
|
|
|
|
const bestMatch = matches.reduce((best, current) =>
|
|
current.confidence > best.confidence ? current : best
|
|
);
|
|
|
|
if (bestMatch.confidence >= this.config.autoMergeThreshold) {
|
|
return this.mergeObservation(bestMatch.object, observation, bestMatch.confidence);
|
|
} else if (bestMatch.confidence >= this.config.waitThreshold) {
|
|
this.stats.uncertain++;
|
|
return {
|
|
action: 'wait',
|
|
objectId: bestMatch.object.id,
|
|
confidence: bestMatch.confidence,
|
|
};
|
|
} else {
|
|
return this.createObject(observation);
|
|
}
|
|
}
|
|
|
|
findCandidates(observation) {
|
|
const radius = this.calculateSearchRadius(observation);
|
|
const gridKeys = this.getGridKeysInRadius(observation.location, radius);
|
|
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
|
|
for (const key of gridKeys) {
|
|
const objectsInGrid = this.spatialIndex.get(key);
|
|
if (!objectsInGrid) continue;
|
|
|
|
for (const objectId of objectsInGrid) {
|
|
if (seen.has(objectId)) continue;
|
|
seen.add(objectId);
|
|
|
|
const object = this.objects.get(objectId);
|
|
if (!object) continue;
|
|
|
|
const distance = this.calculateDistance(observation.location, object.location);
|
|
if (distance <= radius) {
|
|
candidates.push(object);
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
calculateConfidence(observation, object) {
|
|
const spatial = this.calculateSpatialConfidence(observation, object);
|
|
const visual = this.calculateVisualConfidence(observation, object);
|
|
const context = this.calculateContextConfidence(observation, object);
|
|
const temporal = this.calculateTemporalConfidence(observation, object);
|
|
|
|
return spatial * this.config.spatialWeight +
|
|
visual * this.config.visualWeight +
|
|
context * this.config.contextWeight +
|
|
temporal * this.config.temporalWeight;
|
|
}
|
|
|
|
calculateSpatialConfidence(observation, object) {
|
|
const distance = this.calculateDistance(observation.location, object.location);
|
|
const accuracy = Math.max(observation.gpsAccuracy || 5, object.gpsAccuracy || 5);
|
|
|
|
// Större max-avstånd för video-walks
|
|
const maxDistance = Math.max(accuracy * 5, 50); // Minst 50m
|
|
const confidence = Math.max(0, 1 - (distance / maxDistance));
|
|
|
|
return confidence;
|
|
}
|
|
|
|
calculateVisualConfidence(observation, object) {
|
|
const obsAttrs = observation.attributes || {};
|
|
const objAttrs = object.attributes || {};
|
|
|
|
const keys = Object.keys(obsAttrs);
|
|
if (keys.length === 0) return 0.5;
|
|
|
|
let matches = 0;
|
|
let totalWeight = 0;
|
|
|
|
for (const key of keys) {
|
|
const weight = this.getAttributeWeight(key);
|
|
totalWeight += weight;
|
|
|
|
if (obsAttrs[key] === objAttrs[key]) {
|
|
matches += weight;
|
|
} else if (this.areAttributesSimilar(key, obsAttrs[key], objAttrs[key])) {
|
|
matches += weight * 0.5;
|
|
}
|
|
}
|
|
|
|
return totalWeight > 0 ? matches / totalWeight : 0.5;
|
|
}
|
|
|
|
calculateContextConfidence(observation, object) {
|
|
let confidence = 0.5;
|
|
|
|
// Samma objekttyp
|
|
if (observation.objectType === object.type) {
|
|
confidence += 0.3;
|
|
}
|
|
|
|
// Samma video-session (närliggande i tid)
|
|
if (observation.source && object.lastSource) {
|
|
const sameVideo = observation.source.videoId === object.lastSource.videoId;
|
|
if (sameVideo) {
|
|
confidence += 0.2; // Samma video = sannolikt samma område
|
|
}
|
|
}
|
|
|
|
return Math.min(1, confidence);
|
|
}
|
|
|
|
calculateTemporalConfidence(observation, object) {
|
|
if (!observation.timestamp || !object.lastSeen) return 0.5;
|
|
|
|
const timeDiff = Math.abs(new Date(observation.timestamp) - new Date(object.lastSeen));
|
|
const minutesDiff = timeDiff / (1000 * 60);
|
|
|
|
// Inom samma video-session (< 30 min)
|
|
if (minutesDiff < 30) return 1.0;
|
|
if (minutesDiff < 60) return 0.9;
|
|
if (minutesDiff < 120) return 0.8;
|
|
return 0.7;
|
|
}
|
|
|
|
calculateDistance(loc1, loc2) {
|
|
const R = 6371000;
|
|
const dLat = (loc2.lat - loc1.lat) * Math.PI / 180;
|
|
const dLon = (loc2.lng - loc1.lng) * Math.PI / 180;
|
|
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
Math.cos(loc1.lat * Math.PI / 180) * Math.cos(loc2.lat * Math.PI / 180) *
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
}
|
|
|
|
calculateSearchRadius(observation) {
|
|
return Math.min(
|
|
this.config.maxSearchRadius,
|
|
Math.max((observation.gpsAccuracy || 5) * 10, 100) // Minst 100m för video
|
|
);
|
|
}
|
|
|
|
getGridKey(location) {
|
|
const lat = Math.floor(location.lat * 1000 / this.config.gridSize);
|
|
const lng = Math.floor(location.lng * 1000 / this.config.gridSize);
|
|
return `${lat},${lng}`;
|
|
}
|
|
|
|
getGridKeysInRadius(location, radius) {
|
|
const keys = [];
|
|
const latDelta = radius / 111000;
|
|
const lngDelta = radius / (111000 * Math.cos(location.lat * Math.PI / 180));
|
|
|
|
const latSteps = Math.ceil(latDelta * 1000 / this.config.gridSize);
|
|
const lngSteps = Math.ceil(lngDelta * 1000 / this.config.gridSize);
|
|
|
|
const centerLat = Math.floor(location.lat * 1000 / this.config.gridSize);
|
|
const centerLng = Math.floor(location.lng * 1000 / this.config.gridSize);
|
|
|
|
for (let lat = centerLat - latSteps; lat <= centerLat + latSteps; lat++) {
|
|
for (let lng = centerLng - lngSteps; lng <= centerLng + lngSteps; lng++) {
|
|
keys.push(`${lat},${lng}`);
|
|
}
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
|
|
getAttributeWeight(key) {
|
|
const weights = {
|
|
height: 0.8, material: 0.7, paint: 0.5, light: 0.9,
|
|
rust: 0.6, lean: 0.7, signType: 0.9, reflective: 0.6,
|
|
damaged: 0.8, species: 0.7, health: 0.6, diameter: 0.5,
|
|
type: 0.8, condition: 0.7,
|
|
};
|
|
return weights[key] || 0.5;
|
|
}
|
|
|
|
areAttributesSimilar(key, val1, val2) {
|
|
if (typeof val1 === 'number' && typeof val2 === 'number') {
|
|
return Math.abs(val1 - val2) / Math.max(val1, val2) < 0.2;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
createObject(observation) {
|
|
const id = `obj_${crypto.randomBytes(6).toString('hex')}`;
|
|
|
|
const object = {
|
|
id,
|
|
type: observation.objectType,
|
|
location: { ...observation.location },
|
|
gpsAccuracy: observation.gpsAccuracy || 5,
|
|
attributes: { ...observation.attributes },
|
|
firstSeen: observation.timestamp || new Date(),
|
|
lastSeen: observation.timestamp || new Date(),
|
|
lastSource: observation.source,
|
|
evidence: [observation.id],
|
|
confidence: 0.5,
|
|
};
|
|
|
|
this.objects.set(id, object);
|
|
this.addToSpatialIndex(object);
|
|
this.stats.created++;
|
|
|
|
return { action: 'new_object', objectId: id, confidence: 0.5 };
|
|
}
|
|
|
|
mergeObservation(object, observation, confidence) {
|
|
object.evidence.push(observation.id);
|
|
object.lastSeen = observation.timestamp || new Date();
|
|
object.lastSource = observation.source;
|
|
object.confidence = Math.max(object.confidence, confidence);
|
|
|
|
if (observation.attributes) {
|
|
for (const [key, value] of Object.entries(observation.attributes)) {
|
|
if (typeof value === 'number' && typeof object.attributes[key] === 'number') {
|
|
object.attributes[key] = (object.attributes[key] + value) / 2;
|
|
} else if (!object.attributes[key]) {
|
|
object.attributes[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.stats.merged++;
|
|
return { action: 'merge', objectId: object.id, confidence };
|
|
}
|
|
|
|
addToSpatialIndex(object) {
|
|
const key = this.getGridKey(object.location);
|
|
if (!this.spatialIndex.has(key)) {
|
|
this.spatialIndex.set(key, new Set());
|
|
}
|
|
this.spatialIndex.get(key).add(object.id);
|
|
}
|
|
|
|
getStats() {
|
|
return { ...this.stats, objectCount: this.objects.size };
|
|
}
|
|
}
|
|
|
|
module.exports = ObjectIdentityPipelineV3;
|