☰
自研 YOLO 训练管理平台的 23 个踩坑教训:不报错、静默丢数据(FastAPI + subprocess)
2026/10/7 19:30:36 网站建设 项目流程

自研 YOLO 训练管理平台的 23 个踩坑教训:不报错、静默丢数据(FastAPI + subprocess,系列第 1 篇)

想自己搭一个"管数据集 + 在线标注 + 一键训练"的 YOLO 训练管理平台?这篇讲我一个人用 FastAPI + SQLite + subprocess 调 ultralytics 做完之后,真正踩过的 23 个坑——大半是"不报错但结果错了"的静默 bug,查起来极其费时。正在做或打算做同类内部工具的人,照着避雷即可。

先说说这是个什么东西

公司内部缺一个能管数据集、能在线标注、能一键训练的工具。市面上的方案(CVAT、LabelStudio、各种 MLOps 平台)要么太重,要么只管标注不管训练,索性自己写了一个:

  • 后端:FastAPI + SQLite,单进程托管 API、前端静态页和图片,不装数据库、不装 nginx
  • 前端:Vue 3 + Vite + ECharts,自绘 CSS,不套 UI 库
  • 训练:subprocess 调 ultralytics 的yoloCLI,逐行收日志、解析 results.csv 画实时曲线
  • 功能:数据集导入(YOLO/VOC 自动识别)、在线标注、数据集版本快照、串行训练队列、模型版本管理、在线试模型
  • 部署:Windows 优先,做了个图形安装向导,非技术同事双击就能装

整个项目是一个人利用业余时间堆出来的:后端约 7800 行 Python,前端约 9600 行 Vue/JS,配了 13 个 pytest 用例。下面 23 条全是实战里换来的,每条都对应一个真实修过的 bug 或定下的规矩。

本文目录

  • 一、数据集导入:静默丢数据是最要命的(坑 1~5)
  • 二、标注系统:一致性错了就是灾难(坑 6~8)
  • 三、训练进程管理:坑最密集的地方(坑 9~16)
  • 四、多数据集合并训练:细节全是雷(坑 17~19)
  • 五、Windows 平台:被忽视的重灾区(坑 20~23)
  • 六、工程化的小决定,省了大量麻烦
  • 什么时候这套做法不适用
  • 小结 / FAQ

一、数据集导入:静默丢数据是最要命的

1. 图片和标注文件名匹配,一定要统一小写

图片叫IMG_001.JPG,标注叫img_001.txt——在 Windows 上能配上,部署到 Linux 服务器上大小写敏感,静默丢掉全部标注,不报任何错。训练照常跑,只是 mAP 低得离谱,你查三天都想不到是文件名大小写。

# 两边都 .lower() 再匹配,一行代码的事stem_map={p.stem.lower():pforpinimage_files}

2. zip 里的中文文件名是 cp437 编码的

Windows 上打的 zip 包,中文文件名按 GBK 存,但 zip 规范里的 UTF-8 标记位常常没设置,Python 解出来是 cp437 乱码。要做 cp437→GBK 的兜底解码,否则中文名的图片导入后全是乱码文件名,在 Windows 上还可能直接写盘失败。

defdecode_zip_name(raw:str)->str:"""zip 成员名 cp437→GBK 兜底解码(Windows 中文文件名打的包)。"""try:returnraw.encode("cp437").decode("gbk")except(UnicodeEncodeError,UnicodeDecodeError):returnraw

3. 空标注文件不是错误,是负样本

一张没有任何目标的图片,对应的 txt 就是空文件。早期版本把空文件当"缺失标注"跳过,这批图片就没进训练集——实际上它们是非常重要的背景样本,能显著降低误检。空 txt 要合法保留,训练时写空标签。

4. 脏数据容错:越界、零宽、脏行

外部拿到的数据集质量参差不齐:

  • class id 越界(类别表只有 5 类,标注里出现 7)→ 检查并跳过计数
  • 坐标超出 [0,1] → clamp,别让脏数据进库
  • VOC 转 YOLO 时的零宽框、贴着图片右边界的框 → 转换后校验一遍

