Files
AI_spine/lib/validate-parts.mjs
T
2026-09-17 18:50:43 +08:00

142 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}