HVAC 设备检测器训练记录

6 阅读10分钟

记录本次从零拉起 YOLO11 训练管线的完整过程,包含通用流程与本项目特定踩坑结论。 便于下一次接手或迭代时复用。


TL;DR — 5 步走完整个训练

步骤命令用时输入输出
1. 装依赖uv sync --extra training5-10 分钟pyproject.tomltorch + ultralytics 装好
2. 预标注uv run python scripts/autolabel_v2.py <PNG 列表>30 分钟 / 30 张图dataset/rendered/*.pngscripts/autolabel_v2/*.txt
3. 拷标签见 §3 的 Python 片段10 秒scripts/autolabel_v2/*.txtdataset/labels/*.txt
4. 训练uv run python -m training.pipeline.trainCPU 30 分钟 / GPU 几分钟dataset/{labels,rendered,splits}/runs/detect/train/weights/best.pt
5. 评估uv run python -m training.pipeline.evaluate30 秒best.pt测试集 mAP

新手接手:直接跳到第一部分 §1 顺序往下读。 老手回头:直接跳到第二部分 §16 看本项目踩过的坑


项目背景

业务任务:HVAC(暖通空调)工程图纸上识别水路系统的关键设备(冷水机组、水泵、冷却塔等),为后续的"图纸 → 拓扑表达式"理解服务。 本检测器只负责出设备类型 + 边界框,拓扑关系由 LLM(Claude/Qwen-VL)在 prompt 阶段拼。

技术选型:

选择备选选择理由
检测框架YOLO11(Ultralytics)YOLOv8/v9、DETR、Faster-RCNN推理快、社区活跃、单 stage
模型尺寸yolo11n.pt(nano,~2.5M 参数)s/m/l/x从最小起步,数据少时大模型容易过拟合
标注工具Claude CLI 预标注 + Label Studio 人工修订纯人工30 张图人工标 1-2 天,Claude 30 分钟出预标
图源DWG → PNG(aspose-cad)直接喂 DWGYOLO 只吃位图;DWG 矢量信息丢失换来通用性

数据集类别(6 类):

ID英文名中文名典型外观
0chiller冷水机组大长方形主体,带 4 接管口
1pump水泵圆形带 V 字叶轮符号
2cooling_tower冷却塔方形顶视带风扇符号 / 侧视带百叶填充
3heat_exchanger换热器窄长矩形带斜线 / 板片图例
4valve阀门蝴蝶结小符号(本次 autolabel 跳过此类)
5terminal_load末端负荷AHU/FCU/盘管图标

类别 ID 顺序绑定在 config.toml [training].classesdataset/yolo.yaml 里,改名等于改语义,会让历史标签失效


概念词表 / 指标解读

YOLO 训练里反复出现的术语,看表格指标时来这里查。

损失函数(loss)

术语含义期望走势异常
box_loss预测框位置回归误差持续下降不降 → 学习率不对或数据有问题
cls_loss类别分类误差(BCE)持续下降不降但 box_loss 在降 → 类别失衡 / 标注错类
dfl_lossDistribution Focal Loss(框边缘精细化)持续下降一般跟 box_loss 同步
收敛loss 趋于稳定,新 epoch 不再降是训练完成的信号之一没收敛就 early-stop 说明 patience 太小

验证指标

术语全称含义区间
PPrecision模型说"是" 的里面真正是的比例0-1,越高越好
RRecall真正是的里面被模型找出来的比例0-1,越高越好
IoUIntersection over Union预测框 vs 真值框的重叠度0-1,>0.5 通常算"对上了"
mAP50mean Average Precision @ IoU=0.5各类 AP 取均值(IoU 阈值固定 0.5)0-1,主指标
mAP50-95mean AP @ IoU=0.5 step 0.05 to 0.9510 个 IoU 阈值的 mAP 再平均(更严格)0-1,通常远低于 mAP50

经验阈值(以 mAP50 为准):

mAP50状态说明
< 0.05没学到本项目当前状况(0.027)
0.05 - 0.30部分能识别数据不足 / 标注偏差
0.30 - 0.50凑合用优势类能识别,弱势类还得补
0.50 - 0.70基本可用生产部署的最低门槛
> 0.70优秀数据扎实,标注精细

训练机制

术语含义本项目设置
epoch整个训练集过一遍v1: 30 / v2: 150(实际 132 early-stop)
batch一次 forward 喂入的图片数v1: 4 / v2: 2
imgsz输入网络的图像边长(短边)v1: 640 / v2: 1280
patience验证集指标连续 N epoch 不提升就停训v2: 50
early-stop因 patience 触发提前终止v2 在 epoch 132/150 触发
warmup前几个 epoch 学习率从低拉到目标,稳定训练ultralytics 默认 3 epoch
同目录约定YOLO 默认从 <img>.png 同目录找 <img>.txttrain.py 自动把 dataset/labels/*.txt 同步到 dataset/rendered/

文档结构

  • 第一部分:通用训练流程 —— 接手项目后照着跑就能复现的操作手册
  • 第二部分:本项目特定状况 —— 本次踩到的坑、选型决定、数据/参数/结果
  • 第三部分:故障排除 + 衔接 —— FAQ、训练前质检、Label Studio 后续流程

第一部分按"先验证再批量"的顺序写,每一步都附带常见错误与排查。 第二部分用来回答"为什么参数取这个值"、"为什么剔了那张图"。 第三部分是接手后做"v3 训练"或"补 valve"时直接查的实操参考。


第一部分:通用训练流程

前置条件:项目根目录已有 dataset/rendered/*.pngdataset/splits/{train,val,test}.txt。 如果连这两样也没有,先按 src/training/README.md 跑 DWG 入库 + 拆分。

1. 环境准备(一次性)

# 安装训练依赖(torch + ultralytics,约 2-3 GB)
uv sync --extra training

# 验证环境
uv run python -c "import torch, ultralytics; print('torch', torch.__version__, 'cuda', torch.cuda.is_available())"
# 期望输出: torch 2.x.x  cuda True/False
# cuda=False 表示当前环境只能 CPU 训练,后面参数按 CPU 模式设置

2. 用 Claude CLI 生成预标注

前置:config.toml[claude] 段已配好 ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL, 并已经手动跑过 scripts/classify_drawings.py 生成 scripts/classify_output/<stem>.json 设备先验。

2.1 调大子进程超时

autolabel_v2.py 调用 Claude CLI 处理大图常要 1-10 分钟,默认 300 秒不够:

# config.toml
[claude]
timeout = 900   # 推荐值,本项目实测够用

2.2 单图 smoke-test

别上来就跑 30 张,先用 1 张验证管线:

uv run python scripts/autolabel_v2.py B_10.png

期望输出 parsed N boxes -> YOLO valid N 且 5 类数量基本对齐 [OK]。 若失败先排查再继续:

现象排查方向
仍然超时改小 scripts/autolabel_v2.py:192max_side(1600 → 1280),或继续放大 [claude].timeout
框数全部 0检查 scripts/classify_output/<stem>.json 是否存在、parse_ok 是否 True
PARSE FAILscripts/autolabel_v2/<stem>_raw.txt 的 Claude 原始回复格式
数量大幅偏差看 prompt 是否合理,或对该图禁用先验

2.3 批量跑全部图

# 参数列表 = dataset/rendered/ 下的所有 PNG 文件名
uv run python scripts/autolabel_v2.py B_10.png B_11.png ... CH_95.png

跑完检查产物:

# 应当看到 N 个 .txt(不含 _raw.txt)+ summary.json
ls scripts/autolabel_v2/*.txt | grep -v _raw | wc -l

# 看每张图的数量对齐情况,找 [DRIFT] 的
cat scripts/autolabel_v2/summary.json

注意限制:autolabel_v2.py 不标 valve(阀门太小框不准,设计上跳过)。 训练集和验证集都会缺这一类,valve 的 mAP 必然是 0。后续要补 valve 必须靠人工。

2.4 处理失败的图

少数图会超时或解析失败,两种处理:

方案 A:retry 一遍(网关延迟波动很大,多数情况能成功)

uv run python scripts/autolabel_v2.py B_11.png CH_28.png

方案 B:仍然失败则从 splits 剔除

如果 retry 还是失败(比如原图>10000×7000 像素的超大图), 手动编辑 dataset/splits/{train,val,test}.txt 删掉那一行。 不能让 splits 里有"无 .txt"的图,否则 YOLO 会把它当成"背景图"训练,污染分布。

3. 拷贝标签到正式位置

python - <<'EOF'
from pathlib import Path
src = Path("scripts/autolabel_v2")
dst = Path("dataset/labels"); dst.mkdir(parents=True, exist_ok=True)
for txt in src.glob("*.txt"):
    if txt.stem.endswith("_raw"):
        continue
    (dst / txt.name).write_bytes(txt.read_bytes())
print(f"copied to {dst}")
EOF

核对一致性(splits 与 labels 必须 0 缺失 0 多余):

python - <<'EOF'
from pathlib import Path
splits = set()
for n in ("train","val","test"):
    for line in Path(f"dataset/splits/{n}.txt").read_text().splitlines():
        if line.strip(): splits.add(Path(line.strip()).stem)
have = {p.stem for p in Path("dataset/labels").glob("*.txt")}
print(f"splits={len(splits)} labels={len(have)}")
print(f"splits 缺 .txt: {sorted(splits - have)}")
print(f"labels 多余: {sorted(have - splits)}")
EOF

4. 配置训练超参

# config.toml [training] 段

# 选项 A:CPU 环境(本项目实际用的)
epochs = 150
batch_size = 2
imgsz = 1280
device = "cpu"
patience = 50

# 选项 B:有 GPU 环境
# epochs = 300
# batch_size = 16
# imgsz = 1280
# device = "0"
# patience = 80

5. 启动训练

uv run python -m training.pipeline.train

# 或在不改 config.toml 的情况下命令行覆盖:
uv run python -m training.pipeline.train --epochs 150 --batch 2 --imgsz 1280 --device cpu

训练启动后立即检查输出:

指标期望异常说明
train: N images, 0 backgrounds, 0 corruptN = train.txt 行数backgrounds>0 表示有图缺 .txt,回 §3 核对
val: M images, 0 backgrounds, 0 corruptM = val.txt 行数corrupt>0 表示 .txt 格式错,看是不是空文件或坐标越界
Overriding model.yaml nc=80 with nc=6类别数 = config.toml 里 classes 长度不一致检查 dataset/yolo.yaml

每次训练前先备份上一次结果(ultralytics 默认 exist_ok=True 会覆盖):

test -f runs/detect/train/weights/best.pt && \
  cp -r runs/detect/train runs/detect/train_$(date +%Y%m%d_%H%M)_backup

6. 看结果

训练完成后产物在 runs/detect/train/:

文件用途
weights/best.pt最佳权重,部署用这个
weights/last.pt最后一个 epoch 的权重
results.png损失曲线 + mAP 曲线,判断有没有收敛
val_batch0_pred.jpg模型在验证集上的预测,直观看模型在乱画什么
val_batch0_labels.jpg验证集真值,跟上面对比
confusion_matrix.png混淆矩阵
results.csv每个 epoch 的逐项指标

判断是否成功(按总体 mAP50):

mAP50 范围状态处理
≥ 0.5基本可用部署,迭代准确度
0.1 - 0.5部分类能识别看单类 mAP,补少数类样本/标注
0.05 - 0.1模型勉强学到数据量或标注严重不足,先补数据
< 0.05几乎学不到必须改善数据/标注,光调参拉不动(本项目当前状况)

7. 评估测试集(可选)

uv run python -m training.pipeline.evaluate
# 或指定其他权重:
uv run python -m training.pipeline.evaluate runs/detect/train/weights/best.pt

8. 推理调用

训练出的权重可以塞回主服务的本地检测器:

# config.toml
[detector]
mode = "local"
weights_path = "runs/detect/train/weights/best.pt"
skip_llm = false   # true = 纯本地 YOLO 不调 LLM;false = YOLO + LLM 合作

重启服务后查 /tools/drawing_analysis 应显示 detector_mode: local


第二部分:本项目特定状况

记录本次训练的实际决策、踩坑、数据状况、和最终结果。 看通用流程不能解决"为什么 timeout=900"、"为什么 max_side=1600"这类问题时来这里。

9. 起点状况

接手时数据集状态:

项目数量备注
dataset/raw/ (DWG 源文件)0
dataset/rendered/ (PNG)30来自 DWG ingest,子集精选
dataset/labels/ (YOLO .txt)0完全没有标注
dataset/splits/已生成初版 21/6/3,后调整
dataset/yolo.yaml已生成6 类配置 OK

关键发现:数据准备到拆分这一步就停了,30 张图都还没标注。 直接 train.py 会得到 mAP=0 的空权重。

10. 标注方案选型

YOLO 训练硬性要求每张图配对 .txt 标签,无法绕过人工或自动标注。可选路径:

路径用时标注质量本项目选择
纯人工(Label Studio)数小时
Claude 预标注 + LS 人工修订数小时后续迭代
Claude 预标注直接当正式标签30 分钟 autolabel + 立即可训✅ 本次选这条

理由:先用最低成本跑通训练侧管线、出基线 mAP,再决定要不要投入人工修订成本。 如果训练脚本有 bug 没发现,人工标几小时再炸就更亏。

11. autolabel 阶段实际状况

11.1 脚本机制

scripts/autolabel_v2.py 已有,策略:

  • 缩图后喂给 Claude CLI(原版 max_side=2400)
  • scripts/classify_output/ 里的"预期设备数量"作为先验注入 prompt 约束 Claude
  • 跳过 valve:阀门太多太小,Claude 框不准,后期人工补
  • 输出 scripts/autolabel_v2/<stem>.txt(YOLO 格式)+ <stem>_raw.txt(Claude 原始回复)

11.2 第一次 smoke-test 超时

[1/1] B_10.png
  downscale: (10078, 7559) -> (2400,1800), scale=0.238
  ERROR: ClaudeCLIError: Claude CLI 调用超时(300s)

根因深度反思:

  • config.toml[claude].timeout = 300 太短
  • 缩图 2400 边长仍然偏大 → Claude 推理慢
  • [claude.env].API_TIMEOUT_MS = "6000000" 网关那边宽松,外层 Python 子进程先 timeout 杀掉了

11.3 修复方案(本项目实际值)

改动路径旧 → 新备注
外层超时拉长config.toml:47[claude].timeout 300 → 90030 张里仍有 3 张超时,但能容忍
缩图边长减小scripts/autolabel_v2.py:192max_side=2400 → 1600单张 51 秒成功,质量不降

11.4 全量 autolabel 结果

成功率: 27/30 (90%)

数量
总图数30
成功 .txt27
超时失败3 (B_11、CH_20、CH_28)
总框数~696
单图框数范围10 - 50

少数 DRIFT (数量偏离预期 >50%):

  • CH_32: terminal_load 预期 8 实际 1
  • CH_88: heat_exchanger 和 terminal_load 丢了
  • CH_95: chiller 丢了 3 个

观察:网关延迟极不稳定 —— 同一张 B_10 第一次 smoke-test 51 秒,第二次全量跑用了 794 秒。 CH_95 用了 622 秒(接近 900 上限)才返回且出现 DRIFT,900 秒已经吃力

11.5 retry 3 张超时图

文件retry 结果用时处理
B_11.png✅ 5/5 类对齐584s保留
CH_28.png✅ 5/5 类对齐767s保留
CH_20.png❌ 二次超时>900s原图 10078×7559 太大,从 splits 剔除

最终 29 张 .txt 全部可用。

12. 训练准备实际状况

12.1 标签拷贝核对

splits 与 labels 一致性核对结果(用户已手动调整后):

数量
splits 总图数29 ✓
autolabel .txt29 ✓
splits 引用但缺 .txt0 ✓
.txt 多余0 ✓

splits 最终分配(用户手动调整后):

  • train: 20 张
  • val: 5 张
  • test: 4 张

12.2 环境检查 — GPU 不可用 ⚠️

uv run python -c "import torch; print(torch.__version__, torch.cuda.is_available())"
# torch 2.12.0+cpu / cuda False

nvidia-smi
# NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver

结论:当前环境没有可用 NVIDIA GPU,必须 CPU 训练。

  • CPU: AMD Ryzen 9 9950X 16-Core Processor
  • 性能不错,但相比 GPU 慢一两个数量级

13. v1 训练:CPU 减参基线

13.1 参数

为了 CPU 上能在合理时间内跑完一轮,减参方案:

参数原值v1 值理由
epochs10030CPU 上控制总时长
batch_size164减小内存占用
imgsz1280640CPU 单图前向时间随分辨率平方增长
device"0""cpu"无 GPU

13.2 结果

用时: 0.028 小时 ≈ 1.7 分钟 (R9 9950X 在 imgsz=640 上很快)

最终验证集 mAP(5 张 val 图、106 个实例):

类别InstancesPRmAP50mAP50-95
chiller60.0030.3330.0040.001
pump560000
cooling_tower90.0020.1110.0030.0003
heat_exchanger100000
terminal_load250000
all1060.0010.0890.00140.0003

valve 类完全缺失(autolabel 跳过)。

13.3 v1 失败原因深度反思

mAP50 = 0.0014 等于"啥也没学到"。5 个原因叠加:

  1. 数据量太少:20 张训练图,YOLO 常识门槛是每类 50-100 张图、500+ 框。当前每类 10-50 个框,少 5-10 倍
  2. CPU 减参太狠:imgsz 砍到 640,HVAC 小符号(水泵那种小圆圈)只剩几个像素,小目标根本检测不到
  3. epochs=30 太少:loss 全程在 3-4 区间徘徊,未收敛
  4. 标注质量未验证:autolabel 的框位置只是 Claude 估计,可能整体偏移
  5. 类别失衡:pump 56 个 vs heat_exchanger 10 个,差 5 倍,少数类被淹没

13.4 v1 验证管线是通的 ✓

虽然 mAP 难看,但训练管线本身完整可用:

  • ultralytics + torch CPU 安装可用
  • dataset/labels/*.txtdataset/rendered/*.txt 自动同步逻辑正确
  • yolo.yaml + splits + classes 无配置错误
  • 0 corrupt / 0 backgrounds,标签解析正确
  • 产物 runs/detect/train/weights/best.pt 生成

v1 备份保留: runs/detect/train_v1_baseline/

14. v2 训练:拉满参数

14.1 参数调整

参数v1v2理由
epochs30150loss 未收敛,加 5x
imgsz6401280HVAC 符号小,必须高分辨率
batch_size42imgsz 翻倍,batch 减半
patience3050数据少时收敛慢,放宽 early stop

14.2 启动验证

  • ✅ Ultralytics 加载 6 类配置正确(nc=6)
  • ✅ 训练集扫描 20 images, 0 backgrounds, 0 corrupt
  • ✅ 验证集扫描 5 images, 0 backgrounds, 0 corrupt
  • ✅ 无 OOM(imgsz=1280 batch=2 在 R9 9950X 上 CPU 内存压力不大)
  • ✅ box_loss 在前 11 epoch 缓慢下降(3.62 → 3.39)
  • ✅ cls_loss 在前 11 epoch 持续下降(5.26 → 4.60)

14.3 训练结果

EarlyStop 在 epoch 132/150 触发(best.pt = epoch 82,后续 50 epoch 无改善)

用时: 0.496 小时 ≈ 30 分钟

最佳验证集 mAP(best.pt):

类别InstancesPRmAP50mAP50-95
chiller60000
pump56000.00060.00008
cooling_tower9000.0030.0003
heat_exchanger100.7160.10.1280.026
terminal_load251000
all1060.3430.020.02650.0052

14.4 v1 vs v2 对比

指标v1v2倍数
epochs(实际)30132(early-stop)4.4x
imgsz64012802x
用时1.7 min30 min18x
mAP50(all)0.00140.026519x
mAP50-95(all)0.00030.005217x
heat_exchanger mAP5000.128

14.5 v2 结果解读

有改善但仍远未可用:

  • mAP50 提升 19 倍,调参方向是对的
  • 但绝对值 0.0265 仍远低于可用门槛(一般要 0.5+)
  • 单纯调参已经触及天花板,5 个根因里只解决了 epochs / imgsz 这两个,数据量、标注质量、类别失衡都还没动

单类别分析:

类别现象推测
heat_exchanger(10 个)mAP50=0.128, P=0.72形状最有辨识度,少量样本也能学到
terminal_load(25 个)P=1.0 但 R=0 → mAP=0模型只在置信度极高时才预测,几乎不输出
chiller(6 个)v1=0.004 v2=06 个样本太少,方差大于信号
pump(56 个,最多)几乎全 0核心瓶颈:水泵是小圆圈,imgsz=1280 仍嫌不够;autolabel 框可能整体不准
valve不存在autolabel 设计跳过

结论:单靠调参,CPU 环境下 mAP 大概到此为止(~0.03)。 继续提升必须从数据/标注入手,不是参数。

15. 本项目所有改动清单

配置文件改动

文件改动
config.toml:47[claude].timeout 300 → 900
config.toml:72-77[training] 段:epochs 100→150, batch 16→2, imgsz 1280, device cpu, patience 50
scripts/autolabel_v2.py:192max_side 2400 → 1600

数据产物

路径内容
scripts/autolabel_v2/B_*.txt / CH_*.txt29 个 YOLO 格式标签(autolabel 原始产物)
scripts/autolabel_v2/*_raw.txt29 个 Claude 原始 JSON 回复(供 debug)
scripts/autolabel_v2/summary.jsonautolabel 批量统计
dataset/labels/*.txt同上 29 个,已拷到正式位置
dataset/rendered/*.txt训练时自动同步生成的副本(YOLO 同目录约定)

训练产物

路径内容
runs/detect/train_v1_baseline/v1 完整训练结果备份(mAP50=0.0014)
runs/detect/train_v1_baseline/weights/best.ptv1 权重
runs/detect/train/v2 训练结果(132 epochs early-stop,最佳在 epoch 82)
runs/detect/train/weights/best.ptv2 权重(mAP50=0.0265)
runs/detect/train/results.png损失/mAP 曲线
runs/detect/train/val_batch0_pred.jpg验证集预测可视化
runs/detect/train/val_batch0_labels.jpg验证集真值标注可视化
runs/detect/train/confusion_matrix.png混淆矩阵

16. 本项目踩过的坑

按踩到的顺序:

  1. 直接拿默认参数跑 autolabel 会全军覆没 [claude].timeout = 300 配合 max_side = 2400,首张图就超时。 修复:timeout 900 + max_side 1600,单图 51 秒成功

  2. Claude CLI 网关延迟极不稳定 同一张 B_10:首次 smoke-test 51 秒,全量跑时 794 秒,差 15 倍。 timeout 必须留出大量余量,不要按"正常情况"设

  3. autolabel_v2.py 默认从 IMG_B 取图,不是 dataset/rendered 两边 stem 一致(都是 B_* / CH_*),所以能跑,但第一次差点导致全量跑了 159 张而不是 30 张

  4. CH_20 是个超大图(10078×7559),autolabel 必超时 即使 retry 也跑不出来,直接从 splits 剔除。 通用流程里要写明"超时图先 retry,再不行就剔除"

  5. 30 张 + autolabel 标注精度,mAP 上限就 0.03 光调参不可能拉到生产可用门槛。这是本项目当前的根本约束

  6. 每次训练前必须备份 runs/detect/train/ ultralytics 默认 exist_ok=True,第二次跑会直接覆盖第一次的产物。 v1 baseline 是抢救出来的(差点丢)

  7. valve(阀门)始终是 0 autolabel 设计跳过 valve,所以训练集和验证集里 valve 框数都是 0, 该类 mAP 必然 0。要训出可用的 valve 检测必须人工补

17. 给下一次接手的建议(按优先级)

优先级动作预期收益工作量
P1runs/detect/train/results.pngval_batch0_pred.jpg,直观确认 v2 学到了什么校准对模型现状的认知5 分钟
P2用 Label Studio 把 autolabel 框作为 predictions 上传,人工修订位置 + 补 valvemAP 跃升一个数量级数小时
P3把训练集从 20 张扩到 100+ 张(IMG_B 还有 130 张未用)mAP 进一步提升半天到一天(autolabel + 修订)
P4等有 GPU 环境后重训(imgsz=1280 batch=16 epochs=300)训练时间 30min → 几分钟,可以多次实验几小时(含环境)
P5处理 CH_20 这种超大图:切片或预压缩数据集完整性1-2 小时

P2 是性价比最高的一步 —— 修订标注比扩充数据更直接拉升 mAP。详见 §19。


第三部分:故障排除 + 衔接

18. 故障排除 FAQ

18.1 autolabel 阶段

现象可能原因处理
Claude CLI 调用超时(Ns)子进程超时太短;图片太大调大 [claude].timeout(本项目 900);改小 autolabel_v2.py:192max_side
PARSE FAILClaude 回复不是合法 JSONscripts/autolabel_v2/<stem>_raw.txt 末尾,通常是包了 markdown 围栏或截断
MISSING <file>.pngIMG_B/ 里没这张图检查文件名拼写;autolabel_v2.py:185 默认从 IMG_B/ 取,不是 dataset/rendered/
框数全部 0classify_output/<stem>.json 不存在或 parse_ok=false先跑 scripts/classify_drawings.py 生成先验
数量大幅偏差 [DRIFT]Claude 当次回复不稳定retry 这一张;真不行人工补
连接被拒绝 / 网关错误ANTHROPIC_BASE_URL 网关挂了检查 [claude.env] 配置;尝试直连 Anthropic 官方 API
全部图都失败Claude CLI 二进制找不到检查 which claude;[claude].cli_path 留空依赖 PATH

18.2 训练启动阶段

现象可能原因处理
train: N images, K backgrounds 且 K>0有图缺 .txt跑 §3 的核对脚本,补齐或剔除
corrupt > 0.txt 格式错(空文件、坐标越界、列数错)看具体哪张图;手动修或重新 autolabel
Overriding model.yaml nc=80 with nc=X 不是 6classes 数量错检查 dataset/yolo.yamlnames 字典长度
CUDA out of memorybatch 太大或 imgsz 太大减小 batch_size(从 16 → 8 → 4 → 2);减 imgsz(1280 → 960 → 640)
训练立即 exit 0 但无 best.pt训练集为空检查 dataset/splits/train.txt 行数 > 0
ModuleNotFoundError: ultralyticsextras 没装uv sync --extra training
nvidia-smi: command not found 或 CUDA 不可用驱动没装 / torch 是 CPU 版uv pip install torch --index-url https://download.pytorch.org/whl/cu121(注意版本匹配)

18.3 训练过程阶段

现象可能原因处理
loss 不下降(前 10 epoch box_loss>5 不动)学习率不当 / 标签全部坐标错val_batch0_labels.jpg,确认真值框画在合理位置
loss 下降但 mAP 长期 0标签类别全对但位置全错 / IoU 阈值太严val_batch0_pred.jpg,框画对了吗
mAP 剧烈波动(每个 epoch 跳 10x)验证集太小(<10 张)+ 数据失衡扩大验证集;增加 patience 让平均效应起作用
某类 mAP 始终 0该类训练样本太少(<10)看该类在 train.txt 里的实例数;补样本或合并相似类
某类 P=1.0 但 R=0(本项目 terminal_load)模型置信度阈值过高,只在极有把握时预测通常 mAP 仍能涨,继续训;或推理时降 conf 阈值
训练突然 NaN学习率过大 / 输入数据有问题lr0(YOLO 默认 0.01);检查图片是否全白/全黑
early-stop 太早触发patience 太小或验证集波动大调大 patience(50-100);v2 用的 50

18.4 推理阶段

现象可能原因处理
weights_path 不存在best.pt 路径写错检查 runs/detect/train/weights/best.pt 是否存在
推理结果框数 0默认 conf 阈值过高 / 模型没学到降低 [detector].confidence(默认 0.25 → 0.05);若仍 0 → mAP 本身就低
推理框乱画模型欠拟合重新训练,问题不在推理
服务启动后 backend 仍是 remoteweights 路径不对 / 权重加载失败看服务启动日志,YOLO 权重未就绪 表示路径有问题

19. 训练前质检清单

每次重新训练之前(尤其是 v2 → v3),按这个清单走一遍,5 分钟,能避免后面 30 分钟的训练浪费。

19.1 自检脚本

python - <<'EOF'
"""训练前质检:数据一致性 + 标签合法性 + 类别平衡"""
from pathlib import Path

ROOT = Path(".")
SPLITS = ROOT / "dataset/splits"
LABELS = ROOT / "dataset/labels"
RENDERED = ROOT / "dataset/rendered"
YOLO_YAML = ROOT / "dataset/yolo.yaml"

# ── 1. splits 与 labels 一致性 ──
splits_stems = set()
for n in ("train","val","test"):
    f = SPLITS / f"{n}.txt"
    if not f.exists():
        print(f"[FAIL] 缺 {f}"); continue
    for line in f.read_text().splitlines():
        if line.strip(): splits_stems.add(Path(line.strip()).stem)

label_stems = {p.stem for p in LABELS.glob("*.txt")}
img_stems = {p.stem for p in RENDERED.glob("*.png")}

missing = splits_stems - label_stems
extra = label_stems - splits_stems
no_img = label_stems - img_stems

print(f"\n[一致性] splits={len(splits_stems)} labels={len(label_stems)} images={len(img_stems)}")
if missing: print(f"  [FAIL] splits 引用但缺 .txt: {sorted(missing)}")
if extra:   print(f"  [WARN] labels 多余: {sorted(extra)}")
if no_img:  print(f"  [FAIL] labels 对应的 PNG 不存在: {sorted(no_img)}")
if not (missing or no_img): print("  [OK] splits ↔ labels ↔ images 三方对齐")

# ── 2. 标签合法性 + 类别统计 ──
cls_count = {}
bad = []
for txt in LABELS.glob("*.txt"):
    for ln_no, line in enumerate(txt.read_text().splitlines(), 1):
        parts = line.strip().split()
        if not parts: continue
        if len(parts) != 5:
            bad.append(f"{txt.name}:{ln_no} 列数={len(parts)} 应为5"); continue
        try:
            cid = int(parts[0])
            xc, yc, w, h = map(float, parts[1:])
        except ValueError:
            bad.append(f"{txt.name}:{ln_no} 数值解析失败"); continue
        if not (0 <= cid <= 5):
            bad.append(f"{txt.name}:{ln_no} class_id={cid} 越界"); continue
        for name, v in zip("xc yc w h".split(), (xc, yc, w, h)):
            if not (0 <= v <= 1):
                bad.append(f"{txt.name}:{ln_no} {name}={v:.3f} 不在 [0,1]"); break
        if w < 0.005 or h < 0.005:
            bad.append(f"{txt.name}:{ln_no} 框过小 w={w:.4f} h={h:.4f}")
        cls_count[cid] = cls_count.get(cid, 0) + 1

print(f"\n[标签合法性] 检查了 {len(label_stems)} 个 .txt")
if bad:
    print(f"  [FAIL] 发现 {len(bad)} 处异常,前 10 条:")
    for b in bad[:10]: print(f"    {b}")
else:
    print("  [OK] 所有标签格式合法")

# ── 3. 类别分布 ──
class_names = ["chiller","pump","cooling_tower","heat_exchanger","valve","terminal_load"]
total = sum(cls_count.values())
print(f"\n[类别分布] 共 {total} 个框")
for i, n in enumerate(class_names):
    c = cls_count.get(i, 0)
    pct = 100*c/total if total else 0
    flag = "  [WARN] 样本极少" if 0 < c < 10 else ("  [WARN] 缺失" if c == 0 else "")
    print(f"  {i} {n:18s} {c:5d}  ({pct:5.1f}%){flag}")

# 失衡告警
if cls_count:
    mx, mn = max(cls_count.values()), min(v for v in cls_count.values() if v > 0)
    if mx / mn > 10:
        print(f"\n  [WARN] 类别严重失衡,最多/最少={mx}/{mn}={mx/mn:.0f}x,少数类难以学习")

# ── 4. splits 比例 ──
def count_lines(p):
    return len([l for l in p.read_text().splitlines() if l.strip()]) if p.exists() else 0
tr, vl, ts = (count_lines(SPLITS/f"{n}.txt") for n in ("train","val","test"))
print(f"\n[拆分比例] train={tr} val={vl} test={ts} 总={tr+vl+ts}")
if tr < 20: print("  [WARN] 训练集过小(<20),YOLO 通常要 50+ 张/类")
if vl < 5:  print("  [WARN] 验证集过小(<5),指标方差会很大")

print("\n质检完成。FAIL 必须修,WARN 量力而行。")
EOF

把脚本存到 scripts/preflight_train.py 也行,以后每次训练前 uv run python scripts/preflight_train.py

19.2 检查清单(对照人工核对)

  • dataset/splits/{train,val,test}.txt 行数 = dataset/labels/*.txt 数量
  • 没有"splits 里有但 .txt 不存在"的图(训练时会变成"背景图")
  • 所有 .txt 列数都是 5(class_id xc yc w h),坐标在 [0,1]
  • class_id 范围 0-5(本项目 6 类)
  • 训练集每类至少 10 个框(<10 该类基本学不出来)
  • 类别最多/最少之比 < 10(否则严重失衡)
  • 验证集 ≥ 5 张图(<5 mAP 波动剧烈)
  • runs/detect/train/ 已备份(否则 ultralytics 默认会覆盖)
  • 知道这次想验证什么(epochs 加倍?换模型?改 imgsz?)—— 每次只改一个变量,才知道改善来自哪

20. Label Studio 衔接实操

autolabel 出的框只是"参考",位置可能整体偏移、漏标、valve 完全没标。唯一能拉升 mAP 的现实路径是用 Label Studio 修订

20.1 装 Label Studio

uv pip install label-studio

20.2 启动(Windows PowerShell)

⚠️ Label Studio 默认禁用本地文件访问,必须先设环境变量再启动,否则浏览器看不到图:

$env:LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED="true"
$env:LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT="D:\code\drawing-analysis\dataset"
uv run label-studio start

浏览器打开 http://localhost:8080

20.3 新建项目 + 配置标注模板

  1. 注册账号 → 新建项目 → Labeling Setup → 选 Image Object Detection
  2. 粘贴这段 XML(已固定 6 类,顺序与 class_id 严格对应,不能改):
<View>
  <Image name="image" value="$image"/>
  <RectangleLabels name="label" toName="image">
    <Label value="chiller"        background="#F44336"/>
    <Label value="pump"           background="#2196F3"/>
    <Label value="cooling_tower"  background="#4CAF50"/>
    <Label value="heat_exchanger" background="#FF9800"/>
    <Label value="valve"          background="#9C27B0"/>
    <Label value="terminal_load"  background="#795548"/>
  </RectangleLabels>
</View>

20.4 导出待标任务

uv run python -m training.pipeline.labelstudio export

产物在 dataset/labelstudio_tasks.json

在 LS 项目里 Import → 选这个文件。

⚠️ 当前 labelstudio.py 不支持把 autolabel 框作为预测上传。 也就是说在 LS 里打开是空白图,你要从零画框 —— 这违背了"用 autolabel 加速"的初衷。 必须先补一个 export-with-predictions 子命令(见 §20.7),否则别走这条路。

20.5 标注操作要点

  • 快捷键:数字键 1-6 选类别,鼠标拖框,Enter 提交
  • valve 这一类专门要补,autolabel 完全没标
  • 修订位置:autolabel 框可能整体偏移几十像素,移动几下就好
  • 漏标:对照原图查管道节点上是否还有设备没框

20.6 标完导出 → 转 YOLO

LS 项目右上角 Export → 选 JSON-MIN → 下载到 dataset/ls_export.json

uv run python -m training.pipeline.labelstudio import dataset/ls_export.json

dataset/labels/*.txt 会被覆盖更新,之前 autolabel 的版本会丢失(如要保留先备份)。

回到 §5 重新训练。

20.7 [TODO] 补 export-with-predictions 子命令

src/training/pipeline/labelstudio.py 目前 export_tasks() 函数只生成空白任务。 要让 autolabel 框作为预标显示在 LS 里,需要新增逻辑:

# 伪代码:在 export_tasks() 基础上增加
def export_tasks_with_predictions(rendered_dir, labels_dir, output_json, classes, url_prefix):
    tasks = []
    for png in sorted(rendered_dir.glob("*.png")):
        txt = labels_dir / f"{png.stem}.txt"
        predictions = []
        if txt.exists():
            results = []
            for line in txt.read_text().splitlines():
                cid, xc, yc, w, h = line.split()
                cid, xc, yc, w, h = int(cid), *map(float, (xc,yc,w,h))
                # YOLO 中心点 → LS 左上角百分比
                x_pct = (xc - w/2) * 100
                y_pct = (yc - h/2) * 100
                w_pct = w * 100
                h_pct = h * 100
                results.append({
                    "from_name": "label", "to_name": "image", "type": "rectanglelabels",
                    "value": {
                        "x": x_pct, "y": y_pct, "width": w_pct, "height": h_pct,
                        "rectanglelabels": [classes[cid]],
                    },
                })
            predictions.append({"result": results, "model_version": "autolabel_v2"})
        tasks.append({
            "data": {"image": f"{url_prefix}{png.resolve()}", "filename": png.name},
            "predictions": predictions,
        })
    output_json.write_text(json.dumps(tasks, ensure_ascii=False, indent=2))

写完后 LS 导入这个 JSON,每张图会显示 autolabel 框,人工只需要修订位置 + 补 valve。


附录 A:命令速查

# 装训练依赖
uv sync --extra training

# 跑预标注(替换文件列表)
uv run python scripts/autolabel_v2.py B_10.png B_11.png ...

# 拷贝标签到正式位置(详细见 §3)
# 见 §3 的 Python 片段

# 训练前质检
uv run python scripts/preflight_train.py  # 如果存了脚本

# 开始训练
uv run python -m training.pipeline.train

# 命令行覆盖参数训练
uv run python -m training.pipeline.train --epochs 150 --batch 2 --imgsz 1280 --device cpu

# 评估
uv run python -m training.pipeline.evaluate

# 评估指定权重
uv run python -m training.pipeline.evaluate runs/detect/train_v1_baseline/weights/best.pt

# 备份当前训练结果
cp -r runs/detect/train runs/detect/train_$(date +%Y%m%d_%H%M)_backup

# 启动 Label Studio(Windows)
$env:LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED="true"
$env:LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT="D:\code\drawing-analysis\dataset"
uv run label-studio start

# 导出 LS 待标任务
uv run python -m training.pipeline.labelstudio export

# LS 标完后导回 YOLO 格式
uv run python -m training.pipeline.labelstudio import dataset/ls_export.json