导入结束给用户一句"跳过了 N 张损坏图片、M 条异常标注",比什么都重要。

5. 解压 zip 一定拦一下../

防别人发来的 zip 里藏../../路径穿越,把文件写到系统目录。判断就是两行:

frompathlibimportPurePosixPathdefis_evil_name(name:str)->bool:p=PurePosixPath(name)returnp.is_absolute()orany(part==".."forpartinp.parts)

另外解压要按内容读成员再落盘到自己命名的路径,不要信任 zip 内的原始文件名。


二、标注系统:一致性错了就是灾难

6. 标注存像素坐标,不要存归一化坐标

YOLO 训练用的是归一化坐标(0~1),但数据库里一定要存像素 xywh。原因:标注是要反复编辑的,归一化值每次"像素→归一化→像素"往返都有浮点精度损耗,框会越拖越歪。存储用像素,只在训练导出的最后一刻转归一化。

7. 类别顺序是数据的一部分,一旦确定永远不可重排

这是全项目最值钱的一条教训。标注里存的是 class id(0、1、2……),id 的含义完全由类别表的顺序决定。如果有人在中间插入一个类别或者重新排序,历史标注的 class id 全部错位——猫变成狗,狗变成背景,而且同样是静默出错。

规矩只有一条:新类别只能追加到类别表尾部,前端标注页加类别也一样。想"删除"类别就标记弃用,别动顺序。

8. 多边形标注先转外接矩形

老系统里有齿形零件的多边形标注,新系统一期只支持矩形框。导入时取多边形外接矩形即可,别为了 5% 的场景把标注画布的复杂度翻三倍。


三、训练进程管理:坑最密集的地方

9. yolo 的进度条是用\r刷新的,别按行读日志

ultralytics 训练时的进度条用\r回车刷新,一个 epoch 可能只有一行但刷新了几百次。如果你按\n切分读日志,要么读不到进度,要么缓冲区炸掉。解决办法是逐字符读,\r和\n都当分隔符:

deflog_reader(proc,log_path):"""逐字符读 stdout(yolo 进度条用 \r),按 \r/\n 切分段落写文件。"""withopen(log_path,"a",encoding="utf-8",errors="replace",buffering=1)asf:segment=""forchiniter(lambda:proc.stdout.read(1),""):ifchin"\r\n":ifsegment:f.write(segment+"\n")segment=""else:segment+=ch

10. stderr 不读,管道会死锁

只读 stdout 不管 stderr 的话,子进程 stderr 缓冲区写满后会阻塞,整个训练卡住不动——表面看像"训练 hang 了"。最简单的做法是 Popen 时合并:

proc=subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,# stderr 合并进 stdout,避免管道写满死锁text=True,encoding="utf-8",# 子进程输出含 UTF-8 字符,Windows 默认 GBK 解码会崩errors="replace",bufsize=1,)

11. epoch 进度去数 results.csv 的行数,别解析日志百分比

日志里的百分比是给人看的,格式随 ultralytics 版本说变就变。results.csv才是结构化数据:一行 = 一个 epoch,行数就是进度:

importcsvdefepoch_count(results_csv)->int:try:withopen(results_csv,encoding="utf-8",errors="replace")asf:rows=list(csv.reader(f))exceptOSError:return0returnsum(1forrowinrows[1:]ifrowandrow[0].strip().isdigit())

前端拿到的percent = 已完成的行数 * 100 / 总 epoch 数。顺便,列名要做模糊匹配(mAP50前缀匹配),不同 ultralytics 版本的列名后缀不一样。

12. 日志写文件 + offset 增量读,别存内存 dict

老项目把训练日志存在内存 dict 里,服务一重启日志全丢,任务还在跑但页面一片空白。改成:日志直接写run_dir/train.log,前端轮询时带 offset 增量拉取,服务重启、刷新页面都不丢。

13. 服务启动时,把残留的 running 任务批量标记为"中断"

