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
186 lines
5.8 KiB
JavaScript
186 lines
5.8 KiB
JavaScript
/**
|
|
* QUIXZOOM Video-to-Observation Pipeline
|
|
*
|
|
* Konverterar video till strukturerade observationer
|
|
*/
|
|
|
|
const { execSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
class VideoToObservationPipeline {
|
|
constructor(config = {}) {
|
|
this.config = {
|
|
frameInterval: config.frameInterval || 5,
|
|
outputDir: config.outputDir || '/tmp/video-frames',
|
|
...config,
|
|
};
|
|
this.observations = [];
|
|
}
|
|
|
|
async processVideo(videoPath, metadata = {}) {
|
|
console.log(`[VIDEO] Processing: ${videoPath}`);
|
|
|
|
const videoInfo = this.extractMetadata(videoPath);
|
|
console.log(`[VIDEO] Duration: ${videoInfo.duration}s`);
|
|
|
|
const frames = await this.extractFrames(videoPath);
|
|
console.log(`[VIDEO] Extracted ${frames.length} frames`);
|
|
|
|
for (let i = 0; i < frames.length; i++) {
|
|
const frame = frames[i];
|
|
const timestamp = metadata.startTime
|
|
? new Date(metadata.startTime.getTime() + i * this.config.frameInterval * 1000)
|
|
: new Date();
|
|
|
|
const detectedObjects = this.mockObjectDetection(frame, metadata, i);
|
|
|
|
for (const obj of detectedObjects) {
|
|
this.observations.push({
|
|
id: `obs_${path.basename(videoPath)}_${i}_${obj.type}`,
|
|
objectType: obj.type,
|
|
location: { lat: obj.lat, lng: obj.lng },
|
|
gpsAccuracy: obj.accuracy,
|
|
timestamp: timestamp,
|
|
attributes: obj.attributes,
|
|
source: {
|
|
type: 'video',
|
|
videoId: metadata.videoId || path.basename(videoPath),
|
|
frameNumber: i,
|
|
timestamp: i * this.config.frameInterval,
|
|
},
|
|
quality: {
|
|
confidence: obj.confidence,
|
|
blur: 0.1,
|
|
exposure: 0.8,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`[VIDEO] Created ${this.observations.length} observations`);
|
|
return this.observations;
|
|
}
|
|
|
|
extractMetadata(videoPath) {
|
|
try {
|
|
const output = execSync(
|
|
`ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`,
|
|
{ encoding: 'utf-8', timeout: 10000 }
|
|
);
|
|
const info = JSON.parse(output);
|
|
const stream = info.streams.find(s => s.codec_type === 'video');
|
|
return {
|
|
duration: parseFloat(info.format.duration) || 0,
|
|
width: stream?.width || 0,
|
|
height: stream?.height || 0,
|
|
};
|
|
} catch (error) {
|
|
return { duration: 0, width: 0, height: 0 };
|
|
}
|
|
}
|
|
|
|
async extractFrames(videoPath) {
|
|
const outputDir = path.join(this.config.outputDir, path.basename(videoPath, path.extname(videoPath)));
|
|
if (!fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
const fps = 1 / this.config.frameInterval;
|
|
const outputPattern = path.join(outputDir, 'frame_%04d.jpg');
|
|
|
|
try {
|
|
execSync(
|
|
`ffmpeg -i "${videoPath}" -vf "fps=${fps}" -q:v 2 "${outputPattern}"`,
|
|
{ timeout: 60000, stdio: 'pipe' }
|
|
);
|
|
} catch (error) {
|
|
console.warn(`[VIDEO] ffmpeg failed: ${error.message}`);
|
|
return [];
|
|
}
|
|
|
|
return fs.readdirSync(outputDir)
|
|
.filter(f => f.endsWith('.jpg'))
|
|
.map(f => path.join(outputDir, f))
|
|
.sort();
|
|
}
|
|
|
|
mockObjectDetection(framePath, metadata = {}, frameIndex) {
|
|
const objects = [];
|
|
const bangkokObjects = [
|
|
{ type: 'street_lamp', probability: 0.7 },
|
|
{ type: 'traffic_sign', probability: 0.4 },
|
|
{ type: 'tree', probability: 0.6 },
|
|
{ type: 'utility_box', probability: 0.2 },
|
|
{ type: 'manhole', probability: 0.3 },
|
|
];
|
|
|
|
const numObjects = 1 + Math.floor(Math.random() * 3);
|
|
|
|
for (let i = 0; i < numObjects; i++) {
|
|
const template = bangkokObjects[Math.floor(Math.random() * bangkokObjects.length)];
|
|
|
|
if (Math.random() < template.probability) {
|
|
const baseLat = metadata.baseLat || 13.7563;
|
|
const baseLng = metadata.baseLng || 100.5018;
|
|
const drift = frameIndex * 0.0001;
|
|
|
|
objects.push({
|
|
type: template.type,
|
|
lat: baseLat + (Math.random() - 0.5) * 0.001 + drift,
|
|
lng: baseLng + (Math.random() - 0.5) * 0.001 + drift * 0.5,
|
|
accuracy: 2 + Math.random() * 3,
|
|
confidence: 0.6 + Math.random() * 0.35,
|
|
attributes: this.generateAttributes(template.type),
|
|
});
|
|
}
|
|
}
|
|
|
|
return objects;
|
|
}
|
|
|
|
generateAttributes(objectType) {
|
|
const attributes = {
|
|
street_lamp: {
|
|
height: 7 + Math.random() * 3,
|
|
material: ['steel', 'aluminum'][Math.floor(Math.random() * 2)],
|
|
paint: ['grey', 'black', 'green'][Math.floor(Math.random() * 3)],
|
|
light: Math.random() > 0.9 ? 'broken' : 'working',
|
|
},
|
|
traffic_sign: {
|
|
signType: ['speed_limit', 'stop', 'pedestrian'][Math.floor(Math.random() * 3)],
|
|
height: 2 + Math.random() * 1,
|
|
reflective: Math.random() > 0.1,
|
|
},
|
|
tree: {
|
|
species: ['palm', 'banyan', 'eucalyptus'][Math.floor(Math.random() * 3)],
|
|
height: 5 + Math.random() * 10,
|
|
health: Math.random() > 0.9 ? 'poor' : 'good',
|
|
},
|
|
utility_box: {
|
|
type: ['electric', 'telecom'][Math.floor(Math.random() * 2)],
|
|
condition: Math.random() > 0.85 ? 'damaged' : 'good',
|
|
},
|
|
manhole: {
|
|
diameter: 0.5 + Math.random() * 0.3,
|
|
material: ['cast_iron', 'concrete'][Math.floor(Math.random() * 2)],
|
|
},
|
|
};
|
|
|
|
return attributes[objectType] || {};
|
|
}
|
|
|
|
saveToFile(outputPath) {
|
|
const data = {
|
|
version: '1.0',
|
|
generatedAt: new Date().toISOString(),
|
|
observationCount: this.observations.length,
|
|
observations: this.observations,
|
|
};
|
|
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2));
|
|
console.log(`[VIDEO] Saved to ${outputPath}`);
|
|
}
|
|
}
|
|
|
|
module.exports = VideoToObservationPipeline;
|