/** * 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;