import {readFileSync} from 'node:fs'; import path from 'node:path'; import {projectRoot} from './config.mjs'; export const partsSchema = JSON.parse(readFileSync(path.join(projectRoot, 'schemas', 'semantic-parts.schema.json'), 'utf8')); // Validate the subset of JSON Schema used by the checked-in contract. Keeping // the validation driven by that contract avoids two independent field lists. function validate(value, schema, at, errors) { const types = Array.isArray(schema.type) ? schema.type : [schema.type]; const matches = (type) => { if (type === 'null') return value === null; if (type === 'array') return Array.isArray(value); if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value); if (type === 'integer') return Number.isInteger(value); if (type === 'number') return typeof value === 'number' && Number.isFinite(value); return typeof value === type; }; if (!types.some(matches)) { errors.push(`${at}: 类型必须为 ${types.join('/')}`); return; } if (schema.enum && !schema.enum.includes(value)) errors.push(`${at}: 值不在允许列表中`); if (value === null) return; if (typeof value === 'number') { if (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity)) errors.push(`${at}: 数值越界`); } if (typeof value === 'string') { if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) errors.push(`${at}: 格式不合法`); if (value.length < (schema.minLength ?? 0) || value.length > (schema.maxLength ?? Infinity)) errors.push(`${at}: 字符串长度不合法`); } if (Array.isArray(value)) { if (value.length < (schema.minItems ?? 0) || value.length > (schema.maxItems ?? Infinity)) errors.push(`${at}: 数组长度不合法`); value.forEach((item, index) => validate(item, schema.items, `${at}[${index}]`, errors)); } else if (typeof value === 'object') { for (const key of schema.required || []) if (!Object.hasOwn(value, key)) errors.push(`${at}.${key}: 必填`); for (const [key, item] of Object.entries(value)) { if (schema.properties[key]) validate(item, schema.properties[key], `${at}.${key}`, errors); else if (schema.additionalProperties === false) errors.push(`${at}.${key}: 未知字段`); } } } export function parseParts(text) { const cleaned = text.trim().replace(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/u, '$1'); return JSON.parse(cleaned); } export function validateParts(data) { const errors = []; const warnings = []; validate(data, partsSchema, '$', errors); if (errors.length) return {valid: false, errors, warnings}; const ids = new Set(data.parts.map((part) => part.id)); if (ids.size !== data.parts.length) errors.push('部件 ID 重复'); for (const part of data.parts) { if (part.id === 'root') errors.push('root 为后续导出保留 ID'); for (const ref of [part.parent, ...part.occluded_by].filter((item) => item !== null)) { if (!ids.has(ref) || ref === part.id) errors.push(`${part.id}: 引用了不存在的部件或自身 ${ref}`); } if (new Set(part.occluded_by).size !== part.occluded_by.length) errors.push(`${part.id}: occluded_by 重复`); const box = part.bbox_norm_xyxy; if (part.visibility === 'hidden') { if (box !== null || part.positive_points_norm.length || !part.needs_completion) errors.push(`${part.id}: 完全隐藏部件应无可见框/正点,且需要补全`); warnings.push(`${part.id}: 完全隐藏部件需要人工确认,不能直接生成可见 mask`); } else { if (!box || box[0] >= box[2] || box[1] >= box[3]) errors.push(`${part.id}: 可见 bbox 必须具有正面积`); if (!part.positive_points_norm.length) errors.push(`${part.id}: 可见部件需要至少一个正点`); if (box) for (const [x, y] of part.positive_points_norm) { if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) errors.push(`${part.id}: 正点不在可见框内`); } } if (part.confidence < 0.6) warnings.push(`${part.id}: 模型自报低置信度,需要复核`); } if (!errors.length) { const parts = new Map(data.parts.map((part) => [part.id, part])); for (const relation of ['parent', 'occluded_by']) { const visiting = new Set(); const done = new Set(); const visit = (id) => { if (visiting.has(id)) { errors.push(`${relation} 存在环:${id}`); return; } if (done.has(id)) return; visiting.add(id); const part = parts.get(id); const refs = relation === 'parent' ? [part.parent].filter(Boolean) : part.occluded_by; refs.forEach(visit); visiting.delete(id); done.add(id); }; ids.forEach(visit); } } return {valid: errors.length === 0, errors, warnings}; }