从零开始构建仓储视觉识别系统:从图像标注到模型部署(修订)
从零开始构建仓储视觉识别系统:从图像标注到模型部署
📚 系列文章导航
本系列围绕企业知识助手、售后智能体和仓储视觉识别,介绍从数据准备、模型开发到部署评测的实践流程,并提供配套排障指南。有修订版的文章,建议优先阅读修订版。
一、企业知识助手
从业务资料整理开始,逐步完成问答数据构建、模型微调、权重合并、量化与本地部署。
👉 从零开始构建企业知识助手:业务资料整理、模型微调与本地部署(修订版)
二、售后智能体
围绕售后业务,学习工具注册、智能体构建以及上线评测。
三、仓储视觉识别
从图像标注开始,逐步完成视觉模型训练、导出与部署。
👉 从零开始构建仓储视觉识别系统:从图像标注到模型部署(修订版)
四、模型开发排障
遇到环境、依赖、训练、推理、量化或部署问题时,可以按故障所在环节查阅。
👉 模型开发实用排障指南:从 Python(编程语言)环境到训练、推理与部署(修订版)
历史版本
仓库希望从现场照片中找出纸箱、周转箱和托盘的位置,供工作人员核对。我们将围绕这个业务,完成图片整理、人工标注、训练、评估、模型压缩和运行端迁移。这是一份可以边做边查的学习记录,不要求先会编程;示例场景不附带真实业务数据,也不预设任何识别成绩。
这里做的是Object Detection(目标检测):既判断物体是什么,也给出它在图里的位置。人先用矩形写出参考答案,再让模型从这些图片学习;学习后用另一批图片检查效果,最后把模型交给独立程序处理新图片。
文章先给出一条完整实践主线,再集中解释参数与可选做法,最后按现象排查问题。主线统一使用三类目标、640像素输入、一次离线增强、固定版本训练框架、处理器上的独立推理。示例数字只是让各段能衔接,不是普遍最优配置。
Python(编程语言)代码写进文件,终端命令在命令窗口执行,两者不要混贴。代码框上方会标明新建或追加的文件;同名文件按出现顺序拼接,保留缩进,不复制代码框边界。术语在文字中解释,代码标识符和文件名保持程序要求的原样。文中图片为教学示意,不是实际软件截图。
第一部分:按顺序完成实践
1. 建立本地和服务器环境
目标:准备能标注、能运行脚本的工作位置。
操作位置:本地Windows(桌面系统)使用PowerShell 7(命令终端);训练端使用Ubuntu(服务器系统)的Bash(命令解释器)。
先修改:连接地址、端口、账号及可写目录必须换成自己的;主线本地目录为D:\warehouse_vision,服务器目录为/home/user/projects/warehouse_vision。
参数与原理见对应参考。下面按顺序操作。
先认识三个类别:0 → carton(纸箱)、1 → tote(周转箱)、2 → pallet(托盘)。后续标注、数据配置、评估和推理必须使用同一顺序。改变业务类别时先看类别与坐标约定,不能只改界面上的名称。
在本地建立标注环境
以下命令假设已安装Conda(环境管理工具)。找不到命令时先按环境准备与替代方案完成安装或初始化,再回来继续;已有可用环境应先核对版本,不在原环境盲目升级。
conda create -n warehouse_label python=3.10 -y
conda activate warehouse_label
python -m pip install "numpy==1.26.4" "labelme==5.6.1" "opencv-python==4.10.0.84" "pandas==2.2.3"
python -m pip check
New-Item -ItemType Directory -Force D:\warehouse_vision\raw,D:\warehouse_vision\curated,D:\warehouse_vision\review,D:\warehouse_vision\reports | Out-Null
Set-Location D:\warehouse_vision
python -c "import sys; print(sys.executable)"
raw(原件目录)存照片;curated(整理目录)存待标注图片;review(复查目录)存暂不采用的图片;reports(记录目录)存检查记录。这几个目录首次使用应为空。
连接服务器并准备训练环境
另开一个本地终端,输入连接命令。密码输入通常不显示字符,按提示完成登录,不把密码写入代码。
ssh -p 实际端口 实际账号@实际服务器地址
hostname
pwd
nvidia-smi
free -h
df -h
conda env list
mkdir -p /home/user/projects/warehouse_vision/curated /home/user/projects/warehouse_vision/models /home/user/projects/warehouse_vision/reports /home/user/projects/warehouse_vision/exports /home/user/projects/warehouse_vision/deployment
cd /home/user/projects/warehouse_vision
conda create -n warehouse_train python=3.10 -y
conda activate warehouse_train
主线假设服务器有可供PyTorch(训练框架)使用的NVIDIA(显卡厂商)显卡,并且驱动兼容CUDA(显卡计算平台)12.1构建。先让环境提供者确认这一条件;不同硬件请使用对应安装分支,不要直接套用下面的构建。确认兼容后执行:
python -m pip install "torch==2.5.1" "torchvision==0.20.1" --index-url https://download.pytorch.org/whl/cu121
python -m pip install "numpy==1.26.4" "opencv-python==4.10.0.84" "ultralytics==8.3.40" "pandas==2.2.3" "matplotlib==3.9.2" "scikit-learn==1.5.2" "onnx==1.17.0" "onnxruntime==1.20.1" "pillow==11.0.0" "pyyaml==6.0.2"
python -m pip check
python -c "import torch, ultralytics; print(torch.__version__, torch.cuda.is_available(), ultralytics.__version__)"
python -m pip freeze > reports/environment.txt
版本组合用于明确教学接口,不代表已在你的机器上安装成功。后续训练使用显卡编号0;独立推理使用CPU(中央处理器),不能把这两个阶段的速度直接比较。
学会保存和运行代码
本地用文本编辑器打开项目文件夹,新建指定的.py文件,按UTF-8(统一字符编码)保存。不要保存成.py.txt。服务器可用nano 文件名.py(打开文本编辑器);粘贴代码后按Ctrl+O(保存)、回车,再按Ctrl+X(退出)。初次不熟悉编辑器时,先看编辑器具体操作。
保存只会写文件。运行必须回到对应终端,在项目根目录执行python 文件名.py。每次重新连接服务器,都先激活环境并进入项目根目录。
完成检查:本地解释器路径属于标注环境,服务器版本符合所选组合;显卡主线的可用性检查返回True(是),目录可写。安装失败先排错,不带着依赖错误开始训练。
将实际资料接入统一的类别和来源配置
在本地项目根目录建立 classes.json(类别名称文件),保存下面的示例。标注、构建数据、预览和评估均读取这一份文件;列表位置就是类别编号。
["carton", "tote", "pallet"]
拿到其他业务类别时,先逐项确认“编号0是什么、编号1是什么”,再修改这个文件。已有标注采用的编号必须与之相同;不能只按字母重新排序。训练后导出的运行配置从模型读取类别,打包前还会与评估记录核对。
随后把图片复制进 raw(原始资料的工作副本)。无需强制改成某种带分隔符的文件名。新建 source_groups.json(采集来源清单),键为包含扩展名的真实文件名,值为同一次连拍、同一视频片段或已确认独立采集组,例如:
{
"image001.jpg": "session01",
"image002.jpg": "session01",
"image003.jpg": "session04",
"image004.jpg": "session08"
}
文件多时,把下面的独立小例子保存为 make_source_list.py(来源清单草稿),在本地项目目录运行一次;已有清单不会被覆盖。它只列文件名,不自动猜来源。
import json
from pathlib import Path
root = Path(__file__).resolve().parent
files = sorted(p for p in (root / "raw").iterdir() if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"})
draft = {p.name: "" for p in files}
with (root / "source_groups.json").open("x", encoding="utf-8") as file:
json.dump(draft, file, ensure_ascii=False, indent=2)
print("请按真实来源填写每个空字符串;不要一张图随意编一个组")
python make_source_list.py
有人工清单时不要再运行草稿程序。若确实不知道采集来源,应先向数据提供者核对,结合近似画面保守分组并记录依据;不能仅凭不同文件名就断言彼此独立。图片目录含子目录时,先在工作副本整理并解决重名,再同步来源;后面的主线只扫描第一层。
已有标注时,保留原始坐标再导入
没有标注:按第2步整理图片,再到第3步画框。已经有有效标注:使用本节导入分支,随后跳到第4步构建数据。不要对已有标注的图片单独旋转、翻转或裁剪。
此分支支持两角点矩形的 Labelme(图形标注工具)文件和 YOLO(目标检测模型系列)的五列文本标签。其他格式先用对应工具转换,并抽样画回检查。不要把未知格式仅改扩展名。
把图片及同名标注放入 raw,新建 import_labeled_images.py(已有标注导入脚本),先保存第一段。FORMAT(格式)按资料选择;目标 curated(整理目录)必须为空。
from pathlib import Path
from PIL import Image
import json
import shutil
ROOT = Path(__file__).resolve().parent
FORMAT = "yolo" # yolo为五列文本;labelme为矩形结构化文件
if FORMAT not in {"yolo", "labelme"}:
raise ValueError("请先转换为已确认的检测标注格式")
source = ROOT / "raw"
output = ROOT / "curated"
output.mkdir(exist_ok=True)
if any(output.iterdir()):
raise ValueError("请使用新的空整理目录,避免混入不同方向或标注版本")
groups = json.loads((ROOT / "source_groups.json").read_text(encoding="utf-8"))
files = sorted(p for p in source.iterdir() if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"})
mapping, stems = {}, set()
在同一个文件末尾追加第二段。这里不应用 EXIF(图片元信息)自动旋转;沿用原始像素坐标,之后必须画框确认。若已有标注依据的是旋转后的显示方向,应先在专用副本中同步变换图片与坐标,不能继续套用此导入例子。
for path in files:
if path.stem in stems or not isinstance(groups.get(path.name), str) or not groups[path.name].strip():
raise ValueError("图片主干重名或来源组未填写")
annotation = path.with_suffix(".txt" if FORMAT == "yolo" else ".json")
if not annotation.is_file():
raise ValueError(f"缺少标注:{annotation.name};空标签也须人工确认")
target = output / (path.stem + ".png")
with Image.open(path) as image:
image.convert("RGB").save(target)
if FORMAT == "yolo":
shutil.copy2(annotation, target.with_suffix(".txt"))
else:
record = json.loads(annotation.read_text(encoding="utf-8"))
record["imagePath"], record["imageData"] = target.name, None
target.with_suffix(".json").write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
stems.add(path.stem)
mapping[target.name] = groups[path.name]
if not mapping:
raise ValueError("没有导入任何图片,请检查目录层级和扩展名")
(ROOT / "curated_groups.json").write_text(json.dumps(mapping, ensure_ascii=False, indent=2), encoding="utf-8")
print("导入数量:", len(mapping), ";继续构建和画回检查")
python import_labeled_images.py
若导入中断,先读明确报出的文件,再在新的空工作副本重跑,避免把残留目录当作完整结果。后面 build_dataset.py(数据构建脚本)的 ANNOTATION_FORMAT(标注格式)也要改为同样的格式。
传到训练端时一起复制:curated(图片与标注)、curated_groups.json(整理后来源清单)、classes.json(类别顺序)。标注仍在本地而图片去了服务器,会导致后续构建失败。
2. 收集图片并保留清理记录
目标:形成可解码、命名一致、经过人工复核的图片集合。
操作位置:本地标注环境,工作目录D:\warehouse_vision。
先修改:把原始图片放进raw,按前节填写source_groups.json(来源清单)。文件可以保留原名;同一次采集的近似画面使用同一个来源组。
参数与原理见对应参考。下面按顺序操作。
收集不同光照、距离、遮挡和背景的场景;准备各类别的正例,也保留人工确认没有目标的负例。相邻帧不能当作独立场景。主线从现成照片开始;只有视频时先执行可选抽帧,再回到这里。
定位原始图片和输出目录
新建项目根目录的prepare_images.py。
Path(路径对象)帮助程序找到文件;ROOT(项目根目录变量)始终跟随脚本位置。此脚本只处理原始目录第一层。
from pathlib import Path
from PIL import Image, ImageOps
import hashlib
import json
import numpy as np
import cv2
import pandas as pd
ROOT = Path(__file__).resolve().parent
output = ROOT / "curated"
output.mkdir(exist_ok=True)
if any(output.iterdir()):
raise ValueError("整理目录已有文件,请使用新的实验目录,避免覆盖已有标注")
files = sorted(p for p in (ROOT / "raw").iterdir()
if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"})
if not files:
raise ValueError("原始图片目录为空")
seen, names, records = {}, set(), []
source_groups = json.loads((ROOT / "source_groups.json").read_text(encoding="utf-8"))
if any(p.is_dir() for p in (ROOT / "raw").iterdir()):
raise ValueError("原始目录含子目录;请先在副本中整理第一层文件与来源清单")
读取图片、修正方向并检查重复
在prepare_images.py文件末尾追加,紧接该文件上一个示例。
读取失败记为待复查;像素完全相同记为重复。清晰度数值只用于人工排序,不设置自动删图阈值。
for path in files:
try:
with Image.open(path) as im:
im = ImageOps.exif_transpose(im).convert("RGB")
array = np.array(im)
digest = hashlib.sha256(str(array.shape).encode() + array.tobytes()).hexdigest()
if digest in seen:
records.append({"source": path.name, "status": "duplicate", "detail": seen[digest]})
continue
group = source_groups.get(path.name)
if not isinstance(group, str) or not group.strip():
raise ValueError("来源清单缺少此图片的采集组")
if path.stem in names:
raise ValueError("转换后文件主干重名,请在工作副本中解决并同步清单")
gray = cv2.cvtColor(array, cv2.COLOR_RGB2GRAY)
sharpness = float(cv2.Laplacian(gray, cv2.CV_64F).var())
im.save(output / (path.stem + ".png"))
names.add(path.stem)
seen[digest] = path.name
records.append({"source": path.name, "status": "candidate", "detail": sharpness})
except Exception as error:
records.append({"source": path.name, "status": "review", "detail": str(error)})
保存清理清单
在prepare_images.py文件末尾追加,紧接该文件上一个示例。
(ROOT / "reports").mkdir(exist_ok=True)
pd.DataFrame(records).to_csv(ROOT / "reports/image_inventory.csv", index=False, encoding="utf-8-sig")
counts = pd.Series([r["status"] for r in records]).value_counts().to_dict()
print("处理数量:", counts, flush=True)
if counts.get("candidate", 0) == 0:
raise ValueError("零张可用候选,请查看图片清单并修复路径、来源或图片内容")
curated_groups = {Path(r["source"]).stem + ".png": source_groups[r["source"]] for r in records if r["status"] == "candidate"}
(ROOT / "curated_groups.json").write_text(json.dumps(curated_groups, ensure_ascii=False, indent=2), encoding="utf-8")
print("记录已保存,请人工复查候选图片与失败项。")
Set-Location D:\warehouse_vision
python prepare_images.py
打开reports/image_inventory.csv(图片清单),查看candidate(候选)、duplicate(重复)和review(待复查)。再用图片查看器逐张检查整理目录,把不采用的图片移到复查目录;来源清单可保留这些历史记录,划分只读取当前整理目录里的图片。重复图和坏图的原件仍在原件目录。修正方向以后再画框。
完成检查:清理记录与候选图片相符,严重坏图已移出,困难但可辨认的样本没有被一概排除;每张候选图仍能从curated_groups.json(整理后来源清单)追溯;零候选必须停止修复。
3. 使用标注工具画框、修正并保存
目标:给每个目标建立正确的矩形和类别答案。
操作位置:本地Labelme(图像标注工具)5.6.1窗口。
先修改:输入和输出目录均为D:\warehouse_vision\curated;使用矩形、手动保存、不嵌入图片、不保留上一张标注。
参数与原理见对应参考。下面按顺序操作。
约定画框规则并打开目录
一个独立目标画一个框。两个纸箱分开画,纸箱和托盘可分别画框且允许重叠。本例统一框住可辨认目标的可见外接范围,不猜测遮挡后的完整轮廓。边缘目标框到图片边界;无法确认类别的图片先移入复查集合。

conda activate warehouse_label
labelme

- 点击File(文件)→ Open Dir(打开目录),选择整理目录,在File List(文件列表)中单击图片。
- 点击File(文件)→ Change Output Dir(更改输出目录),选择同一个整理目录。
- 取消Save With Image Data(保存时嵌入图片数据);关闭自动保存,先练习手动保存。
- 确认Keep Previous Annotation(保留上一张标注)未启用。Label List(类别列表)是类别名称,Polygon Labels(图形标注列表)才是当前图中已标的对象,不要混淆。
给一个物体画矩形
- 点击Edit(编辑)→ Create Rectangle(创建矩形)。
- 在可见外轮廓左上角单击,再移动到右下角单击,完成两个角点。
- 标签窗口中输入
carton(纸箱),点击OK(确定)。其他类别分别填写tote(周转箱)、pallet(托盘)。若工具直接复用了已选类别,立即检查结果。 - 查看画布及图形标注列表。目标应完整,额外背景尽量少。再次选择创建矩形,为下一个对象画框。
调整大小、位置和类别
先点击Edit(编辑)→ Edit Polygons(编辑图形)。选中框后拖动角点改大小,拖动框内部改位置;双击右侧对应标注条目,或选中后用Edit Label(编辑标签)改类别。选中多余的框,使用Delete Polygons(删除图形)删除。
未完成的形状用Esc(取消)退出。Delete File(删除文件)针对整份标注文件,不能用来代替删除单个框。放大检查边缘,完成后用Fit Window(适应窗口)恢复整图,从左到右再检查漏标。更多快捷键及撤销条件见标注工具参考。
保存后重新打开验证
点击Save(保存),确认文件名与图片同名,仅扩展名变为.json。在系统文件管理器中确认图片和标注都在整理目录。点击Next Image(下一张)后再返回,最后关闭工具并重新打开一张已标图片,核对框能恢复。
没有目标的图片也要人工确认并保存空标注。普通保存不可用时尝试Save As(另存为);打开文件确认shapes(图形列表)为[](空列表)。没有标注文件表示尚未确认,不自动当成负样本。
完成检查:每张采用的图片都有同名标注,重新打开可显示;多个对象分别有框,类别拼写一致,空标注确实对应无目标图片。
4. 传输标注并构建训练、验证数据
目标:把矩形答案转换成模型可读标签,按采集组划分并画回图片复核。
操作位置:先在本地文件传输客户端上传;后面的代码在训练服务器项目根目录编写并运行。
先修改:项目中的classes.json(类别顺序)与真实标注一致;VAL_GROUPS(验证采集组)换成真实存在、覆盖全部类别的一部分采集组,必须留出训练组。
参数与原理见对应参考。下面按顺序操作。
上传图片及同名标注
在文件传输客户端新建连接,选择服务实际支持的SFTP(安全文件传输协议)或FTP(文件传输协议),填写真实地址、端口和账号。进入远端项目根目录,把本地整个整理目录及classes.json、curated_groups.json两个配置文件一起上传。等待队列结束,检查失败项和远端文件数量,防止形成两层同名目录。客户端下载界面和复核动作见文件传输参考。
服务器目标路径例如curated/image001.png和同名标注文件,名称以真实文件为准。下载回一对图片与标注重开检查,确认传输可用后继续。
填写类别和验证采集组
新建项目根目录的build_dataset.py。
示例使用session04与session08,只是命名示范。原始数据不同就修改这两个值;不要为了凑名字把同一视频拆成两个组。
import json
import shutil
from pathlib import Path
from collections import Counter
from PIL import Image
import yaml
ROOT = Path(__file__).resolve().parent
NAMES = json.loads((ROOT / "classes.json").read_text(encoding="utf-8"))
VAL_GROUPS = {"session04", "session08"} # 改成你实际留作验证的采集组
group_map = json.loads((ROOT / "curated_groups.json").read_text(encoding="utf-8"))
if not NAMES or len(NAMES) != len(set(NAMES)) or not all(isinstance(n, str) and n.strip() for n in NAMES):
raise ValueError("类别名必须是非空且不重复的字符串")
ANNOTATION_FORMAT = "labelme" # 已有YOLO文本时改为 yolo
output = ROOT / "dataset"
if output.exists():
raise ValueError("数据集目录已存在,请使用新目录构建,避免混入旧划分")
images = sorted((ROOT / "curated").glob("*.png"))
if not images:
raise ValueError("没有整理后的PNG图片")
核对标注并计算中心坐标
在build_dataset.py文件末尾追加,紧接该文件上一个示例。
输入是像素角点,输出是相对图片大小的中心和宽高。不认识的类别、非矩形、越界框或缺失标注会报错。计算过程见坐标示例。
def read_yolo_labels(path):
if not path.is_file():
raise ValueError(f"缺少标签:{path.name};确认无目标后才能建立空标签")
result = []
for line in path.read_text(encoding="utf-8").splitlines():
values = list(map(float, line.split()))
if len(values) != 5:
raise ValueError("检测标签每行必须有五个数")
category, x, y, w, h = values
if not category.is_integer() or not 0 <= category < len(NAMES):
raise ValueError("类别编号不是有效整数")
if not (0 < w <= 1 and 0 < h <= 1 and 0 <= x-w/2 <= x+w/2 <= 1 and 0 <= y-h/2 <= y+h/2 <= 1):
raise ValueError("坐标越界、非有限值或框面积为零")
result.append((int(category), x, y, w, h))
return result
def read_boxes(image_path):
if ANNOTATION_FORMAT == "yolo":
return read_yolo_labels(image_path.with_suffix(".txt"))
if ANNOTATION_FORMAT != "labelme":
raise ValueError("请先将标注转换为本流程支持的格式")
annotation = image_path.with_suffix(".json")
if not annotation.is_file():
raise ValueError(f"缺少已确认的标注:{image_path.name}")
data = json.loads(annotation.read_text(encoding="utf-8"))
with Image.open(image_path) as image:
width, height = image.size
if (data["imageWidth"], data["imageHeight"]) != (width, height):
raise ValueError("图片尺寸与标注记录不同")
if (annotation.parent / data["imagePath"]).resolve() != image_path.resolve():
raise ValueError("标注指向另一张图片,请先修正图片路径")
result = []
for shape in data["shapes"]:
if shape["shape_type"] != "rectangle" or len(shape["points"]) != 2:
raise ValueError("此转换示例只接受两角点矩形,请先统一标注")
category = NAMES.index(shape["label"])
(ax, ay), (bx, by) = shape["points"]
x1, x2 = sorted([float(ax), float(bx)])
y1, y2 = sorted([float(ay), float(by)])
if not (0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height):
raise ValueError("发现越界或面积为零的框")
result.append((category, (x1+x2)/2/width, (y1+y2)/2/height,
(x2-x1)/width, (y2-y1)/height))
return result
检查训练和验证是否覆盖所有类别
在build_dataset.py文件末尾追加,紧接该文件上一个示例。
items, counts, totals = [], {"train": Counter(), "val": Counter()}, Counter()
if any(p.name not in group_map for p in images):
raise ValueError("来源清单缺少整理后的图片")
groups = {group_map[p.name] for p in images}
if not VAL_GROUPS or not VAL_GROUPS <= groups or groups <= VAL_GROUPS:
raise ValueError("请选择实际存在的部分采集组作为验证集,并保留训练组")
for path in images:
split = "val" if group_map[path.name] in VAL_GROUPS else "train"
boxes = read_boxes(path)
counts[split].update(box[0] for box in boxes)
totals[split] += 1
items.append((path, split, boxes))
for split in ("train", "val"):
if set(counts[split]) != set(range(len(NAMES))):
raise ValueError(f"{split}中有类别没有标注实例,请调整真实数据覆盖")
print("图片数量:", dict(totals), "目标数量:", counts)
for c, name in enumerate(NAMES):
positive = sum(s == "val" and any(box[0] == c for box in boxes) for _, s, boxes in items)
print(name, "验证正图片:", positive, "负图片:", totals["val"]-positive)
if positive == totals["val"]:
print("此类没有负图片,图像级AUC将标记为未定义;如需有效AUC,请补充真实背景覆盖")
生成图片目录、标签目录和数据配置
在build_dataset.py文件末尾追加,紧接该文件上一个示例。
for split in ("train", "val"):
(output / "images" / split).mkdir(parents=True)
(output / "labels" / split).mkdir(parents=True)
for path, split, boxes in items:
shutil.copy2(path, output / "images" / split / path.name)
lines = [str(b[0]) + " " + " ".join(f"{v:.6f}" for v in b[1:]) for b in boxes]
(output / "labels" / split / (path.stem + ".txt")).write_text(
"\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
config = {"path": str(output.resolve()), "train": "images/train", "val": "images/val",
"names": dict(enumerate(NAMES))}
(ROOT / "warehouse.yaml").write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
(ROOT / "reports/split_manifest.json").write_text(
json.dumps({p.name: s for p, s, _ in items}, ensure_ascii=False, indent=2), encoding="utf-8")
cd /home/user/projects/warehouse_vision
python build_dataset.py
cat warehouse.yaml
应得到dataset/images/train(训练图片)、dataset/images/val(验证图片)及对应的dataset/labels(标签目录),以及warehouse.yaml(数据配置)。配置的绝对路径要指向服务器实际数据位置。
准备标签重绘目录
新建项目根目录的preview_labels.py。
from pathlib import Path
from PIL import Image, ImageDraw
import json
ROOT = Path(__file__).resolve().parent
names = json.loads((ROOT / "classes.json").read_text(encoding="utf-8"))
output = ROOT / "reports/label_previews"
output.mkdir(exist_ok=True)
把转换标签画回原图
在preview_labels.py文件末尾追加,紧接该文件上一个示例。
for split in ("train", "val"):
for path in sorted((ROOT / "dataset/images" / split).glob("*.png")):
image = Image.open(path).convert("RGB")
draw = ImageDraw.Draw(image)
width, height = image.size
label = ROOT / "dataset/labels" / split / (path.stem + ".txt")
for line in label.read_text(encoding="utf-8").splitlines():
category, x, y, w, h = map(float, line.split())
box = ((x-w/2)*width, (y-h/2)*height, (x+w/2)*width, (y+h/2)*height)
draw.rectangle(box, outline="lime", width=3)
draw.text((box[0], max(0, box[1]-14)), names[int(category)], fill="red")
image.save(output / (split + "__" + path.name))
python preview_labels.py
完成检查:下载并查看reports/label_previews(标签预览),框的位置和类别与标注一致;reports/split_manifest.json(划分记录)中同一采集组没有跨训练、验证。修正时改原始标注,在新的输出目录重新构建,不改预览图冒充标签修正。
5. 保存训练集增强副本
目标:用一次可追溯的亮度变化与水平翻转扩充训练图片,保持验证集不动。
操作位置:训练服务器项目根目录。
先修改:主线使用固定随机种子42、默认关闭翻转、亮度范围0.85—1.15;只处理训练图片。
参数与原理见对应参考。下面按顺序操作。
准备增强输入和固定随机过程
新建项目根目录的augment_training.py。
from pathlib import Path
from PIL import Image, ImageEnhance, ImageOps
import random
import pandas as pd
ROOT = Path(__file__).resolve().parent
images = ROOT / "dataset/images/train"
labels = ROOT / "dataset/labels/train"
if list(images.glob("*__aug.png")):
raise ValueError("已经有增强副本,请不要重复增强这些副本")
paths = sorted(images.glob("*.png"))
rng = random.Random(42)
records = []
FLIP_PROBABILITY = 0.0 # 方向、文字敏感内容保持0;确认镜像不改变类别含义时才设0.5
同步变换图片和边界框
在augment_training.py文件末尾追加,紧接该文件上一个示例。
for path in paths:
flip = rng.random() < FLIP_PROBABILITY
brightness = rng.uniform(0.85, 1.15)
image = Image.open(path).convert("RGB")
if flip:
image = ImageOps.mirror(image)
image = ImageEnhance.Brightness(image).enhance(brightness)
lines = []
for line in (labels / (path.stem+".txt")).read_text(encoding="utf-8").splitlines():
category, x, y, w, h = map(float, line.split())
x = 1-x if flip else x
lines.append(f"{int(category)} {x:.6f} {y:.6f} {w:.6f} {h:.6f}")
name = path.stem + "__aug"
image.save(images / (name+".png"))
(labels / (name+".txt")).write_text("\n".join(lines)+("\n" if lines else ""), encoding="utf-8")
records.append({"source": path.name, "augmented": name+".png", "flip": flip, "brightness": brightness})
pd.DataFrame(records).to_csv(ROOT / "reports/augmentation_manifest.csv", index=False, encoding="utf-8-sig")
python augment_training.py
python preview_labels.py
打开增强记录,查看新生成的__aug.png(增强副本)和同名标签。翻转必须同步改变框的横坐标;亮度变化不改框。后面的训练示例已经关闭同类在线增强,不需要再手动协调两套设置。方向有业务含义时先把翻转关闭,再重新构建本轮数据。
完成检查:新增副本仅在训练集,重绘后框仍贴合;再次运行会被阻止,防止副本继续叠加。此时可按归档方法备份图片、原始标注、数据配置、划分及增强记录。
6. 加载预训练模型并训练
先确认提供的权重能走这条路线
本文后面的独立解码器只适用于已确认的YOLOv8检测输出,不是所有检测模型的通用接口。资源给出其他模型时,先检查其框架、版本、任务类型与加载说明;不要只把文件重命名成 yolov8n.pt(示例权重)来尝试。
在训练端新建临时检查脚本,把 weight(权重位置)改为真实路径;以下为独立检查,不会训练:
from pathlib import Path
from ultralytics import YOLO
weight = Path("models/yolov8n.pt")
if not weight.is_file():
raise FileNotFoundError(weight)
model = YOLO(str(weight))
print("任务:", model.task)
print("原类别:", model.names)
model.info()
if model.task != "detect":
raise ValueError("当前不是检测权重,请选择对应任务流程")
预训练模型原类别与当前业务不同,可以在检测训练中按新的数据配置调整输出层;这与把分类、分割权重当检测权重是两回事。先把真实路径写进训练脚本的 weight,训练成功后后面的导出使用本次产生的 best.pt(最佳权重),不再使用预训练文件。
若只需调整可训练特征层,先打印 model.model.model(网络层序列),再按检查到的层编号设置 freeze(冻结层)。例如 freeze=2 表示冻结该实现最前面的两层,不等于冻结两个任意命名模块;比较可训练参数与短训练结果后再决定。
若业务确实要求修改网络宽度或深度,可在确认兼容的YOLOv8结构上使用下面的替换示例,替换训练脚本中的 model = YOLO(str(weight)) 这一句,其后的训练调用继续保留。该句位于 main(主函数)内部,粘贴时把下面整段统一缩进四个空格;不要追加到文件末尾:
import copy
import yaml
base = YOLO(str(weight))
architecture = copy.deepcopy(base.model.yaml)
architecture.pop("yaml_file", None)
architecture.pop("scale", None)
architecture.pop("scales", None)
architecture["depth_multiple"] = 0.33
architecture["width_multiple"] = 0.25
config_path = ROOT / "custom_detector.yaml"
config_path.write_text(yaml.safe_dump(architecture, sort_keys=False), encoding="utf-8")
model = YOLO(str(config_path), task="detect").load(str(weight))
model.info()
这两个数是小网络的演示倍率,不是更优的保证。结构改变可能导致部分预训练权重无法匹配,必须阅读加载的匹配数量,先完成一次前向和短训练。只要求常规微调时,不必执行结构替换。导出后若输出含独立目标分数、多个输出、动态维度或已嵌入去重,应使用该结构明确支持的导出和推理分支;不能继续套本文原始候选解码器。
Windows本地训练时,使用该电脑上的实际项目路径;没有可用显卡只能选择 device="cpu"(处理器),并接受训练可能明显变慢。先设 workers=0(不启用加载子进程)定位问题,保留 if __name__ == "__main__"(脚本入口保护),再按资源增加工作进程。
目标:从已有权重学习仓储三类目标,保留日志、参数和模型文件。
操作位置:训练服务器项目根目录,已激活训练环境。
先修改:准备与Ultralytics(检测框架)8.3.40兼容的YOLOv8n(轻量检测模型)权重,保存为models/yolov8n.pt;核对数据配置与显卡编号0。
参数与原理见对应参考。下面按顺序操作。
预训练权重是已有学习结果,不是标注答案。从可信模型发布方获取兼容文件并保留来源;重命名其他模型不等于转换成兼容结构。主线参数固定为40轮、批量8、输入640、不冻结、不开混合精度,并明确关闭重复在线增强。
导入训练框架并定位项目
新建项目根目录的train_detector.py。
from pathlib import Path
from ultralytics import YOLO
ROOT = Path(__file__).resolve().parent
配置训练并设置运行入口
在train_detector.py文件末尾追加,紧接该文件上一个示例。
main(主函数)把训练步骤放在一起;最下面的入口判断让直接运行脚本时才启动,避免多进程重复执行。
def main():
weight = ROOT / "models/yolov8n.pt"
if not weight.is_file():
raise FileNotFoundError("请先准备与框架匹配的预训练权重")
if (ROOT / "runs/warehouse_v1").exists():
raise ValueError("实验目录已存在,请归档旧实验并同步后续路径后再运行")
model = YOLO(str(weight))
model.info()
model.train(
data=str(ROOT / "warehouse.yaml"), imgsz=640,
epochs=40, batch=8, device=0, workers=2,
optimizer="SGD", lr0=0.005, momentum=0.9, weight_decay=0.0005,
patience=10, seed=42, amp=False, freeze=None,
hsv_h=0.0, hsv_s=0.0, hsv_v=0.0,
degrees=0.0, translate=0.0, scale=0.0,
fliplr=0.0, flipud=0.0, mosaic=0.0, mixup=0.0,
project=str(ROOT / "runs"), name="warehouse_v1", exist_ok=False,
plots=True, save=True,
)
if __name__ == "__main__":
main()
python -m py_compile train_detector.py
python -u train_detector.py
语法检查通过后,训练终端应显示数据扫描、每轮损失和验证记录。损失用于观察学习过程,验证指标用于判断泛化;不能只看训练损失。目录已存在时先保留旧实验,再按重训与恢复处理,不覆盖旧结果。
完成检查:检查runs/warehouse_v1/results.csv(逐轮日志)、args.yaml(实际参数)、weights/best.pt(框架按验证准则选出的权重)和last.pt(最后检查点)。没有这些实际文件就先排查训练,不继续导出。
7. 在验证集生成指标、矩阵和曲线
先分清框架验证与最终运行函数验证
本节第一次运行时不加额外开关,检查训练框架指标;第9、10步导出后增加 --runtime(使用部署运行函数评估),对验证集逐图运行第8步的预处理、推理、类别选择、去重和坐标还原,再生成指标。两条路线在报告中分开标明,不把框架指标冒充最终入口指标。
固定版本8.3.40的预测分支不能只依靠 rect=False(关闭矩形填充)保证方形输入,因此下面显式定义方形填充预测器。检测平均精度使用匹配器和插值精度包络;ROC(受试者工作特征曲线)仍明确为图像级类别存在性。见对应版本预测实现及指标实现。
独立运行函数日常置信度阈值为0.25,计算平均精度时显式降至0.001以保留排序候选;预处理与后处理算法不变。固定阈值F1继续用0.25。若接口要求固定阈值下的性能,应另外保留该阈值的完整验证结果,不能混淆两种定义。
目标:从当前模型的真实预测生成评估文件,并记录统计定义。
操作位置:训练服务器项目根目录。
先修改:默认模型为runs/warehouse_v1/weights/best.pt;报告名为baseline(基准),类别顺序保持一致。
参数与原理见对应参考。下面按顺序操作。
本例同时记录mAP(平均精度均值)、固定阈值F1(精确率与召回率的调和平均)、混淆矩阵和PR(精确率—召回率曲线)。ROC(受试者工作特征曲线)与AUC(曲线下面积)另按‘每张图是否出现某类’计算;它不衡量定位精度,不能与检测mAP混为一谈。详细公式和阈值见指标参考。
计算真实框和预测框的重叠比例
新建项目根目录的evaluation_helpers.py。
import numpy as np
def pairwise_iou(a, b):
a = np.asarray(a, dtype=float).reshape(-1, 4)
b = np.asarray(b, dtype=float).reshape(-1, 4)
left_top = np.maximum(a[:, None, :2], b[None, :, :2])
right_bottom = np.minimum(a[:, None, 2:], b[None, :, 2:])
intersection = np.maximum(right_bottom-left_top, 0).prod(axis=2)
area_a = np.maximum(a[:, 2:]-a[:, :2], 0).prod(axis=1)
area_b = np.maximum(b[:, 2:]-b[:, :2], 0).prod(axis=1)
return intersection / np.maximum(area_a[:, None]+area_b[None, :]-intersection, 1e-12)
读取验证图片的真实标签
在evaluation_helpers.py文件末尾追加,紧接该文件上一个示例。
def read_truth(label_path, width, height):
classes, boxes = [], []
for line in label_path.read_text(encoding="utf-8").splitlines():
category, x, y, w, h = map(float, line.split())
classes.append(int(category))
boxes.append([(x-w/2)*width, (y-h/2)*height,
(x+w/2)*width, (y+h/2)*height])
return np.array(classes, dtype=int), np.array(boxes, dtype=float).reshape(-1, 4)
记录命中、错类、漏检和误检
在evaluation_helpers.py文件末尾追加,紧接该文件上一个示例。
def add_confusion(matrix, gt_classes, gt_boxes, pred_classes, pred_boxes, threshold=0.5):
overlaps = pairwise_iou(gt_boxes, pred_boxes)
candidates = np.argwhere(overlaps >= threshold)
candidates = sorted(candidates, key=lambda p: overlaps[p[0], p[1]], reverse=True)
used_gt, used_pred = set(), set()
for g, p in candidates:
if g in used_gt or p in used_pred:
continue
matrix[gt_classes[g], pred_classes[p]] += 1
used_gt.add(g)
used_pred.add(p)
background = len(matrix)-1
for g, category in enumerate(gt_classes):
if g not in used_gt:
matrix[category, background] += 1
for p, category in enumerate(pred_classes):
if p not in used_pred:
matrix[background, category] += 1
建立评估入口和报告目录
新建项目根目录的evaluate_detector.py。
import argparse
import json
import hashlib
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve, roc_auc_score
from ultralytics import YOLO
from ultralytics.models.yolo.detect import DetectionPredictor, DetectionValidator
from ultralytics.data.augment import LetterBox
from ultralytics.utils.metrics import DetMetrics
import torch
import cv2
from evaluation_helpers import read_truth, add_confusion
ROOT = Path(__file__).resolve().parent
parser = argparse.ArgumentParser()
parser.add_argument("--model", default=str(ROOT / "runs/warehouse_v1/weights/best.pt"))
parser.add_argument("--name", default="baseline")
parser.add_argument("--runtime", action="store_true", help="使用最终独立ONNX运行函数评估")
args = parser.parse_args()
OUT = ROOT / "reports" / args.name
OUT.mkdir(parents=True, exist_ok=False)
NAMES = json.loads((ROOT / "classes.json").read_text(encoding="utf-8"))
class SquarePredictor(DetectionPredictor):
def pre_transform(self, images):
return [LetterBox(self.imgsz, auto=False, stride=self.model.stride)(image=im) for im in images]
class RecordingValidator(DetectionValidator):
def get_stats(self):
values = self.stats["pred_cls"]
self.metrics.curve_classes = set(torch.cat(values).cpu().numpy().astype(int)) if values else set()
return super().get_stats()
调用框架验证当前模型
在evaluate_detector.py文件末尾追加,紧接该文件上一个示例。
model = YOLO(args.model, task="detect") if not args.runtime else None
metrics = model.val(data=str(ROOT / "warehouse.yaml"), imgsz=640, batch=1,
device="cpu", workers=0, rect=False, conf=0.001, iou=0.45,
plots=False, validator=RecordingValidator, project=str(OUT), name="framework", exist_ok=False) if not args.runtime else None
if model is not None and [model.names[i] for i in range(len(model.names))] != NAMES:
raise ValueError("模型类别顺序与本项目不一致")
matrix = np.zeros((len(NAMES)+1, len(NAMES)+1), dtype=int)
presence_truth, presence_scores = [], []
paths = sorted((ROOT / "dataset/images/val").glob("*.png"))
if not paths:
raise ValueError("验证图片为空")
runtime_stats = {name: [] for name in ("tp", "conf", "pred_cls", "target_cls")}
data_digest = hashlib.sha256()
if args.runtime:
from vision_runtime import create_session, preprocess, decode, read_image
settings = json.loads((ROOT / "exports/runtime.json").read_text(encoding="utf-8"))
if settings["names"] != NAMES or settings["imgsz"] != 640 or settings["nms_iou"] != 0.45:
raise ValueError("类别、尺寸或去重参数与本评估定义不一致")
session, input_name = create_session(Path(args.model), size=settings["imgsz"], threads=4)
matcher = DetectionValidator(args={"plots": False})
收集每张图片的预测和类别分数
在evaluate_detector.py文件末尾追加,紧接该文件上一个示例。
for path in paths:
data_digest.update(path.name.encode("utf-8"))
data_digest.update(path.read_bytes())
label = ROOT / "dataset/labels/val" / (path.stem + ".txt")
data_digest.update(label.read_bytes())
if args.runtime:
image = read_image(path)
height, width = image.shape[:2]
tensor, info = preprocess(image, size=settings["imgsz"])
raw = session.run(None, {input_name: tensor})[0]
detected = decode(raw, info, NAMES, confidence=0.001, iou=settings["nms_iou"])
pred_boxes = np.array([d["box"] for d in detected], dtype=float).reshape(-1, 4)
pred_classes = np.array([d["class_id"] for d in detected], dtype=int)
scores = np.array([d["confidence"] for d in detected], dtype=float)
else:
prediction = model.predict(str(path), imgsz=640, device="cpu", conf=0.001,
iou=0.45, predictor=SquarePredictor, max_det=300, verbose=False)[0]
height, width = prediction.orig_shape
pred_classes = prediction.boxes.cls.cpu().numpy().astype(int)
pred_boxes = prediction.boxes.xyxy.cpu().numpy()
scores = prediction.boxes.conf.cpu().numpy()
gt_classes, gt_boxes = read_truth(label, width, height)
if args.runtime:
detections = np.column_stack((pred_boxes, scores, pred_classes))
correct = matcher._process_batch(torch.tensor(detections), torch.tensor(gt_boxes), torch.tensor(gt_classes)).numpy()
for key, value in zip(runtime_stats, (correct, scores, pred_classes, gt_classes)):
runtime_stats[key].append(value)
keep = scores >= 0.25
add_confusion(matrix, gt_classes, gt_boxes, pred_classes[keep], pred_boxes[keep])
present = np.zeros(len(NAMES), dtype=int)
present[np.unique(gt_classes)] = 1
highest = np.zeros(len(NAMES), dtype=float)
for category, score in zip(pred_classes, scores):
highest[category] = max(highest[category], float(score))
presence_truth.append(present)
presence_scores.append(highest)
if args.runtime:
metrics = DetMetrics(save_dir=OUT, plot=False, names=dict(enumerate(NAMES)))
metrics.process(**{key: np.concatenate(values) for key, values in runtime_stats.items()})
metrics.curve_classes = set(np.concatenate(runtime_stats["pred_cls"]).astype(int))
保存混淆矩阵与图像级曲线
在evaluate_detector.py文件末尾追加,紧接该文件上一个示例。
labels = NAMES + ["background"]
pd.DataFrame(matrix, index=labels, columns=labels).to_csv(OUT / "confusion_counts.csv", encoding="utf-8-sig")
y, score = np.array(presence_truth), np.array(presence_scores)
auc_values, auc_status = [], []
plt.figure(figsize=(7, 5))
for c, name in enumerate(NAMES):
if len(np.unique(y[:, c])) < 2:
auc_values.append(np.nan)
auc_status.append("undefined: need positive and negative images")
continue
fpr, tpr, _ = roc_curve(y[:, c], score[:, c])
area = float(roc_auc_score(y[:, c], score[:, c]))
auc_values.append(area)
auc_status.append("valid")
plt.plot(fpr, tpr, label=f"{name}: {area:.3f}")
plt.plot([0, 1], [0, 1], "--", color="gray", label="reference")
plt.xlabel("False positive rate")
plt.ylabel("True positive rate")
plt.title("Image-level class presence ROC")
plt.legend()
plt.tight_layout()
plt.savefig(OUT / "presence_roc.jpg", dpi=160)
plt.close()
保存每类检测指标
在evaluate_detector.py文件末尾追加,紧接该文件上一个示例。
ap_by_class = {int(c): np.array(ap) for c, ap in zip(metrics.box.ap_class_index, metrics.box.all_ap)}
rows = []
for c, name in enumerate(NAMES):
tp = int(matrix[c, c])
fp = int(matrix[:, c].sum())-tp
fn = int(matrix[c, :].sum())-tp
precision = tp/(tp+fp) if tp+fp else 0.0
recall = tp/(tp+fn) if tp+fn else 0.0
f1 = 2*precision*recall/(precision+recall) if precision+recall else 0.0
ap = ap_by_class.get(c, np.zeros(10))
rows.append({"class": name, "AP50": float(ap[0]), "AP50_95": float(ap.mean()),
"precision_at025": precision, "recall_at025": recall, "F1_at025": f1,
"image_ROC_AUC": auc_values[c], "AUC_status": auc_status[c],
"TP": tp, "FP": fp, "FN": fn})
pd.DataFrame(rows).to_csv(OUT / "class_metrics.csv", index=False, encoding="utf-8-sig")
输出检测曲线和总体指标
在evaluate_detector.py文件末尾追加,紧接该文件上一个示例。
plt.figure(figsize=(7, 5))
curve_classes = [int(c) for c in metrics.box.ap_class_index if int(c) in metrics.curve_classes]
if curve_classes:
if len(curve_classes) != len(metrics.box.prec_values):
raise ValueError("曲线类别与精度包络数量不同,请核对框架版本")
for index, c in enumerate(curve_classes):
plt.plot(metrics.box.px, metrics.box.prec_values[index], label=NAMES[int(c)])
plt.legend()
else:
plt.text(0.1, 0.5, "No valid PR curve; inspect predictions and labels")
plt.xlabel("Recall")
plt.ylabel("Precision")
plt.title("Detection PR at IoU 0.5")
plt.xlim(0, 1.01)
plt.ylim(0, 1.05)
plt.tight_layout()
plt.savefig(OUT / "detection_pr.jpg", dpi=160)
plt.close()
summary = {"model": str(Path(args.model).resolve()),
"model_sha256": hashlib.sha256(Path(args.model).read_bytes()).hexdigest(),
"mAP50": float(metrics.box.map50), "mAP50_95": float(metrics.box.map),
"macro_F1_at025": float(np.mean([r["F1_at025"] for r in rows])),
"dataset_sha256": data_digest.hexdigest(),
"pipeline": "deployment_runtime" if args.runtime else "framework",
"evaluation_confidence": 0.001, "nms_iou": 0.45, "names": NAMES}
if args.runtime:
summary["runtime_settings"] = settings
summary["runtime_source_sha256"] = hashlib.sha256((ROOT / "vision_runtime.py").read_bytes()).hexdigest()
(OUT / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
combined = [{"scope": "overall", "class": "all", "mAP50": summary["mAP50"],
"mAP50_95": summary["mAP50_95"], "F1_at025": summary["macro_F1_at025"],
"image_ROC_AUC": float(np.mean([v for v in auc_values if np.isfinite(v)])) if any(np.isfinite(v) for v in auc_values) else None,
"AUC_valid_classes": sum(np.isfinite(v) for v in auc_values)}]
combined.extend(dict(row, scope="class") for row in rows)
pd.DataFrame(combined).to_csv(OUT / "validation_metrics.csv", index=False, encoding="utf-8-sig")
记录评估口径
在evaluate_detector.py文件末尾追加,紧接该文件上一个示例。
method = """检测AP/mAP:框架模式使用框架验证;runtime模式使用独立部署预处理、单类别筛选、去重和坐标还原。
两者均使用固定版本匹配器与AP计算,候选置信度0.001,去重IoU为0.45;报告pipeline标明路线。
日常显示阈值为0.25,AP评估为0.001;这是明确的评估阈值覆盖,不声称两者输出完全相同。
同时记录mAP50与mAP50_95。逐类AP不是跨类别平均值。
矩阵:行真实、列预测;背景列为漏检,背景行为多报;按几何IoU降序一对一匹配。
F1:预测置信度0.25,匹配IoU为0.5;无预测时精确率按0记。
ROC/AUC:图像级类别存在性,一图一例,分数为该类候选框的最高置信度,无候选记0。
AUC缺正例或负例时留空并标记未定义;不是检测定位AUC,不替代mAP。
本报告不把背景-背景格当成检测真负例。
"""
(OUT / "metric_definitions.txt").write_text(method, encoding="utf-8")
print("真实评估输出已写入:", OUT)
python evaluate_detector.py --name baseline
完成检查:报告在reports/baseline:矩阵、每类指标、总体摘要、两张曲线和口径说明都对应当前权重。若AUC_status(曲线面积状态)提示缺少正例或负例,应补足独立验证场景并重算,不能填一个虚构数值。详细文件含义见报告解释。
8. 写好独立推理函数、入口和测试脚本
目标:让运行端可以接收任意图片路径,恢复原图坐标,并先检查代码逻辑。
操作位置:训练服务器项目根目录;本阶段只写代码并测试,不要求模型已经导出。
先修改:输入固定[1, 3, 640, 640],输出固定[1, 4+类别数, 候选数];这是本文模型接口,不适用于所有检测模型。
参数与原理见对应参考。下面按顺序操作。
读取带中文路径的图片
新建项目根目录的vision_runtime.py。
from pathlib import Path
import cv2
import numpy as np
def read_image(path):
image = cv2.imdecode(np.fromfile(str(path), dtype=np.uint8), cv2.IMREAD_COLOR)
if image is None:
raise ValueError(f"无法读取图片:{path}")
return image
等比例缩放、填充并调整通道
在vision_runtime.py文件末尾追加,紧接该文件上一个示例。
def preprocess(image, size=640):
height, width = image.shape[:2]
ratio = min(size/width, size/height)
new_width, new_height = round(width*ratio), round(height*ratio)
resized = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
left = round((size-new_width)/2-0.1)
top = round((size-new_height)/2-0.1)
canvas = np.full((size, size, 3), 114, dtype=np.uint8)
canvas[top:top+new_height, left:left+new_width] = resized
rgb = canvas[:, :, ::-1]
tensor = np.ascontiguousarray(rgb.transpose(2, 0, 1)[None], dtype=np.float32)/255.0
return tensor, (ratio, left, top, width, height)
删除同类别重复框
在vision_runtime.py文件末尾追加,紧接该文件上一个示例。
def nms(boxes, scores, threshold=0.45):
order = scores.argsort()[::-1]
keep = []
while len(order):
i = int(order[0])
keep.append(i)
rest = order[1:]
lt = np.maximum(boxes[i, :2], boxes[rest, :2])
rb = np.minimum(boxes[i, 2:], boxes[rest, 2:])
intersection = np.maximum(rb-lt, 0).prod(axis=1)
area_i = np.maximum(boxes[i, 2:]-boxes[i, :2], 0).prod()
area_rest = np.maximum(boxes[rest, 2:]-boxes[rest, :2], 0).prod(axis=1)
iou = intersection/np.maximum(area_i+area_rest-intersection, 1e-12)
order = rest[iou <= threshold]
return keep
读取候选框和类别分数
在vision_runtime.py文件末尾追加,紧接该文件上一个示例。
def candidates(output, class_count, confidence=0.25):
output = np.asarray(output)
if output.ndim != 3 or output.shape[0] != 1 or output.shape[1] != 4+class_count:
raise ValueError(f"不是本教程约定的YOLOv8检测输出:{output.shape}")
if not np.isfinite(output).all():
raise ValueError("模型输出包含非有限数值")
data = output[0].T
categories = data[:, 4:].argmax(axis=1)
scores = data[np.arange(len(data)), categories+4]
good = scores >= confidence
centers, scores, categories = data[good, :4], scores[good], categories[good]
boxes = np.column_stack((centers[:, :2]-centers[:, 2:]/2,
centers[:, :2]+centers[:, 2:]/2))
return boxes, scores, categories
把坐标恢复到原图
在vision_runtime.py文件末尾追加,紧接该文件上一个示例。
def decode(output, info, names, confidence=0.25, iou=0.45):
boxes, scores, categories = candidates(output, len(names), confidence)
chosen = []
for category in np.unique(categories):
indexes = np.where(categories == category)[0]
chosen.extend(indexes[nms(boxes[indexes], scores[indexes], iou)].tolist())
chosen = sorted(chosen, key=lambda i: scores[i], reverse=True)[:300]
ratio, left, top, width, height = info
result = []
for i in chosen:
box = (boxes[i]-np.array([left, top, left, top]))/ratio
box[[0, 2]] = np.clip(box[[0, 2]], 0, width)
box[[1, 3]] = np.clip(box[[1, 3]], 0, height)
if box[2] <= box[0] or box[3] <= box[1]:
continue
category = int(categories[i])
result.append({"class_id": category, "class_name": names[category],
"confidence": float(scores[i]), "box": box.tolist()})
return result
加载固定接口的运行库会话
在vision_runtime.py文件末尾追加,紧接该文件上一个示例。
def create_session(model_path, size=640, threads=4):
import onnxruntime as ort
options = ort.SessionOptions()
options.intra_op_num_threads = threads
options.inter_op_num_threads = 1
session = ort.InferenceSession(str(model_path), sess_options=options,
providers=["CPUExecutionProvider"])
inputs = session.get_inputs()
if len(inputs) != 1 or inputs[0].type != "tensor(float)":
raise ValueError("入口要求一个浮点图片输入")
if inputs[0].shape != [1, 3, size, size] or len(session.get_outputs()) != 1:
raise ValueError("模型的输入或输出接口与本教程不一致")
return session, inputs[0].name
这些函数完整放在同一文件中。后面的量化读取器和推理入口共用它,避免不同环节采用不同预处理。NMS(非极大值抑制)负责去掉同类重复框,数学过程放在推理参考。
创建测试文件并检查颜色与空结果
新建项目根目录的test_runtime.py。
这是保留在项目中的逻辑测试文件,使用人工构造的数值,不是用模型识别准确率作断言。
import unittest
import numpy as np
from vision_runtime import preprocess, decode
class RuntimeTests(unittest.TestCase):
def test_color_and_empty_output(self):
image = np.full((601, 1000, 3), (10, 20, 30), dtype=np.uint8)
tensor, info = preprocess(image)
self.assertEqual(tensor.shape, (1, 3, 640, 640))
np.testing.assert_allclose(tensor[0, :, 320, 320], [30/255, 20/255, 10/255])
result = decode(np.zeros((1, 7, 1)), info, ["carton", "tote", "pallet"])
self.assertEqual(result, [])
检查坐标恢复和重复框处理
在test_runtime.py文件末尾追加,紧接该文件上一个示例。本段第一个方法仍在上面的测试类内部,保留四个空格缩进;最后的入口判断回到行首。
def test_coordinates_and_duplicate_boxes(self):
_, info = preprocess(np.zeros((600, 1000, 3), dtype=np.uint8))
ratio, left, top, _, _ = info
candidate = [300*ratio+left, 270*ratio+top, 400*ratio, 300*ratio, .9, .1, .01]
output = np.repeat(np.array(candidate, dtype=np.float32)[None, :, None], 2, axis=2)
result = decode(output, info, ["carton", "tote", "pallet"])
self.assertEqual(len(result), 1)
np.testing.assert_allclose(result[0]["box"], [100, 120, 500, 420], atol=0.001)
if __name__ == "__main__":
unittest.main()
python test_runtime.py
应通过两个测试。失败先修正拼接、缩进或辅助函数;即使通过,也还没有验证任何真实模型。
接收模型、图片和结果路径
新建项目根目录的run_detector.py。
# 依赖安装:python -m pip install -r requirements.txt
# 运行示例:python run_detector.py --input /absolute/path/image.png --output result.json
import argparse
import hashlib
import json
import time
from pathlib import Path
import cv2
from vision_runtime import read_image, preprocess, decode, create_session
ROOT = Path(__file__).resolve().parent
parser = argparse.ArgumentParser()
parser.add_argument("--model", default=str(ROOT / "model.onnx"))
parser.add_argument("--config", default=str(ROOT / "runtime.json"))
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--visual")
parser.add_argument("--repeat", type=int, default=1)
parser.add_argument("--threads", type=int, default=4)
args = parser.parse_args()
if args.repeat < 1 or args.threads < 1:
parser.error("重复次数和线程数必须大于0")
预热模型并执行图片识别
在run_detector.py文件末尾追加,紧接该文件上一个示例。
settings = json.loads(Path(args.config).read_text(encoding="utf-8"))
image = read_image(args.input)
session, input_name = create_session(args.model, settings["imgsz"], args.threads)
def detect_once():
tensor, info = preprocess(image, settings["imgsz"])
outputs = session.run(None, {input_name: tensor})
return decode(outputs[0], info, settings["names"], settings["confidence"], settings["nms_iou"])
for _ in range(2):
detect_once()
seconds = []
for _ in range(args.repeat):
started = time.perf_counter()
detections = detect_once()
seconds.append(time.perf_counter()-started)
mean_seconds = sum(seconds)/len(seconds)
写出结果文件和预览图
在run_detector.py文件末尾追加,紧接该文件上一个示例。
record = {"model": str(Path(args.model).resolve()),
"model_sha256": hashlib.sha256(Path(args.model).read_bytes()).hexdigest(),
"input": str(Path(args.input).resolve()), "settings": settings,
"threads": args.threads, "repeat": args.repeat, "mean_seconds": mean_seconds,
"images_per_second": 1/mean_seconds, "detections": detections}
destination = Path(args.output)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
if args.visual:
canvas = image.copy()
for item in detections:
x1, y1, x2, y2 = map(lambda x: int(round(x)), item["box"])
cv2.rectangle(canvas, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.putText(canvas, item["class_name"], (x1, max(y1-5, 15)),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
visual = Path(args.visual)
visual.parent.mkdir(parents=True, exist_ok=True)
ok, encoded = cv2.imencode(".png", canvas)
if not ok:
raise RuntimeError("预览图编码失败")
encoded.tofile(str(visual))
print(json.dumps(record, ensure_ascii=False))
python -m py_compile vision_runtime.py test_runtime.py run_detector.py
python run_detector.py --help
完成检查:辅助函数和测试均已写完,入口能显示参数帮助。--help(帮助)和逻辑测试都不等于模型加载成功;下一阶段必须运行导出模型。
9. 导出浮点模型并实际运行入口
目标:保留浮点导出基线,检查格式转换及自写后处理。
操作位置:训练服务器项目根目录。
先修改:源模型是同一份最佳权重;固定输入640、批量1、算子规范17、不开动态尺寸和额外简化。
参数与原理见对应参考。下面按顺序操作。
准备浮点模型输出路径
新建项目根目录的export_detector.py。
from pathlib import Path
import shutil
import json
import onnx
from ultralytics import YOLO
ROOT = Path(__file__).resolve().parent
OUT = ROOT / "exports"
OUT.mkdir(exist_ok=True)
weight = ROOT / "runs/warehouse_v1/weights/best.pt"
model = YOLO(str(weight))
target = OUT / "warehouse_fp32.onnx"
if target.exists():
raise ValueError("导出目标已存在,请给新实验使用新位置")
导出模型并保存运行配置
在export_detector.py文件末尾追加,紧接该文件上一个示例。
exported = model.export(format="onnx", imgsz=640, batch=1, dynamic=False,
simplify=False, opset=17, half=False, device="cpu")
shutil.copy2(exported, target)
onnx.checker.check_model(str(target))
names = [model.names[i] for i in range(len(model.names))]
settings = {"names": names, "imgsz": 640, "confidence": 0.25, "nms_iou": 0.45,
"layout": "NCHW", "color": "RGB", "scale": "divide_by_255",
"padding": 114, "output": "batch_channels_candidates"}
(OUT / "runtime.json").write_text(json.dumps(settings, indent=2), encoding="utf-8")
print("浮点模型已导出并通过结构检查:", target)
python export_detector.py
python evaluate_detector.py --model exports/warehouse_fp32.onnx --name exported_fp32 --runtime
将下面的验证图片名换成实际存在的文件。浮点模型与后面的量化模型使用同一张图片、相同配置和重复次数。
python run_detector.py --model exports/warehouse_fp32.onnx --config exports/runtime.json --input dataset/images/val/session04__0001.png --output reports/fp32_run.json --visual reports/fp32_view.png --repeat 10
完成检查:exports/warehouse_fp32.onnx(浮点模型)通过结构检查,实际入口生成结果和预览图;比较baseline与exported_fp32(浮点导出报告)的指标,确认没有无法解释的退化,再继续量化。还要换无目标、多目标和边缘目标图片检查。
10. 使用训练图片量化并比较变化
目标:生成真实量化模型,对比质量、体积及同条件运行延迟。
操作位置:训练服务器项目根目录。
先修改:新建calibration_files.txt(校准清单),每行填写一张真实训练图片相对项目的路径。主线使用有代表性的原始训练图片,不用验证集调校准范围。
参数与原理见对应参考。下面按顺序操作。
下列是清单格式示例,不是声称两张图片就足够;路径必须替换为实际存在的图片。小项目先覆盖几十到一两百张不同场景图片,数量有限时使用可用训练图并记录局限,范围解释见参考部分。
dataset/images/train/session01__0001.png
dataset/images/train/session02__0007.png
检查校准清单的来源
新建项目根目录的quantize_detector.py。
import json
from pathlib import Path
import onnx
import onnxruntime as ort
from onnxruntime.quantization import CalibrationDataReader, CalibrationMethod
from onnxruntime.quantization import quantize_static, QuantFormat, QuantType
from onnxruntime.quantization.shape_inference import quant_pre_process
from vision_runtime import read_image, preprocess
ROOT = Path(__file__).resolve().parent
OUT = ROOT / "exports"
train_root = (ROOT / "dataset/images/train").resolve()
lines = (ROOT / "calibration_files.txt").read_text(encoding="utf-8-sig").splitlines()
paths = [(ROOT / line.strip()).resolve() for line in lines if line.strip()]
if not paths or len(set(paths)) != len(paths):
raise ValueError("校准清单为空或含重复图片")
if any(not p.is_file() or not p.is_relative_to(train_root) for p in paths):
raise ValueError("校准图片必须存在并来自本项目训练集")
准备模型并逐张读取校准图片
在quantize_detector.py文件末尾追加,紧接该文件上一个示例。
prepared = OUT / "warehouse_prepared.onnx"
target = OUT / "warehouse_int8.onnx"
if prepared.exists() or target.exists():
raise ValueError("量化输出已存在,请先归档或换用新的实验目录")
quant_pre_process(str(OUT / "warehouse_fp32.onnx"), str(prepared), skip_optimization=False)
session = ort.InferenceSession(str(prepared), providers=["CPUExecutionProvider"])
input_name = session.get_inputs()[0].name
class ImageReader(CalibrationDataReader):
def __init__(self, images):
self.images = list(images)
self.rewind()
def get_next(self):
path = next(self.iterator, None)
if path is None:
return None
tensor, _ = preprocess(read_image(path), 640)
return {input_name: tensor}
def rewind(self):
self.iterator = iter(self.images)
量化支持的节点并保存记录
在quantize_detector.py文件末尾追加,紧接该文件上一个示例。
quantize_static(
str(prepared), str(target), ImageReader(paths),
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QInt8, weight_type=QuantType.QInt8,
per_channel=True, calibrate_method=CalibrationMethod.MinMax,
op_types_to_quantize=["Conv", "MatMul"],
)
onnx.checker.check_model(str(target))
graph = onnx.load(str(target)).graph
types = {node.op_type for node in graph.node}
if "QuantizeLinear" not in types or "DequantizeLinear" not in types:
raise ValueError("没有发现预期的量化节点,请检查是否真正执行量化")
record = {"format": "QDQ", "activation": "int8", "weight": "int8", "per_channel": True,
"calibration": "MinMax", "images": [str(p.relative_to(ROOT)) for p in paths],
"before_bytes": (OUT / "warehouse_fp32.onnx").stat().st_size,
"after_bytes": target.stat().st_size}
(OUT / "quantization_record.json").write_text(json.dumps(record, indent=2), encoding="utf-8")
python quantize_detector.py
python evaluate_detector.py --model exports/warehouse_int8.onnx --name quantized_int8 --runtime
python run_detector.py --model exports/warehouse_int8.onnx --config exports/runtime.json --input dataset/images/val/session04__0001.png --output reports/int8_run.json --visual reports/int8_view.png --repeat 10
确认前后测试使用相同条件
新建项目根目录的compare_runs.py。
import json
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parent
def read(relative):
return json.loads((ROOT / relative).read_text(encoding="utf-8"))
before = read("reports/fp32_run.json")
after = read("reports/int8_run.json")
for key in ("input", "settings", "threads", "repeat"):
if before[key] != after[key]:
raise ValueError(f"速度比较条件不同:{key}")
合并真实精度、延迟和体积记录
在compare_runs.py文件末尾追加,紧接该文件上一个示例。
rows = []
reference = read("reports/exported_fp32/summary.json")
for name, run, summary_file in [
("fp32", before, "reports/exported_fp32/summary.json"),
("int8", after, "reports/quantized_int8/summary.json"),
]:
summary = read(summary_file)
for key in ("dataset_sha256", "pipeline", "runtime_source_sha256", "runtime_settings"):
if summary[key] != reference[key]:
raise ValueError(f"精度对照条件不同:{key}")
if summary["model_sha256"] != run["model_sha256"]:
raise ValueError("速度记录与精度记录不是同一份模型,请重新测量")
rows.append({"model": name, "mAP50": summary["mAP50"],
"mAP50_95": summary["mAP50_95"], "mean_seconds": run["mean_seconds"],
"images_per_second": run["images_per_second"],
"bytes": Path(run["model"]).stat().st_size})
pd.DataFrame(rows).to_csv(ROOT / "reports/compression_comparison.csv", index=False, encoding="utf-8-sig")
python compare_runs.py
完成检查:同时保留原权重、浮点模型和八位模型;查看quantization_record.json(量化记录)与reports/compression_comparison.csv(前后对比)。图结构确有量化节点,两份模型都能加载,质量和资源收益才有比较意义。变慢或退化也如实记录,不能预写‘更快、更准’。
11. 封装运行目录并从其他位置复测
目标:把实际运行依赖收齐,使入口离开训练目录后仍可使用。
操作位置:先在训练服务器项目根目录打包,再切到临时目录调用。
先修改:主线准备部署量化模型;必须先审阅前后结果、确认满足业务需要。若改用浮点模型,按参考部分同步更换模型和两份报告。
参数与原理见对应参考。下面按顺序操作。
核对模型与评估记录的内容摘要
新建项目根目录的package_detector.py。
import hashlib
import importlib.metadata
import json
import platform
import shutil
from pathlib import Path
ROOT = Path(__file__).resolve().parent
source = ROOT / "exports/warehouse_int8.onnx"
summary = json.loads((ROOT / "reports/quantized_int8/summary.json").read_text(encoding="utf-8"))
inference = json.loads((ROOT / "reports/int8_run.json").read_text(encoding="utf-8"))
digest = hashlib.sha256(source.read_bytes()).hexdigest()
if digest != summary["model_sha256"] or digest != inference["model_sha256"]:
raise ValueError("模型与最近评估、独立推理记录不一致,请先重新验证")
config = json.loads((ROOT / "exports/runtime.json").read_text(encoding="utf-8"))
if config != inference["settings"]:
raise ValueError("运行配置与最近推理记录不同")
if summary.get("pipeline") != "deployment_runtime" or summary.get("runtime_settings") != config:
raise ValueError("请使用当前配置完成独立入口的验证集评估")
if summary.get("runtime_source_sha256") != hashlib.sha256((ROOT / "vision_runtime.py").read_bytes()).hexdigest():
raise ValueError("运行函数已修改,请重新进行独立入口评估")
output = ROOT / "deployment"
output.mkdir(exist_ok=True)
if any(output.iterdir()):
raise ValueError("部署目录不为空,请先归档旧版本")
复制模型、配置、入口和测试依赖
在package_detector.py文件末尾追加,紧接该文件上一个示例。
shutil.copy2(source, output / "model.onnx")
shutil.copy2(ROOT / "exports/runtime.json", output / "runtime.json")
for name in ("run_detector.py", "vision_runtime.py", "test_runtime.py"):
shutil.copy2(ROOT / name, output / name)
packages = ["numpy", "opencv-python", "onnxruntime"]
requirements = [name + "==" + importlib.metadata.version(name) for name in packages]
(output / "requirements.txt").write_text("\n".join(requirements)+"\n", encoding="utf-8")
生成可复现的运行说明
在package_detector.py文件末尾追加,紧接该文件上一个示例。
readme = f"""# 仓储容器识别运行说明
解释器:Python(编程语言){platform.python_version()}。
本次记录系统:{platform.platform()}。
推理后端:处理器;不自动假设目标机具有显卡运行能力。
依赖安装:python -m pip install -r requirements.txt
逻辑测试:python test_runtime.py(使用合成输入,不衡量识别精度)
运行示例:python run_detector.py --input /absolute/path/image.png --output result.json --visual preview.png
模型与配置默认从脚本所在目录读取,输入和输出使用调用者给定的路径。
类别、尺寸、置信度和去重阈值见runtime.json,必须与已验证版本保持一致。
输出包含原图像素坐标、类别、置信度和计时记录;无目标返回空列表。
计时不包括加载模型、读图和写文件;包含预处理、推理与后处理。
模型摘要:{digest}
部署前请补充:实际业务验收结论、允许的精度变化和目标硬件复测结果。
"""
(output / "README.md").write_text(readme, encoding="utf-8")
print("已封装,请测试部署副本:", output)
python package_detector.py
cd /tmp
python /home/user/projects/warehouse_vision/deployment/test_runtime.py
python /home/user/projects/warehouse_vision/deployment/run_detector.py --input /home/user/projects/warehouse_vision/dataset/images/val/session04__0001.png --output /home/user/projects/warehouse_vision/reports/deployment_run.json --visual /home/user/projects/warehouse_vision/reports/deployment_view.png
cd /home/user/projects/warehouse_vision
运行目录应含model.onnx(模型)、runtime.json(配置)、run_detector.py(入口)、vision_runtime.py(辅助函数)、test_runtime.py(逻辑测试)、requirements.txt(依赖清单)和README.md(使用说明)。不要只拿走模型,把辅助文件留在服务器上。
完成检查:从其他工作目录运行也能找到模型和配置;打开实际结果、查看预览图,再用一张新图片试运行。使用说明中补充真实验收结论和运行硬件,不用占位说明代替测试。
12. 归档、传输并在接收端重新验证
目标:确认文件传输完整,而且目标环境能实际推理。
操作位置:源服务器生成内容摘要,本地传输客户端搬运,目标服务器独立安装与执行。
先修改:接收目录、目标端解释器和输入图片换成真实信息;保留主线的同一套类别、输入尺寸与后处理配置。
参数与原理见对应参考。下面按顺序操作。
cd /home/user/projects/warehouse_vision/deployment
sha256sum model.onnx runtime.json run_detector.py vision_runtime.py test_runtime.py requirements.txt README.md > ../deployment_checksums.txt
将整个运行目录传到接收端,并把摘要清单放在其上一级。训练图片、日志、模型优化过程另外归档,方法见分阶段归档。目标服务器首次准备独立环境:
conda create -n warehouse_infer python=3.10 -y
conda activate warehouse_infer
cd /实际接收目录/deployment
sha256sum -c ../deployment_checksums.txt
python -m pip install -r requirements.txt
python -m pip check
python test_runtime.py
python run_detector.py --input /实际测试图片目录/new_image.png --output verification.json --visual verification.png
目标系统版本可以不同,但必须支持记录下来的依赖与模型算子。安装失败不要直接升级全套库;先查不兼容项,必要时建立新版本配置并重新验收。备份服务若无法运行程序,只能验证文件备份完整,运行验证还要在具备计算环境的机器上做。
完成检查:摘要全部一致,逻辑测试通过,真实图片生成可读结果及正确预览;现场记录接收端实际版本、硬件、运行命令和观察结果。这才完成从数据到迁移复测的一条链路。
按接收接口整理阶段文件
数据构建完成后归档图片、标签、类别配置、来源清单和增强记录;训练完成后归档本次运行目录的 results.csv(训练记录);验证完成后归档指标和曲线;量化与导出完成后归档前后模型及可运行入口。每个阶段完成后按约定传输,不能只在最后一次性寻找所有文件。
当前总体与分类别指标统一提供在 validation_metrics.csv(验证指标总表),scope(记录范围)区分总体和单类,单类用AP、总体用mAP。总表中的总体AUC只对有定义的类别取平均,同时显示有效类别数。它不等于把无定义类别填零。
若接收方要求其他文件名,在新的交付副本中修改文件名并检查字段;不要只把仅含单类结果的文件重命名为总体指标。入口改名时要连同 vision_runtime.py(推理函数)、runtime.json(配置)和模型一起复制,修改说明中的运行命令,并从新目录执行帮助、逻辑测试和真实图片推理。
传输工具应先选择服务端规定的FTP(文件传输协议)或SFTP(安全文件传输协议),再填真实地址、端口和账号;上传后查看队列和远端文件。具体检查见环境与文件传输指南。
第二部分:参数、工具和可选做法参考
环境、版本和安装分支
返回建立环境。Environment(运行环境)是解释器与依赖的组合,激活环境相当于选择这一套工具。Terminal(终端)接受命令;Editor(编辑器)用于写文件;它们不是训练模型本身。
主线约定Python(编程语言)3.10、Labelme(标注工具)5.6.1、Ultralytics(目标检测框架)8.3.40、PyTorch(训练框架)2.5.1和torchvision(视觉扩展)0.20.1。导出使用ONNX(开放神经网络交换格式)库1.17.0,加载与量化使用ONNX Runtime(模型运行库)1.20.1。这里核对的是接口与源码版本,并没有验证这整套组合在所有操作系统和显卡上都可用。
Conda(环境管理工具)未安装时,从官方安装说明选择对应系统,安装后重新打开终端。桌面安装提供的环境管理终端通常已初始化;若PowerShell(命令终端)不能激活环境,在已能执行环境管理命令的终端中运行下面命令,再关闭并重新打开PowerShell 7:
conda init powershell
不要在没有环境管理命令的窗口中反复执行它。重新连接远程机器后,环境和当前位置也要重新确认。
| 工具与参数 | 中文含义 | 工具行为或默认 | 主线值 | 常见选择、影响及限制 |
|---|---|---|---|---|
Conda:-n |
环境名称 | 新建时需给名称或路径 | 标注、训练、推理分别建环境 | 名称自定;激活错误环境会把库装到另一位置 |
Conda:python=3.10 |
解释器版本约束 | 不约束时由求解器选择 | 3.10 | 版本须被所选依赖支持;不能根据编辑器显示名称推断实际解释器 |
pip(包安装工具):== |
精确版本约束 | 不写时不固定版本 | 主线显式固定关键库 | 固定接口便于复现,仍需检查传递依赖;不等于跨平台二进制兼容 |
pip:--index-url |
软件包索引地址 | 来自安装工具配置 | 训练框架使用官方cu121索引 | CPU(处理器)、cu118、cu121、cu124等构建按硬件选;镜像地址和计算构建不是同一概念 |
SSH(安全远程连接):-p |
服务端口 | 通常22,可被服务器修改 | 真实端口占位 | 与账号、地址一起向环境提供方确认,不能猜测服务端开放情况 |
Python:-m pip |
用当前解释器调用安装器 | 无自动选择其他环境的保证 | 始终使用当前解释器 | 比单独调用安装命令更容易核对归属;检查解释器路径 |
Python:-u |
无缓冲输出 | 通常存在缓冲 | 训练时开启 | 有助于及时看到日志,不会加速训练 |
主线的CUDA(显卡计算平台)12.1构建来自训练框架2.5.1安装说明。驱动工具显示的CUDA版本不是当前解释器中已安装的训练框架构建;两者分别检查。硬件不兼容时按官方矩阵选构建,不能只看一个版本数字就宣布可用。
如果只想在没有显卡的机器上先检查少量数据流程,可在另一个新环境使用CPU构建,并把训练参数device(设备)改成"cpu"(处理器):
python -m pip install "torch==2.5.1" "torchvision==0.20.1" --index-url https://download.pytorch.org/whl/cpu
这是替代安装命令,不是在显卡主线后追加的命令。训练速度可能很慢,不能把处理器检查误写成显卡性能结果。非NVIDIA(显卡厂商)设备需要对应硬件的软件栈,本例不提供通用安装保证。
编辑器、终端和代码的基本用法
返回保存与运行代码。File(文件)存代码,Folder(文件夹)整理多个文件,Path(路径)表示文件位置。Absolute Path(绝对路径)从磁盘或系统根目录开始;Relative Path(相对路径)从指定位置开始。本文脚本以自身位置确定项目根目录,但终端中的短命令仍需要先进入项目目录。
使用PyCharm(代码编辑器)时:打开项目文件夹,在项目区右键新建Python文件,确认名称没有重复扩展名;在解释器设置中选择前面创建环境中的解释器;打开终端,激活同一环境,再执行文中的命令。如果点击绿色运行按钮,要额外核对解释器、工作目录和命令行参数,初学阶段采用文中的终端方式更便于对照。
| 工具操作 | 具体动作 | 完成检查与限制 |
|---|---|---|
| 本地创建脚本 | 编辑器新建指定文件,粘贴当前示例,按UTF-8(统一字符编码)保存 | 文件名是.py而非.py.txt,内容不是富文本 |
| 服务器编辑 | 执行nano 文件名.py,按顺序追加同名示例 |
新函数从行首开始;函数体通常缩进四个空格 |
| Nano(文本编辑器)保存 | Ctrl+O(写入文件)后按回车 | 确认底部显示的文件名 |
| Nano退出 | Ctrl+X(退出);有未保存提示先保存 | 不把退出当作自动保存 |
| 检查语法 | python -m py_compile 文件名.py |
只验证能否解析,不检查路径、依赖、指标和模型正确性 |
| 运行脚本 | python 文件名.py |
错误信息要保留最后一段与执行命令,不只看窗口是否关闭 |
| 显示参数 | python run_detector.py --help |
能看帮助不代表加载过模型 |
| 查看所在目录 | 本地执行Get-Location;服务器执行pwd |
目录改变只影响当前终端,不会同时改变另一台机器 |
import(导入)让代码使用其他库;def(定义函数)把可重复动作取名;for(循环)依次处理对象;if(条件判断)决定是否执行;return(返回)交回结果。它们是程序语法,不需要翻译后再写入代码。class(类)把相关方法组织在一起,量化读取器和测试文件都用到了它。True(真)、False(假)、None(无值)有固定大小写。
文中的ROOT(根目录变量)是自定义变量,通常无需修改;调整根目录时移动整个项目比逐行替换路径更可靠。修改脚本变量用编辑器;修改入口参数用终端中的--参数名。这两种操作不要混淆。
数据、类别与采集组参数
返回清理图片或构建数据集。Training Set(训练集)用于更新模型;Validation Set(验证集)用于观察效果和调参;Test Set(测试集)应保留到最终独立验收,不能反复用于挑参数。本例构建训练与验证两份数据,实际业务可另外封存测试集。
文件数量不能代表独立样本数量。同一段视频的相邻帧以及同一原图的增强副本彼此相似;拆进不同数据分区会造成Data Leakage(数据泄漏)。先按采集组划分,再只增强训练集。验证集需要覆盖每类目标;若还计算图像级曲线,每类也需要不含该类的负例。
| 所属脚本或配置 | 参数与中文含义 | 默认性质 | 主线值 | 常见选择、调整条件、影响及关联限制 |
|---|---|---|---|---|
| 图片整理脚本 | raw、curated、review,原件、整理、复查目录 |
自定义,无工具默认 | 项目内同名目录 | 改名须同步脚本与标注界面;不覆盖已有整理目录 |
| 图片整理脚本 | source_groups.json(来源清单) |
自定义文件 | 文件名映射到采集组 | 同一次采集的相似画面归同组;不再强制文件名前缀 |
| 图片整理脚本 | 像素摘要,精确去重依据 | 自定义实现 | 方向修正后的RGB(红绿蓝)像素和尺寸 | 可以发现精确重复;压缩差异、裁剪和近似图片要另行人工或算法复查 |
| 图片整理脚本 | sharpness,清晰度参考数值 |
自定义记录,无删除阈值 | 拉普拉斯方差 | 图片分辨率、纹理会影响数值;没有全场景通用合格线 |
| 数据构建脚本 | NAMES,类别顺序 |
自定义,无框架自动推断 | 从classes.json读取 |
标签编号与此顺序一致;逻辑测试中的合成三类只测试函数,不限定业务类别 |
| 数据构建脚本 | VAL_GROUPS,验证采集组 |
自定义,无默认比例 | 两个示例组名 | 可先按组近似留出20%左右,再检查覆盖;20%是尝试值,不是规定或最优值 |
| 数据配置 | path,数据根目录 |
由配置提供 | 当前数据集绝对路径 | 搬数据后更新,错误路径会指向旧数据或找不到文件 |
| 数据配置 | train、val,图片子目录 |
由配置提供 | images/train与images/val |
相对数据根目录;标签放在匹配的标签目录 |
| 数据配置 | names,编号到类别名称 |
由配置提供 | 编号0、1、2 | 编号连续且从0开始;本例由名称映射确定三类,不另写冲突的类别数 |
| 标签转换 | shape_type,图形类型 |
本文转换器约束 | rectangle(矩形) |
不支持直接把多边形输入当矩形;换类型要明确转换规则 |
| 标签转换 | imagePath,标注对应图片 |
来自标注文件 | 同目录的图片名 | 换目录后应重新检查引用,不能只改图片尺寸字段掩盖错配 |
本例预处理把图片保存为PNG(无损图片格式),避免反复有损编码,但文件可能变大。规则不是“所有任务必须转成这一格式”。参考检测数据格式说明。
矩形坐标怎样转换
返回转换训练标签。Bounding Box(边界框)是目标的外接矩形。标注工具保存两个角点,YOLO(目标检测模型系列)标签用一行表达一个目标:类别编号 中心横坐标 中心纵坐标 宽度 高度。后四项分别除以原图宽或高,变成0到1之间的比例。
例如宽1000、高600,左上角(100, 120)、右下角(500, 420)。中心是(300, 270),宽400、高300,因此类别0的标签是:
0 0.300000 0.450000 0.400000 0.500000

一个对象一行,多个对象多行;已确认无目标的图片对应空标签文件。归一化与缩小图片不是同一动作,不能将所有数值都除以640,也不能把左上角直接当中心。图片旋转或裁剪后,旧坐标必须同步变换。
标注工具菜单、快捷键和配置
返回画框实践。以下按Labelme(标注工具)5.6.1的界面源码和默认配置核对。用户配置可覆盖默认,面板可拖动,图示不保证与你的布局相同。
| 所属配置 | 中文含义 | 5.6.1默认值 | 主线值 | 可选值、影响与注意事项 |
|---|---|---|---|---|
auto_save |
自动保存 | false(否) | 否 | 可开启;主线先练习手动保存并检查实际文件 |
store_data |
标注内嵌图片 | true(是) | 否 | 开启会把图片编码写进标注;关闭时原图片必须一起传输 |
keep_prev |
保留上一张标注 | false(否) | 否 | 连续帧可尝试开启,但必须逐框调整,不能把上一帧当新答案 |
display_label_popup |
弹出标签选择 | true(是) | 保持开启 | 弹窗或已选类别的实际行为以窗口为准,完成后检查标签 |
labels |
预设类别列表 | null(未设置) | 手动输入三类 | 可以在配置中预设列表;仅预设名称不代表全部图片已标注 |
| 输出目录 | 标注保存位置 | 与界面状态、打开方式有关 | 与整理图片同目录 | 每次新项目核对;改位置后要一起检查图片相对引用 |
| 动作 | 菜单或默认快捷键 | 怎样操作及限制 |
|---|---|---|
| 打开目录 | Open Dir(打开目录),Ctrl+U | 选图片目录,文件列表出现图片;不是选择单个标注文件 |
| 打开单个文件 | Open(打开),Ctrl+O | 可打开图片或标注;重开用于验证保存结果 |
| 创建矩形 | Create Rectangle(创建矩形),Ctrl+R | 两次单击两个角点;不是拖住鼠标任意涂抹 |
| 编辑图形 | Edit Polygons(编辑图形),Ctrl+J | 拖角点改大小,拖框内改位置 |
| 编辑标签 | Edit Label(编辑标签),Ctrl+E | 先选目标条目,再改类别 |
| 删除图形 | Delete Polygons(删除图形),Delete | 只针对选中图形;删除整份标注是另一动作 |
| 保存 | Save(保存),Ctrl+S | 检查同名标注存在,再切换图片 |
| 另存为 | Save As(另存为),Ctrl+Shift+S | 核对目录和文件名,避免一张图存成另一张图的标注 |
| 下一张/上一张 | Next Image(下一张)/Prev Image(上一张),D/A | 按键需要画布处于适合接收快捷键的状态;不要在文本框中误输入 |
| 放大/缩小 | Zoom In(放大)/Zoom Out(缩小),Ctrl++/Ctrl+- | 可用菜单按钮,键盘布局不同优先按菜单 |
| 恢复整图 | Fit Window(适应窗口),Ctrl+F | 放大查边缘后再看整图找漏标 |
| 撤销 | Undo(撤销)或Undo Last Point(撤销上一点),Ctrl+Z | 绘制和编辑状态对应不同动作;不是所有文件操作都能撤销 |
| 复用上张标注 | Keep Previous Annotation(保留上一张标注),Ctrl+P | 主线关闭;带Ctrl+Shift的切图组合可能复用标注,勿与普通切图混用 |
关闭面板后,在View(视图)菜单查找对应面板并重新显示。类别拼错时统一修正原始标注,再重建训练标签;不要只改训练目录中的一份文本,留下两套不一致答案。
离线增强与在线增强的区别
返回保存增强副本。Offline Augmentation(离线增强)先写出新图片和标签,便于人工检查;Online Augmentation(在线增强)在训练读取时随机变化,不必预存所有副本。两者都需要同步处理框,验证集通常不做随机增强。
主线只保存一次离线增强副本,并在训练调用中显式把随机颜色、平移、缩放、翻转、拼图和混合关闭。它仍会执行必要的模型输入缩放与填充,不能因此称为“图片完全不经过变换”。
| 所属函数或工具 | 参数与中文含义 | 工具默认 / 主线示例 | 可尝试值与条件 | 影响及关联限制 |
|---|---|---|---|---|
| 自定义增强脚本 | 随机种子 | 无工具默认 / 42 | 对照实验保留同一值 | 固定一次生成过程;不保证不同软件版本逐位一致 |
| 自定义增强脚本 | 水平翻转概率 | 无工具默认 / 0 | 确认语义不变时可试0.25、0.5 | 方向有含义时关闭;翻转后中心横坐标变成1减原值 |
| Pillow(图像库)亮度增强 | 亮度因子 | 调用需给值 / 随机0.85—1.15 | 先小幅比较,如0.9—1.1 | 小于1变暗、大于1变亮;过强可能失去信息;框不变 |
| Ultralytics训练增强 | hsv_h/s/v,色相/饱和度/明度幅度 |
0.015/0.7/0.4 / 主线全0 | 在线替代实验可试0.01/0.3/0.25 | 属于变化幅度,不是变换概率;颜色决定类别时谨慎 |
| 同上 | degrees,随机旋转角度范围 |
0 / 0 | 可从±5度范围试起 | 参数填5表示正负幅度;检查旋转后的外接框与裁切 |
| 同上 | translate,相对平移幅度 |
0.1 / 0 | 在线替代可试0.05 | 不是5个像素;可能裁掉边缘目标 |
| 同上 | scale,随机缩放幅度 |
0.5 / 0 | 在线替代可试0.2 | 0.2近似对应0.8—1.2倍,不是缩成0.2倍 |
| 同上 | fliplr,左右翻转概率 |
0.5 / 0 | 在线替代0或0.5 | 主线离线亮度增强,翻转默认关闭;开启任一翻转前先确认语义 |
| 同上 | flipud,上下翻转概率 |
0 / 0 | 仅业务方向允许时尝试 | 固定相机通常不需要倒置图片 |
| 同上 | mosaic,拼图概率 |
1.0 / 0 | 新实验可比较0、0.5、1 | 拼接改变尺度与背景组合;小目标可能受益也可能消失 |
| 同上 | mixup,图片混合概率 |
0 / 0 | 有基准后才试小值 | 混合画面不一定符合业务;不能无依据宣称有效 |
| 同上 | close_mosaic,末尾关闭拼图轮数 |
10 / 未覆盖,沿用10 | 0或若干轮 | 主线拼图概率为0,所以这项不产生额外拼图变化 |
这些训练默认来自8.3.40配置,操作含义对应该版本增强实现。表中的尝试点是教学候选,不是工具承诺的有效范围或最优数值。其他未覆盖的训练增强按该版本默认执行;主线适用于普通矩形检测,分割和分类专属增强不能直接套用。
若改用在线增强:重新构建一份没有离线副本的数据目录,跳过主线增强脚本,另起实验名称,再在训练调用中替换需要的增强参数。不要删除当前实验的增强记录,也不要把新结果与旧记录混用。
训练参数:含义、默认与调整依据
返回训练实践。Epoch(训练轮)表示遍历一次训练数据;Batch(批量)是一次前向与更新过程使用的一组图片;Learning Rate(学习率)影响参数更新步幅;Loss(损失)衡量当前预测与答案的差异。权重经过更新不代表能处理未见过的图片,因此还需要独立验证。
下表属于YOLO.train(训练函数),默认值按8.3.40的配置及调用行为核对。配置默认不保证等于最终实际值:优化器自动模式、权重衰减缩放和恢复训练都可能改变有效设置,运行后以实际参数和日志为准。
| 参数与中文含义 | 工具默认 | 主线示例值 | 常见选择或尝试点 | 调整条件、影响与关联限制 |
|---|---|---|---|---|
data,数据配置 |
未指定 | 项目数据配置路径 | 自己的配置文件 | 改路径要确认指向本轮数据;不能仍读旧划分 |
imgsz,输入尺寸 |
640 | 640 | 512、640、960等尝试点,注意步长对齐 | 更大可能改善小目标,也增加显存;导出、校准、测试和推理须同步 |
batch,每批图片数 |
16 | 8 | 1、2、4、8、16;-1为框架自动估算 | 显存不足优先降低;自动估算支持取决于设备与模式 |
epochs,训练轮数 |
100 | 40 | 先检查少量轮,再比较20、40等 | 看验证趋势;提前停止可能使实际轮数不足设定值 |
device,计算设备 |
未指定,由环境选择 | 0 | "cpu"、0、其他可用编号 |
编号只在当前可见设备中有效;换设备后速度不能直接归因模型变化 |
workers,数据加载进程数 |
8 | 2 | 0、2、4、8 | 多进程异常先试0;更多进程不保证更快 |
optimizer,优化器 |
auto(自动) | SGD(随机梯度下降) | SGD、AdamW(解耦权重衰减的自适应优化器)、auto | 自动模式可能覆盖学习率和动量选择;本例显式固定便于理解 |
lr0,初始学习率 |
0.01 | 0.005 | 同一优化器下比较0.001、0.005、0.01 | 不稳定可尝试降低;切换优化器时不能机械照搬尺度 |
lrf,末端学习率比例 |
0.01 | 未覆盖,沿用0.01 | 随训练策略对照调整 | 末端目标与初始学习率相乘;不是独立绝对学习率 |
momentum,动量 |
0.937 | 0.9 | SGD下可围绕0.9附近试验 | 利用历史方向;不同优化器含义不同,不与学习率同时大改 |
weight_decay,权重衰减 |
0.0005 | 0.0005 | 如0.0001、0.0005、0.001 | 约束参数,不能代替修复数据泄漏;框架会结合批量和累积缩放实际衰减 |
warmup_epochs,预热轮数 |
3.0 | 未覆盖,沿用3.0 | 0或若干轮 | 预热阶段参数与稳定阶段不同;一轮试跑不足以评价最终收敛 |
patience,提前停止耐心 |
100 | 10 | 10、20等,依据波动与时间 | 连续多轮框架适应度无改善才停止,不能理解为固定少训10轮 |
seed,随机种子 |
0 | 42 | 任何合适固定整数 | 对照实验保持一致,不能保证跨硬件完全一致 |
deterministic,确定性模式 |
True(是) | 未覆盖,沿用True | True或False(否) | 可能影响性能或算子支持;不能单凭该值承诺完全复现 |
amp,自动混合精度 |
True | False | 硬件支持时比较开启 | 可能节省显存,需检查稳定性;训练混合精度与八位量化不同 |
freeze,冻结模块 |
None(不指定) | None | 整数模块数或编号列表 | 冻结减少可更新部分;框架另有专用层处理,None不代表每个参数都必然可训练 |
project、name,输出根目录和名称 |
未指定,由框架组织 | 项目实验根目录、固定实验名 | 新实验换名 | 需同步下游权重路径;本文脚本主动阻止已有同名目录 |
exist_ok,复用已有目录 |
False | False | 新实验通常保持False | 框架可能递增名称;本文在调用前先检查目录以避免误读旧权重 |
save、plots,保存权重和图表 |
True、True | True、True | 保留所需记录 | 不保存会影响后续复现和评估,不要只保留终端截图 |
resume,恢复训练 |
False | 未开启 | 从真实最后检查点恢复 | 恢复与重新加载权重开启新实验不同;见下面说明 |
以上事实以固定配置及训练实现为准。常见尝试点只用于组织对照实验,不是保证适合所有数据的范围。
查看特征层、试跑、重训与恢复(可选)
Backbone(主干特征网络)提取图像特征,Head(检测头)输出位置与类别。冻结模块只是停止部分参数更新,不是重构网络。先查看实际编号,下面是临时观察示例:插在训练脚本的model.info()之后、model.train()之前,保留函数内缩进;看完可删除这两行。
for index, layer in enumerate(model.model.model):
print(index, type(layer).__name__)
将训练调用的freeze=None替换为freeze=2,表示冻结前两个编号模块;替换为freeze=[0, 1]表示明确选择编号。这些仅是参数形式示例,不能在没看网络时认定它们就是整个主干。数据少可比较有限冻结;业务与预训练场景差异大时过度冻结可能限制学习。
通道数、模块重复次数和特征图连接属于网络结构。如果要修改,复制与该权重对应的YAML(结构配置)另存,每次改一个地方,以新结构建立模型,再加载兼容权重并检查匹配日志、输出维度与小批运行。本文主线保留结构,避免把未知架构修改写成通用操作。
只做一轮流程检查时,临时把epochs改为1、实验名称改为warehouse_smoke(试运行);该结果不能用于判断训练充分。确认能扫描数据、训练和保存后,将两处值恢复成主线设置,再运行正式实验。更改新实验名称、输出目录或选择的检查点后,评估默认权重、导出脚本和后续记录路径必须一起检查。
恢复被中断的同一次训练,新建项目根目录的resume_training_demo.py(临时恢复示例),只写下面的内容,加载真实last.pt(最后检查点)并调用恢复接口;不要追加到主线训练脚本后重复启动。需要恢复时才在训练环境运行此文件,完成后可移出项目:
from ultralytics import YOLO
model = YOLO("/home/user/projects/warehouse_vision/runs/warehouse_v1/weights/last.pt")
model.train(resume=True)
恢复时框架会读取检查点参数;先确认原进程已停止,避免双重训练。新调参实验则另起名称、保留旧文件,不把恢复当作任意参数修改工具。
需要断线后继续训练时,可使用服务器已有的tmux(持久终端工具)。新建会话后激活环境、进入项目再运行训练;按Ctrl+B(命令前缀)后松开,再按D(分离会话)。重连后附着原会话,不重复启动:
tmux new -s warehouse_training
tmux attach -t warehouse_training
这两条分别用于新建和重连,不需要在新建会话后立即再附着。
验证参数、指标定义与报告解释
返回生成验证报告。IoU(交并比)等于两个框的交集面积除以并集面积,用于表示重叠程度。它在本文有两种用途:匹配预测与答案,以及删除重复预测。两处阈值不能混同。
TP(真正例)是正确命中;FP(假正例)是多报或错类带来的错误预测;FN(假负例)是未正确识别的真实对象。Precision(精确率)为TP除以TP加FP;Recall(召回率)为TP除以TP加FN。F1(调和平均)综合二者。主线遇到零分母按代码约定处理,并将约定写进报告。
AP(单类别平均精度)来自置信度变化下的检测曲线,mAP是跨类别平均。mAP50使用0.5匹配阈值;mAP50_95再对0.50到0.95、间隔0.05的匹配阈值平均。报告里一行一类的AP不是总体mAP,不能改列名冒充同一个指标。
| 所属函数 | 参数或约定 | 工具默认 | 主线值 | 调整条件、可选值与影响 |
|---|---|---|---|---|
model.val(框架验证) |
conf,候选置信度下限 |
验证模式通常0.001 | 0.001 | 计算曲线保留低分候选;提高下限会截断部分曲线,不是只改变显示 |
model.predict(预测) |
conf,返回候选下限 |
预测模式0.25 | 评估收集阶段0.001 | 业务入口另用0.25;不能直接用业务截断结果复算完整曲线 |
两者的iou |
去重重叠阈值 | 0.7 | 0.45 | 可对照0.45、0.6、0.7;降低更容易删重复框,也可能删掉密集真实目标 |
两者的max_det |
每图最多保留框数 | 300 | 300 | 密集场景要核对上限是否截断;提高会增加后处理与评估开销 |
| 框架验证 | rect,矩形批次处理 |
配置为False,但检测验证调用可覆盖 | 显式False | 为固定640正方形导出保持处理方式;以实际调用和日志为准 |
| 框架验证 | batch、device、workers |
随调用及环境 | 1、处理器、0 | 同格式对比保持一致;不能把设备差异归因量化 |
自定义add_confusion(匹配函数) |
匹配交并比 | 函数默认0.5 | 0.5 | 是判断对应关系,不是去重阈值;改值须更新指标说明 |
| 自定义评估脚本 | F1置信度 | 自定义,无工具默认 | 0.25 | 可比较0.1、0.25、0.5等业务阈值;固定值便于前后对比 |
| 自定义评估脚本 | ROC统计单位 | 自定义,无检测通用默认 | 一张图、一个类别是否存在 | 每类需有正例和负例;不用于解释框定位质量 |
框架默认及版本行为见固定配置与检测指标实现。本文混淆矩阵按几何重叠降序一对一匹配,行是真值、列是预测;背景列表示漏检,背景行表示多报,右下格不是背景真负例。它是本文明确写出的统计约定,可能与框架默认匹配细节不同。
图像级ROC对每张图片判断“是否含某类”,分数取该类返回候选的最高置信度,没候选为0;这个分数受候选下限、去重及框数上限影响。AUC(曲线下面积)缺少正例或负例时无法定义,主线留空并记录状态,不能填0代替。PR曲线则使用检测框架的曲线数据。定义说明见曲线函数。
生成文件的含义:confusion_counts.csv(混淆计数)保存行列标签;class_metrics.csv(各类指标)保存单类AP、固定阈值F1及图像级AUC;summary.json(总体摘要)保存mAP及模型摘要;presence_roc.jpg(图像级曲线)与detection_pr.jpg(检测曲线)各有自己的定义;metric_definitions.txt(口径说明)应随报告保留。
如果新建报告目录已存在,先检查旧报告,另选名称后运行;比较与打包脚本引用的名称也要同步。不要只给文件改一个新名字就认为它来自新模型。
独立推理的预处理、输出与参数
返回编写入口和测试。图片读入后是BGR(蓝绿红)顺序;模型需要RGB(红绿蓝)。主线使用Letterbox(等比例缩放并填充),长边缩放到640附近,用114填充剩余区域,再除以255、改成NCHW(批量、通道、高、宽)。这是预处理协议,不是随意调节的美化参数。
模型输出前三维是[1, 4+类别数, 候选数]:前四项为中心横纵坐标和宽高,其后是类别分数。本例取每个候选的最高分类分数,再按类做NMS(非极大值抑制)。输出不是这种形式时应该停下来核对模型,不能盲目转置或额外乘一个不存在的目标分数。
| 所属函数或入口 | 参数与中文含义 | 默认性质 | 主线值 | 可选值、条件与关联限制 |
|---|---|---|---|---|
preprocess(预处理) |
size,输入边长 |
自定义函数默认640 | 640 | 改尺寸须重导出、重校准、重测;只改调用会触发固定输入错误 |
| 同上 | 填充值、颜色、比例 | 自定义协议 | 114、红绿蓝、除以255 | 不是随机可选项;必须与模型训练导出约定一致 |
decode(解析输出) |
confidence,置信度门槛 |
自定义默认0.25 | 配置0.25 | 更低通常增加召回和误报;修改后重新做业务验收 |
| 同上 | iou,去重阈值 |
自定义默认0.45 | 配置0.45 | 同类过于密集时检查错误抑制;与验证匹配阈值不同 |
| 同上 | 最多输出框数 | 代码约定300 | 300 | 修改需同步其他统计过程,不能只改一处 |
create_session(创建运行会话) |
threads,算子内部线程数 |
自定义默认4;运行库未设置时另有调度策略 | 4 | 比较1、2、4等,受处理器核数和其他负载影响;不是越大越快 |
| 同上 | 算子之间线程数 | 主线显式设置 | 1 | 运行库默认顺序执行模式下,不可把它理解成同时跑多个图片 |
| 同上 | providers,运行后端 |
不依赖自动选择 | CPUExecutionProvider(处理器后端) | 本文不自动回退到显卡;改后端需要匹配依赖与算子支持 |
| 命令行入口 | --model,模型文件 |
自定义默认脚本旁的模型 | 导出阶段显式传路径,部署阶段用默认 | 相对路径按当前终端解析;默认路径按脚本位置解析 |
| 命令行入口 | --config,运行配置 |
自定义默认脚本旁的配置 | 与当前模型配套 | 不要拿另一类别顺序或另一尺寸的配置 |
| 命令行入口 | --input、--output,输入图片与结果文件 |
必填,无自动默认 | 真实图片、指定结果文件 | 输出会写入指定文件;新实验换文件名保留旧结果 |
| 命令行入口 | --visual,预览图路径 |
可选 | 明确指定PNG图片 | 编码固定PNG,文件后缀也用.png |
| 命令行入口 | --repeat,重复测量次数 |
自定义默认1 | 比较时10 | 必须大于0;可增加次数观察稳定性,但重复同图不是完整业务负载 |
主线先预热两次。计时包含预处理、模型运算、后处理,排除加载、读图和写盘;记录的每秒图片数只是同图小实验的倒数延迟,不代表摄像头系统总吞吐。实际服务应使用有代表性的图片集合记录延迟分布。
配置中的names(类别表)、imgsz(尺寸)、confidence(置信度)和nms_iou(去重阈值)被入口读取;其余预处理说明字段主要用于记录协议,并不会自动改写辅助函数实现。改变颜色或布局时要同时改实现并重新验证,不能只改配置文字。
返回结果的box(边界框)是原图像素的左上角与右下角,confidence(置信度)是模型分数,不保证是经过校准的真实概率;class_id(类别编号)与class_name(类别名)必须一致。没有目标返回空列表,是一种合法结果。框架验证可能使用多标签候选,因此主线还要求检查独立入口的真实图片结果,不能仅凭框架mAP证明自写入口正确。
导出参数与模型接口限制
返回浮点导出。Export(导出)改变保存和运行形式;Quantization(量化)改变部分计算的数值表达,二者不是同一动作。本文工具链先导出FP32(三十二位浮点)模型,再在这个格式上量化。
下表属于该版本的model.export(导出函数);核对配置及导出实现。
| 参数与中文含义 | 工具默认或行为 | 主线值 | 选择条件、影响与限制 |
|---|---|---|---|
format,导出格式 |
配置为torchscript(框架脚本格式) | onnx |
格式要被目标运行端支持;改扩展名不是转换 |
imgsz,输入大小 |
配置640,可受模型保存参数影响 | 640 | 主线显式设置;修改时同步全部预处理及固定输入检查 |
batch,批量尺寸 |
导出调用有自己的默认覆盖,不套用训练批量 | 显式1 | 本文只接受单张批量;多张需要重新设计入口 |
dynamic,动态输入维度 |
False | False | True允许部分动态尺寸,但本文固定输入检查会拒绝,不能只改一个开关 |
simplify,模型简化 |
True | False | True依赖对应简化工具;简化后仍需重新比较结果 |
opset,算子规范版本 |
未指定,由导出逻辑决定 | 17 | 不是软件包版本;目标运行库和导出算子必须支持 |
half,半精度导出 |
False | False | FP16(十六位浮点)需要合适硬件;不能用来冒充INT8(八位整数)量化 |
device,导出设备 |
随调用及模型 | "cpu" |
便于建立浮点基线;导出设备不是最终运行后端 |
本文不在图里内置去重,辅助函数做后处理。不同模型版本、分割模型、旋转框模型或内置后处理的图,输出都可能不同。结构检查只证明图满足格式约束,必须再加载、对照图片、比较指标。
量化、校准与调整范围
返回量化实践。Calibration(校准)用代表性图片观察模型数值范围,再选择较低位宽的数值表达。全零图片和随机数组可以检查接口,但不能替代业务校准数据。本文只使用训练集校准,保留验证集用于观察改变后的效果。
以下按ONNX Runtime(模型运行库)1.20.1的quantize_static(静态量化函数)实现核对;方法说明见量化文档。
| 所属函数及参数 | 中文含义 | 1.20.1工具默认 | 主线值 | 可选值、调整条件、影响及限制 |
|---|---|---|---|---|
| 自定义校准读取器 | 图片数量与覆盖 | 无工具默认数量 | 真实训练图清单 | 几十到一两百张仅是小项目起点;先覆盖尺度、类别和光照,再依据误差调整 |
quant_pre_process(量化预处理) |
skip_optimization,跳过图优化 |
False | False | 模型优化不支持时可另开实验比较True,不能悄悄忽略失败 |
quantize_static:quant_format |
量化表示 | QDQ(量化/反量化节点) | QDQ | 另有QOperator(量化算子);运行端支持不同,不能随意互换 |
同上:activation_type |
激活数值类型 | QInt8(有符号八位整数) | QInt8 | QUInt8(无符号八位整数)等需结合后端;换类型重新评估 |
同上:weight_type |
权重数值类型 | QInt8 | QInt8 | 不代表模型每个节点都变成整数 |
同上:per_channel |
按通道量化权重 | False | True | 可对照False;更多缩放参数可能减小部分量化误差,不保证更快 |
同上:calibrate_method |
数值范围估计方法 | MinMax(最小最大值) | MinMax | 可比较Entropy(熵校准)、Percentile(百分位校准);重新校准并保留记录 |
同上:op_types_to_quantize |
要量化的算子类型 | None(按支持算子集合处理) | Conv(卷积)、MatMul(矩阵乘法) | 减少范围可保留敏感部分精度,压缩收益也可能降低 |
同上:nodes_to_exclude |
排除的具体节点 | None | 未指定 | 定位敏感节点后可排除,不要随意猜名字 |
主线不覆盖更多高级量化选项,其行为沿用固定版本;改设置须记录差异。INT8模型在某些处理器上可能变慢,部分算子仍为浮点。是否值得部署,要同时看实际质量、文件大小、延迟和目标后端支持,不靠文件名判定量化成功。
模型输出文件已存在时,先保留旧模型和报告,再为新实验使用新输出路径,并同步验证、比较、封装脚本。不要覆盖权重却保留旧精度记录;脚本用SHA-256(内容摘要)帮助发现这种错配。
封装文件、运行配置与测试范围
返回封装实践。本例入口默认从脚本所在目录读模型和配置,允许调用者从任何目录传入图片。辅助函数、配置和测试文件都属于这个运行包,不是可随意删除的附件。
依赖清单记录实际安装版本,主线使用numpy(数值库)、opencv-python(图像库)和onnxruntime(模型运行库)。不需要在接收端安装整套训练框架。若源环境采用无界面的图像库构建,应按实际包名记录,不能虚构已安装的另一个包。
| 封装配置 | 主线示例 | 默认性质与调整条件 |
|---|---|---|
| 模型源文件 | 八位模型 | 自定义选择;质量不合适时可选择浮点模型 |
| 精度报告 | 八位模型评估摘要 | 改模型必须改对应评估报告 |
| 独立推理报告 | 八位模型运行记录 | 必须与权重内容及配置相同,不能引用另一轮记录 |
| 输出目录 | 空的部署目录 | 脚本拒绝非空目录;先归档旧包,避免混入旧辅助文件 |
| 默认运行配置 | 模型导出时保存的配置 | 类别和输入协议必须与选定模型配套 |
选择浮点部署时,在封装脚本中将模型源文件、精度摘要和独立推理记录一起换为浮点对应产物;测试通过后再生成新包。不是只改模型后缀。接收端安装与文件摘要检查见迁移实践。
测试有不同层次:语法检查只看代码能否解析;test_runtime.py(逻辑测试)只检查人工数值输入下的颜色、空结果、坐标和去重;实际入口运行才加载指定模型;最后还要用独立业务样本判断识别质量。任何一层通过都不自动等于后面几层已通过。
文件传输、分阶段归档和接收端核对
返回上传数据或迁移复测。文件传输客户端的名称和界面可不同,下面描述常见连接字段与操作;不要把示意按钮名当成所有版本都一致的界面。
| 配置字段 | 中文含义 | 默认与主线选择 | 调整条件、影响与限制 |
|---|---|---|---|
| Protocol(协议) | 使用的传输方式 | 主线使用服务器实际开放协议 | SFTP(安全文件传输协议)与FTP(文件传输协议)、FTPS(加密文件传输协议)不同,不能只换端口假装兼容 |
| Host(主机) | 服务器地址 | 环境提供方给定,无文章默认 | 地址不包含你本地磁盘路径 |
| Port(端口) | 服务监听端口 | 协议有常用值,但主线用实际值 | 常见SFTP为22、FTP为21;实际可改,不能自行认定 |
| Username(用户名) | 登录账号 | 实际账号 | 不在脚本和使用说明中保存密码 |
| Remote directory(远端目录) | 接收文件位置 | 项目目录或接收端运行目录 | 确认可写权限;注意是否多套一层同名目录 |
| Transfer mode(传输模式) | 文件字节传输方式 | 优先二进制或确认自动模式不会改文件 | 图片和模型不能做文本换行转换,摘要不一致时优先检查 |
| Overwrite(覆盖) | 同名文件处理 | 新实验用新目录 | 看清正在覆盖什么;目录名一样不代表内容属于同一轮 |
新建连接后填写字段,核对服务器身份提示,进入正确远端目录;从本地面板拖入整个文件夹或使用上传菜单。等待传输队列完成,检查Failed transfers(失败传输)列表,比较文件数量和大小。先回传一对图片标签试开,再批量处理,最后用内容摘要确认字节一致。
SSH连接可用且需要直接命令传输时,可以用SCP(安全复制)作为替代。以下在本地执行,中文占位必须替换,注意复制端口参数是大写-P:
scp -P 实际端口 -r D:\warehouse_vision\curated 实际账号@实际服务器地址:/home/user/projects/warehouse_vision/
scp -P 实际端口 D:\warehouse_vision\classes.json D:\warehouse_vision\curated_groups.json 实际账号@实际服务器地址:/home/user/projects/warehouse_vision/
这只是传输方式替代,不能用来绕过接收端指定目录或权限。
数据整理完成,备份整理图片、原始标注、训练标签、配置、划分和增强记录;训练完成,备份日志、实际参数、最佳与最后权重;评估完成,保留指标、曲线及定义;压缩完成,保留前后模型和比较记录。归档是复制,不移动主线仍在使用的文件。一次实验可以在项目根目录使用下面的日期名称示例,日期自行替换,并确保归档目录此前不存在:
mkdir -p archives
mkdir archives/warehouse_v1_20260918
cp -r curated dataset warehouse.yaml reports exports runs deployment archives/warehouse_v1_20260918/
这是全部阶段完成后的归档示例;某个阶段尚未生成目录时,只复制当时已生成的成果,不把命令报错当成归档成功。敏感业务资料按实际访问权限保管。
SHA-256(内容摘要)检查只验证传输前后字节相同。迁移成功还必须在接收端运行逻辑测试和真实图片入口,并检查结果;文件能下载不代表目标硬件能运行。
从视频抽帧和逐帧检测(可选扩展)
此节不是照片主线的必做步骤。抽帧在本地运行,完成后返回图片整理;逐帧推理在服务器主线模型检查通过后运行。
指定视频、采集组和抽帧间隔
新建项目根目录的extract_frames.py。
from pathlib import Path
import math
import cv2
ROOT = Path(__file__).resolve().parent
video = ROOT / "warehouse_camera.mp4"
group = "session09"
interval_seconds = 3.0
output = ROOT / "raw"
output.mkdir(exist_ok=True)
if list(output.glob(group + "__*")):
raise ValueError("该采集组已有图片,请换名称,避免覆盖")
capture = cv2.VideoCapture(str(video))
fps = capture.get(cv2.CAP_PROP_FPS)
if not capture.isOpened() or not math.isfinite(fps) or fps <= 0:
raise ValueError("视频无法打开或没有有效帧率")
step = max(1, round(fps*interval_seconds))
按间隔写出图片并释放视频资源
在extract_frames.py文件末尾追加,紧接该文件上一个示例。
index, saved = 0, 0
try:
while True:
ok, frame = capture.read()
if not ok:
break
if index % step == 0:
name = output / f"{group}__{index:08d}.png"
if not cv2.imwrite(str(name), frame):
raise RuntimeError("抽帧文件保存失败")
saved += 1
index += 1
finally:
capture.release()
if not saved:
raise ValueError("没有得到可用帧")
print("已保存帧数:", saved)
Set-Location D:\warehouse_vision
python extract_frames.py
修改video(视频路径)、group(采集组名)和interval_seconds(抽帧秒数,例3.0)。它们是自定义示例值,没有工具默认;可从1、3、5秒比较覆盖和重复度。同一视频全部帧仍是一个组。该示例按稳定帧率估算;可变帧率应按时间戳处理。
为整段视频创建一次推理会话
新建项目根目录的detect_video.py。
from pathlib import Path
import json
import cv2
from vision_runtime import preprocess, decode, create_session
ROOT = Path(__file__).resolve().parent
settings = json.loads((ROOT / "exports/runtime.json").read_text(encoding="utf-8"))
session, input_name = create_session(ROOT / "exports/warehouse_int8.onnx", settings["imgsz"])
capture = cv2.VideoCapture(str(ROOT / "warehouse_camera.mp4"))
if not capture.isOpened():
raise ValueError("视频无法打开")
output = (ROOT / "reports/video_detections.jsonl").open("w", encoding="utf-8")
index = 0
逐帧写入检测结果
在detect_video.py文件末尾追加,紧接该文件上一个示例。
try:
while True:
ok, frame = capture.read()
if not ok:
break
tensor, info = preprocess(frame, settings["imgsz"])
values = session.run(None, {input_name: tensor})[0]
detections = decode(values, info, settings["names"], settings["confidence"], settings["nms_iou"])
row = {"frame": index, "detections": detections}
output.write(json.dumps(row, ensure_ascii=False)+"\n")
index += 1
finally:
capture.release()
output.close()
if index == 0:
raise ValueError("没有读取到视频帧")
cd /home/user/projects/warehouse_vision
python detect_video.py
输出为JSONL(每行一条结构化记录)。检查帧号与视频帧数相符,抽取若干帧对照位置。逐帧检测不是对象跟踪,连续帧的同一纸箱不能累加为库存数量。
第三部分:按现象排查问题
先保留现场,再定位出错阶段
记录当前机器、环境名、工作目录、完整命令和错误信息。先找最后一条具体异常,再回查上游文件。一轮只改少数因素,不把重装整个环境作为第一步。失败的真实日志应保留,不能改成预期输出。
环境与代码无法运行
找不到环境管理命令或激活失败。回到环境参考,确认命令确已安装及终端已初始化。新窗口不一定继承上一个窗口的激活状态。
ModuleNotFoundError(找不到模块)。先查正在使用哪个解释器,再用同一解释器安装依赖。服务器执行:
python -c "import sys; print(sys.executable)"
python -m pip --version
python -m pip check
IndentationError(缩进错误)或SyntaxError(语法错误)。回到报错行及上一行,检查是否漏了括号、冒号、引号或缩进。追加代码时不要复制说明文字,不要把函数内代码顶到行首;测试文件的第二个方法仍属于同一个类。
文件不存在。先检查是在本地还是服务器,当前目录是否正确,文件扩展名是否重复。代码中的示例图片名需要替换为真实文件,不能原样照抄一个根本不存在的路径。
图片和标注出问题
标注窗口没有图片。确认选择图片目录而不是只含标注的目录;用系统图片查看器试开;在视图菜单恢复文件列表和画布面板。
下一张仍有上一张的框。检查是否启用保留上一张标注,是否用了带复制行为的快捷键;回到标注参考关闭复用并逐图复核,不要直接保存覆盖。
保存后找不到标注。检查输出目录、同名文件和未保存提示。图片与标注不在一起时检查图片引用;不能只搬标注文件。空目标图片应确认存在合法空标注。
转换提示类别、形状或边界错误。回到原始标注,统一名称,确认是两个角点矩形、图片尺寸一致且框在图内。坐标整体偏移时查方向修正、宽高是否交换、归一化是否用了原图大小。按转换示例手算一张再重绘。
构建目录已存在。这是防混入旧数据的检查。先保存旧数据和记录,再选新实验目录或明确归档旧输出;不要直接删除源图片和标注。整个项目复制到新位置后,先检查数据配置中的绝对根路径。
训练启动不了或效果异常
CUDA out of memory(显卡显存不足)。查看其他进程占用,优先把批量从8降到4、2或1;不要停止不属于自己的进程。确需降低输入尺寸时同步后面的导出、校准和推理配置。更改后记录真实参数。
多进程读取报错。先将加载进程数改为0,并保留主函数入口判断;单进程跑通后再增加。数据损坏时进程数调整不能代替修数据。
损失下降,验证却差。检查标签、类别映射、采集场景差异和每类覆盖,查看漏检与误检图片;再考虑学习率、轮数和增强。参考训练参数,不要一次改所有值。
验证结果好得不合理。查同组相邻帧、重复图或增强副本是否跨数据分区;不能用训练集的预测截图替代独立验证。
断线后不确定训练是否还在跑。先检查原持久终端和进程、日志更新时间,再决定恢复。继续同一次训练与开启新实验的处理不同,见恢复方法。
指标、导出和推理结果不对
曲线图片存在,但某类AUC为空。检查状态列和正负样本数量;无定义不是0分。图像级曲线不评价定位,不能拿它替代检测mAP。参见指标口径。
矩阵与框架图看起来相反。先核对行列方向、背景定义、置信度和匹配规则,再比较数值;不要只为了看起来一致而转置文件。
模型能加载,但框错位或密集满屏。检查颜色、缩放填充、输出布局、类别数、坐标还原,以及是否对已经带后处理的模型再次去重。先运行逻辑测试,再检查真实模型原始输出。未知布局应明确报错,不做任意转置尝试。
浮点导出已经明显退化。先对齐源权重、导出设置和评估路径,确认类别元数据和预处理;修复浮点基线后才量化,不能用量化调参掩盖导出错误。
量化报不支持的算子。保存算子名、版本和后端,核对量化参数。改变量化类型或排除节点后需生成新模型并重算指标,不能静默换回浮点文件却继续写八位结果。
量化后更慢、文件更大或精度下降。这些都可能发生。分别检查硬件支持、图优化、校准覆盖、敏感节点与模型规模。保留真实对比,选择满足业务要求的模型,不伪造压缩收益。
封装和迁移失败
封装时摘要不匹配。模型、精度报告、运行记录或配置被改过;重新评估并运行当前模型,不绕过校验,也不手工改摘要。
换目录后找不到模型或辅助模块。整个运行目录一起复制,核对入口默认路径是否来自脚本位置。仅复制模型不能保留完整推理能力。
目标端摘要不一致。查失败传输、同名覆盖、传输模式和修改时间;重新传输不一致文件,再完整检查。摘要一致后仍需安装依赖、执行逻辑测试和实际推理。
目标环境安装不了固定依赖。查看系统、解释器及处理器架构支持;创建匹配环境,或记录新依赖组合后重新验证。不要宣称源服务器运行成功就等于目标机器可用。
用小规模演练估算成本
第一次操作先用独立的试跑项目完成少量数据、短训练、导出与最终入口,记录每步开始和结束时间。训练先看真实每步耗时,数据生成先看每分钟新增的有效记录,转换先确认模型架构受支持。不要在主项目里把少量试跑产物误当成完整结果。
修复一次错误后,从最近已经独立验证的产物继续;不必重新生成全部数据,也不能复用已经因上游改变而过期的报告。完整训练、模型合并、磁盘复制和传输均需预留时间,软件安装和编译成功不能事先假定。
本文检查范围与资料依据
静态检查包括分段代码拼接语法、终端命令格式、章节锚点、图片路径、文件引用以及主线和参数表的一致性。静态通过不表示模型曾经训练,也不能给出业务识别成绩。
运行验证使用临时合成图片和小型合成网络,检查整理、转换、增强、坐标还原、混淆矩阵、静态量化、逻辑测试以及独立入口生成结果文件的行为。它验证的是这些代码路径,不代表真实仓储检测有效。
本地局部核验使用Windows、Python 3.12、PyTorch 2.5.1、Ultralytics 8.3.40和ONNX Runtime 1.20.1,检查了合成图片、小型合成网络量化、完整指标输出、打包与换目录加载,以及随机检测权重的框架验证分支。没有对真实业务权重完成训练和完整导出评估,没有操作实际标注界面或验收远程服务器;这些结果不代表Ubuntu、Python 3.10环境或业务精度已经通过。真实数据、权重和目标设备仍须按主线复测。
参数依据分别链接在对应参考章节:固定版本的配置与实现优先于不断更新的通用文档。替换版本、模型结构或后端时,重新核对接口与输出形状,不把另一版本的菜单、默认参数或演示结果直接搬过来。
浙公网安备 33010602011771号