第三阶段改进
This commit is contained in:
+96
-15
@@ -23,14 +23,34 @@ function filesIn(dir, prefix = '') {
|
||||
}
|
||||
|
||||
export async function runCompletion({stage, inputDir, outDir, partIds = [], prepareOnly = false,
|
||||
candidatesDir, completionSize = 1024, extensionRadius = 32, python: requestedPython,
|
||||
resume = false, dryRun = false, candidateSizePolicy = 'strict'}, transport) {
|
||||
candidatesDir, completionSize = 1024, extensionRadius, python: requestedPython,
|
||||
resume = false, dryRun = false, candidateSizePolicy = 'strict', completionProfile = 'adaptive',
|
||||
completionReference = 'auto', completionMask = 'auto', completionReview, probeEditCapabilities = false}, transport) {
|
||||
if (!inputDir) throw new Error('部件补全需要 --input-dir 指向 SAM 输出目录');
|
||||
if (!outDir && !dryRun) throw new Error('部件补全需要独立的 --out-dir');
|
||||
if (prepareOnly && candidatesDir) throw new Error('--prepare-only 与 --candidates-dir 不能同时使用');
|
||||
const size = Number(completionSize), radius = Number(extensionRadius);
|
||||
const size = Number(completionSize), radius = extensionRadius === undefined
|
||||
? completionProfile === 'legacy' ? 32 : null : Number(extensionRadius);
|
||||
if (!['legacy', 'adaptive'].includes(completionProfile)) throw new Error('--completion-profile 仅支持 adaptive / legacy');
|
||||
if (!['auto', 'none', 'local', 'full', 'both', 'board'].includes(completionReference)) throw new Error('不支持的 --completion-reference');
|
||||
if (!['auto', 'required', 'off'].includes(completionMask)) throw new Error('--completion-mask 仅支持 auto / required / off');
|
||||
if (probeEditCapabilities && (partIds.length !== 1 || prepareOnly || candidatesDir || completionProfile !== 'adaptive')) {
|
||||
throw new Error('--probe-edit-capabilities 需要 adaptive、恰好一个 --part 和在线请求');
|
||||
}
|
||||
const referenceMode = probeEditCapabilities ? 'both' : completionReference === 'auto'
|
||||
? completionProfile === 'legacy' ? stage.capabilities.multiple_images ? 'local' : 'none'
|
||||
: stage.capabilities.multiple_images ? 'both' : 'board' : completionReference;
|
||||
if (completionProfile === 'legacy' && (completionReview || !['local', 'none'].includes(referenceMode))) {
|
||||
throw new Error('legacy 不支持 review 或 full/both/board 参考,请使用 adaptive');
|
||||
}
|
||||
const nativeReferences = ['local', 'full', 'both'].includes(referenceMode);
|
||||
const maskSent = probeEditCapabilities || completionMask === 'required' || (completionMask === 'auto' && stage.capabilities.mask);
|
||||
const capabilitiesAvailable = (!nativeReferences || stage.capabilities.multiple_images) && (!maskSent || stage.capabilities.mask);
|
||||
if (!capabilitiesAvailable && !probeEditCapabilities && !prepareOnly && !candidatesDir && !dryRun) {
|
||||
throw new Error('请求需要未确认的多图/mask 能力;可先 prepare-only,或用单部件 --probe-edit-capabilities 实测,不要直接更改默认 capabilities');
|
||||
}
|
||||
if (!['strict', 'uniform'].includes(candidateSizePolicy)) throw new Error('--candidate-size-policy 仅支持 strict / uniform');
|
||||
if (!Number.isInteger(size) || size < 128 || size > 4096 || !Number.isInteger(radius) || radius < 1 || radius > 256) {
|
||||
if (!Number.isInteger(size) || size < 128 || size > 4096 || (radius !== null && (!Number.isInteger(radius) || radius < 1 || radius > 256))) {
|
||||
throw new Error('--completion-size 必须为 128~4096 的整数;--extension-radius 必须为 1~256 的整数');
|
||||
}
|
||||
const input = path.resolve(inputDir);
|
||||
@@ -46,15 +66,43 @@ export async function runCompletion({stage, inputDir, outDir, partIds = [], prep
|
||||
const source = readImage(path.join(input, 'source.png'), {pngOnly: true});
|
||||
const inputFiles = ['source.png', 'semantic-parts.json', 'segmentation.json', ...segmentation.parts.map((p) => `masks/${p.id}.png`)];
|
||||
const inputs = Object.fromEntries(inputFiles.map((file) => [file, sha256(readFileSync(path.join(input, file)))]));
|
||||
const reviewFiles = [], reviewHashes = {};
|
||||
let reviewData;
|
||||
if (completionReview) {
|
||||
const file = path.resolve(completionReview);
|
||||
reviewData = readJson(file);
|
||||
if (reviewData.schema_version !== 1 || !reviewData.parts || Array.isArray(reviewData.parts)) throw new Error('review 需要 schema_version=1 和 parts 对象');
|
||||
reviewHashes['review.json'] = sha256(readFileSync(file));
|
||||
reviewFiles.push({file, target: 'review-source.json'});
|
||||
for (const [id, item] of Object.entries(reviewData.parts)) {
|
||||
if (!names.has(id) || !item || item.confirmed !== true) throw new Error('review 部件必须有效且 confirmed=true');
|
||||
for (const field of ['repair_mask', 'replace_mask', 'remove_mask']) {
|
||||
if (!Object.hasOwn(item, field)) continue;
|
||||
const ref = item[field];
|
||||
if (typeof ref !== 'string' || path.isAbsolute(ref)) throw new Error('review mask 必须是相对路径');
|
||||
const maskFile = path.resolve(path.dirname(file), ref), relative = path.relative(path.dirname(file), maskFile);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error('review mask 不能越出 review 目录');
|
||||
const target = `review/${id}-${field}.png`;
|
||||
reviewHashes[`${id}/${field}`] = sha256(readFileSync(maskFile));
|
||||
reviewFiles.push({file: maskFile, target});
|
||||
item[field] = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
const planning = {profile: completionProfile, reference_mode: referenceMode, review_hashes: reviewHashes,
|
||||
extension_radius: radius, planner_version: completionProfile === 'adaptive' ? 2 : 1};
|
||||
const imported = {};
|
||||
if (candidatesDir) {
|
||||
const priorManifest = path.resolve(candidatesDir, 'manifest.json');
|
||||
const previousRun = existsSync(priorManifest) ? readJson(priorManifest) : null;
|
||||
const previousPlanning = previousRun?.planning || {profile: 'legacy', reference_mode: previousRun?.resolved_stage?.capabilities?.multiple_images ? 'local' : 'none',
|
||||
review_hashes: {}, extension_radius: previousRun?.extension_radius, planner_version: 1};
|
||||
if (previousRun && (previousRun.output_kind !== 'bounded_part_completion' || previousRun.completion_size !== size || previousRun.extension_radius !== radius ||
|
||||
JSON.stringify(previousPlanning) !== JSON.stringify(planning) ||
|
||||
Object.entries(inputs).some(([file, hash]) => previousRun.inputs?.[file] !== hash))) {
|
||||
throw new Error('旧补全目录的输入或画布/延展参数不一致,不能复用其候选');
|
||||
}
|
||||
for (const p of plan.parts.filter((p) => p.needs_completion && (!partIds.length || partIds.includes(p.id)))) {
|
||||
for (const p of plan.parts.filter((p) => (completionProfile === 'adaptive' || p.needs_completion) && (!partIds.length || partIds.includes(p.id)))) {
|
||||
const file = previousRun
|
||||
? ['png', 'jpg', 'webp'].map((ext) => path.resolve(candidatesDir, 'tasks', p.id, `candidate-1.${ext}`)).find(existsSync)
|
||||
: path.resolve(candidatesDir, `${p.id}.png`);
|
||||
@@ -66,16 +114,18 @@ export async function runCompletion({stage, inputDir, outDir, partIds = [], prep
|
||||
if (dryRun) {
|
||||
console.log(JSON.stringify({stage: 'completion', input, width: source.width, height: source.height,
|
||||
selected_parts: partIds, completion_size: size, extension_radius: radius, candidate_size_policy: candidateSizePolicy,
|
||||
planning, mask_requested: maskSent, capabilities_available: capabilitiesAvailable,
|
||||
mode: prepareOnly ? 'prepare' : candidatesDir ? 'offline_candidates' : 'image_edit', network_requested: false}, null, 2));
|
||||
return {dryRun: true};
|
||||
}
|
||||
const codeFiles = ['stages/completion-stage.mjs', 'completion_geometry.py', 'normalize_reference.py',
|
||||
const codeFiles = ['stages/completion-stage.mjs', 'completion_geometry.py', 'completion_planning.py', 'normalize_reference.py',
|
||||
'lib/config.mjs', 'lib/python-runtime.mjs', 'lib/run-artifacts.mjs', 'lib/images.mjs',
|
||||
'lib/validate-parts.mjs', 'schemas/semantic-parts.schema.json', 'providers/image-edits.mjs', 'providers/http.mjs'];
|
||||
const identity = {schema_version: 1, stage: 'completion', output_kind: 'bounded_part_completion',
|
||||
mode: prepareOnly ? 'prepare' : candidatesDir ? 'offline_candidates' : 'image_edit',
|
||||
resolved_stage: publicStage(stage), endpoint_sha256: sha256(stage.baseUrl), prompt_sha256: sha256(stage.prompt),
|
||||
inputs, part_ids: partIds, completion_size: size, extension_radius: radius, candidate_size_policy: candidateSizePolicy,
|
||||
planning, mask_requested: maskSent, capability_probe: probeEditCapabilities,
|
||||
imported_candidates: Object.fromEntries(Object.entries(imported).map(([id, value]) => [id, value.sha256])),
|
||||
code_sha256: sha256(codeFiles.map((file) => `${file}\n${readFileSync(path.join(projectRoot, file), 'utf8')}`).join('\n'))};
|
||||
const fingerprint = sha256(JSON.stringify(identity));
|
||||
@@ -83,24 +133,51 @@ export async function runCompletion({stage, inputDir, outDir, partIds = [], prep
|
||||
if (run.reused) return {out: run.out, reused: true};
|
||||
const manifest = {...identity, fingerprint, status: 'running', quality_status: 'pending', started_at: new Date().toISOString(), network_requested: false};
|
||||
jsonFile(run.manifestPath, manifest, stage.apiKey);
|
||||
const result = {schema_version: 1, quality_status: 'needs_review', parts: [], skipped: []};
|
||||
const result = {schema_version: completionProfile === 'adaptive' ? 2 : 1, profile: completionProfile,
|
||||
quality_status: 'needs_review', parts: [], skipped: []};
|
||||
try {
|
||||
const requestSize = `${size}x${size}`;
|
||||
if (!prepareOnly && !candidatesDir && stage.params.size !== undefined && stage.params.size !== requestSize) {
|
||||
throw new Error(`completion.params.size 必须与局部画布一致:${requestSize}`);
|
||||
}
|
||||
const editStage = {...stage, params: {...stage.params, size: requestSize}};
|
||||
const editStage = {...stage, params: {...stage.params, size: requestSize},
|
||||
...(probeEditCapabilities ? {capabilities: {multiple_images: true, mask: true}, maxRetries: 0} : {})};
|
||||
if (probeEditCapabilities) editStage.params.n = 1;
|
||||
for (const file of inputFiles) {
|
||||
mkdirSync(path.dirname(path.join(run.out, file)), {recursive: true});
|
||||
copyFileSync(path.join(input, file), path.join(run.out, file));
|
||||
}
|
||||
pythonCall(python, ['prepare', '--out', run.out, '--size', String(size), '--radius', String(radius), ...partIds.flatMap((id) => ['--part', id])]);
|
||||
for (const {file, target} of reviewFiles) {
|
||||
mkdirSync(path.dirname(path.join(run.out, target)), {recursive: true});
|
||||
copyFileSync(file, path.join(run.out, target));
|
||||
}
|
||||
if (reviewData) jsonFile(path.join(run.out, 'review.json'), reviewData);
|
||||
pythonCall(python, ['prepare', '--out', run.out, '--size', String(size), '--profile', completionProfile,
|
||||
'--reference-mode', referenceMode, ...(radius === null ? [] : ['--radius', String(radius)]),
|
||||
...(reviewData ? ['--review-file', path.join(run.out, 'review.json')] : []), ...partIds.flatMap((id) => ['--part', id])]);
|
||||
const prepared = readJson(path.join(run.out, 'completion-plan.json'));
|
||||
result.skipped = prepared.skipped;
|
||||
result.parts = prepared.tasks.map((task) => ({id: task.id, status: 'prepared', upstream_warnings: task.warnings, semantic_verified: false}));
|
||||
if (probeEditCapabilities && prepared.tasks.length !== 1) throw new Error('所选部件没有可执行任务;请检查 completion-plan.json,不会发送能力探针');
|
||||
for (const [index, task] of prepared.tasks.entries()) {
|
||||
const folder = path.join(run.out, 'tasks', task.id);
|
||||
const prompt = `${stage.prompt}\n\nPart: ${task.id}: ${task.semantic}\nCompletion requirements: ${JSON.stringify(task.completion)}\nCanvas: ${size} x ${size} pixels. Keep the part at its current position and native scale.\nExtend only the hidden contour and requested joint connections, by at most ${radius} pixels from the visible silhouette. Keep all other background pure white or transparent. Do not add the occluder or neighboring body parts.\n${stage.capabilities.multiple_images ? 'The second image is local context only; do not copy its other parts into the output.' : 'Only the isolated visible part is provided.'}\nReturn exactly the same canvas dimensions.\n`;
|
||||
const imageRoles = [{file: 'input.png', role: referenceMode === 'board' ? 'target_with_reference_panels' : 'target'}];
|
||||
if (['local', 'both'].includes(referenceMode)) imageRoles.push({file: 'context.png', role: 'local_reference'});
|
||||
if (['full', 'both'].includes(referenceMode)) imageRoles.push({file: 'reference.png', role: 'full_character_reference'});
|
||||
const images = imageRoles.map(({file}) => readImage(path.join(folder, file), {pngOnly: true}));
|
||||
const mask = maskSent ? readImage(path.join(folder, 'edit-mask.png'), {pngOnly: true}) : null;
|
||||
const requestPlan = {model: stage.model, size: requestSize, reference_mode: referenceMode,
|
||||
images: images.map((im) => im.sha256), image_roles: images.map((im, i) => ({...imageRoles[i], index: i, sha256: im.sha256, width: im.width, height: im.height})),
|
||||
image_field: stage.imageField, mask_editable: mask ? stage.maskEditable : null,
|
||||
edit_mask_sent: Boolean(mask), mask_sha256: mask?.sha256 ?? null,
|
||||
mask_enforcement: mask ? 'provider_requested_and_local' : 'local_only',
|
||||
capability_status: probeEditCapabilities ? 'unverified_probe' : 'configured_not_proven_by_this_run',
|
||||
allowed_mask_sha256: sha256(readFileSync(path.join(folder, 'allowed-mask.png')))};
|
||||
jsonFile(path.join(folder, 'request-plan.json'), {...requestPlan, request_sent: false}, stage.apiKey);
|
||||
const referenceInstruction = referenceMode === 'board'
|
||||
? `The LEFT work panel ${JSON.stringify(task.transform.working_xyxy)} is the only output target. RIGHT panels are LOCAL and FULL character references. Keep the entire ${size}x${size} board layout unchanged; never place reference characters in the target panel. Preserve reference panels.`
|
||||
: `Image order: ${imageRoles.map((im, i) => `${i + 1}=${im.role}`).join(', ')}. Only edit the first image. Subsequent images are references, never extra output parts.`;
|
||||
const prompt = `${stage.prompt}\n\nPart: ${task.id}: ${task.semantic}\nCompletion requirements: ${JSON.stringify(task.completion)}\nCanvas: ${size} x ${size} pixels. Preserve the target's exact current position and displayed scale.\nTask regions: ${JSON.stringify(task.regions)}. segmentation_gap: restore source texture missing from segmentation; attachment_cleanup: paint the underlying material WITHOUT the overlaid attachment; reviewed_replacement: repair incorrect pixels; joint/occlusion regions: extend this part only.\n${referenceInstruction}\n${mask ? 'Transparent mask pixels are editable; opaque mask pixels must remain unchanged.' : 'No provider mask is sent. Follow the bounded edit instructions; output is constrained locally afterwards.'}\nReturn exactly the same canvas dimensions.\n`;
|
||||
const fullPrompt = `${prompt}\nOccluded by: ${task.occluded_by.join(', ') || 'not specified'}.\nLocal pixel bounds for proposed extensions (xyxy, exclusive upper bounds): ${JSON.stringify(task.region_bounds_xyxy)}. Null means no supported region for that requirement. These boxes bound the allowed regions; do not fill whole rectangles.\n`;
|
||||
writeFileSync(path.join(folder, 'prompt.txt'), fullPrompt);
|
||||
if (prepareOnly) continue;
|
||||
@@ -111,11 +188,7 @@ export async function runCompletion({stage, inputDir, outDir, partIds = [], prep
|
||||
if (!imported[task.id]) { record.status = 'missing_candidate'; continue; }
|
||||
outputs = [{bytes: readFileSync(imported[task.id].file), extension: path.extname(imported[task.id].file).slice(1)}];
|
||||
} else {
|
||||
const images = [readImage(path.join(folder, 'input.png'), {pngOnly: true})];
|
||||
if (stage.capabilities.multiple_images) images.push(readImage(path.join(folder, 'context.png'), {pngOnly: true}));
|
||||
const mask = stage.capabilities.mask ? readImage(path.join(folder, 'edit-mask.png'), {pngOnly: true}) : null;
|
||||
jsonFile(path.join(folder, 'request.json'), {model: stage.model, size: requestSize, images: images.map((im) => im.sha256),
|
||||
edit_mask_sent: Boolean(mask), allowed_mask_sha256: sha256(readFileSync(path.join(folder, 'allowed-mask.png')))}, stage.apiKey);
|
||||
jsonFile(path.join(folder, 'request.json'), {...requestPlan, request_sent: true}, stage.apiKey);
|
||||
manifest.network_requested = true;
|
||||
jsonFile(run.manifestPath, manifest, stage.apiKey);
|
||||
try {
|
||||
@@ -126,6 +199,10 @@ export async function runCompletion({stage, inputDir, outDir, partIds = [], prep
|
||||
} catch (error) {
|
||||
record.status = 'request_failed';
|
||||
if (error.diagnostics) jsonFile(path.join(folder, 'response.error.json'), error.diagnostics, stage.apiKey);
|
||||
if (probeEditCapabilities) jsonFile(path.join(folder, 'capability-evidence.json'), {
|
||||
request_accepted: false, request: requestPlan, error: error.message,
|
||||
reference_use_verified: false, mask_semantics_verified: false,
|
||||
verdict: 'request_failed_no_capability_promotion'}, stage.apiKey);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -145,6 +222,10 @@ export async function runCompletion({stage, inputDir, outDir, partIds = [], prep
|
||||
record.status = record.selected_file ? 'geometry_passed_needs_review' : 'rejected';
|
||||
if (!record.qa) record.qa = {...record.candidates[0]?.qa, accepted: false,
|
||||
reasons: [...new Set(record.candidates.flatMap((c) => c.qa.reasons))]};
|
||||
if (probeEditCapabilities) jsonFile(path.join(folder, 'capability-evidence.json'), {
|
||||
request_accepted: true, request: requestPlan, candidates: record.candidates.map((c) => c.qa),
|
||||
reference_use_verified: false, mask_semantics_verified: false,
|
||||
verdict: 'needs_visual_review_no_automatic_capability_promotion'}, stage.apiKey);
|
||||
jsonFile(path.join(run.out, 'completion-result.json'), result, stage.apiKey);
|
||||
}
|
||||
manifest.quality_status = prepareOnly ? 'prepared_needs_review' : 'needs_review';
|
||||
|
||||
Reference in New Issue
Block a user