)
1. 工地安全帽检测为什么总在“最后一公里”翻车安全帽佩戴检测这个题目看起来是目标检测里最标准的场景之一三类目标人、戴帽头部、未戴帽头部数据量不算大模型也不复杂。但真正在工地、园区落地时翻车点往往不在模型精度而在工程链路的衔接上。我见过太多项目训练脚本跑得漂漂亮亮mAP 也好看一到 C 部署就卡在模型转换、层注册、GPU 推理配置这些环节最后交付延期。这篇文章要解决的就是这条完整链路从数据标注策略、VOC 转 YOLO 格式的脚本到训练配置与启动命令再到 ONNX 导出、NCNN 转换、C GPU 推理工程配置。每一步都给出可复制的代码和配置你可以跟着跑通一次训练和一次 GPU 推理核对检测结果与耗时。适合谁看正在做工地/园区安全帽检测的算法工程师、需要把模型落到 C 工程的开发者、以及想了解完整检测项目链路的学生。核心检索词就是安全帽佩戴检测、数据处理、训练数据、模型部署、C 推理这几个全文围绕它们展开。环境基线我沿用一套经过验证的组合Windows 10 RTX 3080CUDA 10.2 cuDNN 7.1OpenCV 4.5YOLOv5 v3.02020 年 8 月发布版本NCNN 20210525VS2019 Anaconda。这套组合不算新但胜在稳定NCNN 的 Vulkan 后端在这套环境下跑 GPU 推理没有奇怪的兼容问题。如果你用更新的版本思路一致个别 API 名称需要对照官方文档调整。整条链路里训练和部署环节会涉及多次外部调用——比如拉取依赖、下载预训练权重、调用模型转换工具。这些环节如果每个都单独配一套凭证管理起来很碎。我的做法是用 TaoToken 作为统一 Key/API 通道把训练和部署环节的调用凭证收敛到一处后面会给出具体配置。2. 数据处理从 VOC 标注到 YOLO 训练集的完整转换数据处理是安全帽检测项目里最容易被低估的一环。标注质量直接决定模型上限而格式转换的细节决定训练能不能顺利启动。2.1 三类标注策略安全帽检测的标注要区分三种形态人体、佩戴安全帽的头部、没有佩戴安全帽的头部。这里有个关键判断——只标注戴在头上的安全帽而且连着头部一起标注拿在手中或放在地上的安全帽不标注。这个策略的原因很简单模型要学的是“头部是否被安全帽覆盖”这个视觉模式而不是“画面里有没有安全帽物体”。如果把手里的安全帽也标成 helmet模型会学到错误的关联实际推理时容易把拿着安全帽的人误判为已佩戴。标注工具用 labelImg按 VOC2007 格式输出 XML。类别名建议统一为 person、head、helmet 三类避免用中文或带空格的名称后面转 YOLO 格式时省事。2.2 VOC 转 YOLO 的转换脚本标注完得到的是 VOC 格式的 XML但 YOLOv5 训练需要的是归一化后的 txt 格式每行是class_id cx cy bw bh。下面这个脚本做三件事读取所有 XML 收集类别、逐张图转换坐标、按 9:1 划分训练集和验证集。import os import glob import argparse import random import xml.etree.ElementTree as ET from PIL import Image from tqdm import tqdm def get_all_classes(xml_path): xml_fns glob.glob(os.path.join(xml_path, *.xml)) class_names [] for xml_fn in xml_fns: tree ET.parse(xml_fn) root tree.getroot() for obj in root.iter(object): cls obj.find(name).text class_names.append(cls) return sorted(list(set(class_names))) def convert_annotation(img_path, xml_path, class_names, out_path): output [] im_fns glob.glob(os.path.join(img_path, *.jpg)) for im_fn in tqdm(im_fns): if os.path.getsize(im_fn) 0: continue xml_fn os.path.join(xml_path, os.path.splitext(os.path.basename(im_fn))[0] .xml) if not os.path.exists(xml_fn): continue img Image.open(im_fn) height, width img.height, img.width tree ET.parse(xml_fn) root tree.getroot() anno [] xml_height int(root.find(size).find(height).text) xml_width int(root.find(size).find(width).text) if height ! xml_height or width ! xml_width: print((height, width), (xml_height, xml_width), im_fn) continue for obj in root.iter(object): cls obj.find(name).text cls_id class_names.index(cls) xmlbox obj.find(bndbox) xmin int(xmlbox.find(xmin).text) ymin int(xmlbox.find(ymin).text) xmax int(xmlbox.find(xmax).text) ymax int(xmlbox.find(ymax).text) cx (xmax xmin) / 2.0 / width cy (ymax ymin) / 2.0 / height bw (xmax - xmin) * 1.0 / width bh (ymax - ymin) * 1.0 / height anno.append({} {} {} {} {}.format(cls_id, cx, cy, bw, bh)) if len(anno) 0: output.append(im_fn) with open(im_fn.replace(.jpg, .txt), w) as f: f.write(\n.join(anno)) random.shuffle(output) train_num int(len(output) * 0.9) with open(os.path.join(out_path, train.txt), w) as f: f.write(\n.join(output[:train_num])) with open(os.path.join(out_path, val.txt), w) as f: f.write(\n.join(output[train_num:])) def parse_args(): parser argparse.ArgumentParser(generate annotation) parser.add_argument(--img_path, typestr, helpinput image directory) parser.add_argument(--xml_path, typestr, helpinput xml directory) parser.add_argument(--out_path, typestr, helpoutput directory) args parser.parse_args() return args if __name__ __main__: args parse_args() class_names get_all_classes(args.xml_path) print(class_names) convert_annotation(args.img_path, args.xml_path, class_names, args.out_path)运行方式python generate_txt.py --img_path data/helmet/JPEGImages --xml_path data/helmet/Annotations --out_path data/helmet跑完之后data/helmet 目录下会生成 train.txt 和 val.txt每行是一张图的绝对或相对路径。同时每张 jpg 旁边会生成同名 txt里面是归一化后的标注。这里有个坑要注意脚本里做了尺寸校验如果 XML 里的宽高和实际图片不一致会打印出来并跳过。工地数据经常有旋转、裁剪后的图片尺寸对不上会导致坐标错位这个校验能帮你提前发现脏数据。2.3 数据增强的取舍YOLOv5 自带 mosaic、HSV 增强、随机翻转等训练时通过 hyp 配置控制。安全帽场景我建议保留 mosaic但把 HSV 的饱和度增强幅度调低一点因为安全帽的颜色黄、红、蓝本身是重要特征过度扰动颜色反而有害。翻转增强要注意水平翻转没问题垂直翻转会让“戴帽”这个上下关系变得不自然建议关闭。数据量方面三类目标各准备 2000 到 5000 个实例比较稳妥。如果未戴帽样本偏少可以在增强里对这类样本做过采样或者在 loss 里给未戴帽类别更高权重——后者在 hyp 配置里通过类别权重调整。3. 训练配置helmet.yaml 与启动命令的可复制写法训练环节的核心是把数据配置、模型配置、超参配置三份文件准备好然后用一条命令启动。这一节给出可直接复制的配置片段。3.1 数据配置文件 helmet.yaml在 YOLOv5 的 data 目录下新建 helmet.yaml内容如下# download command/URL (optional) download: bash data/scripts/get_voc.sh # 训练集txt与验证集txt路径 train: data/helmet/train.txt val: data/helmet/val.txt # 总类别数 nc: 3 # 类别名 names: [person, head, helmet]这里 nc 必须和 names 的长度一致否则训练启动时会报类别索引越界。train 和 val 的路径是相对于 YOLOv5 根目录的如果你把数据放在别处改成绝对路径更省心。3.2 模型配置与超参模型用 yolov5m.yaml比 s 版本精度更好显存占用在 RTX 3080 上完全够用。需要改的是 nc 字段把默认的 80 改成 3。超参文件用 hyp.scratch.yaml重点调两个地方lr0 初始学习率设 0.01warmup_epochs 设 3。工地数据量不大学习率太高容易震荡。3.3 训练启动命令单卡训练python train.py --cfg models/yolov5m.yaml --data data/helmet.yaml --hyp data/hyps/hyp.scratch.yaml --epochs 100 --multi-scale --device 0多卡训练如果你有两张以上 GPUpython train.py --cfg models/yolov5m.yaml --data data/helmet.yaml --hyp data/hyps/hyp.scratch.yaml --epochs 100 --multi-scale --device 0,1几个参数说明--multi-scale 开启多尺度训练对工地场景里远近不同的目标有帮助--device 0 指定第一块 GPU--epochs 100 对这个小数据集够用如果验证集 mAP 还在涨可以加到 200。训练过程中会在 runs/train/exp 下生成权重和日志best.pt 是验证集表现最好的权重后面部署用它。3.4 训练环节的凭证统一管理训练脚本本身不直接调用外部 API但拉取预训练权重、下载依赖、以及后续模型转换工具链的调用会涉及多个外部服务。我的做法是在项目根目录放一份统一的凭证配置通过环境变量注入。TaoToken 在这里的作用是把这些调用收敛到一个 Key 上避免每个工具单独配一套。在项目根目录创建.env文件TAOTOKEN_API_KEYsk-你的统一Key TAOTOKEN_BASE_URLhttps://taotoken.net/api然后在训练脚本或转换脚本里读取import os from dotenv import load_dotenv load_dotenv() api_key os.getenv(TAOTOKEN_API_KEY) base_url os.getenv(TAOTOKEN_BASE_URL)这样训练和部署两个环节用的是同一套凭证换环境时只改.env一处。Key 的获取在控制台完成地址是 https://taotoken.net/api-keys 接入文档在 https://taotoken.net/doc 。如果你需要长期跑编码和 Agent 任务Coding Plan 页面 https://taotoken.net/coding-plan 有更细的额度说明。4. 模型部署ONNX 导出、NCNN 转换与 C GPU 推理部署是这条链路里技术密度最高的部分。整体路径是PyTorch 权重 → ONNX → NCNN param/bin → C 推理工程。4.1 导出 ONNXpython models/export.py --weights weights/yolov5m.pt --img 640 --batch 1执行后会生成 yolov5m.onnx。这个 ONNX 模型可以直接用 onnxruntime 推理也可以继续转 NCNN。导出时注意 opset 版本YOLOv5 v3.0 默认的 opset 在 NCNN 转换时兼容性较好不建议手动改高。4.2 ONNX 转 NCNN先做模型简化用 onnx-simplifierpython -m onnxsim yolov5m.onnx yolov5m-sim.onnx然后转换onnx2ncnn yolov5m-sim.onnx yolov5m.param yolov5m.bin这里有个我踩过的坑用最新版 onnx-simplifier 简化出来的模型会残留一堆带 onnx 前缀的 op在 onnxruntime 和转 NCNN 之后都无法推理。换回 0.36 版本就正常了。如果你遇到转换后推理报错先检查简化版本。4.3 C 推理工程配置NCNN 推理需要动态注册 YoloV5Focus 层这是 YOLOv5 特有的切片操作层。核心代码结构如下#include YoloV5Detect.h class YoloV5Focus : public ncnn::Layer { public: YoloV5Focus() { one_blob_only true; } virtual int forward(const ncnn::Mat bottom_blob, ncnn::Mat top_blob, const ncnn::Option opt) const { int w bottom_blob.w; int h bottom_blob.h; int channels bottom_blob.c; int outw w / 2; int outh h / 2; int outc channels * 4; top_blob.create(outw, outh, outc, 4u, 1, opt.blob_allocator); if (top_blob.empty()) return -100; #pragma omp parallel for num_threads(opt.num_threads) for (int p 0; p outc; p) { const float* ptr bottom_blob.channel(p % channels).row((p / channels) % 2) ((p / channels) / 2); float* outptr top_blob.channel(p); for (int i 0; i outh; i) { for (int j 0; j outw; j) { *outptr *ptr; outptr 1; ptr 2; } ptr w; } } return 0; } }; DEFINE_LAYER_CREATOR(YoloV5Focus)初始化网络时注册这个层并开启 Vulkan GPU 计算int initYolov5Net(std::string param_path, std::string bin_path, ncnn::Net yolov5_net, bool use_gpu) { bool has_gpu false; yolov5_net.clear(); #if NCNN_VULKAN ncnn::create_gpu_instance(); has_gpu ncnn::get_gpu_count() 0; #endif yolov5_net.opt.use_vulkan_compute (use_gpu has_gpu); yolov5_net.opt.use_bf16_storage true; yolov5_net.register_custom_layer(YoloV5Focus, YoloV5Focus_layer_creator); int rp yolov5_net.load_param(param_path.c_str()); int rb yolov5_net.load_model(bin_path.c_str()); if (rp 0 || rb 0) return -1; return 0; }推理主流程包括letterbox 预处理、三尺度输出提取stride 8/16/32、anchor 解码、NMS 后处理、坐标还原。这部分代码较长核心是 generateProposals 函数里对每个尺度的特征图做 sigmoid 解码然后按置信度阈值筛选最后 NMS 去重。VS2019 工程需要配置的依赖库GenericCodeGen.lib glslang.lib MachineIndependent.lib ncnn.lib OGLCompiler.lib onnxruntime.lib opencv_world450.lib OSDependent.lib SPIRV.lib VkLayer_utils.lib vulkan-1.lib配置目录时include 目录指向 ncnn 和 opencv 的头文件lib 目录指向对应的库文件运行目录把 dll 拷过去。这一步配错会直接报链接错误对照报错信息逐个补库即可。5. 常见报错排查从 401 到推理输出异常部署环节的报错往往信息量很大但定位起来有规律。这一节列出几个高频错误和排查路径。5.1 401 与 local proxy failed如果你在调用统一 Key 通道时遇到 401先检查.env里的 Key 是否完整复制有没有多余空格。401 通常是凭证无效或过期。local proxy failed 则多半是本地网络配置问题检查 base_url 是否写成了https://taotoken.net/api注意不要带末尾斜杠。5.2 reading choices 报错这个报错出现在解析模型输出时通常是输出层名称对不上。YOLOv5 不同版本的输出层名称不一样v3.0 版本是 750、771、791 三个层。如果你用的模型版本不同用 netron 打开 param 文件确认输出层名称改 C 代码里的 extract 参数。5.3 OAuth 与 Codex auth.json如果你在部署环节用到 Codex 相关的认证auth.json 的配置要写全三件套Base URL、Key、Model ID。缺任何一个都会导致认证失败。Base URL 填https://taotoken.net/apiKey 填你的统一 KeyModel ID 按实际使用的模型填。5.4 推理结果异常排查检测框位置偏移检查 letterbox 的 padding 计算和坐标还原是否对称。NCNN 推理里 wpad 和 hpad 的除以 2 操作要一致否则框会整体偏移。检测不到目标先确认 prob_threshold 是不是设太高默认 0.25 可以调到 0.1 试试。如果还是不行检查输入图像的归一化NCNN 里用的是substract_mean_normalize(0, norm_vals)norm_vals 是 1/255。GPU 推理没生效确认 NCNN 编译时开了 Vulkan且use_vulkan_compute设为 true。可以用ncnn::get_gpu_count()打印一下返回 0 说明 Vulkan 没启用。耗时异常RTX 3080 上单张 640x640 图片的 GPU 推理耗时应该在 10ms 以内。如果超过 50ms检查是不是回退到了 CPU 推理或者 bf16 存储没开。6. 把训练和部署串成一条可复用的链路整条链路跑通之后你会发现真正花时间的不是写代码而是环境配置和格式转换的细节。我的建议是把这套流程脚本化数据转换一个脚本、训练启动一个脚本、模型导出和转换一个脚本、C 工程配置一份文档。下次换数据集或换模型版本只改配置不改流程。统一 Key 通道的价值在这里体现得比较明显——训练、转换、部署三个环节的凭证收敛到一处换机器或换环境时不用逐个工具重新配。模型对话页面 https://taotoken.net/model-chat 可以用来快速验证模型输出是否符合预期接入文档 https://taotoken.net/doc 里有各环节的配置示例。最后给一个实用技巧C 推理工程里把耗时打印出来每次改动后对比耗时变化。GPU 推理的耗时对 batch size、输入尺寸、是否开 bf16 都很敏感有个基准数字调优时心里有数。检测结果的可视化用 OpenCV 的 rectangle 和 putText 就够重点是确认三类目标的框和标签都对得上。