#!/usr/bin/env node 'use strict'; const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const DEFAULT_PROMPT = `你是专业汽车环车详细检测专家。以下是汽车视频中抽取的帧画面,请对每一帧进行全面的环车详细检测分析。 你需要同时完成以下所有检测项目: ## 一、车身旧伤检测 仔细检查每一帧画面中车身的所有可见部位,识别划痕、凹陷、裂纹、掉漆、锈蚀等损伤。 ## 二、车辆关键信息识别 请在帧画面中识别车辆品牌型号、前后车牌号、挡风玻璃VIN、铭牌VIN、仪表盘总里程、4条胎胎压、4个轮胎纹理健康度、4个轮胎品牌型号规格。 要求: 1. 仔细检查每一帧画面,不要遗漏。 2. 不可见字段填null,不要猜测。 3. 部分遮挡字符用"?"。 4. 不要把反光、阴影、污渍、压缩噪声虚报为旧伤。 5. 只输出合法JSON。 返回格式: { "has_damage": true, "damages": [ { "time_second": 12.4, "location": "左前翼子板", "type": "划痕", "severity": "轻微", "description": "详细描述" } ], "vehicle_info": { "vehicle_brand_model": null, "front_plate": null, "rear_plate": null, "windshield_vin": null, "nameplate_vin": null, "odometer_km": null, "tire_pressure": {"left_front": null, "right_front": null, "left_rear": null, "right_rear": null}, "tire_tread_health": {"left_front": null, "right_front": null, "left_rear": null, "right_rear": null}, "tire_specs": {"left_front": null, "right_front": null, "left_rear": null, "right_rear": null} }, "summary": "本批检测结果简述" }`; function emit(stage, message, status = 'running', extra = {}) { process.stderr.write(JSON.stringify({ type: 'process_event', stage, message, status, timestamp: new Date().toISOString(), ...extra, }) + '\n'); } function readInput() { const raw = String(process.argv[2] || process.env.SKILL_INPUT || process.env.AIFLOW_SKILL_INPUT || fs.readFileSync(0, 'utf8')).trim(); return raw ? JSON.parse(raw) : {}; } function clampNumber(value, min, max, fallback) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.max(min, Math.min(max, number)); } function workspaceRoot(env) { if (env.VEHICLE_SCRATCH_WORKSPACE_ROOT) return path.resolve(env.VEHICLE_SCRATCH_WORKSPACE_ROOT); if (env.RZYX_AI_WORKSPACE_ROOT) { const root = path.resolve(env.RZYX_AI_WORKSPACE_ROOT); return path.basename(root) === 'vehicle-scratch-inspection' ? root : path.join(root, 'vehicle-scratch-inspection'); } if (env.RZYX_AI_DATA_DIR) return path.join(path.resolve(env.RZYX_AI_DATA_DIR), 'workspace', 'vehicle-scratch-inspection'); return path.join(os.tmpdir(), 'vehicle-scratch-inspection'); } function workspaceFor(taskId, env) { const safe = String(taskId || '').trim(); if (!/^[a-zA-Z0-9_-]{1,80}$/.test(safe)) throw new Error('taskId非法或为空'); const workspacePath = path.join(workspaceRoot(env), safe); if (!fs.existsSync(workspacePath)) throw new Error(`workspace不存在: ${workspacePath}`); return { taskId: safe, workspacePath }; } function readJson(filePath, fallback = null) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return fallback; } } function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function parseJsonLoose(value) { if (!value) return null; if (typeof value === 'object') return value; const text = String(value).trim(); const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); const body = fenced ? fenced[1].trim() : text; try { return JSON.parse(body); } catch { const start = body.indexOf('{'); const end = body.lastIndexOf('}'); if (start >= 0 && end > start) { try { return JSON.parse(body.slice(start, end + 1)); } catch { return null; } } return null; } } function chunkArray(items, size) { const out = []; for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); return out; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function runWithConcurrency(tasks, limit) { const results = new Array(tasks.length); let next = 0; async function worker() { while (next < tasks.length) { const index = next++; results[index] = await tasks[index](); } } await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker)); return results; } function emptyVehicleInfo() { return { vehicleBrandModel: null, frontPlate: null, rearPlate: null, windshieldVin: null, nameplateVin: null, odometerKm: null, tirePressure: { leftFront: null, rightFront: null, leftRear: null, rightRear: null }, tireTreadHealth: { leftFront: null, rightFront: null, leftRear: null, rightRear: null }, tireSpecs: { leftFront: null, rightFront: null, leftRear: null, rightRear: null }, }; } const VEHICLE_KEY_MAP = { vehicle_brand_model: 'vehicleBrandModel', vehicleBrandModel: 'vehicleBrandModel', front_plate: 'frontPlate', frontPlate: 'frontPlate', rear_plate: 'rearPlate', rearPlate: 'rearPlate', windshield_vin: 'windshieldVin', windshieldVin: 'windshieldVin', nameplate_vin: 'nameplateVin', nameplateVin: 'nameplateVin', odometer_km: 'odometerKm', odometerKm: 'odometerKm', }; const TIRE_GROUP_MAP = { tire_pressure: 'tirePressure', tirePressure: 'tirePressure', tire_tread_health: 'tireTreadHealth', tireTreadHealth: 'tireTreadHealth', tire_specs: 'tireSpecs', tireSpecs: 'tireSpecs', }; const TIRE_POS_MAP = { left_front: 'leftFront', leftFront: 'leftFront', LF: 'leftFront', right_front: 'rightFront', rightFront: 'rightFront', RF: 'rightFront', left_rear: 'leftRear', leftRear: 'leftRear', LR: 'leftRear', right_rear: 'rightRear', rightRear: 'rightRear', RR: 'rightRear', }; function mergeVehicleInfo(target, raw) { if (!raw || typeof raw !== 'object') return target; for (const [sourceKey, targetKey] of Object.entries(VEHICLE_KEY_MAP)) { const value = raw[sourceKey]; if ((target[targetKey] === null || target[targetKey] === '' || target[targetKey] === undefined) && value !== null && value !== undefined && value !== '') { target[targetKey] = value; } } for (const [sourceGroup, targetGroup] of Object.entries(TIRE_GROUP_MAP)) { const group = raw[sourceGroup]; if (!group || typeof group !== 'object') continue; for (const [sourcePos, targetPos] of Object.entries(TIRE_POS_MAP)) { const value = group[sourcePos]; if (!target[targetGroup][targetPos] && value) target[targetGroup][targetPos] = value; } } return target; } function normalizeDamage(raw, batchNo, index) { const ts = Number(raw.time_second ?? raw.timeSecond ?? raw.timestamp ?? raw.time ?? 0); return { id: raw.id ? String(raw.id) : `damage_raw_${String(batchNo).padStart(2, '0')}_${String(index + 1).padStart(3, '0')}`, timeSecond: Number.isFinite(ts) ? Number(ts.toFixed(3)) : 0, location: String(raw.location || raw.part || raw.position || '未知部位').trim() || '未知部位', type: String(raw.type || raw.damageType || '旧伤').trim() || '旧伤', severity: String(raw.severity || '轻微').trim() || '轻微', description: String(raw.description || raw.detail || '').trim(), sourceBatch: batchNo, raw, }; } function normalizeBatchResult(raw, batchNo) { const parsed = parseJsonLoose(raw) || {}; const damages = Array.isArray(parsed.damages) ? parsed.damages.map((item, index) => normalizeDamage(item, batchNo, index)) : []; return { batch: batchNo, success: true, hasDamage: Boolean(parsed.has_damage ?? parsed.hasDamage ?? damages.length > 0), damages, vehicleInfo: parsed.vehicle_info || parsed.vehicleInfo || {}, summary: parsed.summary || '', raw: parsed, }; } function collectModelResults(input) { if (Array.isArray(input.batchResults)) return input.batchResults; const candidate = input.modelResult ?? input.detectResult ?? input.result ?? input.mockResult; if (!candidate) return null; const parsed = parseJsonLoose(candidate); if (Array.isArray(parsed)) return parsed; if (Array.isArray(parsed?.batches)) return parsed.batches; if (Array.isArray(parsed?.batchResults)) return parsed.batchResults; return [parsed || candidate]; } function imageContent(filePath) { return { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${fs.readFileSync(filePath).toString('base64')}` }, }; } async function callVisionApi({ env, content }) { const apiKey = env.ARK_API_KEY; if (!apiKey) throw new Error('缺少ARK_API_KEY'); const apiUrl = env.ARK_API_URL || 'https://ark.cn-beijing.volces.com/api/v3/chat/completions'; const model = env.DAMAGE_DETECT_MODEL || 'doubao-seed-2-0-pro-260215'; for (let attempt = 1; attempt <= 4; attempt += 1) { emit('vision-api', '请求多模态检测模型', 'running', { model, attempt, totalAttempts: 4 }); try { const response = await fetch(apiUrl, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages: [{ role: 'user', content }], max_tokens: 4096, temperature: 0.1, }), signal: AbortSignal.timeout(600000), }); const text = await response.text(); let body; try { body = JSON.parse(text); } catch { body = { raw: text }; } if (!response.ok) throw new Error(JSON.stringify(body).slice(0, 1000)); return body.choices?.[0]?.message?.content || ''; } catch (err) { const msg = err && err.message ? err.message : String(err); if (attempt < 4 && /429|TooManyRequests|timeout|ECONNRESET|ETIMEDOUT/i.test(msg)) { await sleep(3000 * attempt); continue; } throw err; } } throw new Error('多模态检测模型调用失败'); } async function detectBatch({ batch, batchNo, totalBatches, env, prompt }) { emit('detect_damages', '开始检测批次', 'running', { batch: batchNo, totalBatches, frameCount: batch.length }); const content = []; for (const frame of batch) { content.push({ type: 'text', text: `[${Number(frame.timestamp).toFixed(2)} second] frame ${frame.index}` }); content.push(imageContent(frame.path)); } content.push({ type: 'text', text: prompt }); const modelText = await callVisionApi({ env, content }); const result = normalizeBatchResult(modelText, batchNo); emit('detect_damages', '批次检测完成', 'running', { batch: batchNo, totalBatches, damageCount: result.damages.length }); return result; } function buildVisionRequestOutput({ workspace, batches, prompt }) { return { success: false, needsModelVision: true, taskId: workspace.taskId, workspacePath: workspace.workspacePath, prompt, batches: batches.map((batch, index) => ({ batch: index + 1, frames: batch.map(frame => ({ index: frame.index, timestamp: frame.timestamp, path: frame.path, relativePath: frame.relativePath, })), })), error: '未配置ARK_API_KEY且未提供modelResult/batchResults。请让多模态模型按prompt分析每批图片,并把JSON结果作为batchResults传回本skill。', }; } async function run(input, env = process.env) { if (!input?.taskId) throw new Error('taskId不能为空,请先使用video-frame-extractor抽帧'); const workspace = workspaceFor(input.taskId, env); const videoInfoDoc = readJson(path.join(workspace.workspacePath, 'video_info.json')); if (!videoInfoDoc?.frames?.length) throw new Error('未找到video_info.json或frames为空,请先抽帧'); const batchSize = Math.round(clampNumber(input.batchSize, 1, 100, 50)); const batchDelay = clampNumber(input.batchDelay, 0, 30, 2); const concurrency = Math.round(clampNumber(input.concurrency, 1, 8, 5)); const frames = videoInfoDoc.frames.map(frame => ({ ...frame, path: path.isAbsolute(frame.path) ? frame.path : path.join(workspace.workspacePath, frame.relativePath || frame.path), })); const batches = chunkArray(frames, batchSize); const prompt = input.prompt ? String(input.prompt) : DEFAULT_PROMPT; let batchResults; const externalResults = collectModelResults(input); if (externalResults) { batchResults = externalResults.map((item, index) => normalizeBatchResult(item, index + 1)); } else if (!env.ARK_API_KEY) { return buildVisionRequestOutput({ workspace, batches, prompt }); } else { const tasks = batches.map((batch, index) => async () => { if (index > 0 && batchDelay > 0) await sleep(batchDelay * 1000); return detectBatch({ batch, batchNo: index + 1, totalBatches: batches.length, env, prompt }); }); batchResults = await runWithConcurrency(tasks, concurrency); } const vehicleInfo = emptyVehicleInfo(); const damages = []; for (const result of batchResults) { mergeVehicleInfo(vehicleInfo, result.vehicleInfo); damages.push(...result.damages); } const outputDoc = { taskId: workspace.taskId, workspacePath: workspace.workspacePath, totalBatches: batches.length, successBatches: batchResults.filter(item => item.success).length, damages, vehicleInfo, batches: batchResults, generatedAt: new Date().toISOString(), }; writeJson(path.join(workspace.workspacePath, 'damages.json'), outputDoc); emit('detect_damages', '检测完成并写入damages.json', 'completed', { damageCount: damages.length }); return { success: true, damagesFound: damages.length, totalBatches: outputDoc.totalBatches, successBatches: outputDoc.successBatches, vehicleInfo, }; } (async () => { try { process.stdout.write(JSON.stringify(await run(readInput()))); } catch (err) { process.stdout.write(JSON.stringify({ success: false, error: err && err.message ? err.message : String(err), })); } })();