142 lines
8.1 KiB
JavaScript
142 lines
8.1 KiB
JavaScript
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;
|
||
}
|