增加几何校验

This commit is contained in:
Ukawka
2026-09-17 18:50:43 +08:00
parent 4a49fe14e9
commit 1a7b2f14a9
19 changed files with 662 additions and 88 deletions
+16
View File
@@ -0,0 +1,16 @@
import {execFileSync} from 'node:child_process';
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {projectRoot} from './config.mjs';
export {selectPython as geometryPython} from './python-runtime.mjs';
export function prepareReference(python, input, output) {
return JSON.parse(execFileSync(python, [path.join(projectRoot, 'geometry_grounding.py'), '--prepare', '--input', input, '--output', output], {encoding: 'utf8', windowsHide: true}));
}
export function groundParts(python, out) {
const diagnostics = path.join(out, 'geometry-validation.json');
execFileSync(python, [path.join(projectRoot, 'geometry_grounding.py'), '--input', path.join(out, 'source.png'), '--parts', path.join(out, 'semantic-parts.json'), '--output', diagnostics, '--prompts', path.join(out, 'segmentation-prompts.json')], {encoding: 'utf8', windowsHide: true});
return JSON.parse(readFileSync(diagnostics, 'utf8'));
}
+23
View File
@@ -0,0 +1,23 @@
import {spawnSync} from 'node:child_process';
import path from 'node:path';
export function selectPython(requested) {
const command = requested || (process.env.CONDA_PREFIX
? path.join(process.env.CONDA_PREFIX, process.platform === 'win32' ? 'python.exe' : 'bin/python')
: 'python');
const probe = spawnSync(command, ['-c', [
'import json, sys',
'from pathlib import Path',
"print(json.dumps({'executable': sys.executable, 'name': Path(sys.prefix).name, 'conda': (Path(sys.prefix) / 'conda-meta').is_dir()}))",
].join('\n')], {encoding: 'utf8', windowsHide: true});
let runtime;
try { if (probe.status === 0) runtime = JSON.parse(probe.stdout.trim()); } catch { /* Report an actionable environment error below. */ }
if (!runtime?.conda || runtime.name !== 'ai-spine') {
throw new Error('本项目固定使用 Conda 环境 ai-spine。请先 conda activate ai-spine,或用 conda run -n ai-spine node ...;--python 也必须指向该环境的解释器');
}
const dependencies = spawnSync(runtime.executable, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore', windowsHide: true});
if (dependencies.status !== 0) {
throw new Error('ai-spine 环境缺少或无法加载 Python 依赖。请在 AI_spine 目录执行:conda run -n ai-spine python -m pip install -r requirements.txt');
}
return runtime.executable;
}
+18 -7
View File
@@ -2,11 +2,12 @@ import {dataUrl} from './images.mjs';
const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
export function annotatedSvg(image, parts) {
export function annotatedSvg(image, parts, geometry = null) {
const {width: w, height: h} = image;
const stroke = Math.max(w, h) / 400;
const font = Math.max(w, h) / 65;
const byId = new Map(parts.map((part) => [part.id, part]));
const diagnostics = new Map((geometry?.parts || []).map((part) => [part.id, part]));
const overlays = parts.map((part, index) => {
const color = `hsl(${(index * 137.5) % 360} 85% 55%)`;
const [px, py] = [part.pivot_norm[0] * w, part.pivot_norm[1] * h];
@@ -17,14 +18,24 @@ export function annotatedSvg(image, parts) {
const [x0, y0, x1, y1] = part.bbox_norm_xyxy;
box = `<rect x="${x0 * w}" y="${y0 * h}" width="${(x1 - x0) * w}" height="${(y1 - y0) * h}"/>`;
}
const points = part.positive_points_norm.map(([x, y]) => `<circle cx="${x * w}" cy="${y * h}" r="${stroke * 1.5}" fill="${color}"/>`).join('');
return `<g fill="none" stroke="${color}" stroke-width="${stroke}">${line}${box}${points}<circle cx="${px}" cy="${py}" r="${stroke * 3}"/><text x="${Math.min(px + 4, w * 0.6)}" y="${Math.max(font, py - 5)}" fill="${color}" stroke="#111" stroke-width="${stroke / 2}" paint-order="stroke" font-size="${font}" font-family="sans-serif">${escape(part.id)}</text></g>`;
const diagnostic = diagnostics.get(part.id);
const points = part.positive_points_norm.map(([x, y], i) => {
const status = diagnostic?.model_positive_points[i]?.status;
const fill = status === 'INVALID_SEED' ? '#ff4444' : status === 'UNTRUSTED_SEED' ? '#ffc857' : '#999';
return `<circle cx="${x * w}" cy="${y * h}" r="${stroke * 2}" stroke="#111" fill="${fill}"><title>${escape(part.id)}: ${status || 'UNTRUSTED_MODEL_POINT'}</title></circle>`;
}).join('');
const candidates = (diagnostic?.candidate_points || []).map(({point_norm: [x, y]}) => `<rect x="${x * w - stroke * 2}" y="${y * h - stroke * 2}" width="${stroke * 4}" height="${stroke * 4}" stroke="#111" fill="#44e5ff"><title>${escape(part.id)}: Alpha candidate, semantic unverified</title></rect>`).join('');
return `<g fill="none" stroke="${color}" stroke-width="${stroke}">${line}${box}${points}${candidates}<circle cx="${px}" cy="${py}" r="${stroke * 3}"/><text x="${Math.min(px + 4, w * 0.6)}" y="${Math.max(font, py - 5)}" fill="${color}" stroke="#111" stroke-width="${stroke / 2}" paint-order="stroke" font-size="${font}" font-family="sans-serif">${escape(part.id)}</text></g>`;
}).join('\n');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="100%" height="100%" fill="#262632"/><image href="${dataUrl(image)}" width="${w}" height="${h}"/>${overlays}</svg>`;
}
export function understandingReport(image, data, validation, metadata) {
const svg = data && validation.valid ? annotatedSvg(image, data.parts) : `<img src="${dataUrl(image)}" alt="source">`;
const rows = data && validation.valid ? data.parts.map((part) => `<tr><td>${escape(part.id)}</td><td>${escape(part.parent ?? 'root')}</td><td>${escape(part.visibility)}</td><td>${escape(part.occluded_by.join(', '))}</td><td>${part.confidence}</td></tr>`).join('') : '';
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>角色结构理解检查</title><style>body{font:16px system-ui;max-width:1100px;margin:24px auto;padding:16px;background:#f5f5f5}svg,img{max-width:100%;height:auto;max-height:85vh}td,th{padding:8px;border:1px solid #ccc}table{border-collapse:collapse}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style><h1>角色结构理解</h1><p>此图仅包含模型预测的粗框、关节点(空心圆)和正点(实心圆),尚未执行 SAM 分割或补全。结构校验通过不代表语义正确。</p><p>状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}</p>${svg}<h2>校验</h2><pre>${escape(JSON.stringify(validation, null, 2))}</pre><h2>部件</h2><table><tr><th>ID</th><th>父部件</th><th>可见性</th><th>遮挡物</th><th>自报置信度</th></tr>${rows}</table><h2>模型说明</h2><pre>${escape(JSON.stringify(data?.notes || [], null, 2))}</pre><h2>请求记录</h2><pre>${escape(JSON.stringify(metadata, null, 2))}</pre></html>`;
export function understandingReport(image, data, validation, metadata, geometry = null) {
const svg = data && validation.valid ? annotatedSvg(image, data.parts, geometry) : `<img src="${dataUrl(image)}" alt="source">`;
const diagnostics = new Map((geometry?.parts || []).map((part) => [part.id, part]));
const rows = data && validation.valid ? data.parts.map((part) => {
const g = diagnostics.get(part.id);
return `<tr><td>${escape(part.id)}</td><td>${escape(part.rig_role || '')}<br>${escape(part.bind_to ?? part.parent ?? 'root')}</td><td>${escape(part.visibility)}</td><td>${escape(part.occluded_by.join(', '))}</td><td>${escape(Object.entries(part.completion || {}).filter(([, value]) => value).map(([key]) => key).join(', ') || '无')}</td><td>${g?.bbox_foreground_ratio == null ? '—' : `${(g.bbox_foreground_ratio * 100).toFixed(1)}%`}</td><td>${escape(g?.status || 'pending')}<br>${escape((g?.warnings || []).join(', '))}</td><td>${part.confidence}</td></tr>`;
}).join('') : '';
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>角色结构与几何检查</title><style>body{font:16px system-ui;max-width:1400px;margin:24px auto;padding:16px;background:#f5f5f5}svg,img{max-width:100%;height:auto;max-height:85vh}td,th{padding:8px;border:1px solid #ccc}table{border-collapse:collapse}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style><h1>角色结构与几何检查</h1><p>尚未执行 SAM 分割或补全。红色实心圆:无效模型点;黄色实心圆:通过前景检查但不可信的模型点;青色方块:程序生成的 Alpha 候选。空心圆是原始关节点,不做前景吸附。粗框与候选均需语义复核,Alpha 不能证明点属于目标部件。</p><p>状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}</p>${svg}<h2>几何摘要</h2><pre>${escape(JSON.stringify(geometry?.summary || {}, null, 2))}</pre><h2>部件</h2><table><tr><th>ID</th><th>角色/绑定</th><th>可见性</th><th>遮挡物</th><th>补全项</th><th>框内前景</th><th>候选状态</th><th>自报置信度</th></tr>${rows}</table><h2>校验</h2><pre>${escape(JSON.stringify(validation, null, 2))}</pre><h2>模型说明</h2><pre>${escape(JSON.stringify(data?.notes || [], null, 2))}</pre><h2>请求记录</h2><pre>${escape(JSON.stringify(metadata, null, 2))}</pre></html>`;
}
+54 -4
View File
@@ -3,6 +3,7 @@ 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.
@@ -46,13 +47,16 @@ export function parseParts(text) {
export function validateParts(data) {
const errors = [];
const warnings = [];
validate(data, partsSchema, '$', errors);
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].filter((item) => item !== null)) {
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 重复`);
@@ -62,11 +66,19 @@ export function validateParts(data) {
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 (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) {
@@ -89,3 +101,41 @@ export function validateParts(data) {
}
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;
}