SAM3 微调标注流水线和 Label Studio

记录日期:2026-07-09(Label Studio 流程已按实测踩坑更新)

适用环境:AutoDL Linux(无桌面 GUI)、RTX 3090、已有 SAM3 + SAM3_LoRA

项目根目录:/root/autodl-tmp/label

本文档面向第一次从头搭建的同学,按顺序操作即可复现当前状态:半自动标注 → COCO 导出 → SAM3 LoRA 微调冒烟通过。


目录

  1. 你要做什么
  2. 前置条件检查
  3. [第一步:创建目录与 prompt](#第一步:创建目录与 prompt)
  4. [第二步:安装 Label Studio(独立 venv)](#第二步:安装 Label Studio(独立 venv))
  5. 第三步:放入原始数据
  6. 第四步:视频抽帧(如有视频)
  7. [第五步:SAM3 半自动预标注](#第五步:SAM3 半自动预标注)
  8. [第六步:Label Studio 人工修正(可选但推荐)](#第六步:Label Studio 人工修正(可选但推荐))
  9. [第七步:导出 COCO 格式数据集](#第七步:导出 COCO 格式数据集)
  10. [第八步:SAM3 LoRA 微调](#第八步:SAM3 LoRA 微调)
  11. 一键冒烟测试
  12. 脚本与配置速查索引
  13. 常见问题与踩坑记录
  14. 当前状态摘要(2026-07-09)
  15. 附录:脚本、配置与目录详解
    • [A. 流水线总览](#A. 流水线总览)
    • [B. 目录与文件](#B. 目录与文件)
    • [C. 脚本完整源码](#C. 脚本完整源码)
    • [D. 配置与生成文件](#D. 配置与生成文件)
    • [E. 环境变量](#E. 环境变量)
    • [F. 外部关联路径](#F. 外部关联路径)

1. 你要做什么

SAM3 微调需要 COCO 格式实例分割标注。在 AutoDL 无桌面环境下,推荐流程:

复制代码
原始图片/视频
    → SAM3 自动预标注(mask PNG)
    → Label Studio 浏览器人工修正(可选)
    → 转换为 COCO JSON(RLE)
    → SAM3_LoRA 微调

关键概念 :SAM3 是开放词汇模型,微调时 COCO categories[].name 会直接作为文本 prompt 。因此 prompt.txt 里的英文描述必须与推理时完全一致。


2. 前置条件检查

在 AutoDL 实例上确认以下内容已存在:

项目 路径 说明
SAM3 代码 /root/autodl-tmp/sam3 pip install -e
SAM3 权重 /root/autodl-tmp/weights/sam3_ms/sam3.pt 本地 checkpoint
SAM3_LoRA /root/autodl-tmp/SAM3_LoRA LoRA 微调框架
PyTorch + CUDA torch 2.7.x GPU 可用
ffmpeg 系统命令 视频抽帧

快速检查:

bash 复制代码
nvidia-smi
python3 -c "import sam3, torch; print('sam3 ok, cuda:', torch.cuda.is_available())"
test -f /root/autodl-tmp/weights/sam3_ms/sam3.pt && echo "checkpoint ok"
ffmpeg -version | head -1

安装 ffmpeg:

bash 复制代码
  apt update
  apt install -y ffmpeg

激活环境(若使用 conda):

bash 复制代码
conda activate sam3   # 若你有 sam3 环境

3. 第一步:创建目录与 prompt

项目根目录为 /root/autodl-tmp/label,目录结构如下:

复制代码
label/
├── raw/
│   ├── images/          # 静态原图(.jpg/.png)
│   └── videos/          # 原始视频(.mp4/.mov)
├── frames/
│   └── sam3_001/        # 抽帧后:0.jpg, 1.jpg, ...
├── prelabels/
│   ├── static/          # 静态图 SAM3 预标注
│   └── video/           # 视频跟踪预标注
├── reviewed/            # Label Studio 导入/导出
├── scripts/             # 全部流水线脚本
├── docs/                # 文档
├── prompt.txt           # 当前项目的文本 prompt
└── README.md

若目录尚未创建,执行:

bash 复制代码
mkdir -p /root/autodl-tmp/label/{raw/images,raw/videos,frames,prelabels,reviewed,scripts,docs}

编辑 prompt.txt(示例):

text 复制代码
black rectangular block with a hole

命名规范详见 <prompt_guide.md>。


4. 第二步:安装 Label Studio(独立 venv)

为什么用独立 venv?

  • SAM3 要求 numpy<2
  • Label Studio 1.23 要求 numpy>=2.2
  • 两者不能装在同一 Python 环境,否则会互相冲突

正确做法 :Label Studio 单独装在 .venv-labelstudio,SAM3 继续用主环境。

bash 复制代码
cd /root/autodl-tmp/label

python3 -m venv .venv-labelstudio
.venv-labelstudio/bin/pip install label-studio

验证(见下方「踩坑记录」------不要用 --version 判断):

bash 复制代码
test -x .venv-labelstudio/bin/label-studio && echo "Label Studio venv: OK"
python3 -c "import sam3, numpy; print('SAM3 numpy:', numpy.__version__)"

期望:Label Studio 在 venv 里可用,SAM3 侧 numpy 为 1.26.x

安装成功之后,如果想要再次进入 venv 环境,则运行下面的命令:

bash 复制代码
source /root/autodl-tmp/label/.venv-labelstudio/bin/activate

退出的话用:deactivate

5. 第三步:放入原始数据

静态图片

bash 复制代码
cp /path/to/your/*.jpg /root/autodl-tmp/label/raw/images/

建议至少 50--100 张高质量图用于正式微调;冒烟可先用 10 张。

视频(可选)

bash 复制代码
cp /path/to/your/video.mov /root/autodl-tmp/label/raw/videos/

6. 第四步:视频抽帧(如有视频)

bash 复制代码
cd /root/autodl-tmp/label
bash scripts/extract_frames.sh 5 50

参数说明:

参数 含义 示例
第 1 个 抽帧帧率 fps 5 = 每秒 5 帧
第 2 个 每段视频最多帧数 50 = 最多 50 帧

输出:frames/sam3_001/frames/sam3_002/ ... 帧命名为 0.jpg, 1.jpg, ...


7. 第五步:SAM3 半自动预标注

一条命令同时处理静态图 + 视频:

bash 复制代码
cd /root/autodl-tmp/label
bash scripts/run_prelabel.sh all

也可分开跑:

bash 复制代码
bash scripts/run_prelabel.sh static   # 仅 raw/images/
bash scripts/run_prelabel.sh video    # 仅 frames/sam3_*/

静态图预标注做了什么?

脚本:scripts/prelabel_static.py

  1. 读取 prompt.txt
  2. raw/images/ 每张图跑 SAM3 文本分割
  3. 输出到 prelabels/static/<图片名>/
    • masks/<name>_mask_0.png ... 实例 mask
    • <name>_seg.jpg 可视化
  4. 汇总:prelabels/static/seg_summary.json

视频预标注做了什么?

脚本:scripts/prelabel_video.py

  1. frames/sam3_<id>/ 首帧做 SAM3 分割,生成参考 mask
  2. 调用 use_sam3/track_episode_sop.py 跨帧跟踪传播
  3. 输出到 prelabels/video/<id>/
    • 001_mask_ref.png 参考 mask
    • masks/<id>_frame_<n>_mask.png 逐帧 mask
    • tracking_summary.json 跟踪统计

可调参数(环境变量)

bash 复制代码
CONFIDENCE=0.4 bash scripts/run_prelabel.sh all
PROMPT_FILE=/path/to/prompt.txt bash scripts/run_prelabel.sh static

预标注阶段会占用 GPU,3090 上静态 10 张 + 视频 30 帧约 2--5 分钟

进入第 8 步前自检

预标注完成后、启动 Label Studio 之前,在服务器确认数据已就绪:

bash 复制代码
cd /root/autodl-tmp/label

# 1. 预标注汇总存在
ls prelabels/static/seg_summary.json
ls prelabels/video/*/tracking_summary.json 2>/dev/null   # 无视频可忽略

# 2. import JSON 已有任务(start_labelstudio.sh 也会自动生成,此处提前确认)
python3 -c "import json; print('import tasks:', len(json.load(open('reviewed/labelstudio_import.json'))))"
结果 说明
import tasks: 0 回到 §7:检查 prompt.txt、confidence,或是否跑了 run_prelabel.sh
import tasks: ≥ 1 预标注正常(冒烟数据约 40),可进入 §8

注意 :即使此处 tasks ≥ 1,浏览器里仍看不到任务 ,直到 §8.1 阶段 4 执行 labelstudio_reload.sh 完成 API 导入。


8. 第六步:Label Studio 人工修正(可选但推荐)

SAM3 预标注会有漏检、误检、跟踪漂移。正式训练前建议人工过一遍

AutoDL 要点 :数据在服务器 /root/autodl-tmp/label/reviewed/,浏览器 不能Upload Files 上传本地盘路径。必须用 API 导入 + Local Files 云存储

8.1 四阶段流程(推荐)

常见误区 :只执行「阶段 1 启动」就打开浏览器,期待看到标注数据------此时看不到任务是正常的 。必须完成 阶段 1 → 2 → 3 → 4 全部步骤。

端口说明 :AutoDL 实例默认在 6006/6007 跑 TensorBoard。Label Studio 请用 6006 (或任意空闲端口),不要用 6007,否则浏览器可能打开 TensorBoard 而非 Label Studio。

阶段 1:启动服务(完成后仍无任务,属正常)

新终端常驻运行(不要关):

bash 复制代码
bash /root/autodl-tmp/label/scripts/start_labelstudio.sh 6006

服务器验证(必须看到 OK,再进入阶段 2):

bash 复制代码
curl -sf http://127.0.0.1:6006/health && echo "Label Studio OK"

若失败:检查终端是否有报错;换端口重试(如 6080),三处端口(start / AutoDL 映射 / reload)须一致。

阶段 2:保存 Token

Label Studio 启动后,浏览器登录 → Account & Settings → Access Token → 复制 JWT refresh token

bash 复制代码
echo '<你的 refresh token>' > /root/autodl-tmp/label/reviewed/.ls_token # echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6ODA5MDc5NzY0NiwiaWF0IjoxNzgzNTk3NjQ2LCJqdGkiOiIzYjY5OTM1YWM1ODY0NzdjODAwNzEyZjdmNDA1NzFjZiIsInVzZXJfaWQiOiIxIn0.zXS_H0dWnTkoVyc2GwC_VJ5mtm-CrXljxwz3qi9Vdqk' > /root/autodl-tmp/label/reviewed/.ls_token
chmod 600 /root/autodl-tmp/label/reviewed/.ls_token
阶段 3:浏览器建项目(完成后仍无任务,属正常)
  1. AutoDL 控制台 → 自定义服务 → 映射端口 6006 (与阶段 1 一致)
    • 若用 SSH 隧道:ssh -CNg -L 6006:127.0.0.1:6006 root@<实例地址> -p <端口>
  2. 浏览器打开映射 URL
  3. 确认界面是 Label Studio (Projects 列表),不是 TensorBoard(橙色、顶部有 SCALARS/GRAPHS)
  4. 新建项目,记下 project_id(通常为 1)
阶段 4:API 导入(做完此步才能在浏览器看到任务)
bash 复制代码
bash /root/autodl-tmp/label/scripts/labelstudio_reload.sh <project_id> 6006
# 示例:bash /root/autodl-tmp/label/scripts/labelstudio_reload.sh 1 6006

验证

  • 终端应打印 task_count(冒烟约 40)
  • 浏览器 Ctrl+F5 → 进入 Data ManagerLabel(不是 Dashboard 统计页)→ 打开任务应见原图 + 红色 mask
浏览器现象对照(排错用)
浏览器现象 说明 处理
No dashboards are active for the current data set. + 橙色 TensorBoard 打开了 6007 上的 TensorBoard ,不是 Label Studio;与预标注无关 6006 启动 LS;AutoDL 映射改 6006
Label Studio 项目页但 0 tasks 只做了阶段 1--3,未执行阶段 4 reload 执行 labelstudio_reload.sh <project_id> 6006
Label Studio Dashboard 空白 进了 Dashboard 而非任务列表 LabelData Manager
有 tasks 但图片不加载 Cloud Storage 未配或路径错 见 Q9

labelstudio_reload.sh 会自动完成:

  1. labelstudio_import.py --- 复制图片/mask,生成 import JSON
  2. 同步 labelstudio_config.xml 到项目 DB
  3. labelstudio_push_import.py --replace --- 删除旧任务,API 导入 annotations(红色 mask 打开即见)

8.2 生成导入包(单独执行时)

bash 复制代码
python3 /root/autodl-tmp/label/scripts/labelstudio_import.py

生成:

文件 用途
reviewed/labelstudio_import.json API 导入任务(含 Brush RLE annotations)
reviewed/labelstudio_media/ 原图 + mask 副本(Local Files 读取)
reviewed/labelstudio_config.xml 标注界面配置
reviewed/labelstudio_tasks/labelstudio_import.json Sync 模式备用(不推荐)

导入方式说明

  • SAM3 mask 以 annotations 导入(不是 predictions),打开任务直接显示红色区域
  • 每个 region 带 id,且 ground_truth: false(LS 默认 true 会导致难以编辑)
  • RLE 来自 image2rle()第一个返回值 (三元组 (rle, w, h),勿整段写入 rle 字段)

8.3 启动 Label Studio

bash 复制代码
bash /root/autodl-tmp/label/scripts/start_labelstudio.sh 6006

启动日志应显示:

复制代码
Document root: /root/autodl-tmp/label/reviewed
Storage path:  /root/autodl-tmp/label/reviewed/labelstudio_media

8.4 AutoDL 端口映射

  1. AutoDL 控制台 → 自定义服务
  2. 添加端口 6006 (与 start_labelstudio.sh 参数一致)
  3. 勿映射 6007 (AutoDL 默认 TensorBoard,打开会看到 No dashboards are active...
  4. 浏览器打开映射 URL,确认是 Label Studio 登录/项目页,登录 Label Studio

8.5 项目配置(首次或 reload 后核对)

8.5.1 标注界面(Labeling Setup)

内容来自 reviewed/labelstudio_config.xmlreload 脚本会自动写入 DB。若界面异常,手动粘贴并 Save:

xml 复制代码
<View>
  <Header value="Step1: Edit | Step2: click target | Step3: Brush/Eraser | Step4: Submit"/>
  <Text name="meta" value="Episode: $episode_id | Type: $data_source | SAM3 instances: $instances"/>
  <Image name="image" value="$image" zoom="true" zoomControl="true"/>
  <BrushLabels name="brush" toName="image">
    <Label value="target" background="rgba(255, 0, 0, 0.6)"/>
  </BrushLabels>
</View>

勿用 $source :这是 Label Studio 保留变量,会导致任务 meta 显示整段 JSON。本项目字段名为 data_source

8.5.2 图片 Source Storage

Settings → Cloud Storage → Add Source Storage (只需一条,Files 模式仅用于提供图片 URL):

字段
Storage Type Local files
Absolute local path /root/autodl-tmp/label/reviewed/labelstudio_media
File filter `.*.(jpg
Import method Files

Path 必须是 reviewed/子目录 ,不能填 reviewed/ 本身(会与 Document root 冲突)。

labelstudio_push_import.py 会在 API 导入时自动创建上述 Storage(若不存在)。

8.5.3 导入任务(API,不要用 Upload Files / Files Sync)

不要

  • Import → Upload Files (浏览器无法访问 /root/...
  • Cloud Storage Files Sync 批量建任务(会产生 $undefined$ 字段、与 API 任务重复)

推荐

bash 复制代码
# Token:环境变量 或 reviewed/.ls_token(一行 JWT refresh/access)
export LS_TOKEN="<refresh token>"   # 可选,若已写 .ls_token 可省略
bash /root/autodl-tmp/label/scripts/labelstudio_reload.sh <project_id> 6006

等价分步:

bash 复制代码
python3 scripts/labelstudio_import.py
python3 scripts/labelstudio_push_import.py <project_id> --port 6006 --replace

8.6 浏览器里如何修正 mask

打开任务后应看到 原图 + 红色 SAM3 mask

步骤 操作
1 若界面只读,点右上角或标注栏的 Edit / 铅笔 进入编辑
2 左侧 点红色 target 标签(不点则 Brush 无效)
3 底部工具栏Brush (补画)或 Eraser(擦除)
4 可滚轮缩放、拖拽平移
5 改完点 Submit 保存

预标注更新后,在服务器重新执行:

bash 复制代码
bash scripts/labelstudio_reload.sh 1 6006

浏览器 Ctrl+F5 强制刷新。

8.7 修正后导出 COCO

不要在浏览器 Export 下载 zip。在云服务器终端:

bash 复制代码
bash /root/autodl-tmp/label/scripts/labelstudio_server_export.sh <project_id>

输出:/root/autodl-tmp/label/reviewed/labelstudio_export/result.json

注意 :Label Studio CLI 导出的是 zip 包 (即使文件名叫 result.json),且 Brush/mask 项目必须用 BRUSH_TO_COCO 格式(脚本已默认)。export_coco.py 会自动解压读取。

更细步骤见 <labelstudio_setup.md>。


9. 第七步:导出 COCO 格式数据集

SAM3_LoRA 只接受如下结构:

复制代码
sam3_lora_data/<项目名>/
├── train/
│   ├── *.jpg
│   └── _annotations.coco.json
├── valid/
│   ├── *.jpg
│   └── _annotations.coco.json
└── dataset_meta.json

从预标注直接导出(跳过 Label Studio)

bash 复制代码
python3 /root/autodl-tmp/label/scripts/export_coco.py \
  --source prelabels \
  --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset

从 Label Studio 导出结果

bash 复制代码
python3 /root/autodl-tmp/label/scripts/export_coco.py \
  --source labelstudio \
  --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset

自动选择来源

bash 复制代码
python3 /root/autodl-tmp/label/scripts/export_coco.py \
  --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset

reviewed/labelstudio_export/ 有 COCO JSON,优先用 Label Studio;否则用 prelabels。

划分策略

  • 默认 train/valid = 85% / 15%,seed=42
  • 按 episode 分组 :同一 sam3_001 视频的所有帧不会同时出现在 train 和 valid,避免数据泄漏

10. 第八步:SAM3 LoRA 微调

10.1 准备配置文件

复制并修改 SAM3_LoRA/configs/smoke_hole_block.yaml,关键是 training.data_dir

yaml 复制代码
training:
  data_dir: "/root/autodl-tmp/sam3_lora_data/my_dataset"
  num_epochs: 20        # 正式训练建议 20--50
  batch_size: 1
  learning_rate: 1.0e-4
  # ...

10.2 安装训练依赖

bash 复制代码
pip install decord   # SAM3_LoRA 训练需要,冒烟脚本会自动安装

10.3 开始训练

bash 复制代码
cd /root/autodl-tmp/SAM3_LoRA
python3 train_sam3_lora_native.py --config configs/my_dataset.yaml

输出权重在 outputs/<项目名>/best_lora_weights.pt(约 4MB)。

10.4 验证(可选)

bash 复制代码
python3 validate_sam3_lora.py \
  --config configs/my_dataset.yaml \
  --weights outputs/my_dataset/best_lora_weights.pt \
  --val_data_dir /root/autodl-tmp/sam3_lora_data/my_dataset/valid

11. 一键冒烟测试

若只想验证「整条管线能跑通」,无需自己准备数据:

bash 复制代码
bash /root/autodl-tmp/label/scripts/smoke_pipeline.sh

脚本会自动:

  1. sam3/assets/test/ 复制 10 张静态图 + 30 帧视频
  2. 跑 SAM3 预标注
  3. 导出 COCO 到 sam3_lora_data/label_smoke
  4. 生成 SAM3_LoRA/configs/label_smoke.yaml 并训练 1 epoch

日志:/root/autodl-tmp/label/smoke_pipeline.log


12. 脚本与配置速查索引

正文各步只写「怎么跑」;每个脚本、配置文件、生成物的完整说明见 [附录](#每个脚本、配置文件、生成物的完整说明见 附录)

类别 数量 详见
流水线脚本 13 个 shell/python + labelstudio_config.xml [附录 C](#类别 数量 详见 流水线脚本 13 个 shell/python + labelstudio_config.xml 附录 C 配置文件 4 个(prompt、LS XML、token、gitignore) 附录 D 运行时生成物 prelabels / reviewed / frames 等 附录 B 环境变量 LS_TOKEN、CONFIDENCE 等 附录 E)
配置文件 4 个(prompt、LS XML、token、gitignore) [附录 D](#类别 数量 详见 流水线脚本 13 个 shell/python + labelstudio_config.xml 附录 C 配置文件 4 个(prompt、LS XML、token、gitignore) 附录 D 运行时生成物 prelabels / reviewed / frames 等 附录 B 环境变量 LS_TOKEN、CONFIDENCE 等 附录 E)
运行时生成物 prelabels / reviewed / frames 等 [附录 B](#类别 数量 详见 流水线脚本 13 个 shell/python + labelstudio_config.xml 附录 C 配置文件 4 个(prompt、LS XML、token、gitignore) 附录 D 运行时生成物 prelabels / reviewed / frames 等 附录 B 环境变量 LS_TOKEN、CONFIDENCE 等 附录 E)
环境变量 LS_TOKENCONFIDENCE [附录 E](#类别 数量 详见 流水线脚本 13 个 shell/python + labelstudio_config.xml 附录 C 配置文件 4 个(prompt、LS XML、token、gitignore) 附录 D 运行时生成物 prelabels / reviewed / frames 等 附录 B 环境变量 LS_TOKEN、CONFIDENCE 等 附录 E)

按阶段最常用命令

阶段 命令
抽帧 bash scripts/extract_frames.sh 5 50
预标注 bash scripts/run_prelabel.sh all
启动 LS bash scripts/start_labelstudio.sh 6006
导入/重载 bash scripts/labelstudio_reload.sh 1 6006
导出 COCO bash scripts/labelstudio_server_export.sh 1export_coco.py
冒烟 bash scripts/smoke_pipeline.sh
文档 内容
<prompt_guide.md> prompt 命名规范
<labelstudio_setup.md> Label Studio 专项步骤
本文档附录 脚本完整源码(C)/ 配置与目录说明(D--F)

13. 常见问题与踩坑记录

Q1:label-studio --version 卡住不动

现象 :执行 label-studio --version 后长时间无输出,或后台任务显示 error。

原因 :Label Studio 的 --version 会触发数据库初始化,并非快速打印版本号就退出。

结论不能--version 判断安装是否成功。

正确验证方式

bash 复制代码
test -x /root/autodl-tmp/label/.venv-labelstudio/bin/label-studio && echo "OK"
python3 -c "import sam3, numpy; print(numpy.__version__)"   # 应为 1.26.x

启动标注 QA 请用:

bash 复制代码
bash /root/autodl-tmp/label/scripts/start_labelstudio.sh 6006
bash /root/autodl-tmp/label/scripts/labelstudio_reload.sh 1 6006

Q2:pip install label-studio 后 SAM3 报错

原因:Label Studio 会把 numpy 升级到 2.x,破坏 SAM3(要求 numpy<2)。

解决 :Label Studio 只装在 .venv-labelstudio,不要 pip install label-studio 到 SAM3 主环境。若已误装:

bash 复制代码
pip install 'numpy<2,>=1.26'

Q3:训练报 ModuleNotFoundError: No module named 'decord'

bash 复制代码
pip install decord

smoke_pipeline.sh 训练前会自动尝试安装。

Q4:视频抽帧路径含空格导致 cp 失败

视频目录名如 2026-07-02 191302_frames 含空格。smoke_pipeline.sh 已用 find -print0 处理;自写脚本时注意引号。

Q5:预标注 0 instance

  • 检查 prompt.txt 是否匹配画面内容
  • 降低 confidence:CONFIDENCE=0.3 bash scripts/run_prelabel.sh static
  • 冒烟中 IMG_8098/8099hole block prompt 无匹配,属正常(prompt 与内容不符)

Q6:valid 集只有 1 张图

episode 划分时,episode 数量少则 valid 样本也少。可增加数据量,或修改 export_coco.py--valid-ratio

Q7:GPU 已被占用

预标注/训练需 GPU。用 nvidia-smi 查看占用;可等其它任务结束再跑,或调小 batch。

Q8:Label Studio 找不到 Import / 不能用 Upload Files

原因 :AutoDL 是远程 Linux,标注数据在 /root/autodl-tmp/label/reviewed/,浏览器 Upload Files 只能传你电脑上的文件。

正确做法

bash 复制代码
bash scripts/labelstudio_reload.sh <project_id> 6006

Token 写入 reviewed/.ls_tokenexport LS_TOKEN=...。详见第 8 节。

Q9:图片加载失败(There was an issue loading URL from $image

  1. 确认 Cloud Storage 路径为 /root/autodl-tmp/label/reviewed/labelstudio_media
  2. 确认 Label Studio 端口与 AutoDL 映射一致
  3. 执行 reload 后 Ctrl+F5 刷新
  4. 服务端自检:curl -I "http://127.0.0.1:6006/data/local-files/?d=labelstudio_media/static/xxx.jpg" 应返回 200

Q10:能看到 mask 但 Runtime error / 无法编辑

现象 原因 解决
argument should be integer or bytes-like object, not 'str' image2rle() 返回 (rle,w,h),误把整段三元组写入 rle 已修复;重新 labelstudio_reload.sh
mask 可见但 Brush 无效 未点 target 标签,或未点 Edit 先 Edit → 左侧 target → 底部 Brush/Eraser
导入后只读 LS 导入默认 ground_truth: true 脚本已设 ground_truth: false,reload 后生效
用了 predictions predictions 只读,需 Accept 本项目改用 annotations 导入

Q11:Token 401 / refresh 与 access

Label Studio 1.23 的 JWT refresh token 不能直接当 access 用。labelstudio_push_import.py 会自动调用 /api/token/refresh/ 换取 access。reviewed/.ls_token 存 refresh 即可。

Q12:任务重复或字段变成 $undefined$

不要混用 Upload FilesFiles SyncAPI 导入。出问题时:

bash 复制代码
bash scripts/labelstudio_reload.sh 1 6006   # --replace 会删光旧任务再导入

Q13:Label Studio 配置里 $source 显示整段 JSON

$source 是 LS 保留变量。任务字段请用 data_source (见 labelstudio_config.xml)。

Q14:Web 端提示 No dashboards are active for the current data set.

现象 :浏览器显示上述英文,界面为橙色 TensorBoard(顶部有 SCALARS / GRAPHS 等标签)。

原因(按优先级排查)

  1. 8.1 未完成 :只执行了 start_labelstudio.sh 就打开浏览器------阶段 1--3 完成后本来就没有任务 ,须执行阶段 4 labelstudio_reload.sh
  2. 打开了错误服务 :AutoDL 6006/6007 默认跑 TensorBoard(空 logdir),不是 Label Studio
  3. 不是预标注失败 :先查 reviewed/labelstudio_import.json 的 tasks 数量;若为 0 才回到 §7

处理

bash 复制代码
# 1. 确认预标注数据存在(tasks 应 ≥ 1)
python3 -c "import json; print(len(json.load(open('reviewed/labelstudio_import.json'))))"

# 2. 用 6006 启动 Label Studio(勿用 6007)
bash scripts/start_labelstudio.sh 6006
curl -sf http://127.0.0.1:6006/health && echo OK

# 3. AutoDL 映射 6006,浏览器确认是 Label Studio(Projects 页)

# 4. 完成导入
bash scripts/labelstudio_reload.sh <project_id> 6006

正确界面 :Label Studio → Data Manager / Label 中有 tasks,打开任务可见红色 mask。

Q15:export_coco.py --source labelstudioUnicodeDecodeError

现象

复制代码
UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 10-11: invalid continuation byte

原因

  1. label-studio export ... COCO 写入的是 zip 包 (即使路径叫 result.json),不是纯文本 JSON
  2. 本项目用 Brush mask ,普通 COCO 导出 annotations 为空 ;须用 BRUSH_TO_COCO

处理

bash 复制代码
# 1. 重新导出(脚本已改为 BRUSH_TO_COCO)
bash scripts/labelstudio_server_export.sh <project_id>

# 2. 再转训练集
python3 scripts/export_coco.py --source labelstudio \
  --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset

若尚未做 Label Studio 人工修正,可直接跳过 LS 导出:

bash 复制代码
python3 scripts/export_coco.py --source prelabels \
  --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset

14. 当前状态摘要(2026-07-09)

已验证的 Label Studio 流程

项目 状态
导入方式 API + labelstudio_reload.sh不用 Upload Files)
Mask 显示 annotations + Brush RLE,打开即见红色区域
典型任务量 40 tasks / 38 annotations(冒烟数据)
Token reviewed/.ls_token(JWT refresh,已 gitignore)
推荐端口 6006(AutoDL 6006/6007 常为 TensorBoard,勿用于 LS)

已搭建内容

组件 路径
流水线根目录 /root/autodl-tmp/label
Label Studio venv /root/autodl-tmp/label/.venv-labelstudio
冒烟数据集 /root/autodl-tmp/sam3_lora_data/label_smoke
LoRA 权重 /root/autodl-tmp/SAM3_LoRA/outputs/label_smoke/best_lora_weights.pt
训练配置 /root/autodl-tmp/SAM3_LoRA/configs/label_smoke.yaml

冒烟测试数据

项目 数值
静态图输入 10 张(其中 2 张 0 instance)
视频帧 30 帧(episode 001
有效 COCO 样本 38
train 37 图 / 60 标注
valid 1 图 / 2 标注
训练 1 epoch,val_loss = 0.386

环境验证

  • Label Studio 在独立 venv:.venv-labelstudio/
  • SAM3 环境未受影响(numpy 1.26.4
  • 不要label-studio --version 判断安装(会卡住初始化 DB)

日常使用最短路径

bash 复制代码
cd /root/autodl-tmp/label

# 1. 数据 + prompt
# 2. 抽帧(视频)
bash scripts/extract_frames.sh 5 50

# 3. 预标注
bash scripts/run_prelabel.sh all

# 4. Label Studio QA
bash scripts/start_labelstudio.sh 6006
bash scripts/labelstudio_reload.sh 1 6006    # 浏览器修正 mask

# 5. 导出 COCO
python3 scripts/export_coco.py --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset

# 6. 微调
cd /root/autodl-tmp/SAM3_LoRA
python3 train_sam3_lora_native.py --config configs/my_dataset.yaml

附录

本附录 C 提供 scripts/ 下全部脚本的完整源码D / E / F 对配置文件、生成目录与环境变量做说明,便于查阅和维护。


附录 A:流水线总览与脚本调用关系

A.1 数据流(与正文一致)
复制代码
raw/images/ ──────────► prelabel_static.py ──► prelabels/static/
                                                      │
raw/videos/ ──► extract_frames.sh ──► frames/sam3_* ──┤
                              │                       │
                              └──► prelabel_video.py ──► prelabels/video/
                                                      │
                                                      ▼
                         labelstudio_import.py ──► reviewed/(浏览器 QA)
                                                      │
                              labelstudio_reload.sh ──┘(import + push + config)
                                                      │
                                                      ▼
                              export_coco.py ──► sam3_lora_data/<name>/
                                                      │
                                                      ▼
                              train_sam3_lora_native.py ──► best_lora_weights.pt
A.2 脚本调用链
你执行的命令 内部实际调用
run_prelabel.sh all prelabel_static.py + prelabel_video.py
start_labelstudio.sh 6006 labelstudio_import.py(刷新 JSON)→ label-studio start
labelstudio_reload.sh 1 6006 labelstudio_import.py → 写 DB label_configlabelstudio_push_import.py --replace
labelstudio_reset_import.sh → 同 reloadreset 为别名脚本)
labelstudio_server_import.sh curl POST import API( Cloud Storage 自动配置,建议用 reload)
labelstudio_server_export.sh label-studio export ... BRUSH_TO_COCO
smoke_pipeline.sh → 复制测试数据 → run_prelabel.shexport_coco.py → 写 SAM3_LoRA yaml → 训练
A.3 脚本分层
层级 脚本 说明
入口 shell extract_frames.sh, run_prelabel.sh, start_labelstudio.sh, labelstudio_reload.sh, smoke_pipeline.sh 人类日常只记这些
核心 Python prelabel_*.py, labelstudio_import.py, export_coco.py 业务逻辑
LS API labelstudio_push_import.py REST 导入、删任务、建 Storage
遗留/备选 labelstudio_server_import.sh, labelstudio_reset_import.sh 可用但推荐 reload

附录 B:目录与文件说明

项目根目录:/root/autodl-tmp/label

复制代码
label/
├── prompt.txt                 # [配置] 文本 prompt,= COCO category name
├── .gitignore                 # [配置] 忽略 .ls_token、.venv-labelstudio
├── .venv-labelstudio/         # [运行时] Label Studio 独立 Python 环境
├── README.md                  # 英文快速入口
│
├── raw/
│   ├── images/                # 输入:静态原图
│   └── videos/                # 输入:原始视频
│
├── frames/
│   └── sam3_001/              # 中间:0.jpg, 1.jpg, ...(episode 编号三位)
│
├── prelabels/
│   ├── static/
│   │   ├── seg_summary.json   # 汇总:每张图的 mask 路径、instance 数
│   │   └── IMG_8086/          # 每图一目录:masks/*.png, *_seg.jpg
│   └── video/
│       └── 001/
│           ├── tracking_summary.json
│           ├── 001_mask_ref.png
│           └── masks/
│
├── reviewed/                  # Label Studio 专用
│   ├── .ls_token              # [Secret] JWT refresh token,chmod 600
│   ├── labelstudio_import.json      # API 导入任务列表
│   ├── labelstudio_config.xml       # 标注界面(从 scripts/ 复制)
│   ├── labelstudio_media/           # 原图+mask 副本(Local Files 读取)
│   ├── labelstudio_tasks/           # Sync 模式备用 JSON(不推荐)
│   └── labelstudio_export/
│       └── result.json              # LS 导出的 COCO
│
├── scripts/                   # 全部流水线脚本(见附录 C)
└── docs/                      # 文档
路径 谁写入 谁读取 可否删后重建
raw/ 你手动放数据 prelabel、extract 否(源数据)
frames/ extract_frames.sh prelabel_video.py 可(重新抽帧)
prelabels/ prelabel 脚本 import、export_coco 可(重新预标注)
reviewed/labelstudio_media/ labelstudio_import.py LS Local Files 可(reload 重建)
reviewed/labelstudio_import.json labelstudio_import.py push_import
reviewed/labelstudio_export/ labelstudio_server_export.sh export_coco.py 人工修正后需保留
~/.label-studio/ Label Studio LS 自身 删则丢项目/用户

附录 C:脚本完整源码

以下按流水线顺序 列出 scripts/ 下全部脚本及 labelstudio_config.xml完整源码。文档与仓库文件不一致时,以仓库为准。

C.1 scripts/extract_frames.sh
bash 复制代码
#!/usr/bin/env bash
# =============================================================================
# extract_frames.sh --- 视频抽帧
# =============================================================================
#
# 功能:
#   将 raw/videos/ 下的视频文件用 ffmpeg 抽帧,输出到 frames/sam3_<episode_id>/
#   帧命名约定为 0.jpg, 1.jpg, ... 与 use_sam3 / prelabel_video.py 保持一致
#
# 用法:
#   ./extract_frames.sh [fps] [max_frames]
#
# 参数:
#   fps         抽帧帧率,默认 5(每秒 5 帧,避免相邻帧过于冗余)
#   max_frames  每段视频最多抽取帧数,0 表示不限制(默认 0)
#
# 示例:
#   ./extract_frames.sh 5        # 5 fps,抽完全部
#   ./extract_frames.sh 5 50     # 5 fps,每段视频最多 50 帧
#
# 输入:label/raw/videos/*.{mp4,mov,avi,mkv}
# 输出:label/frames/sam3_001/, sam3_002/, ...
#
# 依赖:ffmpeg(apt-get install -y ffmpeg)
# 下一步:bash scripts/run_prelabel.sh video
# =============================================================================

set -euo pipefail  # 遇错退出;未定义变量报错;管道中任一命令失败则失败

# 定位脚本目录与项目根目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LABEL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"

# 路径与参数
VIDEO_DIR="${LABEL_ROOT}/raw/videos"   # 原始视频存放处
FRAMES_DIR="${LABEL_ROOT}/frames"      # 抽帧输出根目录
FPS="${1:-5}"                          # 第 1 个参数:帧率,默认 5
MAX_FRAMES="${2:-0}"                   # 第 2 个参数:最大帧数,0=不限

# 检查 ffmpeg 是否可用
if ! command -v ffmpeg >/dev/null 2>&1; then
  echo "ffmpeg not found. Install with: apt-get install -y ffmpeg" >&2
  exit 1
fi

# nullglob:无匹配文件时展开为空数组,而非字面量 "*.mp4"
shopt -s nullglob
videos=("${VIDEO_DIR}"/*.{mp4,mov,avi,mkv,MP4,MOV,AVI,MKV})

if [[ ${#videos[@]} -eq 0 ]]; then
  echo "No videos found in ${VIDEO_DIR}"
  exit 0  # 无视频不算错误,静默退出
fi

# 按视频文件顺序分配 episode_id:001, 002, ...
episode_idx=1
for video in "${videos[@]}"; do
  episode_id="$(printf '%03d' "${episode_idx}")"
  out_dir="${FRAMES_DIR}/sam3_${episode_id}"
  mkdir -p "${out_dir}"

  echo "[extract] ${video} -> ${out_dir}/ (${FPS} fps)"

  # -hide_banner -loglevel error:减少 ffmpeg 日志
  # -vf fps=N:按目标帧率采样
  # -frames:v M:可选,限制输出帧数
  # %d.jpg:输出 1.jpg, 2.jpg... 后续 prelabel_video 会重命名为 0.jpg 起(若需从 0 开始可改 ffmpeg 表达式)
  # 注意:ffmpeg 默认从 1 开始编号;prelabel_video 的 list_frame_paths 按数字 stem 排序,0.jpg 优先
  if [[ "${MAX_FRAMES}" -gt 0 ]]; then
    ffmpeg -hide_banner -loglevel error -y -i "${video}" \
      -vf "fps=${FPS}" -frames:v "${MAX_FRAMES}" \
      "${out_dir}/%d.jpg"
  else
    ffmpeg -hide_banner -loglevel error -y -i "${video}" \
      -vf "fps=${FPS}" \
      "${out_dir}/%d.jpg"
  fi

  count="$(find "${out_dir}" -maxdepth 1 -name '*.jpg' | wc -l)"
  echo "  -> ${count} frame(s)"
  episode_idx=$((episode_idx + 1))
done

echo "Done. Frame dirs under ${FRAMES_DIR}/sam3_*"

C.2 scripts/run_prelabel.sh
bash 复制代码
#!/usr/bin/env bash
# =============================================================================
# run_prelabel.sh --- SAM3 预标注统一入口
# =============================================================================
#
# 功能:
#   调用 prelabel_static.py / prelabel_video.py,对静态图和视频帧序列做 SAM3 半自动预标注
#
# 用法:
#   ./run_prelabel.sh [static|video|all]
#
# 模式:
#   static  仅处理 raw/images/ 下的静态图
#   video   仅处理 frames/sam3_*/ 下的视频抽帧(需先跑 extract_frames.sh)
#   all     两者都跑(默认)
#
# 环境变量(可选覆盖):
#   PROMPT_FILE   文本 prompt 文件,默认 label/prompt.txt
#   CONFIDENCE    SAM3 置信度阈值,默认 0.4(偏低以减少漏检,人工后续删误检)
#   CHECKPOINT    SAM3 权重路径
#
# 输出:
#   prelabels/static/seg_summary.json
#   prelabels/video/<episode>/tracking_summary.json
#
# 依赖:GPU、SAM3 已安装、权重文件存在
# 下一步:Label Studio QA(可选)或 export_coco.py
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LABEL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MODE="${1:-all}"

# 默认配置,可通过环境变量覆盖
PROMPT_FILE="${PROMPT_FILE:-${LABEL_ROOT}/prompt.txt}"
CONFIDENCE="${CONFIDENCE:-0.4}"
CHECKPOINT="${CHECKPOINT:-/root/autodl-tmp/weights/sam3_ms/sam3.pt}"

# ---------------------------------------------------------------------------
# run_static --- 静态图预标注
# ---------------------------------------------------------------------------
run_static() {
  local n
  # 统计 raw/images/ 下支持的图片格式数量
  n="$(find "${LABEL_ROOT}/raw/images" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.png' -o -iname '*.jpeg' -o -iname '*.webp' \) | wc -l)"
  if [[ "${n}" -eq 0 ]]; then
    echo "[prelabel] skip static: no images in raw/images/"
    return 0
  fi
  echo "[prelabel] static images (${n})"
  python3 "${SCRIPT_DIR}/prelabel_static.py" \
    --prompt-file "${PROMPT_FILE}" \
    --confidence "${CONFIDENCE}" \
    --checkpoint "${CHECKPOINT}"
}

# ---------------------------------------------------------------------------
# run_video --- 视频帧序列预标注(首帧分割 + 跨帧跟踪)
# ---------------------------------------------------------------------------
run_video() {
  local n
  # 匹配 frames/sam3_001 等目录,排除 sam3_seg* 可视化目录
  n="$(find "${LABEL_ROOT}/frames" -maxdepth 1 -type d -name 'sam3_*' ! -name 'sam3_seg*' | wc -l)"
  if [[ "${n}" -eq 0 ]]; then
    echo "[prelabel] skip video: no frames/sam3_* dirs (run extract_frames.sh first)"
    return 0
  fi
  echo "[prelabel] video episodes (${n})"
  python3 "${SCRIPT_DIR}/prelabel_video.py" \
    --prompt-file "${PROMPT_FILE}" \
    --confidence "${CONFIDENCE}" \
    --checkpoint "${CHECKPOINT}" \
    --rematch-each-frame   # 每帧与 ref mask 做 IoU 重匹配,跟踪漂移时更稳健
}

case "${MODE}" in
  static) run_static ;;
  video)  run_video ;;
  all)
    run_static
    run_video
    ;;
  *)
    echo "Usage: $0 [static|video|all]" >&2
    exit 1
    ;;
esac

echo "[prelabel] done. Outputs under ${LABEL_ROOT}/prelabels/"

C.3 scripts/prelabel_static.py
python 复制代码
#!/usr/bin/env python3
"""
prelabel_static.py --- 静态图 SAM3 预标注

功能:
    对 raw/images/ 下每张图片运行 SAM3 文本提示实例分割,输出 mask PNG 与可视化。

输入:
    - raw/images/*.jpg|png|...
    - prompt.txt(或通过 --prompt 指定)

输出(prelabels/static/<图片stem>/):
    - masks/<stem>_mask_0.png, _mask_1.png, ...  各实例二值 mask
    - <stem>_seg.jpg                              叠加可视化
    - seg_summary.json(汇总所有图片的元信息)

下游:
    - labelstudio_import.py(人工 QA)
    - export_coco.py(转 COCO)

用法:
    python3 prelabel_static.py
    python3 prelabel_static.py --confidence 0.3 --dry-run

依赖:GPU、sam3、matplotlib、Pillow
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

import matplotlib

matplotlib.use("Agg")  # 无头环境,不弹 GUI 窗口
import matplotlib.pyplot as plt
import sam3
import sam3.perflib.fused as fused
from PIL import Image
from sam3.model_builder import build_sam3_image_model
from sam3.model.sam3_image_processor import Sam3Processor
from sam3.visualization_utils import COLORS, plot_bbox, plot_mask

# ---------------------------------------------------------------------------
# RTX 3090 等 GPU 上 bf16/fp16 融合算子可能不稳定,强制 addmm 走 fp32
# 与 use_sam3/seg_all.py 保持一致
# ---------------------------------------------------------------------------
_orig_addmm_act = fused.addmm_act


def _addmm_act_fp32(activation, linear, mat1):
    return _orig_addmm_act(activation, linear, mat1).float()


fused.addmm_act = _addmm_act_fp32

import sam3.model.vitdet as vitdet

vitdet.addmm_act = _addmm_act_fp32

LABEL_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CKPT = Path("/root/autodl-tmp/weights/sam3_ms/sam3.pt")
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}


def read_prompt(prompt_file: Path) -> str:
    """从 prompt.txt 读取文本 query;文件为空则回退为 'object'。"""
    if prompt_file.exists():
        text = prompt_file.read_text(encoding="utf-8").strip()
        if text:
            return text
    return "object"


def list_images(images_dir: Path) -> list[Path]:
    """列出目录下所有支持的图片文件,按文件名排序。"""
    files = [
        p
        for p in sorted(images_dir.iterdir())
        if p.is_file() and p.suffix.lower() in IMAGE_EXTS
    ]
    return files


def build_processor(checkpoint: Path, confidence: float) -> Sam3Processor:
    """
    加载 SAM3 图像模型并构建 Sam3Processor。

    Args:
        checkpoint: sam3.pt 本地路径
        confidence: 检测置信度阈值,低于此值的实例会被过滤
    """
    bpe_path = Path(sam3.__file__).parent / "assets/bpe_simple_vocab_16e6.txt.gz"
    model = build_sam3_image_model(
        bpe_path=str(bpe_path),
        checkpoint_path=str(checkpoint),
        load_from_HF=False,
    )
    return Sam3Processor(model, confidence_threshold=confidence)


def save_visualization(image: Image.Image, state: dict, out_path: Path, prompt: str) -> None:
    """保存 mask + bbox 叠加可视化图,供人工快速质检。"""
    fig, ax = plt.subplots(figsize=(12, 8))
    ax.imshow(image)
    ax.axis("off")
    ax.set_title(f'"{prompt}" -> {len(state["scores"])} instance(s)', fontsize=12)
    img_w, img_h = image.size
    for i in range(len(state["scores"])):
        color = COLORS[i % len(COLORS)]
        plot_mask(state["masks"][i].squeeze(0).cpu(), color=color, ax=ax)
        score = state["scores"][i].item()
        plot_bbox(
            img_h,
            img_w,
            state["boxes"][i].cpu(),
            text=f"id={i + 1}, score={score:.2f}",
            box_format="XYXY",
            color=color,
            relative_coords=False,
            ax=ax,
        )
    fig.tight_layout()
    fig.savefig(out_path, dpi=150, bbox_inches="tight")
    plt.close(fig)


def segment_image(
    image_path: Path,
    out_dir: Path,
    processor: Sam3Processor,
    prompt: str,
) -> dict:
    """
    对单张图片做 SAM3 文本分割并保存结果。

    Returns:
        包含 episode_id、mask 路径、实例数等的 dict,写入 seg_summary.json
    """
    out_dir.mkdir(parents=True, exist_ok=True)
    masks_dir = out_dir / "masks"
    masks_dir.mkdir(exist_ok=True)

    image = Image.open(image_path).convert("RGB")
    state = processor.set_image(image)
    processor.reset_all_prompts(state)
    state = processor.set_text_prompt(state=state, prompt=prompt)

    stem = image_path.stem
    mask_paths: list[str] = []
    # 每个检测实例保存为独立 PNG(255=前景,0=背景)
    for i in range(len(state["scores"])):
        mask_name = f"{stem}_mask_{i}.png"
        mask_np = state["masks"][i].squeeze(0).cpu().numpy().astype("uint8") * 255
        Image.fromarray(mask_np).save(masks_dir / mask_name)
        mask_paths.append(f"masks/{mask_name}")

    save_visualization(image, state, out_dir / f"{stem}_seg.jpg", prompt)

    scores = [round(s.item(), 4) for s in state["scores"]]
    return {
        "source": "static",
        "episode_id": f"static_{stem}",  # export_coco 按 episode 分组划分 train/valid
        "image": image_path.name,
        "image_path": str(image_path.resolve()),
        "output_dir": str(out_dir.resolve()),
        "instances": len(scores),
        "scores": scores,
        "mask_paths": [str((out_dir / p).resolve()) for p in mask_paths],
        "visualization": f"{stem}_seg.jpg",
    }


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Pre-label static images with SAM3",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "--images-dir",
        type=Path,
        default=LABEL_ROOT / "raw" / "images",
        help="静态原图目录",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=LABEL_ROOT / "prelabels" / "static",
        help="预标注输出目录",
    )
    parser.add_argument(
        "--prompt-file",
        type=Path,
        default=LABEL_ROOT / "prompt.txt",
        help="文本 prompt 文件(= COCO category name)",
    )
    parser.add_argument("--prompt", type=str, default=None, help="直接指定 prompt,覆盖 prompt-file")
    parser.add_argument("--checkpoint", type=Path, default=DEFAULT_CKPT, help="SAM3 权重路径")
    parser.add_argument(
        "--confidence",
        type=float,
        default=0.4,
        help="置信度阈值;预标注建议 0.3--0.5,宁可多检后人工删",
    )
    parser.add_argument("--dry-run", action="store_true", help="仅列出待处理图片,不加载模型")
    args = parser.parse_args()

    images = list_images(args.images_dir)
    if not images:
        print(f"No images in {args.images_dir}", file=sys.stderr)
        sys.exit(1)

    prompt = args.prompt or read_prompt(args.prompt_file)
    print(f"Images: {len(images)} from {args.images_dir}")
    print(f"Prompt: {prompt!r}")

    if args.dry_run:
        for p in images:
            print(f"  would segment: {p.name}")
        return

    processor = build_processor(args.checkpoint, args.confidence)
    results: list[dict] = []
    for image_path in images:
        out_dir = args.output_dir / image_path.stem
        print(f"  [{image_path.name}] -> {out_dir.name}/")
        item = segment_image(image_path, out_dir, processor, prompt)
        print(f"    -> {item['instances']} instance(s)")
        results.append(item)

    # 汇总 JSON 供 export_coco / labelstudio_import 读取
    summary_path = args.output_dir / "seg_summary.json"
    summary_path.write_text(json.dumps(results, indent=2), encoding="utf-8")
    print(f"\nSummary -> {summary_path}")


if __name__ == "__main__":
    main()

C.4 scripts/prelabel_video.py
python 复制代码
#!/usr/bin/env python3
"""
prelabel_video.py --- 视频帧序列 SAM3 预标注

功能:
    1. 对 frames/sam3_<id>/ 首帧(0.jpg)做 SAM3 文本分割,取最高分实例作为参考 mask
    2. 调用 use_sam3/track_episode_sop.py 将参考 mask 传播到后续所有帧

输入:
    - frames/sam3_001/, sam3_002/, ...(0.jpg, 1.jpg, ...)
    - prompt.txt

输出(prelabels/video/<episode_id>/):
    - <id>_mask_ref.png          首帧参考 mask
    - masks/<id>_frame_<n>_mask.png  逐帧传播 mask
    - tracking_summary.json      每帧 IoU、是否有效等统计

下游:
    - labelstudio_import.py
    - export_coco.py

用法:
    python3 prelabel_video.py
    python3 prelabel_video.py --episode 001 --rematch-each-frame

依赖:GPU、sam3、use_sam3/track_episode_sop.py
"""

from __future__ import annotations

import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import sam3
import sam3.perflib.fused as fused
from PIL import Image
from sam3.model_builder import build_sam3_image_model
from sam3.model.sam3_image_processor import Sam3Processor
from sam3.visualization_utils import COLORS, plot_bbox, plot_mask

# fp32 补丁,与 prelabel_static.py 相同
_orig_addmm_act = fused.addmm_act


def _addmm_act_fp32(activation, linear, mat1):
    return _orig_addmm_act(activation, linear, mat1).float()


fused.addmm_act = _addmm_act_fp32

import sam3.model.vitdet as vitdet

vitdet.addmm_act = _addmm_act_fp32

LABEL_ROOT = Path(__file__).resolve().parents[1]
USE_SAM3 = Path("/root/autodl-tmp/use_sam3/track_episode_sop.py")  # 已有跟踪 SOP 脚本
DEFAULT_CKPT = Path("/root/autodl-tmp/weights/sam3_ms/sam3.pt")
SAM3_DIR_PATTERN = re.compile(r"^sam3_(.+)$")  # 匹配 sam3_001 → episode_id=001


def read_prompt(prompt_file: Path) -> str:
    """从 prompt.txt 读取文本 query。"""
    if prompt_file.exists():
        text = prompt_file.read_text(encoding="utf-8").strip()
        if text:
            return text
    return "object"


def list_frame_dirs(frames_root: Path) -> list[tuple[str, Path]]:
    """
    扫描 frames/ 下所有 sam3_<episode_id> 目录。

    排除 sam3_seg* 前缀目录(那是 seg_all 可视化输出,不是帧序列)。
    """
    dirs: list[tuple[str, Path]] = []
    for path in sorted(frames_root.iterdir()):
        if not path.is_dir():
            continue
        m = SAM3_DIR_PATTERN.match(path.name)
        if m and not path.name.startswith("sam3_seg"):
            dirs.append((m.group(1), path))
    return dirs


def build_processor(checkpoint: Path, confidence: float) -> Sam3Processor:
    """加载 SAM3 图像模型(首帧分割用)。"""
    bpe_path = Path(sam3.__file__).parent / "assets/bpe_simple_vocab_16e6.txt.gz"
    model = build_sam3_image_model(
        bpe_path=str(bpe_path),
        checkpoint_path=str(checkpoint),
        load_from_HF=False,
    )
    return Sam3Processor(model, confidence_threshold=confidence)


def save_ref_mask_from_frame0(
    frames_dir: Path,
    ref_path: Path,
    processor: Sam3Processor,
    prompt: str,
) -> int:
    """
    在首帧上跑 SAM3 分割,取置信度最高的实例作为跟踪参考 mask。

    Args:
        frames_dir: 帧目录,优先读 0.jpg
        ref_path:   参考 mask 输出路径(PNG,255=前景)
        processor:  SAM3 处理器
        prompt:     文本 query

    Returns:
        被选中的实例索引(0-based)

    Raises:
        RuntimeError: 首帧上未检测到任何实例(需调整 prompt 或 confidence)
    """
    frame0 = frames_dir / "0.jpg"
    if not frame0.exists():
        # 若无 0.jpg,取数字 stem 最小的帧
        frame_paths = sorted(frames_dir.glob("*.jpg"), key=lambda p: int(p.stem))
        if not frame_paths:
            raise FileNotFoundError(f"No frames in {frames_dir}")
        frame0 = frame_paths[0]

    image = Image.open(frame0).convert("RGB")
    state = processor.set_image(image)
    processor.reset_all_prompts(state)
    state = processor.set_text_prompt(state=state, prompt=prompt)

    if len(state["scores"]) == 0:
        raise RuntimeError(f"No instances on {frame0} for prompt {prompt!r}")

    # 多实例时选 score 最高的作为跟踪目标
    best_idx = max(range(len(state["scores"])), key=lambda i: state["scores"][i].item())
    mask_np = state["masks"][best_idx].squeeze(0).cpu().numpy().astype("uint8") * 255
    ref_path.parent.mkdir(parents=True, exist_ok=True)
    Image.fromarray(mask_np).save(ref_path)

    # 保存参考帧预览图,便于人工确认跟踪目标是否正确
    preview = frames_dir.parent / f"ref_preview_{frames_dir.name}.jpg"
    fig, ax = plt.subplots(figsize=(10, 6))
    ax.imshow(image)
    ax.axis("off")
    color = COLORS[best_idx % len(COLORS)]
    plot_mask(state["masks"][best_idx].squeeze(0).cpu(), color=color, ax=ax)
    plot_bbox(
        image.size[1],
        image.size[0],
        state["boxes"][best_idx].cpu(),
        text=f"ref score={state['scores'][best_idx].item():.2f}",
        box_format="XYXY",
        color=color,
        relative_coords=False,
        ax=ax,
    )
    fig.tight_layout()
    fig.savefig(preview, dpi=120, bbox_inches="tight")
    plt.close(fig)
    return best_idx


def run_tracking(
    episode_id: str,
    frames_dir: Path,
    ref_mask: Path,
    output_dir: Path,
    prompt: str,
    checkpoint: Path,
    iou_threshold: float,
    rematch_each_frame: bool,
) -> None:
    """
    调用 track_episode_sop.py 做 SAM3 视频传播 + ref mask IoU 映射。

    track_episode_sop 会:
      - 用文本 prompt 在视频上跑 SAM3 跟踪
      - 将 tracker 输出的多个 obj_id 与 ref_mask 做 IoU 匹配
      - IoU 低于阈值的帧输出空 mask
      - 写出 tracking_summary.json
    """
    output_dir.mkdir(parents=True, exist_ok=True)
    cmd = [
        sys.executable,
        str(USE_SAM3),
        "--episode",
        episode_id,
        "--frames-dir",
        str(frames_dir),
        "--ref-mask",
        str(ref_mask),
        "--output-dir",
        str(output_dir / "masks"),  # 逐帧 mask PNG 输出目录
        "--prompt",
        prompt,
        "--checkpoint",
        str(checkpoint),
        "--iou-threshold",
        str(iou_threshold),
        "--no-video",  # 冒烟/批处理不需要 overlay 视频
    ]
    if rematch_each_frame:
        # 每帧重新与 ref_mask 做 IoU 匹配,跟踪 ID 漂移时更稳健
        cmd.append("--rematch-each-frame")

    subprocess.run(cmd, check=True)

    # track_episode_sop 把 summary 写在 masks/ 下,移到 episode 根目录方便读取
    summary_src = output_dir / "masks" / "tracking_summary.json"
    summary_dst = output_dir / "tracking_summary.json"
    if summary_src.exists():
        shutil.move(str(summary_src), str(summary_dst))


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Pre-label video frame sequences",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "--frames-dir",
        type=Path,
        default=LABEL_ROOT / "frames",
        help="抽帧根目录,内含 sam3_* 子目录",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=LABEL_ROOT / "prelabels" / "video",
        help="视频预标注输出目录",
    )
    parser.add_argument("--prompt-file", type=Path, default=LABEL_ROOT / "prompt.txt")
    parser.add_argument("--prompt", type=str, default=None)
    parser.add_argument("--checkpoint", type=Path, default=DEFAULT_CKPT)
    parser.add_argument("--confidence", type=float, default=0.4, help="首帧分割置信度阈值")
    parser.add_argument(
        "--iou-threshold",
        type=float,
        default=0.5,
        help="传播帧与 ref mask 的 IoU 低于此值则输出空 mask",
    )
    parser.add_argument(
        "--rematch-each-frame",
        action="store_true",
        help="每帧 IoU 重匹配(run_prelabel.sh 默认开启)",
    )
    parser.add_argument("--episode", type=str, default=None, help="仅处理指定 episode,如 001")
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    episodes = list_frame_dirs(args.frames_dir)
    if args.episode:
        episodes = [(eid, p) for eid, p in episodes if eid == args.episode]
    if not episodes:
        print(f"No sam3_* frame dirs under {args.frames_dir}", file=sys.stderr)
        sys.exit(1)

    prompt = args.prompt or read_prompt(args.prompt_file)
    print(f"Episodes: {len(episodes)}")
    print(f"Prompt: {prompt!r}")

    if args.dry_run:
        for eid, p in episodes:
            print(f"  would track: sam3_{eid} ({len(list(p.glob('*.jpg')))} frames)")
        return

    processor = build_processor(args.checkpoint, args.confidence)
    results: list[dict] = []

    for episode_id, frames_dir in episodes:
        out_dir = args.output_dir / episode_id
        ref_mask = out_dir / f"{episode_id}_mask_ref.png"
        print(f"\n[sam3_{episode_id}] frames={len(list(frames_dir.glob('*.jpg')))}")

        try:
            ref_idx = save_ref_mask_from_frame0(frames_dir, ref_mask, processor, prompt)
            print(f"  ref mask from instance {ref_idx} -> {ref_mask.name}")
            run_tracking(
                episode_id,
                frames_dir,
                ref_mask,
                out_dir,
                prompt,
                args.checkpoint,
                args.iou_threshold,
                args.rematch_each_frame,
            )
            summary_path = out_dir / "tracking_summary.json"
            if summary_path.exists():
                summary = json.loads(summary_path.read_text(encoding="utf-8"))
                # 写入 frames_dir 绝对路径,export_coco 据此找原图
                summary["frames_dir"] = str(frames_dir.resolve())
                summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
                results.append(summary)
                print(
                    f"  tracked: {summary.get('frames_with_mask', 0)}/"
                    f"{summary.get('num_frames', 0)} frames with mask"
                )
        except (FileNotFoundError, RuntimeError, subprocess.CalledProcessError) as exc:
            print(f"  ERROR: {exc}", file=sys.stderr)

    if results:
        summary_all = args.output_dir / "tracking_summary_all.json"
        summary_all.write_text(json.dumps(results, indent=2), encoding="utf-8")
        print(f"\nAll summaries -> {summary_all}")


if __name__ == "__main__":
    main()

C.5 scripts/labelstudio_import.py
python 复制代码
#!/usr/bin/env python3
"""
labelstudio_import.py --- 生成 Label Studio 导入任务

功能:
    读取 prelabels/static 与 prelabels/video 的预标注结果,
    将原图与 mask 复制到 reviewed/labelstudio_media/,
    生成 Label Studio 可导入的 JSON 任务列表。

输出:
    - reviewed/labelstudio_import.json   导入任务(含 /data/local-files/ URL)
    - reviewed/labelstudio_media/        图片与 mask 副本(Local Files 根目录)
    - reviewed/labelstudio_config.xml    标注界面配置(从 scripts/ 复制)

Label Studio 任务字段说明:
    - image:          原图 URL,格式 /data/local-files/?d=<相对路径>
    - episode_id:     用于 export_coco 按 episode 分组
    - source:         static | video
    - prelabel_mask(s): 预标注 mask URL(备用)
    - annotations:        SAM3 mask 转为 Brush RLE,打开任务直接显示、可编辑

用法:
    python3 labelstudio_import.py
    python3 labelstudio_import.py --output /path/to/import.json

下游:
    start_labelstudio.sh → 浏览器 QA → Export COCO → export_coco.py --source labelstudio
"""

from __future__ import annotations

import argparse
import json
import shutil
import uuid
from pathlib import Path

from label_studio_sdk.converter.brush import image2rle
from PIL import Image

LABEL_ROOT = Path(__file__).resolve().parents[1]
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}  # 预留,当前通过 summary 路径过滤
# Local Files 根目录的父路径;Cloud Storage 必须为其子目录(Label Studio 安全限制)
LOCAL_FILES_DOCUMENT_ROOT_NAME = "reviewed"
MEDIA_SUBDIR = "labelstudio_media"


def to_local_url(rel_under_media: str) -> str:
    """生成 /data/local-files/ URL,相对 DOCUMENT_ROOT=reviewed/。"""
    return f"/data/local-files/?d={MEDIA_SUBDIR}/{rel_under_media}"


def read_prompt(prompt_file: Path) -> str:
    """读取 prompt(本脚本不直接使用,保留供扩展)。"""
    if prompt_file.exists():
        text = prompt_file.read_text(encoding="utf-8").strip()
        if text:
            return text
    return "object"


def build_brush_results(image_path: Path, mask_paths: list[Path]) -> list[dict]:
    """将 mask PNG 转为 Label Studio Brush 区域(直接可编辑的 annotation)。"""
    if not image_path.exists() or not mask_paths:
        return []

    with Image.open(image_path) as img:
        width, height = img.size

    results: list[dict] = []
    for mask_path in mask_paths:
        if not mask_path.exists():
            continue
        rle, _mask_w, _mask_h = image2rle(str(mask_path))
        results.append(
            {
                "id": str(uuid.uuid4())[:8],
                "from_name": "brush",
                "to_name": "image",
                "type": "brushlabels",
                "origin": "manual",
                "original_width": width,
                "original_height": height,
                "image_rotation": 0,
                "value": {
                    "format": "rle",
                    "rle": rle,
                    "brushlabels": ["target"],
                },
            }
        )
    return results


def make_task(data: dict, image_path: Path, mask_paths: list[Path]) -> dict:
    """组装任务;SAM3 mask 作为 annotations 导入,打开即可见、可改。"""
    task: dict = {"data": data}
    brush_results = build_brush_results(image_path, mask_paths)
    if brush_results:
        # ground_truth 默认 True 会导致导入后难以编辑;显式设为 False
        task["annotations"] = [{"result": brush_results, "ground_truth": False}]
    return task


def collect_static_tasks(static_dir: Path, media_dir: Path) -> list[dict]:
    """
    从 prelabels/static/seg_summary.json 构建静态图 Label Studio 任务。

    每张图一个 task;多实例时 prelabel_masks 为 mask URL 列表。
    """
    summary_path = static_dir / "seg_summary.json"
    if not summary_path.exists():
        return []

    summary = json.loads(summary_path.read_text(encoding="utf-8"))
    tasks: list[dict] = []
    for item in summary:
        image_path = Path(item["image_path"])
        if not image_path.exists():
            continue

        # 复制原图到 media 目录,路径相对于 LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT
        rel_name = f"static/{image_path.name}"
        dst = media_dir / rel_name
        dst.parent.mkdir(parents=True, exist_ok=True)
        if not dst.exists():
            shutil.copy2(image_path, dst)

        # 复制各实例 mask,供标注员对照
        mask_urls: list[str] = []
        mask_files: list[Path] = []
        for mask_path in item.get("mask_paths", []):
            mp = Path(mask_path)
            if mp.exists():
                # 目录结构:static/masks/<图片stem>/<mask文件名>
                mask_rel = f"static/masks/{mp.parent.parent.name}/{mp.name}"
                mask_dst = media_dir / mask_rel
                mask_dst.parent.mkdir(parents=True, exist_ok=True)
                if not mask_dst.exists():
                    shutil.copy2(mp, mask_dst)
                mask_urls.append(to_local_url(mask_rel))
                mask_files.append(mask_dst)

        tasks.append(
            make_task(
                {
                    "image": to_local_url(rel_name),
                    "episode_id": item.get("episode_id", f"static_{image_path.stem}"),
                    "data_source": "static",
                    "prelabel_masks": mask_urls,
                    "instances": item.get("instances", 0),
                },
                dst,
                mask_files,
            )
        )
    return tasks


def collect_video_tasks(video_prelabel_dir: Path, frames_dir: Path, media_dir: Path) -> list[dict]:
    """
    从 prelabels/video/<episode>/tracking_summary.json 构建视频帧任务。

    仅导入 has_target=True 的帧(跟踪成功且 IoU 达标的帧)。
    跳过空 mask 帧,减少 Label Studio 无效任务量。
    """
    tasks: list[dict] = []
    if not video_prelabel_dir.exists():
        return tasks

    for episode_dir in sorted(video_prelabel_dir.iterdir()):
        if not episode_dir.is_dir():
            continue
        summary_path = episode_dir / "tracking_summary.json"
        if not summary_path.exists():
            continue

        summary = json.loads(summary_path.read_text(encoding="utf-8"))
        episode_id = summary.get("episode_id", episode_dir.name)
        frames_source = Path(summary.get("frames_dir", frames_dir / f"sam3_{episode_id}"))

        for frame_info in summary.get("per_frame", []):
            if not frame_info.get("has_target"):
                continue
            frame_name = frame_info["frame_name"]
            frame_path = frames_source / frame_name
            if not frame_path.exists():
                continue

            rel_image = f"video/{episode_id}/{frame_name}"
            dst_image = media_dir / rel_image
            dst_image.parent.mkdir(parents=True, exist_ok=True)
            if not dst_image.exists():
                shutil.copy2(frame_path, dst_image)

            mask_name = frame_info.get("output_mask", "")
            mask_path = episode_dir / "masks" / mask_name
            if not mask_path.exists():
                mask_path = episode_dir / mask_name
            mask_files: list[Path] = []
            if mask_path.exists():
                mask_rel = f"video/{episode_id}/masks/{mask_name}"
                dst_mask = media_dir / mask_rel
                dst_mask.parent.mkdir(parents=True, exist_ok=True)
                if not dst_mask.exists():
                    shutil.copy2(mask_path, dst_mask)
                mask_files.append(dst_mask)

            mask_urls = [to_local_url(m.relative_to(media_dir).as_posix()) for m in mask_files]
            tasks.append(
                make_task(
                    {
                        "image": to_local_url(rel_image),
                        "episode_id": episode_id,
                        "data_source": "video",
                        "frame_index": frame_info.get("frame_index"),
                        "prelabel_masks": mask_urls,
                        "iou_vs_ref": frame_info.get("iou_vs_ref"),
                    },
                    dst_image,
                    mask_files,
                )
            )
    return tasks


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Build Label Studio import JSON from prelabels",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "--label-root",
        type=Path,
        default=LABEL_ROOT,
        help="label 项目根目录",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=LABEL_ROOT / "reviewed" / "labelstudio_import.json",
        help="Label Studio 导入 JSON 输出路径",
    )
    args = parser.parse_args()

    label_root = args.label_root
    media_dir = label_root / LOCAL_FILES_DOCUMENT_ROOT_NAME / MEDIA_SUBDIR
    media_dir.mkdir(parents=True, exist_ok=True)

    tasks = collect_static_tasks(label_root / "prelabels" / "static", media_dir)
    tasks.extend(
        collect_video_tasks(
            label_root / "prelabels" / "video",
            label_root / "frames",
            media_dir,
        )
    )

    args.output.parent.mkdir(parents=True, exist_ok=True)
    payload = json.dumps(tasks, indent=2)
    args.output.write_text(payload, encoding="utf-8")

    # 供 Cloud Storage「Tasks」模式 Sync 使用(路径必须是 reviewed/ 的子目录)
    tasks_sync_dir = label_root / LOCAL_FILES_DOCUMENT_ROOT_NAME / "labelstudio_tasks"
    tasks_sync_dir.mkdir(parents=True, exist_ok=True)
    tasks_sync_path = tasks_sync_dir / "labelstudio_import.json"
    tasks_sync_path.write_text(payload, encoding="utf-8")

    # 同步标注界面 XML 到 reviewed/,方便在 LS 项目中粘贴
    config_src = label_root / "scripts" / "labelstudio_config.xml"
    config_dst = label_root / "reviewed" / "labelstudio_config.xml"
    if config_src.exists():
        shutil.copy2(config_src, config_dst)

    print(f"Tasks: {len(tasks)}")
    print(f"Import JSON (API):  {args.output}")
    print(f"Import JSON (Sync): {tasks_sync_path}")
    print(f"Media dir:          {media_dir}")
    print(f"LS document root:   {label_root / LOCAL_FILES_DOCUMENT_ROOT_NAME}")
    print(f"LS storage path:    {media_dir}")
    print(f"Config:             {config_dst}")


if __name__ == "__main__":
    main()

C.6 scripts/labelstudio_push_import.py
python 复制代码
#!/usr/bin/env python3
"""Push Label Studio tasks + SAM3 brush predictions via REST API (not Upload Files)."""

from __future__ import annotations

import argparse
import json
import sqlite3
import sys
import urllib.error
import urllib.request
from pathlib import Path

LABEL_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_IMPORT = LABEL_ROOT / "reviewed" / "labelstudio_import.json"
DEFAULT_DB = Path.home() / ".label-studio" / "label_studio.sqlite3"
DEFAULT_STORAGE_PATH = LABEL_ROOT / "reviewed" / "labelstudio_media"


def enable_legacy_api_token(db_path: Path) -> None:
    if not db_path.exists():
        return
    with sqlite3.connect(db_path) as conn:
        conn.execute(
            "UPDATE jwt_auth_jwtsettings SET legacy_api_tokens_enabled = 1 WHERE organization_id = 1"
        )
        conn.commit()


def auth_headers(token: str) -> dict[str, str]:
    token = token.strip()
    if token.lower().startswith("bearer "):
        return {"Authorization": token}
    # JWT access token (three base64 segments)
    if token.count(".") == 2:
        return {"Authorization": f"Bearer {token}"}
    return {"Authorization": f"Token {token}"}


def decode_jwt_payload(token: str) -> dict:
    try:
        part = token.split(".")[1]
        part += "=" * (-len(part) % 4)
        return json.loads(__import__("base64").urlsafe_b64decode(part).decode("utf-8"))
    except Exception:
        return {}


def resolve_access_token(host: str, port: int, token: str) -> str:
    """Accept legacy Token, JWT access, or JWT refresh (auto-exchange)."""
    token = token.strip()
    if token.lower().startswith("bearer "):
        token = token.split(" ", 1)[1].strip()

    payload = decode_jwt_payload(token)
    token_type = payload.get("token_type")
    if token_type == "access":
        return token
    if token_type == "refresh":
        print("检测到 JWT refresh token,正在换取 access token ...")
        resp = api_post_raw(
            f"http://{host}:{port}/api/token/refresh/",
            "",
            {"refresh": token},
            use_auth=False,
        )
        access = resp.get("access")
        if not access:
            raise RuntimeError(f"refresh 换 access 失败: {resp}")
        return access

    # Legacy Token (40 hex chars) --- keep as-is
    return token


def api_post_raw(url: str, token: str, payload, *, use_auth: bool = True) -> dict:
    body = json.dumps(payload).encode("utf-8")
    headers = {"Content-Type": "application/json"}
    if use_auth and token:
        headers.update(auth_headers(token))
    req = urllib.request.Request(url, data=body, headers=headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc


def api_post(url: str, token: str, payload) -> dict:
    return api_post_raw(url, token, payload, use_auth=True)


def api_get(url: str, token: str) -> dict | list:
    req = urllib.request.Request(url, headers=auth_headers(token), method="GET")
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc


def ensure_local_storage(host: str, port: int, token: str, project_id: int, storage_path: Path) -> None:
    """创建 Local Files Source Storage(图片 /data/local-files/ 依赖此项)。"""
    base = f"http://{host}:{port}/api/storages/localfiles"
    existing = api_get(f"{base}?project={project_id}", token)
    target = str(storage_path.resolve())
    for item in existing if isinstance(existing, list) else []:
        if item.get("path") == target:
            print(f"Cloud Storage 已存在: {target}")
            return

    print(f"创建 Cloud Storage Source: {target}")
    payload = {
        "project": project_id,
        "title": "SAM3 media",
        "path": target,
        "use_blob_urls": True,
        "regex_filter": r".*\.(jpg|jpeg|png|webp)",
    }
    api_post(f"{base}", token, payload)
    print("Cloud Storage 创建成功(不要点 Sync)")


def api_delete(url: str, token: str) -> None:
    req = urllib.request.Request(url, headers=auth_headers(token), method="DELETE")
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            resp.read()
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc


def delete_project_tasks(host: str, port: int, token: str, project_id: int) -> int:
    """删除项目内全部任务,便于 --replace 重新导入。"""
    deleted = 0
    while True:
        data = api_get(
            f"http://{host}:{port}/api/tasks?project={project_id}&page_size=200&fields=id",
            token,
        )
        tasks = data.get("tasks", []) if isinstance(data, dict) else []
        if not tasks:
            break
        for task in tasks:
            api_delete(f"http://{host}:{port}/api/tasks/{task['id']}/", token)
            deleted += 1
    return deleted


def import_tasks(host: str, port: int, project_id: int, token: str, tasks: list[dict]) -> dict:
    url = (
        f"http://{host}:{port}/api/projects/{project_id}/import"
        f"?commit_to_project=true&return_task_ids=true"
    )
    return api_post(url, token, tasks)


def import_predictions(host: str, port: int, project_id: int, token: str, items: list[dict]) -> dict:
    url = f"http://{host}:{port}/api/projects/{project_id}/import/predictions"
    return api_post(url, token, items)


def main() -> None:
    import os

    parser = argparse.ArgumentParser(description="Import Label Studio tasks with SAM3 mask predictions")
    parser.add_argument("project_id", type=int, help="Label Studio project id")
    default_port = int(os.environ.get("LS_PORT", "6006"))
    parser.add_argument("--port", type=int, default=default_port, help="Label Studio 端口(或 export LS_PORT=6007)")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--import-json", type=Path, default=DEFAULT_IMPORT)
    parser.add_argument("--token", default="", help="JWT refresh/access 或 Legacy Token;或 set LS_TOKEN")
    parser.add_argument("--storage-path", type=Path, default=DEFAULT_STORAGE_PATH)
    parser.add_argument("--skip-storage-setup", action="store_true", help="跳过自动创建 Cloud Storage")
    parser.add_argument(
        "--replace",
        action="store_true",
        help="导入前删除项目内全部旧任务(推荐,避免 predictions/annotations 混用)",
    )
    parser.add_argument("--enable-legacy-token", action="store_true", default=True)
    args = parser.parse_args()

    token = args.token or os.environ.get("LS_TOKEN", "")
    if not token:
        print("错误:请设置 LS_TOKEN 或 --token", file=sys.stderr)
        sys.exit(1)

    if args.enable_legacy_token:
        enable_legacy_api_token(DEFAULT_DB)

    if not args.import_json.exists():
        print(f"错误:找不到 {args.import_json}", file=sys.stderr)
        print("请先运行: python3 scripts/labelstudio_import.py", file=sys.stderr)
        sys.exit(1)

    tasks = json.loads(args.import_json.read_text(encoding="utf-8"))
    with_annotations = sum(1 for t in tasks if t.get("annotations"))
    print(f"Importing {len(tasks)} tasks ({with_annotations} with SAM3 brush annotations)...")

    access_token = resolve_access_token(args.host, args.port, token)

    if args.replace:
        n = delete_project_tasks(args.host, args.port, access_token, args.project_id)
        print(f"已删除旧任务: {n} 条")

    if not args.skip_storage_setup:
        ensure_local_storage(args.host, args.port, access_token, args.project_id, args.storage_path)

    result = import_tasks(args.host, args.port, args.project_id, access_token, tasks)
    summary = {
        k: result.get(k)
        for k in ("task_count", "annotation_count", "prediction_count", "duration", "file_upload_ids")
        if k in result
    }
    if result.get("task_ids"):
        summary["task_ids_count"] = len(result["task_ids"])
    print("Import response:", json.dumps(summary, indent=2))

    annotation_count = result.get("annotation_count") or 0
    task_ids = result.get("task_ids") or []

    if annotation_count:
        print("\n完成。浏览器操作:")
        print("  1. 刷新页面,打开任意任务")
        print("  2. 应看到原图 + 红色 SAM3 mask(已是 annotation,无需 Accept / 齿轮)")
        print("  3. 左侧点 target → 底部 Brush/Eraser 修改 → 右上角 Submit")
    else:
        print("\n警告:annotations 未导入成功。", file=sys.stderr)


if __name__ == "__main__":
    main()

C.7 scripts/labelstudio_reload.sh
bash 复制代码
#!/usr/bin/env bash
# =============================================================================
# labelstudio_reload.sh --- 一键 reload Label Studio 任务
#
# 流程:
#   1. 从 prelabels 重新生成 import JSON + 媒体文件
#   2. 同步 Labeling Setup(labelstudio_config.xml → 项目 DB)
#   3. 删除项目旧任务,API 重新导入(SAM3 mask 为 annotations,打开即见)
#
# 用法:
#   bash scripts/labelstudio_reload.sh [project_id] [port]
#
# Token(优先级):
#   1. 环境变量 LS_TOKEN
#   2. reviewed/.ls_token 文件(一行,refresh 或 access JWT 均可)
#
# 示例:
#   bash scripts/labelstudio_reload.sh        # 项目 1,端口 LS_PORT 或 6007
#   bash scripts/labelstudio_reload.sh 1 6007
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LABEL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PROJECT_ID="${1:-1}"
PORT="${2:-${LS_PORT:-6007}}"
HOST="${LS_HOST:-127.0.0.1}"
TOKEN_FILE="${LABEL_ROOT}/reviewed/.ls_token"
CONFIG_SRC="${SCRIPT_DIR}/labelstudio_config.xml"
DB_PATH="${HOME}/.label-studio/label_studio.sqlite3"

if [[ -n "${LS_TOKEN:-}" ]]; then
  TOKEN="${LS_TOKEN}"
elif [[ -f "${TOKEN_FILE}" ]]; then
  TOKEN="$(tr -d ' \n\r' < "${TOKEN_FILE}")"
else
  echo "错误:未找到 token。"
  echo "  方式1: echo '<refresh token>' > ${TOKEN_FILE} && chmod 600 ${TOKEN_FILE}"
  echo "  方式2: export LS_TOKEN='<refresh token>'"
  exit 1
fi

if [[ -z "${TOKEN}" ]]; then
  echo "错误:token 为空"
  exit 1
fi

echo "== Label Studio reload =="
echo "  project: ${PROJECT_ID}"
echo "  port:    ${PORT}"
echo ""

# 检查服务是否在跑
if ! curl -sf -o /dev/null --max-time 3 "http://${HOST}:${PORT}/health" 2>/dev/null; then
  if ! curl -sf -o /dev/null --max-time 3 "http://${HOST}:${PORT}/" 2>/dev/null; then
    echo "警告:Label Studio 似乎未在 http://${HOST}:${PORT} 运行"
    echo "  请先: bash ${SCRIPT_DIR}/start_labelstudio.sh ${PORT}"
    exit 1
  fi
fi

echo "[1/3] 生成 import JSON 与媒体文件 ..."
python3 "${SCRIPT_DIR}/labelstudio_import.py" --label-root "${LABEL_ROOT}"

echo "[2/3] 同步 Labeling Setup 到项目 ${PROJECT_ID} ..."
if [[ ! -f "${CONFIG_SRC}" ]]; then
  echo "错误:找不到 ${CONFIG_SRC}"
  exit 1
fi
python3 - "${PROJECT_ID}" "${CONFIG_SRC}" "${DB_PATH}" <<'PY'
import sqlite3
import sys
from pathlib import Path

project_id = int(sys.argv[1])
config_path = Path(sys.argv[2])
db_path = Path(sys.argv[3])
cfg = config_path.read_text(encoding="utf-8")

if not db_path.exists():
    print(f"警告:数据库不存在 {db_path},跳过 config 同步(请在 UI 手动粘贴 config)")
    sys.exit(0)

conn = sqlite3.connect(db_path)
cur = conn.execute("UPDATE project SET label_config = ? WHERE id = ?", (cfg, project_id))
conn.commit()
if cur.rowcount:
    print(f"  label_config 已更新(project {project_id})")
else:
    print(f"  警告:project {project_id} 不存在,请在 UI 新建项目后重试")
PY

echo "[3/3] 删除旧任务并 API 导入 annotations ..."
export LS_TOKEN="${TOKEN}"
export LS_PORT="${PORT}"
python3 "${SCRIPT_DIR}/labelstudio_push_import.py" "${PROJECT_ID}" --port "${PORT}" --host "${HOST}" --replace

echo ""
echo "Reload 完成。浏览器:"
echo "  1. Ctrl+F5 强制刷新"
echo "  2. 打开任务 → 应看到原图 + 红色 SAM3 mask"
echo "  3. 先点 Edit(若只读)→ 左侧点 target → 底部 Brush/Eraser → Submit"

C.8 scripts/start_labelstudio.sh
bash 复制代码
#!/usr/bin/env bash
# =============================================================================
# start_labelstudio.sh --- 启动 Label Studio 人工修正服务
# =============================================================================
#
# 功能:
#   1. 调用 labelstudio_import.py 生成/更新导入任务
#   2. 配置 Local Files 存储根目录
#   3. 在指定端口启动 Label Studio Web 服务
#
# 用法:
#   ./start_labelstudio.sh [port] [host]
#
# 参数:
#   port  监听端口,默认 8080
#   host  绑定地址,默认 0.0.0.0(AutoDL 需对外映射)
#
# AutoDL 访问步骤:
#   1. 在本机运行本脚本
#   2. AutoDL 控制台 → 自定义服务 → 映射端口 8080
#   3. 浏览器打开映射 URL
#   4. 新建项目,Labeling Setup 粘贴 reviewed/labelstudio_config.xml
#   5. Settings → Cloud Storage → Local files:
#        Path: /root/autodl-tmp/label/reviewed/labelstudio_media
#        (必须是 DOCUMENT_ROOT 的子目录,不能与 DOCUMENT_ROOT 相同)
#   6. 导入任务(云服务器,不要用 Upload Files):
#        export LS_TOKEN="<token>"
#        bash scripts/labelstudio_server_import.sh <project_id> <port>
#      读取:reviewed/labelstudio_import.json
#   7. 修正完成后导出 COCO 到服务器:
#        bash scripts/labelstudio_server_export.sh <project_id>
#      输出:reviewed/labelstudio_export/result.json
#
# 注意:
#   - Label Studio 安装在 .venv-labelstudio/ 独立 venv,避免与 SAM3 的 numpy 冲突
#   - 不要用 label-studio --version 验证安装(会卡住初始化数据库)
#
# 下一步:python3 scripts/export_coco.py --source labelstudio
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LABEL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PORT="${1:-8080}"
HOST="${2:-0.0.0.0}"

# Label Studio Local Files:DOCUMENT_ROOT 为父目录,Cloud Storage 填其子目录
export LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED=true
export LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT="${LABEL_ROOT}/reviewed"
LS_STORAGE_PATH="${LABEL_ROOT}/reviewed/labelstudio_media"

# 启动前刷新导入 JSON 与媒体文件副本
python3 "${SCRIPT_DIR}/labelstudio_import.py" --label-root "${LABEL_ROOT}"

mkdir -p "${LABEL_ROOT}/reviewed/labelstudio_export"  # COCO 导出目标目录
mkdir -p "${HOME}/.label-studio"                       # LS 数据库与配置

# 写入 .env,避免重启后 DOCUMENT_ROOT 与 Storage Path 再次撞车
ENV_FILE="${HOME}/.label-studio/.env"
if [[ -f "${ENV_FILE}" ]] && grep -q '^SECRET_KEY=' "${ENV_FILE}"; then
  SECRET_KEY_LINE="$(grep '^SECRET_KEY=' "${ENV_FILE}")"
else
  SECRET_KEY_LINE=""
fi
{
  [[ -n "${SECRET_KEY_LINE}" ]] && echo "${SECRET_KEY_LINE}"
  echo "LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED=true"
  echo "LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT=${LABEL_ROOT}/reviewed"
} > "${ENV_FILE}"

echo "Label Studio starting on ${HOST}:${PORT}"
echo "  Document root: ${LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT}"
echo "  Storage path:  ${LS_STORAGE_PATH}   <-- 在 UI 里填这个路径"
echo ""
echo "  说明:你刚才填的路径没错;失败是因为旧脚本把 Document root 也设成了"
echo "        labelstudio_media,两者相同才会报错。现已改为 reviewed/ 作根目录。"
echo ""
echo "  Import (API):  ${LABEL_ROOT}/reviewed/labelstudio_import.json"
echo "  Import (Sync): ${LABEL_ROOT}/reviewed/labelstudio_tasks/labelstudio_import.json"
echo "  Export COCO:   ${LABEL_ROOT}/reviewed/labelstudio_export/result.json"
echo ""
echo "  导入(API,端口用 LS_PORT 或 --port):"
echo "    export LS_TOKEN=\"<refresh 或 access token>\""
echo "    export LS_PORT=${PORT}"
echo "    python3 ${SCRIPT_DIR}/labelstudio_push_import.py <project_id> --port ${PORT}"
echo ""
echo "  导出 COCO 到服务器:"
echo "    bash ${SCRIPT_DIR}/labelstudio_server_export.sh <project_id>"
echo "    python3 ${SCRIPT_DIR}/export_coco.py --source labelstudio"

# 优先使用项目内独立 venv 的 label-studio,避免污染 SAM3 主环境
LS_VENV="${LABEL_ROOT}/.venv-labelstudio"
if [[ -x "${LS_VENV}/bin/label-studio" ]]; then
  LS_BIN="${LS_VENV}/bin/label-studio"
else
  LS_BIN="label-studio"  # 回退到 PATH 中的全局安装
fi

# exec:用 label-studio 进程替换当前 shell,Ctrl+C 直接终止服务
# 注意:import JSON 需在 Web UI 中手动 Import,不能作为 start 的位置参数
exec "${LS_BIN}" start \
  --no-browser \
  --enable-legacy-api-token \
  --internal-host "${HOST}" \
  --port "${PORT}" \
  --data-dir "${HOME}/.label-studio"

C.9 scripts/labelstudio_server_export.sh
bash 复制代码
#!/usr/bin/env bash
# =============================================================================
# labelstudio_server_export.sh --- 导出 COCO 到云服务器本地路径
# =============================================================================
#
# 不要用浏览器 Export 下载后再手动上传;直接在服务器上写文件。
#
# 用法:
#   bash scripts/labelstudio_server_export.sh <project_id>
#
# 输出(默认):
#   /root/autodl-tmp/label/reviewed/labelstudio_export/result.json
#
# 下游:
#   python3 scripts/export_coco.py --source labelstudio \
#     --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LABEL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PROJECT_ID="${1:?用法: $0 <project_id>}"
EXPORT_DIR="${LABEL_ROOT}/reviewed/labelstudio_export"
EXPORT_PATH="${EXPORT_PATH:-${EXPORT_DIR}/result.json}"
DATA_DIR="${LABEL_STUDIO_DATA_DIR:-${HOME}/.label-studio}"

LS_VENV="${LABEL_ROOT}/.venv-labelstudio"
if [[ -x "${LS_VENV}/bin/label-studio" ]]; then
  LS_BIN="${LS_VENV}/bin/label-studio"
else
  LS_BIN="label-studio"
fi

mkdir -p "${EXPORT_DIR}"

echo "Exporting project ${PROJECT_ID} → ${EXPORT_PATH}"
echo "  format: BRUSH_TO_COCO (Brush/mask 项目必须用此格式,普通 COCO 无 annotations)"

"${LS_BIN}" export "${PROJECT_ID}" BRUSH_TO_COCO \
  --export-path "${EXPORT_PATH}" \
  --data-dir "${DATA_DIR}"

echo "Done: ${EXPORT_PATH}"
echo "Next: python3 ${SCRIPT_DIR}/export_coco.py --source labelstudio --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset"

C.10 scripts/labelstudio_server_import.sh
bash 复制代码
#!/usr/bin/env bash
# =============================================================================
# labelstudio_server_import.sh --- 从云服务器本地路径导入 Label Studio 任务
# =============================================================================
#
# AutoDL 等云服务器上,浏览器无法直接访问 /root/... 路径,
# 不要用 Web UI 的 Import → Upload Files。
#
# 本脚本通过 REST API 读取服务器上的 JSON 并导入项目。
#
# 用法:
#   export LS_TOKEN="<Access Token>"   # LS 网页:Account & Settings → Access Token
#   bash scripts/labelstudio_server_import.sh <project_id> [port]
#
# 示例:
#   export LS_TOKEN="abc123..."
#   bash scripts/labelstudio_server_import.sh 1 6006
#
# 导入文件(默认):
#   /root/autodl-tmp/label/reviewed/labelstudio_import.json
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LABEL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
IMPORT_JSON="${IMPORT_JSON:-${LABEL_ROOT}/reviewed/labelstudio_import.json}"
PROJECT_ID="${1:?用法: $0 <project_id> [port]}"
PORT="${2:-6006}"
LS_HOST="${LS_HOST:-127.0.0.1}"

if [[ -z "${LS_TOKEN:-}" ]]; then
  echo "错误:请设置环境变量 LS_TOKEN(Label Studio → Account & Settings → Access Token)"
  exit 1
fi

if [[ ! -f "${IMPORT_JSON}" ]]; then
  echo "错误:找不到导入文件 ${IMPORT_JSON}"
  echo "请先运行: python3 ${SCRIPT_DIR}/labelstudio_import.py"
  exit 1
fi

echo "Importing ${IMPORT_JSON}"
echo "  → project ${PROJECT_ID} @ http://${LS_HOST}:${PORT}"

response="$(curl -sf -X POST \
  "http://${LS_HOST}:${PORT}/api/projects/${PROJECT_ID}/import?commit_to_project=true" \
  -H "Authorization: Token ${LS_TOKEN}" \
  -H "Content-Type: application/json" \
  --data-binary @"${IMPORT_JSON}")"

echo "${response}"
echo "Done. Open Label Studio Data Manager to verify task count."

C.11 scripts/labelstudio_reset_import.sh
bash 复制代码
#!/usr/bin/env bash
# 一键重置并重新导入(annotations 版,mask 直接可见可编辑)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ID="${1:-1}"
PORT="${LS_PORT:-6007}"

if [[ -z "${LS_TOKEN:-}" ]]; then
  echo "请先: export LS_TOKEN=\"<你的 refresh token>\""
  exit 1
fi

python3 "${SCRIPT_DIR}/labelstudio_import.py"
python3 "${SCRIPT_DIR}/labelstudio_push_import.py" "${PROJECT_ID}" --port "${PORT}" --replace

echo ""
echo "请在浏览器:"
echo "  1. Settings → Labeling Setup → 粘贴 reviewed/labelstudio_config.xml → Save"
echo "  2. 刷新 → 打开任务 → 应看到红色 mask"
echo "  3. 左侧点 target → Brush/Eraser → Submit"

C.12 scripts/export_coco.py
python 复制代码
#!/usr/bin/env python3
"""
export_coco.py --- 导出 SAM3_LoRA 所需的 COCO 格式数据集

功能:
    将 prelabels(或 Label Studio COCO 导出)转为 SAM3_LoRA 训练目录结构:
        <output_dir>/
        ├── train/
        │   ├── <episode>_<filename>.jpg
        │   └── _annotations.coco.json
        ├── valid/
        │   └── ...
        └── dataset_meta.json

关键约定:
    - categories[0].name = prompt.txt 内容(= SAM3 微调时的文本 query)
    - segmentation 使用 RLE 格式(pycocotools 编码)
    - bbox 格式 [x, y, w, h],从 mask 自动计算
    - train/valid 按 episode_id 分组划分,避免同一视频帧泄漏

数据来源(--source):
    prelabels    直接读 prelabels/static + prelabels/video
    labelstudio  读 reviewed/labelstudio_export/(Label Studio CLI 导出为 zip,内含 COCO JSON)
    auto         若存在 Label Studio 导出则优先 labelstudio,否则 prelabels

用法:
    python3 export_coco.py --output-dir /root/autodl-tmp/sam3_lora_data/my_dataset
    python3 export_coco.py --source prelabels --valid-ratio 0.2

下游:
    SAM3_LoRA/train_sam3_lora_native.py
"""

from __future__ import annotations

import argparse
import io
import json
import shutil
import zipfile
from collections import defaultdict
from pathlib import Path

import numpy as np
from PIL import Image
from pycocotools import mask as mask_utils

LABEL_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = Path("/root/autodl-tmp/sam3_lora_data/label_export")


def read_prompt(prompt_file: Path) -> str:
    """读取 prompt,作为 COCO categories[].name。"""
    if prompt_file.exists():
        text = prompt_file.read_text(encoding="utf-8").strip()
        if text:
            return text
    return "object"


def mask_to_rle(mask: np.ndarray) -> dict:
    """二值 mask (H,W) → COCO RLE dict;counts 解码为 str 以便 JSON 序列化。"""
    binary = (mask > 127).astype(np.uint8)
    rle = mask_utils.encode(np.asfortranarray(binary))
    if isinstance(rle["counts"], bytes):
        rle["counts"] = rle["counts"].decode("utf-8")
    return rle


def bbox_from_mask(mask: np.ndarray) -> list[float]:
    """从 mask 前景像素计算 COCO bbox [x, y, w, h]。"""
    ys, xs = np.where(mask > 127)
    if len(xs) == 0:
        return [0.0, 0.0, 0.0, 0.0]
    x_min, x_max = float(xs.min()), float(xs.max())
    y_min, y_max = float(ys.min()), float(ys.max())
    return [x_min, y_min, x_max - x_min + 1.0, y_max - y_min + 1.0]


def area_from_mask(mask: np.ndarray) -> float:
    """mask 前景像素数。"""
    return float((mask > 127).sum())


def load_mask(path: Path) -> np.ndarray:
    """读取 PNG mask,统一为单通道灰度数组。"""
    mask = np.array(Image.open(path))
    if mask.ndim == 3:
        mask = mask[:, :, 0]
    return mask


def polygon_to_mask(segmentation: list, height: int, width: int) -> np.ndarray | None:
    """COCO polygon 列表 → 二值 mask(Label Studio 导出可能用 polygon)。"""
    if not segmentation:
        return None
    rles = mask_utils.frPyObjects(segmentation, height, width)
    if isinstance(rles, dict):
        rles = [rles]
    merged = mask_utils.merge(rles)
    return mask_utils.decode(merged)


def segmentation_to_mask(segmentation, height: int, width: int) -> np.ndarray | None:
    """统一处理 RLE dict / polygon list 两种 COCO segmentation 格式。"""
    if segmentation is None:
        return None
    if isinstance(segmentation, dict):
        return mask_utils.decode(segmentation)
    if isinstance(segmentation, list) and segmentation:
        if isinstance(segmentation[0], list):
            return polygon_to_mask(segmentation, height, width)
    return None


def collect_from_prelabels_static(static_dir: Path) -> list[dict]:
    """
    从 prelabels/static/seg_summary.json 收集样本。

    跳过无 mask 的条目(如 prompt 不匹配导致 0 instance)。
    """
    summary_path = static_dir / "seg_summary.json"
    if not summary_path.exists():
        return []

    summary = json.loads(summary_path.read_text(encoding="utf-8"))
    samples: list[dict] = []
    for item in summary:
        image_path = Path(item["image_path"])
        mask_paths = [Path(p) for p in item.get("mask_paths", []) if Path(p).exists()]
        if not image_path.exists() or not mask_paths:
            continue
        samples.append(
            {
                "source": "static",
                "episode_id": item.get("episode_id", f"static_{image_path.stem}"),
                "image_path": image_path,
                "mask_paths": mask_paths,
            }
        )
    return samples


def collect_from_prelabels_video(video_dir: Path, frames_dir: Path) -> list[dict]:
    """
    从 prelabels/video/<episode>/tracking_summary.json 收集有效视频帧样本。

    仅 has_target=True 的帧;每帧通常 1 个 mask。
    """
    samples: list[dict] = []
    if not video_dir.exists():
        return samples

    for episode_dir in sorted(video_dir.iterdir()):
        if not episode_dir.is_dir():
            continue
        summary_path = episode_dir / "tracking_summary.json"
        if not summary_path.exists():
            continue

        summary = json.loads(summary_path.read_text(encoding="utf-8"))
        episode_id = str(summary.get("episode_id", episode_dir.name))
        frames_source = Path(summary.get("frames_dir", frames_dir / f"sam3_{episode_id}"))
        masks_dir = episode_dir / "masks"

        for frame_info in summary.get("per_frame", []):
            if not frame_info.get("has_target"):
                continue
            frame_path = frames_source / frame_info["frame_name"]
            mask_name = frame_info.get("output_mask", "")
            mask_path = masks_dir / mask_name
            if not mask_path.exists():
                mask_path = episode_dir / mask_name
            if not frame_path.exists() or not mask_path.exists():
                continue
            samples.append(
                {
                    "source": "video",
                    "episode_id": episode_id,
                    "image_path": frame_path,
                    "mask_paths": [mask_path],
                }
            )
    return samples


def load_coco_json_from_export(path: Path) -> dict | None:
    """
    从 Label Studio 导出文件加载 COCO JSON。

    Label Studio CLI 的 COCO / BRUSH_TO_COCO 导出实际是 zip(即使 --export-path 以 .json 结尾)。
    zip 内常见:result_coco.json(BRUSH_TO_COCO)或 result.json(COCO)。
    """
    try:
        raw = path.read_bytes()
    except OSError:
        return None
    if not raw:
        return None

    if raw[:2] == b"PK":
        try:
            with zipfile.ZipFile(io.BytesIO(raw)) as zf:
                for member in ("result_coco.json", "result.json"):
                    if member in zf.namelist():
                        return json.loads(zf.read(member))
                for member in sorted(zf.namelist()):
                    if member.endswith(".json"):
                        data = json.loads(zf.read(member))
                        if isinstance(data, dict) and "images" in data:
                            return data
        except (zipfile.BadZipFile, json.JSONDecodeError, KeyError, UnicodeDecodeError):
            return None
        return None

    try:
        return json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError):
        return None


def is_coco_export(data: dict | None) -> bool:
    return isinstance(data, dict) and "images" in data and "annotations" in data


def resolve_labelstudio_image_path(
    file_name: str, media_dir: Path, coco_path: Path
) -> Path | None:
    """解析 Label Studio COCO 导出中的 file_name → 本地图片路径。"""
    name = file_name.replace("\\", "/")
    if "labelstudio_media/" in name:
        rel = name.split("labelstudio_media/", 1)[-1]
        candidate = media_dir / rel
        if candidate.exists():
            return candidate

    basename = Path(name).name
    candidates = [
        media_dir / name,
        media_dir / basename,
        media_dir / "static" / basename,
    ]
    if media_dir.exists():
        for sub in sorted(media_dir.iterdir()):
            if sub.is_dir():
                candidates.append(sub / basename)

    for candidate in candidates:
        if candidate.exists():
            return candidate

    if media_dir.exists():
        matches = list(media_dir.rglob(basename))
        if matches:
            return matches[0]

    images_dir = coco_path.parent / "images"
    candidate = images_dir / basename
    if candidate.exists():
        return candidate
    return None


def find_labelstudio_coco(reviewed_dir: Path) -> Path | None:
    """
    在 reviewed/ 下查找 Label Studio 导出的 COCO JSON。

    搜索顺序:
      1. reviewed/labelstudio_export/*.{json,zip}
      2. reviewed/*coco*.{json,zip}
    优先返回 annotations 最多的文件(COCO 对 Brush 项目常为空,BRUSH_TO_COCO 才有 mask)。
    """
    export_dir = reviewed_dir / "labelstudio_export"
    candidates: list[Path] = []
    if export_dir.exists():
        candidates.extend(sorted(export_dir.glob("*.json")))
        candidates.extend(sorted(export_dir.glob("*.zip")))
    candidates.extend(sorted(reviewed_dir.glob("*coco*.json")))
    candidates.extend(sorted(reviewed_dir.glob("*coco*.zip")))

    best: Path | None = None
    best_anns = -1
    for path in candidates:
        data = load_coco_json_from_export(path)
        if not is_coco_export(data):
            continue
        ann_count = len(data.get("annotations", []))
        if ann_count > best_anns:
            best = path
            best_anns = ann_count
    return best


def collect_from_labelstudio(coco_path: Path, media_dir: Path) -> list[dict]:
    """
    从 Label Studio COCO 导出解析样本。

    将 polygon/RLE segmentation 转为临时 PNG mask,
    再统一走 write_split 的 RLE 导出流程。
    """
    coco = load_coco_json_from_export(coco_path)
    if not is_coco_export(coco):
        return []
    images_by_id = {img["id"]: img for img in coco.get("images", [])}
    anns_by_image: dict[int, list[dict]] = defaultdict(list)
    for ann in coco.get("annotations", []):
        anns_by_image[ann["image_id"]].append(ann)

    samples: list[dict] = []
    for image_id, anns in anns_by_image.items():
        img_info = images_by_id.get(image_id)
        if not img_info:
            continue

        file_name = img_info["file_name"]
        image_path = resolve_labelstudio_image_path(file_name, media_dir, coco_path)
        if image_path is None:
            continue

        height = img_info.get("height")
        width = img_info.get("width")
        if height is None or width is None:
            with Image.open(image_path) as im:
                width, height = im.size

        mask_paths: list[Path] = []
        tmp_dir = coco_path.parent / "_export_masks"
        tmp_dir.mkdir(exist_ok=True)
        for i, ann in enumerate(anns):
            mask_arr = segmentation_to_mask(ann.get("segmentation"), height, width)
            if mask_arr is None or mask_arr.sum() == 0:
                continue
            mask_path = tmp_dir / f"{image_id}_{i}.png"
            Image.fromarray((mask_arr * 255).astype(np.uint8)).save(mask_path)
            mask_paths.append(mask_path)

        if not mask_paths:
            continue

        episode_id = img_info.get(
            "episode_id",
            Path(file_name).parts[0] if "/" in file_name else "labelstudio",
        )
        samples.append(
            {
                "source": "labelstudio",
                "episode_id": str(episode_id),
                "image_path": image_path,
                "mask_paths": mask_paths,
            }
        )
    return samples


def split_by_episode(
    samples: list[dict],
    valid_ratio: float,
    seed: int,
) -> tuple[list[dict], list[dict]]:
    """
    按 episode_id 分组划分 train/valid,防止同一视频的不同帧同时出现在两个 split。

    若只有 1 个 episode,则退化为按样本随机划分(仍保证 train 非空)。
    """
    by_episode: dict[str, list[dict]] = defaultdict(list)
    for sample in samples:
        by_episode[sample["episode_id"]].append(sample)

    episode_ids = sorted(by_episode.keys())

    # 单 episode 或小数据集:按样本级随机划分
    if len(episode_ids) <= 1:
        rng = np.random.default_rng(seed)
        indices = np.arange(len(samples))
        rng.shuffle(indices)
        if len(samples) <= 1:
            return samples, []
        valid_count = max(1, int(round(len(samples) * valid_ratio)))
        valid_count = min(valid_count, len(samples) - 1)
        valid_idx = set(indices[:valid_count].tolist())
        train = [samples[i] for i in range(len(samples)) if i not in valid_idx]
        valid = [samples[i] for i in range(len(samples)) if i in valid_idx]
        return train, valid

    # 多 episode:整组 episode 划入 train 或 valid
    rng = np.random.default_rng(seed)
    shuffled = episode_ids.copy()
    rng.shuffle(shuffled)

    valid_ep_count = max(1, int(round(len(shuffled) * valid_ratio)))
    valid_ep_count = min(valid_ep_count, len(shuffled) - 1)
    valid_eps = set(shuffled[:valid_ep_count])

    train, valid = [], []
    for ep in episode_ids:
        bucket = valid if ep in valid_eps else train
        bucket.extend(by_episode[ep])
    return train, valid


def write_split(
    split_name: str,
    samples: list[dict],
    output_root: Path,
    category_name: str,
) -> dict:
    """
    将样本列表写入一个 split 目录,生成 COCO JSON。

    图片复制为 <episode_id>_<原文件名>,避免不同 episode 同名帧冲突。
    每张图可有多条 annotation(多实例)。
    """
    split_dir = output_root / split_name
    split_dir.mkdir(parents=True, exist_ok=True)

    coco = {
        "images": [],
        "annotations": [],
        "categories": [{"id": 1, "name": category_name, "supercategory": "object"}],
    }

    image_id = 1
    ann_id = 1
    for sample in samples:
        src_image = Path(sample["image_path"])
        with Image.open(src_image) as im:
            width, height = im.size

        dst_name = f"{sample['episode_id']}_{src_image.name}"
        dst_path = split_dir / dst_name
        if src_image.resolve() != dst_path.resolve():
            shutil.copy2(src_image, dst_path)

        coco["images"].append(
            {
                "id": image_id,
                "file_name": dst_name,
                "width": width,
                "height": height,
            }
        )

        for mask_path in sample["mask_paths"]:
            mask = load_mask(mask_path)
            area = area_from_mask(mask)
            if area <= 0:
                continue
            coco["annotations"].append(
                {
                    "id": ann_id,
                    "image_id": image_id,
                    "category_id": 1,
                    "bbox": bbox_from_mask(mask),
                    "area": area,
                    "segmentation": mask_to_rle(mask),
                    "iscrowd": 0,
                }
            )
            ann_id += 1

        image_id += 1

    ann_path = split_dir / "_annotations.coco.json"
    ann_path.write_text(json.dumps(coco, indent=2), encoding="utf-8")
    return {
        "split": split_name,
        "images": len(coco["images"]),
        "annotations": len(coco["annotations"]),
        "annotation_file": str(ann_path),
    }


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Export COCO dataset for SAM3_LoRA",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "--label-root",
        type=Path,
        default=LABEL_ROOT,
        help="label 项目根目录",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=DEFAULT_OUTPUT,
        help="COCO 数据集输出根目录",
    )
    parser.add_argument(
        "--source",
        choices=["prelabels", "labelstudio", "auto"],
        default="auto",
        help="auto: 有 Label Studio 导出则优先,否则 prelabels",
    )
    parser.add_argument(
        "--prompt-file",
        type=Path,
        default=LABEL_ROOT / "prompt.txt",
        help="category name 来源(= SAM3 text prompt)",
    )
    parser.add_argument(
        "--valid-ratio",
        type=float,
        default=0.15,
        help="验证集比例(按 episode 分组)",
    )
    parser.add_argument("--seed", type=int, default=42, help="随机划分种子")
    args = parser.parse_args()

    label_root = args.label_root
    category_name = read_prompt(args.prompt_file)
    samples: list[dict] = []

    ls_coco = find_labelstudio_coco(label_root / "reviewed")
    use_labelstudio = args.source == "labelstudio" or (
        args.source == "auto" and ls_coco is not None
    )

    if use_labelstudio:
        if ls_coco is None:
            raise SystemExit(
                "No Label Studio COCO export found under reviewed/.\n"
                "  Run: bash scripts/labelstudio_server_export.sh <project_id>\n"
                "  Or use: --source prelabels"
            )
        media_dir = label_root / "reviewed" / "labelstudio_media"
        samples = collect_from_labelstudio(ls_coco, media_dir)
        if not samples:
            coco_data = load_coco_json_from_export(ls_coco)
            ann_n = len(coco_data.get("annotations", [])) if coco_data else 0
            raise SystemExit(
                f"Label Studio export has 0 usable samples (annotations in file: {ann_n}).\n"
                "  Brush 项目请用 BRUSH_TO_COCO 导出:bash scripts/labelstudio_server_export.sh <project_id>\n"
                "  或跳过 LS:--source prelabels"
            )
        source_note = f"labelstudio:{ls_coco.name}"
    else:
        samples.extend(
            collect_from_prelabels_static(label_root / "prelabels" / "static")
        )
        samples.extend(
            collect_from_prelabels_video(
                label_root / "prelabels" / "video",
                label_root / "frames",
            )
        )
        source_note = "prelabels"

    if not samples:
        raise SystemExit("No samples found. Run run_prelabel.sh first or export from Label Studio.")

    train_samples, valid_samples = split_by_episode(samples, args.valid_ratio, args.seed)
    args.output_dir.mkdir(parents=True, exist_ok=True)

    episodes = sorted({s["episode_id"] for s in samples})
    meta = {
        "prompt": category_name,
        "category_name": category_name,
        "source": source_note,
        "total_samples": len(samples),
        "episodes": episodes,
        "splits": [
            write_split("train", train_samples, args.output_dir, category_name),
            write_split("valid", valid_samples, args.output_dir, category_name),
        ],
    }

    meta_path = args.output_dir / "dataset_meta.json"
    meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
    print(json.dumps(meta, indent=2, ensure_ascii=False))
    print(f"\nDataset ready at: {args.output_dir}")


if __name__ == "__main__":
    main()

C.13 scripts/smoke_pipeline.sh
bash 复制代码
#!/usr/bin/env bash
# =============================================================================
# smoke_pipeline.sh --- 端到端冒烟测试
# =============================================================================
#
# 功能:
#   无需自备数据,自动从 sam3/assets/test/ 复制样本,跑通完整流水线:
#     准备数据 → SAM3 预标注 → COCO 导出 → SAM3_LoRA 训练 1 epoch
#
# 用法:
#   bash scripts/smoke_pipeline.sh
#
# 测试数据:
#   - 10 张静态图(SAM3 测试集 4 张 + wyw 2 张 + 4 张 dup 副本)
#   - 30 帧视频(来自 VIDEO/2026-07-02 191302_frames)
#
# 输出:
#   数据集:/root/autodl-tmp/sam3_lora_data/label_smoke/
#   配置:  SAM3_LoRA/configs/label_smoke.yaml
#   权重:  SAM3_LoRA/outputs/label_smoke/best_lora_weights.pt
#   日志:  label/smoke_pipeline.log(若用 tee 重定向)
#
# 依赖:GPU、SAM3、SAM3_LoRA、decord(训练时自动 pip install)
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LABEL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
TEST_ROOT="/root/autodl-tmp/sam3/assets/test"       # 内置测试物料
SAM3_LORA="/root/autodl-tmp/SAM3_LoRA"
DATASET_NAME="label_smoke"
OUTPUT_DATA="/root/autodl-tmp/sam3_lora_data/${DATASET_NAME}"
SMOKE_CONFIG="${SAM3_LORA}/configs/${DATASET_NAME}.yaml"

# ---------------------------------------------------------------------------
# [1/5] 准备静态图:复制到 raw/images/
# ---------------------------------------------------------------------------
echo "=== [1/5] Prepare smoke raw data ==="
mkdir -p "${LABEL_ROOT}/raw/images" "${LABEL_ROOT}/frames"

# SAM3 静态测试图 4 张
for img in IMG_8086 IMG_8087 IMG_8088 IMG_8089; do
  cp -n "${TEST_ROOT}/SAM3/${img}.jpg" "${LABEL_ROOT}/raw/images/" 2>/dev/null || true
done
# wyw 子目录 2 张(prompt 不匹配时可能 0 instance,属预期)
for img in IMG_8098 IMG_8099; do
  cp -n "${TEST_ROOT}/wyw/${img}.jpg" "${LABEL_ROOT}/raw/images/" 2>/dev/null || true
done
# 再复制 4 张 dup 变体,凑够 10 张独立文件名
i=1
for img in IMG_8086 IMG_8087 IMG_8088 IMG_8089; do
  cp -n "${TEST_ROOT}/SAM3/${img}.jpg" "${LABEL_ROOT}/raw/images/${img}_dup${i}.jpg" 2>/dev/null || true
  i=$((i + 1))
done

echo "  static images: $(find "${LABEL_ROOT}/raw/images" -maxdepth 1 -type f | wc -l)"

# ---------------------------------------------------------------------------
# [2/5] 准备视频帧:复制 30 帧到 frames/sam3_001/
# 注意:源目录名含空格,必须用 find -print0 安全处理
# ---------------------------------------------------------------------------
echo "=== [2/5] Prepare smoke video frames (30 frames) ==="
SMOKE_FRAMES="${LABEL_ROOT}/frames/sam3_001"
rm -rf "${SMOKE_FRAMES}"
mkdir -p "${SMOKE_FRAMES}"
SRC_FRAMES="${TEST_ROOT}/VIDEO/2026-07-02 191302_frames"
count=0
if [[ -d "${SRC_FRAMES}" ]]; then
  while IFS= read -r -d '' f; do
    cp "${f}" "${SMOKE_FRAMES}/${count}.jpg"   # 重命名为 0.jpg, 1.jpg, ...
    count=$((count + 1))
    [[ "${count}" -ge 30 ]] && break
  done < <(find "${SRC_FRAMES}" -maxdepth 1 -name '*.jpg' -print0 | sort -z)
fi
echo "  video frames: ${count}"

# ---------------------------------------------------------------------------
# [3/5] SAM3 预标注
# ---------------------------------------------------------------------------
echo "=== [3/5] SAM3 pre-label ==="
bash "${SCRIPT_DIR}/run_prelabel.sh" all

# ---------------------------------------------------------------------------
# [4/5] 导出 COCO(跳过 Label Studio,直接用 prelabels)
# ---------------------------------------------------------------------------
echo "=== [4/5] Export COCO ==="
python3 "${SCRIPT_DIR}/export_coco.py" \
  --source prelabels \
  --output-dir "${OUTPUT_DATA}"

# ---------------------------------------------------------------------------
# [5/5] SAM3_LoRA 训练 1 epoch
# ---------------------------------------------------------------------------
echo "=== [5/5] SAM3_LoRA smoke train (1 epoch) ==="
# SAM3_LoRA 训练依赖 decord 读取视频相关模块,冒烟时按需安装
python3 -c "import decord" 2>/dev/null || pip install decord -q

# 生成最小 LoRA 训练配置(1 epoch 仅验证管线可通)
cat > "${SMOKE_CONFIG}" <<EOF
model:
  name: "facebook/sam3"
  checkpoint_path: "/root/autodl-tmp/weights/sam3_ms/sam3.pt"
  load_from_HF: false
  cache_dir: null

lora:
  rank: 8
  alpha: 16
  dropout: 0.05
  target_modules:
    - "q_proj"
    - "k_proj"
    - "v_proj"
    - "out_proj"
    - "linear1"
    - "linear2"
  apply_to_vision_encoder: false
  apply_to_text_encoder: false
  apply_to_geometry_encoder: false
  apply_to_detr_encoder: true
  apply_to_detr_decoder: true
  apply_to_mask_decoder: true

training:
  data_dir: "${OUTPUT_DATA}"
  num_negatives: 1
  batch_size: 1
  num_workers: 2
  learning_rate: 1.0e-4
  weight_decay: 0.01
  adam_beta1: 0.9
  adam_beta2: 0.999
  adam_epsilon: 1.0e-8
  max_grad_norm: 1.0
  num_epochs: 1
  warmup_steps: 5
  lr_scheduler: "cosine"
  logging_steps: 5
  eval_steps: 50
  save_steps: 999999
  save_total_limit: 1
  mixed_precision: "bf16"
  seed: 42
  gradient_accumulation_steps: 1

output:
  output_dir: "outputs/${DATASET_NAME}"
  logging_dir: "logs/${DATASET_NAME}"
  save_lora_only: true
  push_to_hub: false
  hub_model_id: null

evaluation:
  metric: "iou"
  save_predictions: false
  compute_metrics_during_training: true

hardware:
  device: "cuda"
  dataloader_pin_memory: true
  use_compile: false
EOF

cd "${SAM3_LORA}"
python3 train_sam3_lora_native.py --config "${SMOKE_CONFIG}"

echo ""
echo "Smoke pipeline complete."
echo "  Dataset: ${OUTPUT_DATA}"
echo "  Config:  ${SMOKE_CONFIG}"
echo "  LoRA:    ${SAM3_LORA}/outputs/${DATASET_NAME}/"

C.14 scripts/labelstudio_config.xml

Label Studio Labeling Setup 界面配置;labelstudio_reload.sh 会同步写入项目 DB,并复制到 reviewed/labelstudio_config.xml

xml 复制代码
<!--
  SAM3 标注界面:mask 以 annotations 导入,打开任务即可看到红色区域。
  操作:① 点 Edit 进入编辑 → ② 左侧点 target → ③ 底部 Brush/Eraser → ④ Submit
-->
<View>
  <Header value="Step1: Edit | Step2: click target | Step3: Brush/Eraser | Step4: Submit"/>
  <Text name="meta" value="Episode: $episode_id | Type: $data_source | SAM3 instances: $instances"/>
  <Image name="image" value="$image" zoom="true" zoomControl="true"/>
  <BrushLabels name="brush" toName="image">
    <Label value="target" background="rgba(255, 0, 0, 0.6)"/>
  </BrushLabels>
</View>

附录 D:配置与生成文件说明

D.1 prompt.txt(项目根)
说明
格式 单行英文描述,如 black rectangular block with a hole
作用 SAM3 推理文本 prompt;导出 COCO 时写入 categories[0].name
约束 必须与微调/推理时使用的 prompt 完全一致 (见 <prompt_guide.md>)
谁读 prelabel_*.pyexport_coco.py

D.2 scripts/labelstudio_config.xmlreviewed/labelstudio_config.xml

Label Studio Labeling Setup 界面定义(XML)。完整源码见 [附录 C.14](#完整源码见 附录 C.14)

标签 name 说明
Header --- 顶部操作提示
Text meta 显示 $episode_id$data_source$instances
Image image 绑定任务字段 $image(Local Files URL)
BrushLabels brush 画笔标签;toName="image"
Label --- 标签名 target (与 import JSON 中 brushlabels 一致)

注意

  • 勿用 $source(LS 保留变量)
  • reload.sh 会写入项目 DB;改 XML 后需 reload 或手动 Save

D.3 reviewed/.ls_token
说明
内容 一行 JWT refresh token(Account & Settings → Access Token)
权限 chmod 600;已在 .gitignore勿提交 git
谁读 labelstudio_reload.shlabelstudio_push_import.py(自动 refresh → access)

D.4 reviewed/labelstudio_import.json

API 导入用的任务数组。每条任务结构:

json 复制代码
{
  "data": {
    "image": "/data/local-files/?d=labelstudio_media/static/IMG_8086.jpg",
    "episode_id": "static_IMG_8086",
    "data_source": "static",
    "prelabel_masks": ["..."],
    "instances": 1
  },
  "annotations": [{
    "ground_truth": false,
    "result": [{
      "id": "a1b2c3d4",
      "type": "brushlabels",
      "from_name": "brush",
      "to_name": "image",
      "original_width": 3024,
      "original_height": 4032,
      "value": {
        "format": "rle",
        "rle": [2, 232, 48, ...],
        "brushlabels": ["target"]
      }
    }]
  }]
}
字段 含义
data.image 原图 URL,相对 DOCUMENT_ROOT=reviewed/
data_source staticvideo
annotations SAM3 mask(非 predictions)
value.rle 整数数组,Label Studio Brush 专用 RLE

D.5 reviewed/labelstudio_media/
说明
作用 Label Studio Local Files 实际读盘的目录
Cloud Storage 配置 Absolute path = 本目录绝对路径
结构 static/ 静态图;static/masks/ mask;video/<episode>/ 视频帧
DOCUMENT_ROOT 父目录 reviewed/(由 start_labelstudio.sh 设置)

D.6 .gitignore
复制代码
reviewed/.ls_token
.venv-labelstudio/

防止 token 与大型 venv 误入版本库。


D.7 预标注汇总 JSON
文件 字段要点
prelabels/static/seg_summary.json image_path, mask_paths[], instances, episode_id
prelabels/video/<id>/tracking_summary.json episode_id, frames_dir, per_frame[].has_target, output_mask

D.8 export_coco.py 输出结构
复制代码
sam3_lora_data/<name>/
├── train/
│   ├── <episode>_<file>.jpg
│   └── _annotations.coco.json    # COCO instances + RLE segmentation
├── valid/
│   └── ...
└── dataset_meta.json             # prompt、episode 列表、划分统计

附录 E:环境变量与 Secret 文件

变量 / 文件 默认值 作用 使用脚本
LS_TOKEN --- Label Studio API 认证 push_import, reload, server_import
LS_PORT 6006(reload 默认;AutoDL 6006/6007 为 TensorBoard) LS 端口 push_import, reload
LS_HOST 127.0.0.1 LS 主机 push_import, reload
PROMPT_FILE prompt.txt 覆盖 prompt 路径 run_prelabel.sh
CONFIDENCE 0.4 SAM3 置信度阈值 run_prelabel.sh
CHECKPOINT .../sam3.pt SAM3 权重 run_prelabel.sh
IMPORT_JSON reviewed/labelstudio_import.json 自定义导入路径 server_import
LABEL_STUDIO_DATA_DIR ~/.label-studio LS 数据目录 server_export
reviewed/.ls_token --- 持久化 refresh token reload

Label Studio 进程环境(由 start_labelstudio.sh 设置):

变量
LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED true
LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT /root/autodl-tmp/label/reviewed

附录 F:外部关联路径

路径 说明
/root/autodl-tmp/sam3 SAM3 源码,pip install -e
/root/autodl-tmp/weights/sam3_ms/sam3.pt 默认 checkpoint
/root/autodl-tmp/SAM3_LoRA LoRA 训练框架;configs、outputs
/root/autodl-tmp/sam3_lora_data/ COCO 数据集默认输出根
~/.label-studio/label_studio.sqlite3 LS 项目、任务、标注 DB
~/.label-studio/.env LS 持久化 SECRET_KEY、DOCUMENT_ROOT

SAM3_LoRA 训练配置示例(非本仓库文件,但流水线终点):

yaml 复制代码
# SAM3_LoRA/configs/my_dataset.yaml
training:
  data_dir: "/root/autodl-tmp/sam3_lora_data/my_dataset"
  num_epochs: 20
  batch_size: 1
  learning_rate: 1.0e-4

文档维护 :新增或修改 scripts/ 下文件时,请同步更新本附录与 README.md

相关推荐
java1234_小锋1 小时前
Maven 4 要来了:15 年后,Java 构建工具迎来“彻底重构“
java·重构·maven
Bomangedd1 小时前
NSK RA25BN 滚子直线导轨技术详解
运维·服务器·经验分享·规格说明书
自然 醒1 小时前
前端如何实现在线预览office常见文件功能?
前端·vue.js
霸道流氓气质1 小时前
Java 工程师 AI 智能体学习路线 · 阶段 1:AI / LLM 基础认知 详解
java·人工智能·学习
JerrySir2 小时前
`spring.threads.virtual.enabled=true`:用三类负载定位等待、争用与拒绝
java·spring boot
尚早立志2 小时前
Spring Boot 之 BeanDefinitionLoader 详解
java·spring boot·后端
月光有害2 小时前
DDD 核心概念梳理:从领域模型到领域事件
java·ddd
肉肉不吃 肉2 小时前
前端调试跨域如何解决
前端·vue.js
Liora_Yvonne2 小时前
为什么你写了3年前端还是搭不好一个项目
前端·架构