服务崩了/机器重启后,数据库里还躺着一堆status=running的任务,但进程早死了。不清理的话这些任务永远卡在"运行中",队列也被占死。启动时扫一遍,全部标记 interrupted,配上"续训"按钮(ultralytics 原生支持从 last.pt 恢复),体验直接拉满。

14. 发起训练的"检查 + 启动"必须加锁

两个人同时点"开始训练",检查队列时都看到"空闲",然后同时启动——GPU 直接爆显存。检查和启动要在一个锁里完成。

15. Windows 下 terminate 杀不掉进程树,用 psutil

训练是 spawn 出来的独立进程,yolo 自己还会起 dataloader 子进程。Windows 上proc.terminate()经常只杀了壳,训练还在跑。用 psutil 拿到整棵进程树一起杀,Windows/Linux 行为一致:

importpsutildefkill_tree(pid:int):try:proc=psutil.Process(pid)exceptpsutil.NoSuchProcess:returnprocs=[proc]+proc.children(recursive=True)forpinprocs:try:p.terminate()exceptpsutil.Error:pass_,alive=psutil.wait_procs(procs,timeout=1)forpinalive:# 1 秒还没死的强杀try:p.kill()exceptpsutil.Error:pass

16. 推理显存不够?try 一把 GPU,失败就转 CPU

别费劲做显存预估和状态判断,直接 try GPU 推理,OOM 异常就 fallback 到 CPU。几行代码,比任何"智能调度"都可靠:

defpredict(model,source,conf):"""先尝试 GPU,RuntimeError/OOM 自动 fallback CPU 重试。"""primary=0iftorch.cuda.is_available()else"cpu"try:returnmodel.predict(source=source,conf=conf,device=primary,verbose=False)exceptRuntimeError:ifprimary=="cpu":raisereturnmodel.predict(source=source,conf=conf,device="cpu",verbose=False)

四、多数据集合并训练:细节全是雷

17. 不同数据集图片重名,合并时必须重命名

两个数据集里都有0001.jpg,拷到同一个训练目录直接互相覆盖。按数据集 id 分子目录,或者统一重命名。

18. label 里的 class id 必须按新类别表重写

数据集 A 的 class 0 是"划痕",数据集 B 的 class 0 是"凹陷",合并训练用统一的类别表后,所有 txt 里的 id 都要重写。漏了这一步,训出来的模型类别完全是乱的——又是静默出错。

19. 合并结果拷到独立 staging 目录再训

直接在数据集目录里拼凑训练数据,会和其他人正在进行的导入任务读写冲突。拷贝到独立的data/runs/task_<id>/再训,任务结束后保留(断点续训还要用)。


五、Windows 平台:被忽视的重灾区

20. 批处理第一行先chcp 65001

Windows 控制台默认 GBK,Python 输出中文直接乱码。start.bat第一行切 UTF-8 代码页,一行解决。

21. 全程 pathlib,禁止手拼路径、禁止 os.system

"data" + "/" + name这种代码在 Windows 上早晚出事。统一 pathlib,命令调用全部用参数列表形式的 subprocess,不经过 shell。

22. 训练输出目录用纯 ASCII 路径

中文用户名 + 中文安装路径 + 深度学习框架,是 Windows 上的经典翻车组合。所有程序自己生成的路径(data/runs/task_123/)保持纯 ASCII,用户数据爱叫什么叫什么。

23. 文件下载 URL 用 id,别用中文文件名

/api/images/123/file永远不会有编码问题;/api/files/零件照片.jpg在 Windows + 各种浏览器的组合下迟早乱码。


六、工程化的小决定,省了大量麻烦

最后几条不是 bug,是几个事后看特别正确的小决定:

  • 训练必须引用数据集版本快照,而不是"当前数据"——这样"这个模型到底是哪版标注训出来的"永远可追溯,改完标注旧版本还能回滚查看
  • 被训练任务用过的数据集禁止删除——一行引用检查,防止误删后模型变成"孤儿"
  • best.pt 不存在就把任务标记失败并写明原因——全空标注是训不出模型的,别让任务显示"成功"但库里没有模型
  • 整个 data/ 目录就是全部数据——备份 = 打包它,迁移 = 拷走,没有数据库导出导入那些破事

