Files
AI_spine/lib/validate-parts.mjs
T
2026-09-17 17:44:24 +08:00

92 lines
4.7 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'));
// 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};
}