第二步SAM模型接入

This commit is contained in:
Ukawka
2026-09-18 20:29:29 +08:00
parent 1a7b2f14a9
commit f095c28858
16 changed files with 788 additions and 26 deletions
+3 -2
View File
@@ -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
+24 -4
View File
@@ -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)。
### 测试图片补全接口
此命令只验证图片编辑适配器并保存原始候选,不代表补全部件已经验收通过:
+1 -1
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -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"
}
}
}
+2 -2
View File
@@ -363,8 +363,8 @@ POST /v1/responses
= 可信的像素 mask + 透明拆件 + 隐藏区域补全 + 最终骨骼
~~~
下一阶段应读取 <code>semantic-parts.json</code> 和 <code>segmentation-prompts.json</code>,仅使用 status=candidate 的语义、粗框和程序候选点。模型原始正负点不传给 SAM。候选始终 semantic_verified=false:框内可能混有多个部件,未来仍需要 mask 语义选择、越出角色 Alpha 比例等 QA。
已实现的[第二阶段](2-segmentation-stage.md)读取 <code>source.png</code>、<code>semantic-parts.json</code> 和 <code>segmentation-prompts.json</code>,仅处理 status=candidate 的可见部件。SAM 图像概念接口接收文本和框;程序候选点参与返回 mask 的排序,不作为点提示传入此接口。模型原始正负点不传给 SAM。候选始终 semantic_verified=false:框内可能混有多个部件,Alpha、重叠和连通域检查不能替代语义复核。
已有 v1 结果可以通过 <code>--parts-file 旧计划.json --reference 原图.png --out-dir 新目录</code> 离线迁移与校验,不请求模型。迁移按 ID 推断骨骼/附件类型,袖子优先绑定同侧上臂,面部绑定头部;部分可见部件开启遮挡补全,骨骼连接处保守建议延展。这些推断在 notes 标注为待复核,不替代模型或人工判断。输入计划哈希和所有新产物参与 resume 校验。
<code>manifest.json</code> 当前只记录 SAM checkpoint 是否存在,<code>runtime_status</code> 仍为 <code>not_integrated</code>,不会加载权重或占用 GPU。
第一阶段 <code>manifest.json</code> 只记录 SAM 路径信息,<code>runtime_status=local_subprocess_not_probed</code>,第一阶段不加载 SAM。第二阶段单独探测 Conda/CUDA、严格加载本地权重,并在自己的 manifest 中记录运行环境、权重哈希及子进程退出状态。
+108
View File
@@ -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/<id>.png` | 紧裁剪的 RGBA 部件,保留原图像素及 Alpha |
| `masks/<id>.png` | 原图尺寸黑白 mask,已裁掉 Alpha 背景 |
| `candidates/<id>-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` 哈希校验复用通过。测试通过仅验证程序行为,不表示上述分割质量合格。
+4 -4
View File
@@ -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)。
## 测试
+8 -4
View File
@@ -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'};
}
+22 -3
View File
@@ -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.
+85
View File
@@ -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
+177
View File
@@ -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()
+130
View File
@@ -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'<tr><td><span style="background:rgb({",".join(map(str, p["color_rgb"]))})">&nbsp;&nbsp;</span> <a href="{p["image"]}">{html.escape(p["id"])}</a></td><td>{html.escape(p["semantic"])}</td><td>{p["model_score"]:.3f}</td><td>{html.escape(", ".join(p.get("warnings", [])))}</td></tr>' for p in result['parts'])
body = f'''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>SAM 部件分割</title>
<style>body{{font:16px system-ui;max-width:1400px;margin:24px auto;padding:16px}}img{{max-width:100%;height:auto}}td,th{{border:1px solid #ccc;padding:8px}}table{{border-collapse:collapse}}pre{{white-space:pre-wrap}}</style>
<h1>SAM 本地分割结果</h1><p>颜色表示候选部件 mask。白底拆解图保留原始可见像素,尚未补全遮挡部分。重叠 mask 保留并计入诊断,不代表已经获得互斥、语义正确的最终拆件。</p>
<h2>原图半透明标注</h2><img src="annotated.png"><h2>白底拆解图</h2><img src="parts-sheet.png">
<h2>部件</h2><table><tr><th>ID / 颜色</th><th>语义</th><th>模型分数</th><th>需复核</th></tr>{rows}</table>
<h2>未生成的部件</h2><pre>{html.escape(json.dumps(result['skipped'], ensure_ascii=False, indent=2))}</pre>
<h2>覆盖与重叠</h2><p>覆盖率只表示有 mask 的像素比例,不是分割准确率。模型分数也不代表部件语义已验证。</p><p><a href="unassigned-mask.png">漏分区域(白色)</a> · <a href="overlap-mask.png">重叠区域(白色)</a></p><pre>{html.escape(json.dumps(result['coverage'], indent=2))}</pre>
<h2>疑似重复分割</h2><p>以下不同部件的 mask IoU ≥ 0.8,需要检查是否分到了同一物体。</p><pre>{html.escape(json.dumps(result['near_duplicate_masks'], indent=2))}</pre></html>'''
(out / 'report.html').write_text(body, encoding='utf-8')
+101
View File
@@ -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;
}
}
+13 -1
View File
@@ -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));
});
+84
View File
@@ -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()
+25 -4
View File
@@ -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}`);