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')); const legacySchema = JSON.parse(readFileSync(path.join(projectRoot, 'schemas', 'semantic-parts-v1.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 = []; const legacy = data?.schema_version === 1; validate(data, legacy ? legacySchema : partsSchema, '$', errors); if (errors.length) return {valid: false, errors, warnings}; const ids = new Set(data.parts.map((part) => part.id)); const byId = new Map(data.parts.map((part) => [part.id, part])); if (legacy) warnings.push('旧版计划:rig_role、bind_to 和 completion 将按保守规则迁移,需复核'); 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, ...(!legacy ? [part.bind_to] : [])].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 (box) for (const [x, y] of part.positive_points_norm) { if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) warnings.push(`${part.id}: 模型正点不在可见框内,将丢弃并重新生成候选`); } } if (part.positive_points_norm.length || part.negative_points_norm.length) warnings.push(`${part.id}: 模型提示点仅用于诊断,不直接传给 SAM`); if (!legacy) { if (part.rig_role === 'bone_part' && part.bind_to !== null) errors.push(`${part.id}: bone_part 使用 parent,bind_to 必须为 null`); if (part.bind_to !== null && byId.get(part.bind_to)?.rig_role !== 'bone_part') errors.push(`${part.id}: bind_to 必须引用 bone_part`); if (part.rig_role === 'bone_part' && part.parent !== null && byId.get(part.parent)?.rig_role !== 'bone_part') errors.push(`${part.id}: bone_part 的 parent 必须引用 bone_part`); if (part.needs_completion !== Object.values(part.completion).some(Boolean)) errors.push(`${part.id}: needs_completion 必须等于 completion 各项的逻辑或`); if (part.visibility !== 'visible' && !part.completion.occlusion_fill) errors.push(`${part.id}: partial/hidden 部件需要 occlusion_fill`); if (part.rig_role !== 'bone_part' && (part.completion.joint_extension_parent || part.completion.joint_extension_child)) 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}; } // Legacy results remain readable. These inferred rig/completion decisions are // explicit review candidates, not a claim that the model supplied them. export function upgradeParts(data) { if (data.schema_version !== 1) return structuredClone(data); const result = structuredClone(data); result.schema_version = 2; const byId = new Map(result.parts.map((part) => [part.id, part])); for (const part of result.parts) { part.rig_role = /^(eye|eyes|eyebrow|nose|mouth|mustache)(_|$)/u.test(part.id) ? 'facial_feature' : /^(pelvis|torso|neck|head|upper_arm|forearm|hand|thigh|shin|foot)(_|$)/u.test(part.id) ? 'bone_part' : 'attachment'; } const nearestBone = (id) => { const seen = new Set(); while (id && !seen.has(id)) { seen.add(id); const part = byId.get(id); if (!part) return null; if (part.rig_role === 'bone_part') return id; id = part.parent; } return null; }; for (const part of result.parts) { const sleeveBone = part.id.replace(/^sleeve_/u, 'upper_arm_'); part.bind_to = part.rig_role === 'bone_part' ? null : part.id.startsWith('sleeve_') && byId.get(sleeveBone)?.rig_role === 'bone_part' ? sleeveBone : nearestBone(part.parent); if (part.rig_role === 'bone_part') part.parent = nearestBone(part.parent); part.completion = { occlusion_fill: part.visibility !== 'visible' || part.needs_completion, joint_extension_parent: part.rig_role === 'bone_part' && part.parent !== null, joint_extension_child: part.rig_role === 'bone_part' && result.parts.some((child) => child.rig_role === 'bone_part' && nearestBone(child.parent) === part.id), }; part.needs_completion = Object.values(part.completion).some(Boolean); } result.notes.push('Migrated from schema v1: rig roles/bindings and completion are heuristic review candidates. Original model points are diagnostic only; use segmentation-prompts.json for geometry candidates.'); return result; }