记录本次从零拉起 YOLO11 训练管线的完整过程,包含通用流程与本项目特定踩坑结论。 便于下一次接手或迭代时复用。
TL;DR — 5 步走完整个训练
| 步骤 | 命令 | 用时 | 输入 | 输出 |
|---|---|---|---|---|
| 1. 装依赖 | uv sync --extra training | 5-10 分钟 | pyproject.toml | torch + ultralytics 装好 |
| 2. 预标注 | uv run python scripts/autolabel_v2.py <PNG 列表> | 30 分钟 / 30 张图 | dataset/rendered/*.png | scripts/autolabel_v2/*.txt |
| 3. 拷标签 | 见 §3 的 Python 片段 | 10 秒 | scripts/autolabel_v2/*.txt | dataset/labels/*.txt |
| 4. 训练 | uv run python -m training.pipeline.train | CPU 30 分钟 / GPU 几分钟 | dataset/{labels,rendered,splits}/ | runs/detect/train/weights/best.pt |
| 5. 评估 | uv run python -m training.pipeline.evaluate | 30 秒 | 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) | 直接喂 DWG | YOLO 只吃位图;DWG 矢量信息丢失换来通用性 |
数据集类别(6 类):
| ID | 英文名 | 中文名 | 典型外观 |
|---|---|---|---|
| 0 | chiller | 冷水机组 | 大长方形主体,带 4 接管口 |
| 1 | pump | 水泵 | 圆形带 V 字叶轮符号 |
| 2 | cooling_tower | 冷却塔 | 方形顶视带风扇符号 / 侧视带百叶填充 |
| 3 | heat_exchanger | 换热器 | 窄长矩形带斜线 / 板片图例 |
| 4 | valve | 阀门 | 蝴蝶结小符号(本次 autolabel 跳过此类) |
| 5 | terminal_load | 末端负荷 | AHU/FCU/盘管图标 |
类别 ID 顺序绑定在 config.toml [training].classes 和 dataset/yolo.yaml 里,改名等于改语义,会让历史标签失效。
概念词表 / 指标解读
YOLO 训练里反复出现的术语,看表格指标时来这里查。
损失函数(loss)
| 术语 | 含义 | 期望走势 | 异常 |
|---|---|---|---|
box_loss | 预测框位置回归误差 | 持续下降 | 不降 → 学习率不对或数据有问题 |
cls_loss | 类别分类误差(BCE) | 持续下降 | 不降但 box_loss 在降 → 类别失衡 / 标注错类 |
dfl_loss | Distribution Focal Loss(框边缘精细化) | 持续下降 | 一般跟 box_loss 同步 |
| 收敛 | loss 趋于稳定,新 epoch 不再降 | 是训练完成的信号之一 | 没收敛就 early-stop 说明 patience 太小 |
验证指标
| 术语 | 全称 | 含义 | 区间 |
|---|---|---|---|
| P | Precision | 模型说"是" 的里面真正是的比例 | 0-1,越高越好 |
| R | Recall | 真正是的里面被模型找出来的比例 | 0-1,越高越好 |
| IoU | Intersection over Union | 预测框 vs 真值框的重叠度 | 0-1,>0.5 通常算"对上了" |
| mAP50 | mean Average Precision @ IoU=0.5 | 各类 AP 取均值(IoU 阈值固定 0.5) | 0-1,主指标 |
| mAP50-95 | mean AP @ IoU=0.5 step 0.05 to 0.95 | 10 个 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>.txt | train.py 自动把 dataset/labels/*.txt 同步到 dataset/rendered/ |
文档结构
- 第一部分:通用训练流程 —— 接手项目后照着跑就能复现的操作手册
- 第二部分:本项目特定状况 —— 本次踩到的坑、选型决定、数据/参数/结果
- 第三部分:故障排除 + 衔接 —— FAQ、训练前质检、Label Studio 后续流程
第一部分按"先验证再批量"的顺序写,每一步都附带常见错误与排查。 第二部分用来回答"为什么参数取这个值"、"为什么剔了那张图"。 第三部分是接手后做"v3 训练"或"补 valve"时直接查的实操参考。
第一部分:通用训练流程
前置条件:项目根目录已有
dataset/rendered/*.png和dataset/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:192 的 max_side(1600 → 1280),或继续放大 [claude].timeout |
| 框数全部 0 | 检查 scripts/classify_output/<stem>.json 是否存在、parse_ok 是否 True |
PARSE FAIL | 看 scripts/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 corrupt | N = train.txt 行数 | backgrounds>0 表示有图缺 .txt,回 §3 核对 |
val: M images, 0 backgrounds, 0 corrupt | M = 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 → 900 | 30 张里仍有 3 张超时,但能容忍 |
| 缩图边长减小 | scripts/autolabel_v2.py:192 | max_side=2400 → 1600 | 单张 51 秒成功,质量不降 |
11.4 全量 autolabel 结果
成功率: 27/30 (90%)
| 项 | 数量 |
|---|---|
| 总图数 | 30 |
| 成功 .txt | 27 |
| 超时失败 | 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 .txt | 29 ✓ |
| splits 引用但缺 .txt | 0 ✓ |
| .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 值 | 理由 |
|---|---|---|---|
| epochs | 100 | 30 | CPU 上控制总时长 |
| batch_size | 16 | 4 | 减小内存占用 |
| imgsz | 1280 | 640 | CPU 单图前向时间随分辨率平方增长 |
| device | "0" | "cpu" | 无 GPU |
13.2 结果
用时: 0.028 小时 ≈ 1.7 分钟 (R9 9950X 在 imgsz=640 上很快)
最终验证集 mAP(5 张 val 图、106 个实例):
| 类别 | Instances | P | R | mAP50 | mAP50-95 |
|---|---|---|---|---|---|
| chiller | 6 | 0.003 | 0.333 | 0.004 | 0.001 |
| pump | 56 | 0 | 0 | 0 | 0 |
| cooling_tower | 9 | 0.002 | 0.111 | 0.003 | 0.0003 |
| heat_exchanger | 10 | 0 | 0 | 0 | 0 |
| terminal_load | 25 | 0 | 0 | 0 | 0 |
| all | 106 | 0.001 | 0.089 | 0.0014 | 0.0003 |
valve 类完全缺失(autolabel 跳过)。
13.3 v1 失败原因深度反思
mAP50 = 0.0014 等于"啥也没学到"。5 个原因叠加:
- 数据量太少:20 张训练图,YOLO 常识门槛是每类 50-100 张图、500+ 框。当前每类 10-50 个框,少 5-10 倍
- CPU 减参太狠:imgsz 砍到 640,HVAC 小符号(水泵那种小圆圈)只剩几个像素,小目标根本检测不到
- epochs=30 太少:loss 全程在 3-4 区间徘徊,未收敛
- 标注质量未验证:autolabel 的框位置只是 Claude 估计,可能整体偏移
- 类别失衡:pump 56 个 vs heat_exchanger 10 个,差 5 倍,少数类被淹没
13.4 v1 验证管线是通的 ✓
虽然 mAP 难看,但训练管线本身完整可用:
- ultralytics + torch CPU 安装可用
dataset/labels/*.txt→dataset/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 参数调整
| 参数 | v1 | v2 | 理由 |
|---|---|---|---|
| epochs | 30 | 150 | loss 未收敛,加 5x |
| imgsz | 640 | 1280 | HVAC 符号小,必须高分辨率 |
| batch_size | 4 | 2 | imgsz 翻倍,batch 减半 |
| patience | 30 | 50 | 数据少时收敛慢,放宽 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):
| 类别 | Instances | P | R | mAP50 | mAP50-95 |
|---|---|---|---|---|---|
| chiller | 6 | 0 | 0 | 0 | 0 |
| pump | 56 | 0 | 0 | 0.0006 | 0.00008 |
| cooling_tower | 9 | 0 | 0 | 0.003 | 0.0003 |
| heat_exchanger | 10 | 0.716 | 0.1 | 0.128 | 0.026 |
| terminal_load | 25 | 1 | 0 | 0 | 0 |
| all | 106 | 0.343 | 0.02 | 0.0265 | 0.0052 |
14.4 v1 vs v2 对比
| 指标 | v1 | v2 | 倍数 |
|---|---|---|---|
| epochs(实际) | 30 | 132(early-stop) | 4.4x |
| imgsz | 640 | 1280 | 2x |
| 用时 | 1.7 min | 30 min | 18x |
| mAP50(all) | 0.0014 | 0.0265 | 19x |
| mAP50-95(all) | 0.0003 | 0.0052 | 17x |
| heat_exchanger mAP50 | 0 | 0.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=0 | 6 个样本太少,方差大于信号 |
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:192 | max_side 2400 → 1600 |
数据产物
| 路径 | 内容 |
|---|---|
scripts/autolabel_v2/B_*.txt / CH_*.txt | 29 个 YOLO 格式标签(autolabel 原始产物) |
scripts/autolabel_v2/*_raw.txt | 29 个 Claude 原始 JSON 回复(供 debug) |
scripts/autolabel_v2/summary.json | autolabel 批量统计 |
dataset/labels/*.txt | 同上 29 个,已拷到正式位置 |
dataset/rendered/*.txt | 训练时自动同步生成的副本(YOLO 同目录约定) |
训练产物
| 路径 | 内容 |
|---|---|
runs/detect/train_v1_baseline/ | v1 完整训练结果备份(mAP50=0.0014) |
runs/detect/train_v1_baseline/weights/best.pt | v1 权重 |
runs/detect/train/ | v2 训练结果(132 epochs early-stop,最佳在 epoch 82) |
runs/detect/train/weights/best.pt | v2 权重(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. 本项目踩过的坑
按踩到的顺序:
-
直接拿默认参数跑 autolabel 会全军覆没
[claude].timeout = 300配合max_side = 2400,首张图就超时。 修复:timeout 900+max_side 1600,单图 51 秒成功 -
Claude CLI 网关延迟极不稳定 同一张 B_10:首次 smoke-test 51 秒,全量跑时 794 秒,差 15 倍。
timeout必须留出大量余量,不要按"正常情况"设 -
autolabel_v2.py 默认从 IMG_B 取图,不是 dataset/rendered 两边 stem 一致(都是
B_*/CH_*),所以能跑,但第一次差点导致全量跑了 159 张而不是 30 张 -
CH_20 是个超大图(10078×7559),autolabel 必超时 即使 retry 也跑不出来,直接从 splits 剔除。 通用流程里要写明"超时图先 retry,再不行就剔除"
-
30 张 + autolabel 标注精度,mAP 上限就 0.03 光调参不可能拉到生产可用门槛。这是本项目当前的根本约束
-
每次训练前必须备份 runs/detect/train/ ultralytics 默认
exist_ok=True,第二次跑会直接覆盖第一次的产物。 v1 baseline 是抢救出来的(差点丢) -
valve(阀门)始终是 0 autolabel 设计跳过 valve,所以训练集和验证集里 valve 框数都是 0, 该类 mAP 必然 0。要训出可用的 valve 检测必须人工补
17. 给下一次接手的建议(按优先级)
| 优先级 | 动作 | 预期收益 | 工作量 |
|---|---|---|---|
| P1 | 看 runs/detect/train/results.png 和 val_batch0_pred.jpg,直观确认 v2 学到了什么 | 校准对模型现状的认知 | 5 分钟 |
| P2 | 用 Label Studio 把 autolabel 框作为 predictions 上传,人工修订位置 + 补 valve | mAP 跃升一个数量级 | 数小时 |
| 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:192 的 max_side |
PARSE FAIL | Claude 回复不是合法 JSON | 看 scripts/autolabel_v2/<stem>_raw.txt 末尾,通常是包了 markdown 围栏或截断 |
MISSING <file>.png | IMG_B/ 里没这张图 | 检查文件名拼写;autolabel_v2.py:185 默认从 IMG_B/ 取,不是 dataset/rendered/ |
| 框数全部 0 | classify_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 不是 6 | classes 数量错 | 检查 dataset/yolo.yaml 的 names 字典长度 |
CUDA out of memory | batch 太大或 imgsz 太大 | 减小 batch_size(从 16 → 8 → 4 → 2);减 imgsz(1280 → 960 → 640) |
| 训练立即 exit 0 但无 best.pt | 训练集为空 | 检查 dataset/splits/train.txt 行数 > 0 |
ModuleNotFoundError: ultralytics | extras 没装 | 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 仍是 remote | weights 路径不对 / 权重加载失败 | 看服务启动日志,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 新建项目 + 配置标注模板
- 注册账号 → 新建项目 → Labeling Setup → 选 Image Object Detection
- 粘贴这段 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