什么时候这套做法不适用

上面这套"FastAPI 单进程 + SQLite + subprocess 串行队列"是为小团队内部工具设计的,有几个明确的失效边界:

  • 多人同时训练 / 多 GPU 调度:串行队列一次只跑一个任务,机器多、任务多就得换 Celery/RQ 这类真队列加 worker 池。
  • 数据集特别大:图片到几十万张级别,SQLite 单文件和"整个 data/ 目录打包备份"都会开始吃力,缩略图、分页、存储策略都得重做。
  • 强权限和审计要求:这套只有简单的登录鉴权,没有操作审计、细粒度角色权限,过不了企业合规那一关。
  • 公网部署:单进程托管 + 本地文件存储是内网信任环境的前提,暴露到公网要补的东西太多,不如直接上成熟平台。

一句话:它是"几个人、一台带显卡的 Windows 机器、把训练流程跑顺"的最省方案,不是平台型产品。


小结

  • 这类系统最大的敌人是静默失败:所有"跳过"的地方都要计数并告诉用户,别替用户做决定还不吱声。
  • 类别表顺序是数据的一部分,只能尾部追加,永远不能重排。
  • 训练进度看 results.csv 的行数,别解析日志里给人看的百分比。
  • 服务启动第一件事:清理数据库里残留的 running 状态。
  • Windows 部署三关:chcp 65001、全程 pathlib、程序自生成路径保持纯 ASCII。

FAQ

Q:为什么不直接用 CVAT / LabelStudio?
它们标注很强,但不管训练、不管模型版本,部署也重。我们要的是"标完点一下就开始训、训完直接在线试"的闭环,市面上的开源方案拼起来比自己写一个还费劲。

Q:SQLite 单进程真的扛得住吗?
内部工具、几个人并发,绰绰有余,而且备份就是拷目录。真有几十人并发或多机部署的需求,再换 PostgreSQL + 独立 worker 不迟——但那时候你该考虑的已经不是这个架构了。

Q:训练实时曲线具体怎么画的?
后端每 2 秒数一次 results.csv 的行数算进度、解析最后一行取 mAP 等指标,前端 ECharts 轮询刷新。细节(含断点续训和日志增量拉取)在系列第 3 篇单独讲。

Q:没有 GPU 的机器能用吗?
管理、标注、导入都没问题;在线试模型会自动 fallback 到 CPU,单张推理能接受;CPU 训练只够跑通流程做 smoke test,正经训练还是要显卡。

Q:这套代码开源吗?
目前是公司内部项目,没有开源。但上面所有代码片段都是从真实代码里精简出来的,不依赖项目内部结构,可以直接抄走改改用。


这类"内部 AI 工具"的项目,技术栈都不难,真正的工作量全在上面这些边角细节里。回头看这 23 条,至少 10 条属于同一个类型:不报错、静默出错、重启后状态对不上、换台 Windows 机器就翻车。防它们的办法也不高级——统一约定、写死规则、启动时清理残留状态、所有静默失败的地方都改成"跳过并计数"。

项目里还有一份 221 行的避坑清单(PLAN.md),开发时照着逐条检查,少走了非常多弯路。如果你也在做类似的系统,建议从第一天就维护一份这样的清单。


技术栈:FastAPI · SQLite · Vue 3 · ultralytics(Windows 优先部署)

本系列共 6 篇:

  • 系列第 1 篇:开发总览——23 个实践教训(本篇)
  • 系列第 2 篇:需求设计与技术选型
  • 系列第 3 篇:subprocess 训练进程管理
  • 系列第 4 篇:标注数据一致性的 4 个设计
  • 系列第 5 篇:Windows 双击即用与 PyInstaller 打包
  • 系列第 6 篇:业余时间做内部工具不烂尾的心得

有问题欢迎评论区交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询