新方案v1角色结构理解
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function readEnvFile(file) {
|
||||
if (!existsSync(file)) return {};
|
||||
const values = {};
|
||||
for (const line of readFileSync(file, 'utf8').replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
|
||||
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/u);
|
||||
if (!match) continue;
|
||||
let value = match[2];
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
} else value = value.replace(/\s+#.*$/u, '');
|
||||
values[match[1]] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function loadConfig({configFile, envFile, env = process.env} = {}) {
|
||||
const file = path.resolve(configFile || path.join(projectRoot, 'configs', 'astra-qwen.json'));
|
||||
const config = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
|
||||
if (config.schema_version !== 1 || !config.providers || !config.stages) {
|
||||
throw new Error('配置必须声明 schema_version: 1、providers 和 stages');
|
||||
}
|
||||
return {config, file, env: {...readEnvFile(envFile || path.join(projectRoot, '.env')), ...env}};
|
||||
}
|
||||
|
||||
export function resolveStage(loaded, name, overrides = {}, {requireKey = true} = {}) {
|
||||
const stage = loaded.config.stages[name];
|
||||
if (!stage) throw new Error(`没有配置阶段:${name}`);
|
||||
const provider = loaded.config.providers[stage.provider];
|
||||
if (!provider) throw new Error(`没有配置提供方:${stage.provider}`);
|
||||
const model = String(overrides.model || stage.model || loaded.env[stage.model_env] || stage.default_model || '').trim();
|
||||
if (!model) throw new Error(`缺少 ${name} 的模型 ID`);
|
||||
const baseUrl = String(loaded.env[provider.base_url_env] || '').trim().replace(/\/+$/u, '');
|
||||
const apiKey = String(loaded.env[provider.api_key_env] || '').trim();
|
||||
if (requireKey && (!baseUrl || !apiKey)) throw new Error(`需要配置 ${provider.base_url_env} 和 ${provider.api_key_env}`);
|
||||
if (baseUrl) {
|
||||
const url = new URL(baseUrl);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
||||
throw new Error('提供方 base URL 必须是无用户名、密码、查询参数的 HTTP(S) 地址');
|
||||
}
|
||||
}
|
||||
const protocol = overrides.protocol || provider.protocol;
|
||||
const allowed = name === 'understanding' ? ['chat-completions', 'responses'] : ['images-edits'];
|
||||
if (!allowed.includes(protocol)) throw new Error(`${name} 不支持协议:${protocol}`);
|
||||
const params = {...stage.params, ...overrides.params};
|
||||
// Semantic fields are owned by adapters, never overridden via opaque params.
|
||||
for (const key of ['model', 'messages', 'input', 'instructions', 'prompt', 'image', 'image[]', 'mask', 'response_format', 'text', 'stream', 'Authorization', 'api_key', 'apiKey']) {
|
||||
if (Object.hasOwn(params, key)) throw new Error(`params 不能覆盖保留字段:${key}`);
|
||||
}
|
||||
const timeoutMs = provider.timeout_ms ?? 180000;
|
||||
const maxRetries = provider.max_retries ?? 2;
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) {
|
||||
throw new Error('timeout_ms 必须为正数,max_retries 必须为 0~5 的整数');
|
||||
}
|
||||
const outputMode = overrides.outputMode || stage.output_mode || 'text-json';
|
||||
if (!['text-json', 'json-object', 'json-schema'].includes(outputMode)) throw new Error(`未知 output_mode:${outputMode}`);
|
||||
const stream = overrides.stream ?? stage.stream ?? false;
|
||||
if (typeof stream !== 'boolean') throw new Error('stream 必须为 boolean');
|
||||
const promptPath = path.resolve(path.dirname(loaded.file), overrides.prompt || stage.prompt);
|
||||
const capabilities = {multiple_images: false, mask: false, ...provider.capabilities};
|
||||
if (typeof capabilities.mask !== 'boolean' || typeof capabilities.multiple_images !== 'boolean') {
|
||||
throw new Error('capabilities.mask 和 multiple_images 必须为 boolean');
|
||||
}
|
||||
const imageField = provider.image_field || 'image';
|
||||
if (!['image', 'image[]'].includes(imageField)) throw new Error('image_field 必须是 image 或 image[]');
|
||||
return {
|
||||
name, provider: stage.provider, protocol, model, baseUrl, apiKey, timeoutMs, maxRetries,
|
||||
params, outputMode, stream, promptPath, prompt: readFileSync(promptPath, 'utf8'), capabilities,
|
||||
imageField, maskEditable: provider.mask_editable || 'transparent',
|
||||
};
|
||||
}
|
||||
|
||||
export function publicStage(stage) {
|
||||
return {
|
||||
provider: stage.provider, protocol: stage.protocol, model: stage.model,
|
||||
params: stage.params, output_mode: stage.outputMode, stream: stage.stream, timeout_ms: stage.timeoutMs,
|
||||
max_retries: stage.maxRetries, capabilities: stage.capabilities,
|
||||
image_field: stage.imageField, mask_editable: stage.maskEditable,
|
||||
};
|
||||
}
|
||||
|
||||
export function samInfo(loaded) {
|
||||
const stage = loaded.config.stages.segmentation || {};
|
||||
const modelDir = loaded.env[stage.model_dir_env] || stage.default_model_dir || '';
|
||||
const checkpoint = modelDir ? path.join(modelDir, 'sam3.1_multiplex.pt') : '';
|
||||
return {model_dir: modelDir, checkpoint, checkpoint_exists: Boolean(checkpoint && existsSync(checkpoint)), runtime_status: 'not_integrated'};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createHash} from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
export function sha256(value) { return createHash('sha256').update(value).digest('hex'); }
|
||||
|
||||
export function imageFormat(bytes) {
|
||||
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
||||
return {mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20)};
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return {mime: 'image/jpeg', extension: 'jpg'};
|
||||
if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return {mime: 'image/webp', extension: 'webp'};
|
||||
throw new Error('图片数据不是支持的 PNG/JPEG/WebP 格式');
|
||||
}
|
||||
|
||||
export function readImage(file, {pngOnly = false} = {}) {
|
||||
const bytes = readFileSync(file);
|
||||
const format = imageFormat(bytes);
|
||||
if (pngOnly && (format.extension !== 'png' || !format.width || !format.height)) throw new Error('结构理解与局部画布当前要求非空 PNG 图片');
|
||||
return {file: path.resolve(file), bytes, ...format, sha256: sha256(bytes)};
|
||||
}
|
||||
|
||||
export function dataUrl(image) { return `data:${image.mime};base64,${image.bytes.toString('base64')}`; }
|
||||
@@ -0,0 +1,30 @@
|
||||
import {dataUrl} from './images.mjs';
|
||||
|
||||
const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
||||
|
||||
export function annotatedSvg(image, parts) {
|
||||
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 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];
|
||||
const parent = byId.get(part.parent);
|
||||
const line = parent ? `<line x1="${px}" y1="${py}" x2="${parent.pivot_norm[0] * w}" y2="${parent.pivot_norm[1] * h}" stroke-dasharray="${stroke * 3}"/>` : '';
|
||||
let box = '';
|
||||
if (part.bbox_norm_xyxy) {
|
||||
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>`;
|
||||
}).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>`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {sha256} from './images.mjs';
|
||||
import {redact} from '../providers/http.mjs';
|
||||
|
||||
export function jsonFile(file, value, secret = '') {
|
||||
writeFileSync(file, `${redact(JSON.stringify(value, null, 2), secret)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export function prepareRun(outDir, fingerprint, resume) {
|
||||
const out = path.resolve(outDir);
|
||||
const manifestPath = path.join(out, 'manifest.json');
|
||||
if (existsSync(out) && readdirSync(out).length) {
|
||||
if (!resume) throw new Error(`输出目录非空,请使用新目录或 --resume:${out}`);
|
||||
if (!existsSync(manifestPath)) throw new Error('无法复用:目录没有 manifest.json');
|
||||
const previous = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
if (previous.fingerprint !== fingerprint) throw new Error('无法复用:模型、配置、输入或代码版本变化,请使用新的输出目录');
|
||||
if (previous.status !== 'completed') throw new Error('上次任务没有完整结束,请保留诊断文件并使用新目录重跑');
|
||||
for (const [name, hash] of Object.entries(previous.artifact_hashes || {})) {
|
||||
const file = path.join(out, name);
|
||||
if (!existsSync(file) || sha256(readFileSync(file)) !== hash) throw new Error(`缓存产物缺失或被修改:${name}`);
|
||||
}
|
||||
if (!Object.keys(previous.artifact_hashes || {}).length) throw new Error('缓存缺少产物校验记录');
|
||||
return {out, manifestPath, reused: true};
|
||||
}
|
||||
mkdirSync(out, {recursive: true});
|
||||
return {out, manifestPath, reused: false};
|
||||
}
|
||||
|
||||
export function finishRun(run, manifest, artifacts, secret) {
|
||||
manifest.artifact_hashes = Object.fromEntries(artifacts.map((name) => [name, sha256(readFileSync(path.join(run.out, name)))]));
|
||||
manifest.status = 'completed';
|
||||
jsonFile(run.manifestPath, manifest, secret);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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};
|
||||
}
|
||||
Reference in New Issue
Block a user