semantic-parts.json 和 segmentation-prompts.json,仅使用 status=candidate 的语义、粗框和程序候选点。模型原始正负点不传给 SAM。候选始终 semantic_verified=false:框内可能混有多个部件,未来仍需要 mask 语义选择、越出角色 Alpha 比例等 QA。
+已实现的[第二阶段](2-segmentation-stage.md)读取 source.png、semantic-parts.json 和 segmentation-prompts.json,仅处理 status=candidate 的可见部件。SAM 图像概念接口接收文本和框;程序候选点参与返回 mask 的排序,不作为点提示传入此接口。模型原始正负点不传给 SAM。候选始终 semantic_verified=false:框内可能混有多个部件,Alpha、重叠和连通域检查不能替代语义复核。
已有 v1 结果可以通过 --parts-file 旧计划.json --reference 原图.png --out-dir 新目录 离线迁移与校验,不请求模型。迁移按 ID 推断骨骼/附件类型,袖子优先绑定同侧上臂,面部绑定头部;部分可见部件开启遮挡补全,骨骼连接处保守建议延展。这些推断在 notes 标注为待复核,不替代模型或人工判断。输入计划哈希和所有新产物参与 resume 校验。
-manifest.json 当前只记录 SAM checkpoint 是否存在,runtime_status 仍为 not_integrated,不会加载权重或占用 GPU。
+第一阶段 manifest.json 只记录 SAM 路径信息,runtime_status=local_subprocess_not_probed,第一阶段不加载 SAM。第二阶段单独探测 Conda/CUDA、严格加载本地权重,并在自己的 manifest 中记录运行环境、权重哈希及子进程退出状态。
diff --git a/docs/2-segmentation-stage.md b/docs/2-segmentation-stage.md
new file mode 100644
index 0000000..32e508f
--- /dev/null
+++ b/docs/2-segmentation-stage.md
@@ -0,0 +1,108 @@
+# 第二阶段:本地 SAM 分割
+
+本阶段读取第一阶段的语义计划,使用本地 SAM 3.1 图像检测分支生成部件 mask,从原图提取可见像素,并输出两张预览图。Python 固定使用 `ai-spine` Conda 环境,遵守 [AGENTS.md](../AGENTS.md)。
+
+## 运行方式
+
+在 `AI_spine` 目录执行:
+
+```powershell
+conda run --no-capture-output -n ai-spine node .\tools\model-check.mjs `
+ --stage segmentation `
+ --input-dir .\output\sombrero-grounding-v2 `
+ --out-dir .\output\sombrero-sam-local `
+ --sam-dir F:/Study/SAM3
+```
+
+输出目录必须是新目录。相同输入、代码、权重、参数及完整产物可加 `--resume` 复用;失败结果保留,另建目录重跑。独立分割不调用视觉 LLM 或图片编辑接口,不需要 API Key。
+
+输入目录必须包含:
+
+| 文件 | 要求 |
+| --- | --- |
+| `source.png` | 第一阶段预处理后的原尺寸 RGBA |
+| `semantic-parts.json` | 通过结构校验的 v2 语义计划 |
+| `segmentation-prompts.json` | 与计划 ID、画布匹配的程序候选提示 |
+
+仅 `status=candidate` 的可见部件进入推理。hidden/blocked 记入跳过列表;无可用 SAM mask 的部件记录失败原因,不用矩形或候选点伪造分割。
+
+可以在命令末尾加 `--dry-run` 检查代码路径、权重存在性、Conda、依赖导入和 CUDA;它不加载权重,因此不验证权重内容或完整推理显存需求。
+
+如需从第一阶段接续运行,在原结构理解命令中使用 `--stop-after segment --sam-dir F:/Study/SAM3`。第二阶段输出到理解目录下的 `segmentation/`。原理解命令仍可能请求付费模型;已有计划优先使用上面的独立命令。
+
+## 环境与权重
+
+本机已经验证:Python 3.11、PyTorch `2.7.1+cu126`、torchvision `0.22.1+cu126`、NumPy `1.26.4`、OpenCV `4.11.0.86`、Windows Triton `3.3.1.post21`,GPU 为 RTX 3060 Laptop 6 GB。
+
+环境补全命令见 [README](../README.md#python-与外部项目),依赖记录在 [requirements.txt](../requirements.txt)。程序不会自动安装依赖、创建环境或切换到 CPU。
+
+默认布局:
+
+```text
+F:/Study/SAM3/
+ sam3/ 源码仓库
+ sam3/model_builder.py
+ sam3.1checkpoint/
+ sam3.1_multiplex.pt
+```
+
+路径优先级为 CLI 覆盖、环境变量、配置默认值。`SAM_MODEL_DIR` 指定根目录,`SAM_CODE_DIR` 可单独指定源码仓库。若 `.env` 中仍是旧路径,修改为上述路径或显式传入 CLI 参数。
+
+| 参数 | 含义 |
+| --- | --- |
+| `--sam-dir` | SAM 根目录 |
+| `--sam-code-dir` | 源码仓库目录,内部应包含 `sam3/model_builder.py` |
+| `--sam-checkpoint` | 本地权重文件 |
+| `--sam-confidence` | SAM 候选分数阈值,默认 0.2,不是语义正确率 |
+| `--sam-precision` | 默认 bf16 自动混合精度;可设 fp32,可能需要更多显存 |
+
+Node 先启动环境探测子进程,再启动一次推理子进程。模型只加载到后者;没有监听端口或常驻服务,正常结束、报错、超时或收到取消信号后退出。推理超时为 20 分钟。禁用 Hugging Face 在线下载,权重只能来自本地。
+
+`sam_runtime.py` 从 multiplex checkpoint 提取 `detector.*`。SAM 3.1 的检测分支有三个特征尺度;图像 builder 的第四尺度原本会被 scalp 丢弃,因此适配器移除该未使用尺度并取消 scalp。忽略视频交互/传播 neck,随后 `strict=True` 加载所有实际使用参数;结构不匹配会报错。未修改外部 SAM 源码。
+
+保留 FP32 参数和复数位置编码缓冲区,仅在推理上下文开启 BF16 autocast。不能直接把整个模型转换为 BF16,这会破坏复数缓冲区及上游要求 FP32 的解码器路径。
+
+## 提示、选择与质量检查
+
+1. 在白色背景上合成参考图,保持原尺寸;GPU 只编码一次图像。
+2. 每个部件先用语义文本推理,再加入归一化粗框推理,保留两组候选。
+3. 根据模型分数、粗框内比例、越出角色 Alpha 的比例及候选点命中情况排序。拒绝前景不足 4 像素或框内比例低于 10% 的结果。
+4. 最终 mask 与原图 Alpha 前景取交集,从原图提取 RGB 和 Alpha,不重绘、不缩放。原始选中 mask 另存用于检查越界。
+5. 记录漏分、重叠、碎片化、候选点未覆盖,以及不同部件 mask IoU ≥ 0.8 的疑似重复。
+
+当前使用的 SAM 图像概念接口接受文本与框,不接受点。第一阶段的 Alpha 候选点仅用于 mask 排序;模型原始点仍只用于第一阶段诊断。这次 mask 来自真实 GPU 模型推理。
+
+粗框、候选点及模型分数都不能保证部件语义正确。因此所有结果保持 `quality_status=needs_review`、`semantic_verified=false`。重叠 mask 保留,未强行分配成互斥部件,也未补全遮挡纹理。人工复核通过后才能继续补全或导出。
+
+## 输出
+
+| 文件 | 内容 |
+| --- | --- |
+| `annotated.png` | 原尺寸角色图,各候选部件用不同半透明颜色叠加,透明背景显示为白色 |
+| `parts-sheet.png` | 白底拆解图,部件保持原尺寸、分开排布,并标注 ID 和颜色 |
+| `parts/颜色表示候选部件 mask。白底拆解图保留原始可见像素,尚未补全遮挡部分。重叠 mask 保留并计入诊断,不代表已经获得互斥、语义正确的最终拆件。
+
+| ID / 颜色 | 语义 | 模型分数 | 需复核 |
|---|
{html.escape(json.dumps(result['skipped'], ensure_ascii=False, indent=2))}
+覆盖率只表示有 mask 的像素比例,不是分割准确率。模型分数也不代表部件语义已验证。
{html.escape(json.dumps(result['coverage'], indent=2))}
+以下不同部件的 mask IoU ≥ 0.8,需要检查是否分到了同一物体。
{html.escape(json.dumps(result['near_duplicate_masks'], indent=2))}'''
+ (out / 'report.html').write_text(body, encoding='utf-8')
diff --git a/stages/segmentation-stage.mjs b/stages/segmentation-stage.mjs
new file mode 100644
index 0000000..37438cc
--- /dev/null
+++ b/stages/segmentation-stage.mjs
@@ -0,0 +1,101 @@
+import {spawn} from 'node:child_process';
+import {createHash} from 'node:crypto';
+import {createReadStream, createWriteStream, existsSync, readFileSync, readdirSync, writeFileSync} from 'node:fs';
+import path from 'node:path';
+import {projectRoot, samInfo} from '../lib/config.mjs';
+import {selectPython} from '../lib/python-runtime.mjs';
+import {readImage, sha256} from '../lib/images.mjs';
+import {validateParts} from '../lib/validate-parts.mjs';
+import {prepareRun, jsonFile, finishRun} from '../lib/run-artifacts.mjs';
+
+const worker = path.join(projectRoot, 'sam_segment.py');
+
+export function runWorker(python, args, {logFile, timeoutMs = 1200000} = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(python, ['-u', worker, ...args], {cwd: projectRoot, windowsHide: true,
+ env: {...process.env, PYTHONUTF8: '1', HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1'},
+ stdio: ['ignore', 'pipe', 'pipe']});
+ const log = logFile ? createWriteStream(logFile) : null;
+ let stdout = '', stderr = '', timedOut = false, interrupted = false;
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs);
+ const stop = () => { interrupted = true; child.kill(); };
+ process.once('SIGINT', stop);
+ process.once('SIGTERM', stop);
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); log?.write(chunk); if (log) process.stdout.write(chunk); });
+ child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-20000); log?.write(chunk); if (log) process.stderr.write(chunk); });
+ const cleanup = () => { clearTimeout(timer); process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); };
+ child.once('error', (error) => { cleanup(); log?.end(); reject(error); });
+ child.once('close', (code) => {
+ cleanup();
+ const done = () => {
+ if (code !== 0 || timedOut || interrupted) reject(new Error(timedOut ? 'SAM 子进程超时,已终止' : interrupted ? 'SAM 子进程已取消' : `SAM 子进程失败 (${code}):${stderr.slice(-3500)}`));
+ else resolve(stdout);
+ };
+ if (log) log.end(done); else done();
+ });
+ });
+}
+
+async function fileHash(file) {
+ const hash = createHash('sha256');
+ for await (const chunk of createReadStream(file)) hash.update(chunk);
+ return hash.digest('hex');
+}
+
+function filesIn(dir, prefix = '') {
+ return readdirSync(dir, {withFileTypes: true}).flatMap((entry) => entry.isDirectory()
+ ? filesIn(path.join(dir, entry.name), `${prefix}${entry.name}/`) : [`${prefix}${entry.name}`]);
+}
+
+export async function runSegmentation(options) {
+ const {loaded, inputDir, outDir, resume = false, dryRun = false} = options;
+ if (!inputDir) throw new Error('SAM 分割需要 --input-dir 指向已完成的结构理解目录');
+ if (!outDir && !dryRun) throw new Error('SAM 分割需要独立的 --out-dir');
+ const sourceDir = path.resolve(inputDir);
+ const image = readImage(path.join(sourceDir, 'source.png'), {pngOnly: true});
+ const planBytes = readFileSync(path.join(sourceDir, 'semantic-parts.json'));
+ const promptBytes = readFileSync(path.join(sourceDir, 'segmentation-prompts.json'));
+ const plan = JSON.parse(planBytes);
+ const validation = validateParts(plan);
+ if (!validation.valid || plan.schema_version !== 2) throw new Error(`需要有效的 v2 计划;请先离线迁移/校验:${validation.errors.join(', ')}`);
+ const prompts = JSON.parse(promptBytes);
+ if (prompts.width !== image.width || prompts.height !== image.height) throw new Error('候选点画布尺寸与 source.png 不一致');
+ const sam = samInfo(loaded, options);
+ if (!sam.checkpoint_exists || !sam.code_exists) throw new Error(`SAM 代码或权重不存在:${JSON.stringify(sam)}`);
+ const python = selectPython(options.python);
+ const confidence = Number(options.samConfidence ?? .2);
+ const precision = options.samPrecision || 'bf16';
+ if (!(confidence > 0 && confidence < 1) || !['bf16', 'fp32'].includes(precision)) throw new Error('sam-confidence 必须在 0~1 之间,sam-precision 仅支持 bf16/fp32');
+ const baseArgs = ['--code-dir', sam.code_dir, '--checkpoint', sam.checkpoint, '--confidence', String(confidence), '--precision', precision];
+ const runtime = JSON.parse((await runWorker(python, [...baseArgs, '--probe'], {timeoutMs: 120000})).trim());
+ if (dryRun) { console.log(JSON.stringify({sam, runtime, confidence, precision, network_requested: false}, null, 2)); return {dryRun: true}; }
+ const codeFiles = ['sam_segment.py', 'sam_runtime.py', 'segmentation_outputs.py', 'stages/segmentation-stage.mjs', 'lib/python-runtime.mjs', 'lib/validate-parts.mjs', 'schemas/semantic-parts.schema.json'];
+ const identity = {schema_version: 1, stage: 'segmentation', runtime, sam, confidence, precision,
+ inputs: {source_sha256: image.sha256, plan_sha256: sha256(planBytes), prompts_sha256: sha256(promptBytes)},
+ checkpoint_sha256: await fileHash(sam.checkpoint),
+ code_sha256: sha256(codeFiles.map((f) => `${f}\n${readFileSync(path.join(projectRoot, f), 'utf8')}`).join('\n'))};
+ const fingerprint = sha256(JSON.stringify(identity));
+ const run = prepareRun(outDir, fingerprint, resume);
+ if (run.reused) return {out: run.out, reused: true};
+ const manifest = {...identity, fingerprint, status: 'running', quality_status: 'pending', started_at: new Date().toISOString()};
+ jsonFile(run.manifestPath, manifest);
+ writeFileSync(path.join(run.out, 'source.png'), image.bytes);
+ writeFileSync(path.join(run.out, 'semantic-parts.json'), planBytes);
+ writeFileSync(path.join(run.out, 'segmentation-prompts.json'), promptBytes);
+ try {
+ await runWorker(python, [...baseArgs, '--output', run.out], {logFile: path.join(run.out, 'worker.log')});
+ const result = JSON.parse(readFileSync(path.join(run.out, 'segmentation.json'), 'utf8'));
+ manifest.quality_status = 'needs_review';
+ manifest.completed_at = new Date().toISOString();
+ manifest.part_count = result.parts.length;
+ manifest.skipped_count = result.skipped.length;
+ manifest.coverage = result.coverage;
+ manifest.gpu_worker_exited = true;
+ finishRun(run, manifest, filesIn(run.out).filter((name) => name !== 'manifest.json'));
+ return {out: run.out, reused: false, parts: result.parts.length};
+ } catch (error) {
+ manifest.status = 'failed'; manifest.quality_status = 'failed'; manifest.error = error.message;
+ jsonFile(run.manifestPath, manifest);
+ throw error;
+ }
+}
diff --git a/tests/model-stages.test.mjs b/tests/model-stages.test.mjs
index 6c3669a..8602322 100644
--- a/tests/model-stages.test.mjs
+++ b/tests/model-stages.test.mjs
@@ -267,7 +267,7 @@ test('pipeline new mode reaches a mock relay with geometry; model listing and un
const list = await exec(process.execPath, ['tools/model-check.mjs', '--list-models'], {cwd: projectRoot, env});
assert.match(list.stdout, /relay-vision-test/u);
assert.equal(requests, 2);
- await assert.rejects(exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--stop-after', 'segment'], {cwd: projectRoot, env}), (error) => /仅支持 understand/u.test(error.stderr));
+ await assert.rejects(exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--stop-after', 'export'], {cwd: projectRoot, env}), (error) => /仅支持 understand/u.test(error.stderr));
assert.equal(requests, 2);
});
@@ -290,3 +290,15 @@ test('Python geometry regression suite', async () => {
const result = await exec(geometryPython(), ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_geometry.py'], {cwd: projectRoot});
assert.match(result.stderr, /OK/u);
});
+
+test('Python segmentation QA, prompt integrity and rendering regressions (no model/GPU needed)', async () => {
+ const result = await exec(geometryPython(), ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_segmentation.py'], {cwd: projectRoot});
+ assert.match(result.stderr, /OK/u);
+});
+
+test('SAM stage validates local input without requiring relay credentials', async (t) => {
+ const dir = temporary(t);
+ const envFile = path.join(dir, 'empty.env'); writeFileSync(envFile, '');
+ const env = {...process.env, VECTOR_ENGINE_BASE_URL: '', VECTOR_ENGINE_API_KEY: ''};
+ await assert.rejects(exec(process.execPath, ['tools/model-check.mjs', '--env-file', envFile, '--stage', 'segmentation', '--out-dir', path.join(dir, 'result')], {cwd: projectRoot, env}), (error) => /--input-dir/u.test(error.stderr));
+});
diff --git a/tests/test_segmentation.py b/tests/test_segmentation.py
new file mode 100644
index 0000000..a4870ed
--- /dev/null
+++ b/tests/test_segmentation.py
@@ -0,0 +1,84 @@
+import copy
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+import numpy as np
+from PIL import Image
+
+from sam_segment import choose_candidate, validate_prompts
+from segmentation_outputs import assess_candidate, render_outputs, write_report
+
+
+class SegmentationTests(unittest.TestCase):
+ def setUp(self):
+ self.rgba = np.zeros((24, 32, 4), dtype=np.uint8)
+ self.rgba[4:20, 5:27] = [120, 50, 200, 255]
+ self.rgba[5, 6, 3] = 80
+ self.image = Image.fromarray(self.rgba)
+ self.mask = self.rgba[:, :, 3] > 0
+
+ def test_background_leak_measured_before_clipping(self):
+ raw = np.ones((24, 32), dtype=bool)
+ qa = assess_candidate(raw, self.rgba[:, :, 3], [5, 4, 27, 20], [[6, 5]])
+ self.assertGreater(qa['outside_alpha_ratio'], .5)
+ self.assertEqual(qa['seed_hit_fraction'], 1)
+ self.assertEqual(qa['area_px'], 16 * 22)
+
+ def test_rank_prefers_local_mask_and_never_invents_empty_mask(self):
+ wrong = self.mask.copy()
+ local = np.zeros_like(wrong)
+ local[8:13, 8:13] = True
+ best, _ = choose_candidate([wrong, local], [.95, .7], self.rgba[:, :, 3], [8, 8, 13, 13], [[10, 10]])
+ self.assertEqual(best['index'], 1)
+ best, _ = choose_candidate([np.zeros_like(local)], [.99], self.rgba[:, :, 3], [8, 8, 13, 13], [[10, 10]])
+ self.assertIsNone(best)
+
+ def test_prompts_reject_transparent_points_mismatched_canvas_and_duplicate_ids(self):
+ plan = {'parts': [{'id': 'hand', 'visibility': 'visible'}]}
+ prompts = {'width': 32, 'height': 24, 'parts': [{'id': 'hand', 'status': 'candidate',
+ 'seed_source': 'alpha_roi_distance_transform', 'bbox_px_xyxy': [0, 0, 32, 24],
+ 'positive_points_px': [[10, 10]]}]}
+ validate_prompts(plan, prompts, self.image)
+ for variant in range(3):
+ bad = copy.deepcopy(prompts)
+ if variant == 0:
+ bad['parts'][0]['positive_points_px'] = [[0, 0]]
+ elif variant == 1:
+ bad['width'] = 99
+ else:
+ bad['parts'].append(copy.deepcopy(bad['parts'][0]))
+ with self.assertRaises(ValueError):
+ validate_prompts(plan, bad, self.image)
+
+ def test_render_preserves_cutout_pixels_alpha_and_measures_overlap(self):
+ parts = [{'id': 'hand', 'semantic': 'hand', 'mask': self.mask, 'model_score': .9},
+ {'id': 'forearm', 'semantic': 'forearm', 'mask': self.mask, 'model_score': .8}]
+ with tempfile.TemporaryDirectory() as directory:
+ result = render_outputs(self.image, parts, directory)
+ self.assertEqual(result['coverage']['coverage_ratio'], 1)
+ self.assertEqual(result['coverage']['overlap_pixels'], int(self.mask.sum()))
+ self.assertEqual(result['near_duplicate_masks'][0]['iou'], 1)
+ self.assertIn('NEAR_DUPLICATE_MASK', result['parts'][0]['warnings'])
+ cut = np.array(Image.open(Path(directory) / 'parts/hand.png'))
+ np.testing.assert_array_equal(cut, self.rgba[4:20, 5:27])
+ self.assertEqual(Image.open(Path(directory) / 'annotated.png').size, self.image.size)
+ sheet = np.array(Image.open(Path(directory) / 'parts-sheet.png'))
+ self.assertTrue(np.all(sheet[0, 0] == 255))
+ a, b = [p['sheet_xyxy'] for p in result['parts']]
+ self.assertTrue(a[2] <= b[0] or b[2] <= a[0] or a[3] <= b[1] or b[3] <= a[1])
+ result['skipped'] = []
+ write_report(directory, result)
+ self.assertTrue((Path(directory) / 'report.html').exists())
+ json.dumps(result)
+
+ def test_renderer_rejects_empty_masks_and_unsafe_ids(self):
+ with tempfile.TemporaryDirectory() as directory:
+ for name, mask in [('hand', np.zeros_like(self.mask)), ('../bad', self.mask)]:
+ with self.assertRaises(ValueError):
+ render_outputs(self.image, [{'id': name, 'mask': mask}], directory)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tools/model-check.mjs b/tools/model-check.mjs
index 2547804..f6f0976 100644
--- a/tools/model-check.mjs
+++ b/tools/model-check.mjs
@@ -5,10 +5,12 @@ import {loadConfig, resolveStage, publicStage, samInfo} from '../lib/config.mjs'
import {requestJson} from '../providers/http.mjs';
import {runUnderstanding, runCompletionProbe} from '../stages/model-stage.mjs';
import {readImage} from '../lib/images.mjs';
+import {runSegmentation} from '../stages/segmentation-stage.mjs';
const help = `模型接入与结构理解(新增模式,不调用旧拆件流程)
node pipeline.mjs --mode annotated-parts --reference image.png --out-dir output/run --stop-after understand
node tools/model-check.mjs --stage completion --image local.png --prompt-file task.txt --out-dir output/edit
+ node tools/model-check.mjs --stage segmentation --input-dir output/understand --out-dir output/segment --sam-dir F:/Study/SAM3
node tools/model-check.mjs --list-models
node tools/model-check.mjs --dry-run
@@ -23,11 +25,17 @@ const help = `模型接入与结构理解(新增模式,不调用旧拆件流
--resume 输入/配置/代码和产物哈希相同时复用
--parts-file FILE 离线读取已有 v1/v2 计划,做 Alpha 校验与种子生成,不请求模型
--python FILE ai-spine Conda 环境解释器(默认使用已激活环境)
+ --input-dir DIR 已完成的结构理解目录(SAM 阶段输入)
+ --sam-dir DIR 本地 SAM 根目录,包含 sam3 与 sam3.1checkpoint
+ --sam-code-dir DIR SAM 源码仓库目录覆盖
+ --sam-checkpoint FILE 本地权重路径覆盖
+ --sam-confidence FLOAT 模型候选分数阈值,默认 0.2
+ --sam-precision NAME bf16(默认)或 fp32
--image FILE 图片编辑输入,可重复;多图需配置 capabilities
--mask FILE 透明处可编辑的 RGBA PNG;需配置 capabilities
--dry-run 检查配置与本地输入,不请求模型、不生成产物
-本轮 --stop-after 仅支持 understand。SAM 路径已配置,但分割/合并/导出尚未接入。`;
+--stop-after 支持 understand / segment。SAM 在临时 GPU 子进程运行,完成后退出;补全合并/导出尚未接入。`;
export function parseOptions(argv) {
const options = {stage: 'understanding', imagePaths: []};
@@ -37,6 +45,8 @@ export function parseOptions(argv) {
['--understanding-protocol', 'understandingProtocol'], ['--output-mode', 'outputMode'],
['--stage', 'stage'], ['--mask', 'maskPath'], ['--prompt-file', 'promptFile'], ['--stop-after', 'stopAfter'],
['--mode', 'mode'], ['--python', 'python'], ['--stream', 'streamRaw'], ['--parts-file', 'partsFile'],
+ ['--input-dir', 'inputDir'], ['--sam-dir', 'samDir'], ['--sam-code-dir', 'samCodeDir'],
+ ['--sam-checkpoint', 'samCheckpoint'], ['--sam-confidence', 'samConfidence'], ['--sam-precision', 'samPrecision'],
]);
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
@@ -57,15 +67,22 @@ export function parseOptions(argv) {
export async function main(argv = process.argv.slice(2)) {
const options = parseOptions(argv);
if (options.help) { console.log(help); return; }
- if (!['understanding', 'completion'].includes(options.stage)) throw new Error('--stage 仅支持 understanding 或 completion');
+ if (!['understanding', 'completion', 'segmentation'].includes(options.stage)) throw new Error('--stage 仅支持 understanding / completion / segmentation');
if (options.partsFile && (options.stage !== 'understanding' || options.listModels)) throw new Error('--parts-file 仅用于离线结构理解');
if (options.streamRaw !== undefined && !['true', 'false'].includes(options.streamRaw)) throw new Error('--stream 仅支持 true 或 false');
options.stream = options.streamRaw === undefined ? undefined : options.streamRaw === 'true';
if (options.mode && options.mode !== 'annotated-parts') throw new Error('新入口仅支持 --mode annotated-parts');
- if (options.stopAfter && (options.stopAfter !== 'understand' || options.stage !== 'understanding')) {
- throw new Error('本轮仅落地模型接入和结构理解;--stop-after 目前仅支持 understand');
+ if (options.stopAfter && (!['understand', 'segment'].includes(options.stopAfter) || options.stage === 'completion' || (options.stage === 'segmentation' && options.stopAfter !== 'segment'))) {
+ throw new Error('--stop-after 仅支持 understand / segment,且必须匹配阶段');
}
const loaded = loadConfig(options);
+ if (options.stage === 'segmentation' || (options.stopAfter === 'segment' && options.inputDir)) {
+ if (options.reference || options.partsFile || options.imagePaths.length || options.maskPath || options.promptFile || options.listModels) throw new Error('独立 SAM 阶段使用 --input-dir,不接受模型请求或编辑参数');
+ const result = await runSegmentation({...options, loaded});
+ if (!result.dryRun) console.log(`SAM 分割${result.reused ? '复用完成' : '完成'}:${path.join(result.out, 'report.html')}`);
+ return result;
+ }
+ if (options.inputDir) throw new Error('--input-dir 仅用于 SAM 分割阶段');
const overrides = options.stage === 'understanding'
? {model: options.understandingModel, protocol: options.understandingProtocol, outputMode: options.outputMode, stream: options.stream}
: {model: options.completionModel};
@@ -93,6 +110,10 @@ export async function main(argv = process.argv.slice(2)) {
console.log(options.partsFile ? '角色结构理解:离线计划几何校验' : `角色结构理解:${stage.model} (${stage.protocol})`);
result = await runUnderstanding({...options, loaded, stage});
console.log(`${result.reused ? '复用已校验产物' : '结构理解完成,语义待复核'}:${path.join(result.out, 'report.html')}`);
+ if (options.stopAfter === 'segment') {
+ result = await runSegmentation({...options, loaded, inputDir: result.out, outDir: path.join(result.out, 'segmentation')});
+ console.log(`SAM 分割完成:${path.join(result.out, 'report.html')}`);
+ }
} else {
if (options.reference) throw new Error('图片编辑使用 --image 指定局部画布;额外参考图也通过重复 --image 指定');
console.log(`图片编辑接入测试:${stage.model}`);