diff --git a/.env.example b/.env.example index 00fae72..b99649e 100644 --- a/.env.example +++ b/.env.example @@ -9,5 +9,6 @@ VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1200000 SPINE_UNDERSTANDING_MODEL=gpt-6-astra SPINE_COMPLETION_MODEL=qwen-image-3.0 -# Checkpoint location only; the SAM runtime is integrated in milestone 2. -SAM_MODEL_DIR=F:/Study/sam3.1 +# Local source + checkpoint root. SAM runs in a temporary ai-spine GPU process. +SAM_MODEL_DIR=F:/Study/SAM3 +SAM_CODE_DIR=F:/Study/SAM3/sam3 diff --git a/README.md b/README.md index 7399873..06ada04 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 2. 使用 SAM 根据语义、粗框和程序候选生成 mask,经语义/几何检查后从原图提取可见像素。 3. 使用图片编辑模型补全被遮挡的部件,再进行几何验收和 Spine 导出。 -目前第一阶段已经可以完整运行。SAM 3.1 checkpoint 路径和图片编辑适配器已经接入配置,但 SAM 推理、补全结果合并及新管线的 Spine 导出尚未完成。 +目前第一阶段和第二阶段均可运行。第二阶段在 `ai-spine` Conda 环境的临时子进程中加载本地 SAM 3.1 权重,使用 GPU 分割并生成半透明标注图、白底拆解图和透明部件。分割结果仍需语义复核;补全结果合并及新管线的 Spine 导出尚未完成。 仓库仍保留旧版“AI 生成白底拆件图 → OpenCV 拆分 → Genielabs 定位 → Spine 导出”流程,用于对照和回归测试。 @@ -16,6 +16,7 @@ - [项目修改约定](AGENTS.md):每次修改都必须遵守,Python 固定使用 `ai-spine` Conda 环境。 - [第一阶段:视觉 LLM 角色结构理解](docs/1-understanding-stage.md):执行流程、代码对应关系、数据格式和输出文件。 +- [第二阶段:本地 SAM 分割](docs/2-segmentation-stage.md):环境、运行命令、权重适配、输出和质量边界。 - [模型阶段配置与实验说明](docs/model-stages.md):模型切换、协议适配、图片编辑探针、缓存和错误处理。 ## 环境准备 @@ -47,8 +48,9 @@ VECTOR_ENGINE_API_KEY=你的令牌 SPINE_UNDERSTANDING_MODEL=deepseek-flash SPINE_COMPLETION_MODEL=qwen-image-3.0 -# 当前只记录 checkpoint;SAM 推理尚未接入 -SAM_MODEL_DIR=F:/Study/sam3.1 +# 本地 SAM 根目录和源码仓库;分割结束后子进程退出 +SAM_MODEL_DIR=F:/Study/SAM3 +SAM_CODE_DIR=F:/Study/SAM3/sam3 ``` 模型 ID 会原样发送给中转站,不进行隐式别名转换。不要把 API Key 写进 JSON 配置或提交到版本库。 @@ -65,11 +67,13 @@ SAM_MODEL_DIR=F:/Study/sam3.1 - `Pillow`、`numpy`、`opencv-python`; - 相邻目录中的 `spine-animation-ai` 项目。 -在 `AI_spine` 目录安装缺失的 Python 依赖(已满足版本要求的包会保留): +当前本机验证组合为 Python 3.11、PyTorch 2.7.1 + CUDA 12.6、torchvision 0.22.1。在 `AI_spine` 目录补全依赖;已有此 CUDA 版本时跳过第一条安装命令: ```powershell conda activate ai-spine +python -m pip install torch==2.7.1 torchvision==0.22.1 --index-url https://download.pytorch.org/whl/cu126 python -m pip install -r .\requirements.txt +python -m pip install --no-deps --no-build-isolation -e F:\Study\SAM3\sam3 ``` 不激活终端也可明确指定安装目标: @@ -168,6 +172,22 @@ node .\pipeline.mjs --mode annotated-parts ` 每组模型实验应使用独立的输出目录,避免覆盖对照结果。只有输入、配置、代码和产物哈希全部一致时,才可以通过 `--resume` 复用结果。 +### 使用本地 SAM 生成标注和拆解图 + +复用已有第一阶段结果,不重新请求 DeepSeek,也不需要中转站密钥: + +```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 +``` + +打开输出目录的 `report.html`:`annotated.png` 是原图上的不同半透明颜色标注,`parts-sheet.png` 是白底拆解图,`parts/` 保存原像素透明部件。运行期间加载 GPU 权重,完成后子进程退出,不启动常驻服务。 + +加入 `--dry-run` 可检查环境与本地路径,不加载权重推理。输入必须含 `source.png`、v2 `semantic-parts.json`、`segmentation-prompts.json`。完整参数与质量检查见[第二阶段文档](docs/2-segmentation-stage.md)。 + ### 测试图片补全接口 此命令只验证图片编辑适配器并保存原始候选,不代表补全部件已经验收通过: diff --git a/configs/astra-qwen.json b/configs/astra-qwen.json index db0e6a9..29cbd7c 100644 --- a/configs/astra-qwen.json +++ b/configs/astra-qwen.json @@ -39,7 +39,7 @@ "segmentation": { "provider": "sam-local", "model_dir_env": "SAM_MODEL_DIR", - "default_model_dir": "F:/Study/sam3.1" + "default_model_dir": "F:/Study/SAM3" } } } diff --git a/configs/deepseek-qwen.json b/configs/deepseek-qwen.json index 2fe1a7b..430a61e 100644 --- a/configs/deepseek-qwen.json +++ b/configs/deepseek-qwen.json @@ -39,7 +39,7 @@ "segmentation": { "provider": "sam-local", "model_dir_env": "SAM_MODEL_DIR", - "default_model_dir": "F:/Study/sam3.1" + "default_model_dir": "F:/Study/SAM3" } } } diff --git a/docs/1-understanding-stage.md b/docs/1-understanding-stage.md index 5594f8b..de6c441 100644 --- a/docs/1-understanding-stage.md +++ b/docs/1-understanding-stage.md @@ -363,8 +363,8 @@ POST /v1/responses = 可信的像素 mask + 透明拆件 + 隐藏区域补全 + 最终骨骼 ~~~ -下一阶段应读取 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/.png` | 紧裁剪的 RGBA 部件,保留原图像素及 Alpha | +| `masks/.png` | 原图尺寸黑白 mask,已裁掉 Alpha 背景 | +| `candidates/-raw.png` | 选中的 SAM 原始 mask,裁掉背景前 | +| `unassigned-mask.png` | 白色表示没有被任何部件 mask 覆盖的角色像素 | +| `overlap-mask.png` | 白色表示被多个部件 mask 覆盖的像素 | +| `segmentation.json` | 裁剪坐标、拆解图坐标、颜色、质量指标、运行环境和模型加载信息 | +| `candidate-diagnostics.json` | 两种提示的候选分数、排序、拒绝原因及最终选择 | +| `report.html` | 两张预览、部件链接、告警、漏分及重复检查 | +| `worker.log` / `worker-error.json` | 子进程日志 / 失败诊断(发生错误时) | +| `manifest.json` | 状态、输入/代码/权重/产物哈希,完成时记录子进程已退出 | + +`crop_xyxy` 使用原图坐标,右下角为开区间;`sheet_xyxy` 使用白底拆解图坐标。覆盖率是像素覆盖比例,不是准确率。 + +## 代码与验证记录 + +- [stages/segmentation-stage.mjs](../stages/segmentation-stage.mjs):输入校验、Conda 子进程、哈希、缓存、日志和失败处理。 +- [sam_runtime.py](../sam_runtime.py):环境探测和本地权重严格加载。 +- [sam_segment.py](../sam_segment.py):GPU 推理、两组候选选择和诊断。 +- [segmentation_outputs.py](../segmentation_outputs.py):mask QA、原像素拆件、两张预览和报告。 +- [tests/test_segmentation.py](../tests/test_segmentation.py):Alpha 越界、候选选择、提示完整性、像素保真和重叠/重复检查。 + +2026-09-18 实测 `output/sombrero-grounding-v2` → `output/sombrero-sam31-v3`:28 个部件返回 mask,严格加载 1130 个检测参数/缓冲张量;推理及渲染约 11.64 秒(不含环境探测和加载权重),PyTorch 峰值已分配显存约 4.97 GiB,结束后无 SAM 子进程。 + +这组结果覆盖原图前景约 67.18%,仍有 64286 像素未覆盖、30569 像素重叠。`shin_left` 和 `foot_left` 的 mask IoU 约 0.843,实际都分到了鞋子;躯干及其他肢体也存在碎片或错分。文本候选对照未解决这些问题。这说明推理和输出链路已跑通,但当前旧语义计划与提示策略尚不能产出合格的最终拆件;需要修正部件粗框并继续验证提示及 mask 语义。 + +同日 `conda run --no-capture-output -n ai-spine npm test` 的 17 项测试全部通过(包括 Python 分割回归用例);真实产物 `--resume` 哈希校验复用通过。测试通过仅验证程序行为,不表示上述分割质量合格。 diff --git a/docs/model-stages.md b/docs/model-stages.md index a5dac4c..bb27bb8 100644 --- a/docs/model-stages.md +++ b/docs/model-stages.md @@ -2,7 +2,7 @@ 本轮实现结构理解、图片编辑适配器、结构 JSON 校验、Alpha 几何校验、候选种子、诊断预览和实验缓存。几何层需要 Python 与 requirements.txt,不运行旧的“拆件图 → SIFT 定位”流程。 -结构理解当前停在 `understand`;SAM 分割、掩码修正、隐藏部件合并、Spine 骨骼导出是后续阶段。图片编辑测试保存的是原始候选,不是验收通过的最终资产。 +结构理解支持停在 `understand`,也可通过 `--stop-after segment` 接续本地 SAM 分割,或使用 `--stage segmentation --input-dir` 独立执行第二阶段。掩码语义修正、隐藏部件合并、Spine 骨骼导出尚未完成。图片编辑测试保存的是原始候选,不是验收通过的最终资产。 ## 环境与模型 @@ -13,7 +13,7 @@ ```dotenv SPINE_UNDERSTANDING_MODEL=gpt-6-astra SPINE_COMPLETION_MODEL=qwen-image-3.0 -SAM_MODEL_DIR=F:/Study/sam3.1 +SAM_MODEL_DIR=F:/Study/SAM3 ``` 不配置这三项时,`configs/astra-qwen.json` 已提供相同默认值。不会自动改写用户的 `.env`。 @@ -153,13 +153,13 @@ node .\tools\model-check.mjs --stage completion --image .\local-part.png --promp 同一命令增加 `--resume`,仅当上次完整结束,且模型、协议、参数、提示词、Schema、源图、适配器代码和所有产物校验一致时复用。模型变化时要求新的输出目录,防止覆盖对照组。失败任务也保留原目录,使用新目录重跑。 -本轮实现单阶段缓存;跨 SAM/补全/导出的依赖缓存留到后续阶段接入。对比补全模型时,固定相同局部画布、任务文本与掩码。 +理解、SAM 和补全探针各自记录独立缓存。SAM 额外校验输入计划、候选提示、本地源码和权重哈希。对比补全模型时,固定相同局部画布、任务文本与掩码。 显式 429/5xx 最多按配置重试,超时或网络断开不自动重试,因为服务端可能已经执行任务。完整请求不保证模型内容可复现;seed 只在提供方确认支持时放入 params,usage 不存在时记录 null,不估造价格或 token 数。 ## SAM 路径 -默认 `F:/Study/sam3.1`,检查 `sam3.1_multiplex.pt` 是否存在。该目录 README 明确说明它仅提供 checkpoint,不是可直接调用的推理服务。本轮仅记录路径和 checkpoint 存在性,状态为 `not_integrated`,不加载权重、不占用 GPU。 +默认根目录 `F:/Study/SAM3`:源码位于 `sam3/`,权重位于 `sam3.1checkpoint/sam3.1_multiplex.pt`。可通过 `--sam-dir`、`--sam-code-dir`、`--sam-checkpoint` 覆盖。独立分割无需中转站凭据,在 `ai-spine` Conda 临时子进程加载 GPU 模型,结束即退出。命令、权重适配及当前质量限制见[第二阶段文档](2-segmentation-stage.md)。 ## 测试 diff --git a/lib/config.mjs b/lib/config.mjs index f33292a..3b3dd2f 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -84,9 +84,13 @@ export function publicStage(stage) { }; } -export function samInfo(loaded) { +export function samInfo(loaded, overrides = {}) { 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'}; + const modelDir = overrides.samDir || loaded.env[stage.model_dir_env] || stage.default_model_dir || ''; + const checkpoint = overrides.samCheckpoint || (modelDir ? [path.join(modelDir, 'sam3.1checkpoint', 'sam3.1_multiplex.pt'), path.join(modelDir, 'sam3.1_multiplex.pt')].find(existsSync) || path.join(modelDir, 'sam3.1checkpoint', 'sam3.1_multiplex.pt') : ''); + const codeDir = overrides.samCodeDir || loaded.env.SAM_CODE_DIR || (modelDir ? path.join(modelDir, 'sam3') : ''); + return {model_dir: modelDir, code_dir: codeDir, checkpoint, + checkpoint_exists: Boolean(checkpoint && existsSync(checkpoint)), + code_exists: Boolean(codeDir && existsSync(path.join(codeDir, 'sam3', 'model_builder.py'))), + runtime_status: 'local_subprocess_not_probed'}; } diff --git a/requirements.txt b/requirements.txt index 1655c70..684337f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,22 @@ -Pillow>=10.0 -numpy>=1.26 -opencv-python>=4.9 +Pillow>=10.1 +numpy==1.26.4 +opencv-python==4.11.0.86 +# SAM runtime. Install the CUDA torch/torchvision wheels first as documented. +torch==2.7.1 +torchvision==0.22.1 +setuptools==80.9.0 +wheel +timm>=1.0.17 +tqdm +ftfy==6.1.1 +regex +iopath>=0.1.10 +typing_extensions +huggingface_hub +einops +psutil +scipy +pycocotools +triton-windows>=3.3,<3.4; platform_system == "Windows" +triton>=3.3,<3.4; platform_system == "Linux" +# SAM source is provided separately through --sam-code-dir / SAM_CODE_DIR. diff --git a/sam_runtime.py b/sam_runtime.py new file mode 100644 index 0000000..0d0383f --- /dev/null +++ b/sam_runtime.py @@ -0,0 +1,85 @@ +"""Local SAM image detector from a SAM 3.1 multiplex checkpoint. + +No downloads, service, video tracker, or silently uninitialized parameters. +""" +import gc +import hashlib +import importlib.metadata +import os +from pathlib import Path +import sys + + +def check_environment(code_dir): + if Path(sys.prefix).name != 'ai-spine' or not (Path(sys.prefix) / 'conda-meta').is_dir(): + raise RuntimeError('Run inside Conda ai-spine: conda run -n ai-spine ...') + code = Path(code_dir).resolve() + if not (code / 'sam3' / 'model_builder.py').is_file(): + raise FileNotFoundError(f'SAM source checkout missing: {code}') + sys.path.insert(0, str(code)) + os.environ['HF_HUB_OFFLINE'] = '1' + os.environ['TRANSFORMERS_OFFLINE'] = '1' + import torch + import sam3 + from sam3.model_builder import build_sam3_image_model # noqa: F401 + from sam3.model.sam3_image_processor import Sam3Processor # noqa: F401 + if Path(sam3.__file__).resolve().parent != code / 'sam3': + raise RuntimeError('Imported SAM package does not match --sam-code-dir') + if not torch.cuda.is_available(): + raise RuntimeError('CUDA GPU unavailable; this stage requires GPU inference') + source_hash = hashlib.sha256() + for file in sorted((code / 'sam3').rglob('*')): + if file.is_file() and (file.suffix == '.py' or file.name.endswith('.txt.gz')): + source_hash.update(file.relative_to(code).as_posix().encode()) + source_hash.update(file.read_bytes()) + versions = {} + for name in ['torch', 'torchvision', 'numpy', 'Pillow', 'opencv-python', 'timm', 'triton-windows']: + try: + versions[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + versions[name] = None + return {'python': sys.executable, 'sam_source': str(code), 'sam_source_sha256': source_hash.hexdigest(), + 'versions': versions, 'cuda': torch.version.cuda, 'gpu': torch.cuda.get_device_name(0), + 'gpu_total_bytes': torch.cuda.get_device_properties(0).total_memory, + 'bf16_supported': torch.cuda.is_bf16_supported(), 'network_requested': False} + + +def load_detector(checkpoint, precision='bf16'): + import torch + from sam3.model_builder import build_sam3_image_model + model = build_sam3_image_model(device='cpu', load_from_HF=False, + enable_inst_interactivity=False, compile=False) + weights = torch.load(checkpoint, map_location='cpu', weights_only=True, mmap=True) + weights = weights.get('model', weights) + detector = {key.removeprefix('detector.'): value for key, value in weights.items() if key.startswith('detector.')} + if not detector: + raise RuntimeError('Checkpoint has no detector.* weights; refusing an uninitialized model') + # The image builder uses four scales then scalp=1 discards the last one. + # SAM 3.1 stores only the three used detection scales. Remove the unused + # fourth scale and scalp, preserving exactly the same three feature maps. + multiplex = any(k.startswith('backbone.vision_backbone.interactive_convs.') for k in detector) + if multiplex: + neck = model.backbone.vision_backbone + if len(neck.convs) != 4 or model.backbone.scalp != 1: + raise RuntimeError('Unsupported SAM image backbone layout; review adapter for this source version') + neck.convs = torch.nn.ModuleList(list(neck.convs)[:3]) + neck.scale_factors = list(neck.scale_factors)[:3] + model.backbone.scalp = 0 + ignored_prefixes = ('backbone.vision_backbone.interactive_convs.', + 'backbone.vision_backbone.propagation_convs.') if multiplex else () + selected = {k: v for k, v in detector.items() if not k.startswith(ignored_prefixes)} + # strict=True checks every active parameter and every tensor shape. + model.load_state_dict(selected, strict=True) + info = {'checkpoint_kind': 'sam3.1_multiplex_detector' if multiplex else 'sam3_detector', + 'strict_load': True, 'loaded_tensors': len(selected), + 'ignored_video_neck_tensors': len(detector) - len(selected), + 'instance_interactivity': False, 'compile': False, 'precision': precision} + del selected, detector, weights + gc.collect() + if precision == 'bf16' and not torch.cuda.is_bf16_supported(): + raise RuntimeError('BF16 unsupported on this GPU; use --sam-precision fp32') + # Keep FP32 parameters and complex RoPE buffers intact. The upstream + # decoder explicitly disables autocast for its FP32 FFN; casting the whole + # model to BF16 would both break it and discard complex imaginary values. + model = model.to(device='cuda').eval() + return model, info diff --git a/sam_segment.py b/sam_segment.py new file mode 100644 index 0000000..cdb5fef --- /dev/null +++ b/sam_segment.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""One-shot GPU worker: semantic ROI prompts -> real SAM masks -> previews.""" +import argparse +from contextlib import nullcontext +import json +from pathlib import Path +import sys +import time +import traceback + +import numpy as np +from PIL import Image + +from sam_runtime import check_environment, load_detector +from segmentation_outputs import assess_candidate, render_outputs, write_report + + +def choose_candidate(masks, scores, alpha, box, points): + """Rank real model candidates; ROI and seeds are hints, not ground truth.""" + ranked = [] + for index, (mask, score) in enumerate(zip(masks, scores)): + qa = assess_candidate(mask, alpha, box, points) + reasons = [] + if qa['area_px'] < 4: + reasons.append('EMPTY_OR_TINY_FOREGROUND') + if qa['inside_roi_fraction'] < .1: + reasons.append('OUTSIDE_TARGET_ROI') + rank = float(score) * (.25 + .75 * qa['inside_roi_fraction']) * (1 - qa['outside_alpha_ratio']) + rank *= .8 + .2 * qa['seed_hit_fraction'] + ranked.append({'index': index, 'model_score': float(score), 'rank_score': rank, + 'qa': qa, 'rejected': reasons}) + eligible = [c for c in ranked if not c['rejected']] + return max(eligible, key=lambda c: c['rank_score']) if eligible else None, ranked + + +def validate_prompts(plan, prompts, image): + w, h = image.size + if (prompts.get('width'), prompts.get('height')) != (w, h): + raise ValueError('Prompt canvas does not match source image') + parts = {p['id']: p for p in plan['parts']} + entries = prompts['parts'] + if len(entries) != len(parts) or {p['id'] for p in entries} != set(parts): + raise ValueError('Prompt IDs must match the semantic plan exactly') + alpha = np.array(image)[:, :, 3] + for entry in entries: + part = parts[entry['id']] + if entry['status'] not in ['candidate', 'hidden', 'blocked']: + raise ValueError('Unknown prompt status') + if entry['status'] != 'candidate': + continue + if part['visibility'] == 'hidden' or entry.get('seed_source') != 'alpha_roi_distance_transform': + raise ValueError('Only visible, program-generated candidate prompts are supported') + box = entry['bbox_px_xyxy'] + if len(box) != 4 or any(type(v) is not int for v in box) or not (0 <= box[0] < box[2] <= w and 0 <= box[1] < box[3] <= h): + raise ValueError('Invalid pixel ROI') + if not entry['positive_points_px']: + raise ValueError('Candidate prompt has no foreground seeds') + for point in entry['positive_points_px']: + if len(point) != 2 or any(type(v) is not int for v in point): + raise ValueError('Invalid pixel seed') + x, y = point + if not (box[0] <= x < box[2] and box[1] <= y < box[3]) or alpha[y, x] == 0: + raise ValueError('Candidate seed is outside foreground/ROI') + return parts + + +def run(args, runtime): + import torch + from sam3.model.sam3_image_processor import Sam3Processor + out = Path(args.output) + plan = json.loads((out / 'semantic-parts.json').read_text(encoding='utf-8')) + prompts = json.loads((out / 'segmentation-prompts.json').read_text(encoding='utf-8')) + image = Image.open(out / 'source.png').convert('RGBA') + parts_by_id = validate_prompts(plan, prompts, image) + alpha = np.array(image)[:, :, 3] + print('Loading local SAM detector weights...', flush=True) + model, loading = load_detector(args.checkpoint, args.precision) + processor = Sam3Processor(model, device='cuda', confidence_threshold=args.confidence) + rgb = Image.alpha_composite(Image.new('RGBA', image.size, 'white'), image).convert('RGB') + candidates_dir = out / 'candidates' + candidates_dir.mkdir(exist_ok=True) + selected_parts, skipped, diagnostics = [], [], [] + started = time.monotonic() + context = torch.autocast('cuda', dtype=torch.bfloat16) if args.precision == 'bf16' else nullcontext() + with torch.inference_mode(), context: + print('Encoding reference image on GPU...', flush=True) + state = processor.set_image(rgb) + for number, entry in enumerate(prompts['parts'], 1): + name = entry['id'] + if entry['status'] != 'candidate': + skipped.append({'id': name, 'reason': entry['status']}) + continue + part = parts_by_id[name] + print(f'[{number}/{len(prompts["parts"])}] {name}: text / text + coarse ROI', flush=True) + processor.reset_all_prompts(state) + # The official image concept API accepts text and boxes, not points. + # Alpha seeds are used only to rank the returned mask candidates. + processor.set_text_prompt(part['semantic'], state) + x0, y0, x1, y1 = entry['bbox_px_xyxy'] + w, h = image.size + # Preserve text-only candidates: an inaccurate coarse box can + # steer the concept detector toward an unrelated fragment. + text_masks = state['masks'].detach().cpu().numpy().reshape(-1, h, w).copy() + text_scores = state['scores'].detach().float().cpu().numpy().reshape(-1).copy() + processor.add_geometric_prompt([(x0+x1)/(2*w), (y0+y1)/(2*h), (x1-x0)/w, (y1-y0)/h], True, state) + box_masks = state['masks'].detach().cpu().numpy().reshape(-1, h, w) + box_scores = state['scores'].detach().float().cpu().numpy().reshape(-1) + masks = np.concatenate([text_masks, box_masks]) + scores = np.concatenate([text_scores, box_scores]) + best, ranked = choose_candidate(masks, scores, alpha, entry['bbox_px_xyxy'], entry['positive_points_px']) + for candidate in ranked: + candidate['prompt_mode'] = 'text_only' if candidate['index'] < len(text_masks) else 'text_and_box' + diagnostic = {'id': name, 'text_prompt': part['semantic'], 'candidates': ranked, + 'selected_index': best['index'] if best else None} + diagnostics.append(diagnostic) + if best is None: + skipped.append({'id': name, 'reason': 'NO_ACCEPTABLE_SAM_MASK', 'candidate_count': len(ranked)}) + continue + mask = masks[best['index']] + Image.fromarray(mask.astype(np.uint8)*255).save(candidates_dir / f'{name}-raw.png') + warnings = ['SEMANTIC_REVIEW_REQUIRED'] + if best['qa']['outside_alpha_ratio'] > .1: + warnings.append('MASK_OUTSIDE_CHARACTER') + if best['qa']['inside_roi_fraction'] < .5: + warnings.append('MASK_EXTENDS_BEYOND_COARSE_ROI') + if best['qa']['seed_hit_fraction'] < 1: + warnings.append('CANDIDATE_SEED_NOT_COVERED') + if best['qa']['components_at_least_4px'] > 10: + warnings.append('FRAGMENTED_MASK') + selected_parts.append({'id': name, 'semantic': part['semantic'], 'rig_role': part['rig_role'], + 'mask': mask, 'model_score': best['model_score'], 'qa': best['qa'], + 'prompt_mode': best['prompt_mode'], + 'warnings': warnings, 'semantic_verified': False}) + if not selected_parts: + (out / 'candidate-diagnostics.json').write_text(json.dumps(diagnostics, indent=2), encoding='utf-8') + raise RuntimeError('SAM produced no acceptable masks; see candidate-diagnostics.json') + result = render_outputs(image, selected_parts, out) + result.update(schema_version=1, quality_status='needs_review', skipped=skipped, runtime=runtime, + model_loading=loading, elapsed_seconds=time.monotonic()-started, + peak_cuda_memory_bytes=torch.cuda.max_memory_allocated(), + prompt_strategy='rank text_only and text_and_box candidates; alpha seeds rank masks only') + (out / 'segmentation.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8') + (out / 'candidate-diagnostics.json').write_text(json.dumps(diagnostics, ensure_ascii=False, indent=2), encoding='utf-8') + write_report(out, result) + print(f'SAM finished: {len(selected_parts)} masks, {len(skipped)} skipped. GPU worker exiting.', flush=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--code-dir', required=True) + parser.add_argument('--checkpoint', required=True) + parser.add_argument('--output') + parser.add_argument('--probe', action='store_true') + parser.add_argument('--confidence', type=float, default=.2) + parser.add_argument('--precision', choices=['bf16', 'fp32'], default='bf16') + args = parser.parse_args() + if not Path(args.checkpoint).is_file(): + parser.error('Local checkpoint does not exist') + if not 0 < args.confidence < 1: + parser.error('--confidence must be between 0 and 1') + try: + runtime = check_environment(args.code_dir) + if args.probe: + print(json.dumps(runtime)) + return + if not args.output: + parser.error('--output is required') + run(args, runtime) + except Exception as error: + if args.output and Path(args.output).is_dir(): + (Path(args.output) / 'worker-error.json').write_text(json.dumps({'type': type(error).__name__, 'message': str(error), 'traceback': traceback.format_exc()}, indent=2), encoding='utf-8') + traceback.print_exc() + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/segmentation_outputs.py b/segmentation_outputs.py new file mode 100644 index 0000000..4ec63d3 --- /dev/null +++ b/segmentation_outputs.py @@ -0,0 +1,130 @@ +"""Measured mask QA and previews made only from the original character pixels.""" +import colorsys +import html +import json +import math +import re +from pathlib import Path + +import numpy as np +import cv2 +from PIL import Image, ImageDraw, ImageFont + + +def assess_candidate(mask, alpha, bbox, points): + mask = np.asarray(mask, dtype=bool) + if mask.shape != alpha.shape: + raise ValueError('Mask dimensions do not match the source') + clipped = mask & (alpha > 0) + _, _, stats, _ = cv2.connectedComponentsWithStats(clipped.astype(np.uint8), 8) + area, foreground_area = int(mask.sum()), int(clipped.sum()) + x0, y0, x1, y1 = bbox + hit = sum(bool(clipped[y, x]) for x, y in points) + return { + 'raw_area_px': area, 'area_px': foreground_area, + 'outside_alpha_ratio': (area - foreground_area) / max(1, area), + 'inside_roi_fraction': int(clipped[y0:y1, x0:x1].sum()) / max(1, foreground_area), + 'seed_hit_fraction': hit / max(1, len(points)), + 'components_at_least_4px': int((stats[1:, cv2.CC_STAT_AREA] >= 4).sum()), + } + + +def render_outputs(image, parts, out_dir): + out = Path(out_dir) + (out / 'parts').mkdir(parents=True, exist_ok=True) + (out / 'masks').mkdir(parents=True, exist_ok=True) + rgba = np.array(image.convert('RGBA')) + h, w = rgba.shape[:2] + coverage = np.zeros((h, w), np.uint16) + white = Image.new('RGBA', (w, h), 'white') + overlay = Image.alpha_composite(white, image.convert('RGBA')) + records, crops, clipped_masks = [], [], [] + seen = set() + for index, part in enumerate(parts): + name = part['id'] + if not re.fullmatch('[a-z][a-z0-9_-]{0,63}', name) or name in seen: + raise ValueError(f'Invalid or duplicate part ID: {name}') + seen.add(name) + mask = np.asarray(part['mask'], dtype=bool) + if mask.shape != (h, w): + raise ValueError(f'{name}: mask dimensions mismatch') + mask = mask & (rgba[:, :, 3] > 0) + clipped_masks.append(mask) + ys, xs = np.nonzero(mask) + if not len(xs): + raise ValueError(f'{name}: empty foreground mask') + coverage += mask + bbox = [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1] + color = [round(v * 255) for v in colorsys.hsv_to_rgb((index * .61803398875) % 1, .75, .95)] + cut = rgba.copy() + cut[~mask] = 0 + crop = Image.fromarray(cut).crop(bbox) + crop.save(out / 'parts' / f'{name}.png') + Image.fromarray(mask.astype(np.uint8) * 255).save(out / 'masks' / f'{name}.png') + tint = np.zeros_like(rgba) + tint[mask, :3] = color + tint[mask, 3] = 100 + overlay = Image.alpha_composite(overlay, Image.fromarray(tint)) + records.append({k: v for k, v in part.items() if k != 'mask'} | { + 'color_rgb': color, 'crop_xyxy': bbox, 'area_px': int(mask.sum()), + 'image': f'parts/{name}.png', 'mask': f'masks/{name}.png', + }) + crops.append(crop) + near_duplicates = [] + for i, a in enumerate(clipped_masks): + for j in range(i + 1, len(clipped_masks)): + b = clipped_masks[j] + iou = int((a & b).sum()) / max(1, int((a | b).sum())) + if iou >= .8: + near_duplicates.append({'part_a': records[i]['id'], 'part_b': records[j]['id'], 'iou': iou}) + for index in [i, j]: + warnings = records[index].setdefault('warnings', []).copy() + if 'NEAR_DUPLICATE_MASK' not in warnings: + warnings.append('NEAR_DUPLICATE_MASK') + records[index]['warnings'] = warnings + # Keep native scale, separated by padding and a label band. No rotation or + # resampling of source pixels is used in the exploded sheet. + pad, label = 24, 26 + cell_widths = [max(c.width, len(r['id']) * 8 + 8) for c, r in zip(crops, records)] + sheet_width = max(640, max(cell_widths, default=1) + pad * 2, + int(math.sqrt(sum((cw + pad) * (c.height + label + pad) for cw, c in zip(cell_widths, crops)) * 1.5))) + x, y, row_height = pad, pad, 0 + for record, crop, cw in zip(records, crops, cell_widths): + if x + cw + pad > sheet_width and x != pad: + x, y, row_height = pad, y + row_height + pad, 0 + record['sheet_xyxy'] = [x, y + label, x + crop.width, y + label + crop.height] + x += cw + pad + row_height = max(row_height, label + crop.height) + sheet = Image.new('RGB', (sheet_width, max(100, y + row_height + pad)), 'white') + draw = ImageDraw.Draw(sheet) + font = ImageFont.load_default(size=14) + for record, crop in zip(records, crops): + x0, y0, _, _ = record['sheet_xyxy'] + draw.rectangle((x0, y0 - label + 4, x0 + 10, y0 - label + 14), fill=tuple(record['color_rgb'])) + draw.text((x0 + 16, y0 - label + 1), record['id'], fill='black', font=font) + sheet.paste(crop, (x0, y0), crop) + overlay.convert('RGB').save(out / 'annotated.png') + sheet.save(out / 'parts-sheet.png') + Image.fromarray(((rgba[:, :, 3] > 0) & (coverage == 0)).astype(np.uint8) * 255).save(out / 'unassigned-mask.png') + Image.fromarray((coverage > 1).astype(np.uint8) * 255).save(out / 'overlap-mask.png') + total = int((rgba[:, :, 3] > 0).sum()) + return {'parts': records, 'near_duplicate_masks': near_duplicates, 'coverage': { + 'foreground_pixels': total, 'covered_pixels': int((coverage > 0).sum()), + 'coverage_ratio': int((coverage > 0).sum()) / max(1, total), + 'overlap_pixels': int((coverage > 1).sum()), + 'unassigned_pixels': int(((rgba[:, :, 3] > 0) & (coverage == 0)).sum()), + }, 'outputs': {'annotated': 'annotated.png', 'parts_sheet': 'parts-sheet.png'}} + + +def write_report(out_dir, result): + out = Path(out_dir) + rows = ''.join(f'   {html.escape(p["id"])}{html.escape(p["semantic"])}{p["model_score"]:.3f}{html.escape(", ".join(p.get("warnings", [])))}' for p in result['parts']) + body = f'''SAM 部件分割 + +

SAM 本地分割结果

颜色表示候选部件 mask。白底拆解图保留原始可见像素,尚未补全遮挡部分。重叠 mask 保留并计入诊断,不代表已经获得互斥、语义正确的最终拆件。

+

原图半透明标注

白底拆解图

+

部件

{rows}
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}`);