:從數(shù)據(jù)處理、訓練數(shù)據(jù)到C++模型部署落地(含TaoToken統(tǒng)一Key接入))
1. 工地安全帽檢測為什么總在“最后一公里”翻車安全帽佩戴檢測這個題目看起來是目標檢測里最標準的場景之一三類目標人、戴帽頭部、未戴帽頭部數(shù)據(jù)量不算大模型也不復雜。但真正在工地、園區(qū)落地時翻車點往往不在模型精度而在工程鏈路的銜接上。我見過太多項目訓練腳本跑得漂漂亮亮mAP 也好看一到 C 部署就卡在模型轉(zhuǎn)換、層注冊、GPU 推理配置這些環(huán)節(jié)最后交付延期。這篇文章要解決的就是這條完整鏈路從數(shù)據(jù)標注策略、VOC 轉(zhuǎn) YOLO 格式的腳本到訓練配置與啟動命令再到 ONNX 導出、NCNN 轉(zhuǎn)換、C GPU 推理工程配置。每一步都給出可復制的代碼和配置你可以跟著跑通一次訓練和一次 GPU 推理核對檢測結(jié)果與耗時。適合誰看正在做工地/園區(qū)安全帽檢測的算法工程師、需要把模型落到 C 工程的開發(fā)者、以及想了解完整檢測項目鏈路的學生。核心檢索詞就是安全帽佩戴檢測、數(shù)據(jù)處理、訓練數(shù)據(jù)、模型部署、C 推理這幾個全文圍繞它們展開。環(huán)境基線我沿用一套經(jīng)過驗證的組合Windows 10 RTX 3080CUDA 10.2 cuDNN 7.1OpenCV 4.5YOLOv5 v3.02020 年 8 月發(fā)布版本NCNN 20210525VS2019 Anaconda。這套組合不算新但勝在穩(wěn)定NCNN 的 Vulkan 后端在這套環(huán)境下跑 GPU 推理沒有奇怪的兼容問題。如果你用更新的版本思路一致個別 API 名稱需要對照官方文檔調(diào)整。整條鏈路里訓練和部署環(huán)節(jié)會涉及多次外部調(diào)用——比如拉取依賴、下載預訓練權(quán)重、調(diào)用模型轉(zhuǎn)換工具。這些環(huán)節(jié)如果每個都單獨配一套憑證管理起來很碎。我的做法是用 TaoToken 作為統(tǒng)一 Key/API 通道把訓練和部署環(huán)節(jié)的調(diào)用憑證收斂到一處后面會給出具體配置。2. 數(shù)據(jù)處理從 VOC 標注到 YOLO 訓練集的完整轉(zhuǎn)換數(shù)據(jù)處理是安全帽檢測項目里最容易被低估的一環(huán)。標注質(zhì)量直接決定模型上限而格式轉(zhuǎn)換的細節(jié)決定訓練能不能順利啟動。2.1 三類標注策略安全帽檢測的標注要區(qū)分三種形態(tài)人體、佩戴安全帽的頭部、沒有佩戴安全帽的頭部。這里有個關(guān)鍵判斷——只標注戴在頭上的安全帽而且連著頭部一起標注拿在手中或放在地上的安全帽不標注。這個策略的原因很簡單模型要學的是“頭部是否被安全帽覆蓋”這個視覺模式而不是“畫面里有沒有安全帽物體”。如果把手里的安全帽也標成 helmet模型會學到錯誤的關(guān)聯(lián)實際推理時容易把拿著安全帽的人誤判為已佩戴。標注工具用 labelImg按 VOC2007 格式輸出 XML。類別名建議統(tǒng)一為 person、head、helmet 三類避免用中文或帶空格的名稱后面轉(zhuǎn) YOLO 格式時省事。2.2 VOC 轉(zhuǎn) YOLO 的轉(zhuǎn)換腳本標注完得到的是 VOC 格式的 XML但 YOLOv5 訓練需要的是歸一化后的 txt 格式每行是class_id cx cy bw bh。下面這個腳本做三件事讀取所有 XML 收集類別、逐張圖轉(zhuǎn)換坐標、按 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 里的寬高和實際圖片不一致會打印出來并跳過。工地數(shù)據(jù)經(jīng)常有旋轉(zhuǎn)、裁剪后的圖片尺寸對不上會導致坐標錯位這個校驗能幫你提前發(fā)現(xiàn)臟數(shù)據(jù)。2.3 數(shù)據(jù)增強的取舍YOLOv5 自帶 mosaic、HSV 增強、隨機翻轉(zhuǎn)等訓練時通過 hyp 配置控制。安全帽場景我建議保留 mosaic但把 HSV 的飽和度增強幅度調(diào)低一點因為安全帽的顏色黃、紅、藍本身是重要特征過度擾動顏色反而有害。翻轉(zhuǎn)增強要注意水平翻轉(zhuǎn)沒問題垂直翻轉(zhuǎn)會讓“戴帽”這個上下關(guān)系變得不自然建議關(guān)閉。數(shù)據(jù)量方面三類目標各準備 2000 到 5000 個實例比較穩(wěn)妥。如果未戴帽樣本偏少可以在增強里對這類樣本做過采樣或者在 loss 里給未戴帽類別更高權(quán)重——后者在 hyp 配置里通過類別權(quán)重調(diào)整。3. 訓練配置helmet.yaml 與啟動命令的可復制寫法訓練環(huán)節(jié)的核心是把數(shù)據(jù)配置、模型配置、超參配置三份文件準備好然后用一條命令啟動。這一節(jié)給出可直接復制的配置片段。3.1 數(shù)據(jù)配置文件 helmet.yaml在 YOLOv5 的 data 目錄下新建 helmet.yaml內(nèi)容如下# download command/URL (optional) download: bash data/scripts/get_voc.sh # 訓練集txt與驗證集txt路徑 train: data/helmet/train.txt val: data/helmet/val.txt # 總類別數(shù) nc: 3 # 類別名 names: [person, head, helmet]這里 nc 必須和 names 的長度一致否則訓練啟動時會報類別索引越界。train 和 val 的路徑是相對于 YOLOv5 根目錄的如果你把數(shù)據(jù)放在別處改成絕對路徑更省心。3.2 模型配置與超參模型用 yolov5m.yaml比 s 版本精度更好顯存占用在 RTX 3080 上完全夠用。需要改的是 nc 字段把默認的 80 改成 3。超參文件用 hyp.scratch.yaml重點調(diào)兩個地方lr0 初始學習率設(shè) 0.01warmup_epochs 設(shè) 3。工地數(shù)據(jù)量不大學習率太高容易震蕩。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幾個參數(shù)說明--multi-scale 開啟多尺度訓練對工地場景里遠近不同的目標有幫助--device 0 指定第一塊 GPU--epochs 100 對這個小數(shù)據(jù)集夠用如果驗證集 mAP 還在漲可以加到 200。訓練過程中會在 runs/train/exp 下生成權(quán)重和日志best.pt 是驗證集表現(xiàn)最好的權(quán)重后面部署用它。3.4 訓練環(huán)節(jié)的憑證統(tǒng)一管理訓練腳本本身不直接調(diào)用外部 API但拉取預訓練權(quán)重、下載依賴、以及后續(xù)模型轉(zhuǎn)換工具鏈的調(diào)用會涉及多個外部服務。我的做法是在項目根目錄放一份統(tǒng)一的憑證配置通過環(huán)境變量注入。TaoToken 在這里的作用是把這些調(diào)用收斂到一個 Key 上避免每個工具單獨配一套。在項目根目錄創(chuàng)建.env文件TAOTOKEN_API_KEYsk-你的統(tǒng)一Key TAOTOKEN_BASE_URLhttps://taotoken.net/api然后在訓練腳本或轉(zhuǎn)換腳本里讀取import os from dotenv import load_dotenv load_dotenv() api_key os.getenv(TAOTOKEN_API_KEY) base_url os.getenv(TAOTOKEN_BASE_URL)這樣訓練和部署兩個環(huán)節(jié)用的是同一套憑證換環(huán)境時只改.env一處。Key 的獲取在控制臺完成地址是 https://taotoken.net/api-keys 接入文檔在 https://taotoken.net/doc 。如果你需要長期跑編碼和 Agent 任務Coding Plan 頁面 https://taotoken.net/coding-plan 有更細的額度說明。4. 模型部署ONNX 導出、NCNN 轉(zhuǎn)換與 C GPU 推理部署是這條鏈路里技術(shù)密度最高的部分。整體路徑是PyTorch 權(quán)重 → ONNX → NCNN param/bin → C 推理工程。4.1 導出 ONNXpython models/export.py --weights weights/yolov5m.pt --img 640 --batch 1執(zhí)行后會生成 yolov5m.onnx。這個 ONNX 模型可以直接用 onnxruntime 推理也可以繼續(xù)轉(zhuǎn) NCNN。導出時注意 opset 版本YOLOv5 v3.0 默認的 opset 在 NCNN 轉(zhuǎn)換時兼容性較好不建議手動改高。4.2 ONNX 轉(zhuǎn) NCNN先做模型簡化用 onnx-simplifierpython -m onnxsim yolov5m.onnx yolov5m-sim.onnx然后轉(zhuǎn)換onnx2ncnn yolov5m-sim.onnx yolov5m.param yolov5m.bin這里有個我踩過的坑用最新版 onnx-simplifier 簡化出來的模型會殘留一堆帶 onnx 前綴的 op在 onnxruntime 和轉(zhuǎn) NCNN 之后都無法推理。換回 0.36 版本就正常了。如果你遇到轉(zhuǎn)換后推理報錯先檢查簡化版本。4.3 C 推理工程配置NCNN 推理需要動態(tài)注冊 YoloV5Focus 層這是 YOLOv5 特有的切片操作層。核心代碼結(jié)構(gòu)如下#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)初始化網(wǎng)絡時注冊這個層并開啟 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 函數(shù)里對每個尺度的特征圖做 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 目錄指向?qū)膸煳募\行目錄把 dll 拷過去。這一步配錯會直接報鏈接錯誤對照報錯信息逐個補庫即可。5. 常見報錯排查從 401 到推理輸出異常部署環(huán)節(jié)的報錯往往信息量很大但定位起來有規(guī)律。這一節(jié)列出幾個高頻錯誤和排查路徑。5.1 401 與 local proxy failed如果你在調(diào)用統(tǒng)一 Key 通道時遇到 401先檢查.env里的 Key 是否完整復制有沒有多余空格。401 通常是憑證無效或過期。local proxy failed 則多半是本地網(wǎng)絡配置問題檢查 base_url 是否寫成了https://taotoken.net/api注意不要帶末尾斜杠。5.2 reading choices 報錯這個報錯出現(xiàn)在解析模型輸出時通常是輸出層名稱對不上。YOLOv5 不同版本的輸出層名稱不一樣v3.0 版本是 750、771、791 三個層。如果你用的模型版本不同用 netron 打開 param 文件確認輸出層名稱改 C 代碼里的 extract 參數(shù)。5.3 OAuth 與 Codex auth.json如果你在部署環(huán)節(jié)用到 Codex 相關(guān)的認證auth.json 的配置要寫全三件套Base URL、Key、Model ID。缺任何一個都會導致認證失敗。Base URL 填https://taotoken.net/apiKey 填你的統(tǒng)一 KeyModel ID 按實際使用的模型填。5.4 推理結(jié)果異常排查檢測框位置偏移檢查 letterbox 的 padding 計算和坐標還原是否對稱。NCNN 推理里 wpad 和 hpad 的除以 2 操作要一致否則框會整體偏移。檢測不到目標先確認 prob_threshold 是不是設(shè)太高默認 0.25 可以調(diào)到 0.1 試試。如果還是不行檢查輸入圖像的歸一化NCNN 里用的是substract_mean_normalize(0, norm_vals)norm_vals 是 1/255。GPU 推理沒生效確認 NCNN 編譯時開了 Vulkan且use_vulkan_compute設(shè)為 true??梢杂胣cnn::get_gpu_count()打印一下返回 0 說明 Vulkan 沒啟用。耗時異常RTX 3080 上單張 640x640 圖片的 GPU 推理耗時應該在 10ms 以內(nèi)。如果超過 50ms檢查是不是回退到了 CPU 推理或者 bf16 存儲沒開。6. 把訓練和部署串成一條可復用的鏈路整條鏈路跑通之后你會發(fā)現(xiàn)真正花時間的不是寫代碼而是環(huán)境配置和格式轉(zhuǎn)換的細節(jié)。我的建議是把這套流程腳本化數(shù)據(jù)轉(zhuǎn)換一個腳本、訓練啟動一個腳本、模型導出和轉(zhuǎn)換一個腳本、C 工程配置一份文檔。下次換數(shù)據(jù)集或換模型版本只改配置不改流程。統(tǒng)一 Key 通道的價值在這里體現(xiàn)得比較明顯——訓練、轉(zhuǎn)換、部署三個環(huán)節(jié)的憑證收斂到一處換機器或換環(huán)境時不用逐個工具重新配。模型對話頁面 https://taotoken.net/model-chat 可以用來快速驗證模型輸出是否符合預期接入文檔 https://taotoken.net/doc 里有各環(huán)節(jié)的配置示例。最后給一個實用技巧C 推理工程里把耗時打印出來每次改動后對比耗時變化。GPU 推理的耗時對 batch size、輸入尺寸、是否開 bf16 都很敏感有個基準數(shù)字調(diào)優(yōu)時心里有數(shù)。檢測結(jié)果的可視化用 OpenCV 的 rectangle 和 putText 就夠重點是確認三類目標的框和標簽都對得上。