☰
YOLO标签可视化与标注质检实战:从坐标换算到批量流水线
2026/10/26 13:16:39 网站建设 项目流程

做目标检测的人几乎都绕不开这么一段经历:标注结果交回来,随手挑一张图配上标签一画,框不是偏了半格,就是整片挤在图像左上角,还有的类别编号对不上,明明标的是“安全帽”,画出来却显示一个 2。这类问题如果不在训练前发现,后面就是几个小时的训练白跑,指标怎么调都上不去。yolo 格式的标签可视化,说白了就是把每张图对应的 txt 里那几行归一化数字,还原成看得见的矩形框、多边形或者关键点,再盖回原图上,用眼睛做一次人肉质检。这件事听起来简单,真正落地的时候坑不少:坐标系是中心点还是左上角、数值是归一化还是像素、类别索引从 0 起还是从 1 起、图片读进来有没有被 EXIF 转过、分割标签的点数对不上、空标签文件要不要跳过。这篇就把我这些年画框、验标注、查数据集的经验整理出来,从一行坐标换算讲到批量流水线,从矩形框讲到分割和关键点,代码可以直接抄,参数怎么定也一并说清楚。适合刚拿到 YOLO 格式数据集准备训练的人,也适合已经跑起来但怀疑标注有问题、想回头做数据体检的人。

1. 先把 YOLO 标签文件里的内容拆开看

1.1 一行五个数,每个数都在说什么

YOLO 检测格式的标签文件是纯文本,一张图对应一个同名 txt,文件里每一行代表一个目标。标准的一行是五个数值,中间用空格隔开:

0 0.481250 0.533333 0.126563 0.244444

依次是类别索引、中心点 x、中心点 y、框宽、框高。这里有两个关键点必须记牢。第一,坐标不是左上角加右下角,而是中心点加宽高,这是 YOLO 系列从一开始就定下的约定,是为了让输出层直接预测偏移量,训练时更稳定。第二,后四个数全部是归一化到 0 到 1 之间的小数,也就是相对于图片宽高的比例,不是像素值。上面那行里的 0.481250 乘以图片宽度,才是真正的像素横坐标。

这两个设计在训练侧很优雅:模型输入会被统一缩放到固定尺寸(比如 640×640),归一化坐标不受原始分辨率影响,同一套标签在 416、640、1280 输入下都能直接用。但在可视化侧就变成了一道必须手动做的换算题,很多人第一次画框画歪,八成就是漏了“乘回图片宽高”这一步,或者把中心点当成了左上角。

1.2 归一化带来的便利和隐藏的陷阱

归一化的好处很直接:标签和图片解耦。你把原图从 1920×1080 缩放成 640×360,标签文件一个字节都不用改。但这份便利也藏了几个陷阱,我在实际项目里都撞过。

第一个陷阱是宽高比失真。如果你的数据准备环节把图片直接 resize 成正方形,而没有做 letterbox 填充,标签仍然按原图归一化保存,那画出来的框在拉伸后的图上是歪的。反过来,如果你在可视化时用cv2.resize把图强行拉成正方形,然后拿原始标签去画,框会沿着一个方向被压扁,看起来“框住了一半”。这不是标签错了,是你的绘制流程和目标检测预处理流程不一致。

第二个陷阱是浮点精度与边界溢出。归一化坐标理论上最大是 1.0,实际标注工具偶尔会输出 1.000001 这种值,乘以宽度后超出图像边界一两个像素,用 OpenCV 画框时不会报错,但框的边会贴着图边看不见。更常见的是小目标的框,宽度只有 0.003,乘以 1280 像素是 3.84 像素,取整之后可能变成 3 像素甚至 2 像素,肉眼看就是一个小点,很容易被误判成“标注错了”。

第三个陷阱是不同工具的归一化基准。绝大多数标注工具按图像实际宽高归一化,但有些工具在图片被旋转(手机拍摄的 JPEG 带 EXIF Orientation)之后,按未旋转的原始尺寸算出标签,PIL 读进来会自动应用 EXIF 旋转,OpenCV 的imread默认不会。同一张图,用两个库读,宽高可能互换,标签就整体错位 90 度。这个坑后面第 5 章会专门讲。

1.3 为什么必须做可视化:三类看不见的标注问题

有人觉得标注文件只要能读、训练能跑,就说明数据没问题。实际情况是,标签格式合法和标注质量合格完全是两件事。可视化能帮你抓到三类问题,这三类问题仅靠看 loss 曲线基本发现不了。

第一类是系统性偏移。比如整个批次的框都往左上偏了大约 5%,原因可能是标注工具在缩放预览图时算错了比例。这种偏差不会让训练崩溃,模型照样收敛,但检测框会稳定地偏一点,mAP 掉几个点,你调学习率、换增强策略都没用。

第二类是类别错标与索引错位。标注规范里“0 是行人、1 是骑行者”,结果某个标注员从 1 开始数,整批数据错位一格。训练出来的模型会把行人判成骑行者,混淆矩阵上两个类互相污染得很严重,但你在损失值上完全看不出来。

第三类是冗余框与漏标。同一辆车被画了两个几乎重合的框,或者一张图里明显有五个目标只标了两个。重复框会让模型学到模糊的回归目标,漏标则变成“把正样本当背景”,是检测任务里最伤模型的一类噪声。这类问题唯一的发现手段就是把框画回去看,批量扫一遍,几十张图里总能揪出问题样本。

2. 工具选型:从二十行脚本到现成库

2.1 三种常见方案的实际对比

可视化这件事,从完全手写到调用现成接口,中间有好几个档位。我把常用的几种方案列个表,按自己的使用场景挑就行。

方案依赖上手成本灵活度适合场景
OpenCV 手写绘制opencv-python、numpy低,二十行代码极高,想加什么加什么日常验标注、定制输出、批量流水线
Pillow 绘制Pillow低高,字体渲染更漂亮需要中文标签、出报告图
matplotlib 叠加matplotlib中中,适合做统计图论文配图、分布分析
检测框架自带的绘制接口对应框架很低低,样式固定快速看一眼,不想写代码
通用可视化工具库额外第三方库中中需要统一风格、画关键点和轨迹

我自己的习惯是:验标注用 OpenCV 手写,出材料用 Pillow,做统计分析用 matplotlib。框架自带的绘制接口确实省事,一行就能出图,但它的样式(颜色、线宽、字体)基本写死,标签多的时候文字互相遮挡,而且它内部往往还会做一次图像缩放,你很难判断看到的结果和原始标签是否严格对应。做数据体检这种需要“眼见为实”的事情,我宁可自己写二十行,可控性高得多。

2.2 什么时候该自己写,什么时候别重复造轮子

判断标准其实很简单:你要不要控制输出的每一个像素。如果只是想知道“这批标注大概长什么样”,用现成接口完全够,几分钟出结果。但如果你要做的是:

  • 批量生成几千张带框图,压缩打包给标注团队返工;
  • 把类别名、置信度、目标尺寸一起写在框上;
  • 按类别过滤,只画某一类看分布;
  • 输出带框视频做演示;

那就自己写。核心逻辑无非三步:读标签、换算坐标、画。真正花时间的从来不是绘制本身,而是文件路径匹配、异常行容错、类别名映射这些边角料,这些恰恰是现成接口不管的部分。

2.3 环境与依赖准备

基础环境很轻,三条命令搞定:

pip install opencv-python numpy pyyaml # 需要漂亮中文字体渲染时再加 pip install pillow

pyyaml是用来读data.yaml的,这个文件里存着类别名列表,是可视化时把数字翻译成人话的唯一依据。很多数据集会把类别名写在classes.txt里,一行一个,顺序即索引,那就更简单,直接读文本。

注意:opencv-python和opencv-python-headless不要同时装,后者没有 GUI 但适合服务器批量跑。如果脚本要在无显示器的机器上跑,务必用 headless 版本,并且全程不要调用cv2.imshow,一律cv2.imwrite落盘。

3. 坐标换算与绘制逻辑的核心细节

3.1 从归一化中心宽高还原到像素左上右下

这是整件事的地基,我把它单独抽成函数,所有脚本都调它,避免每处各写一遍导致不一致。换算分两步:先乘回像素,再从中点展开成左上右下。

def yolo_to_xyxy(xc, yc, w, h, img_w, img_h): """归一化中心宽高 -> 像素左上右下""" x1 = (xc - w / 2.0) * img_w y1 = (yc - h / 2.0) * img_h x2 = (xc + w / 2.0) * img_w y2 = (yc + h / 2.0) * img_h return int(round(x1)), int(round(y1)), int(round(x2)), int(round(y2))

写法上有几个细节值得说。先减后乘还是先乘后减,结果一样,但先算(xc - w/2)再乘宽度,浮点误差更小,也更好读。用round而不是int,int是截断,会让所有框系统性地往左上偏半像素,小目标上这个偏差肉眼可见。最后一定要做边界夹取,把坐标限制在[0, img_w-1]和[0, img_h-1]范围内,否则 OpenCV 画线时超出部分会直接裁掉,看起来像框被切了一角。

那要不要顺手算出框的面积和长宽比?建议要。验标注的时候,我经常在框上同时打印w×h的像素尺寸,一眼就能看出哪些是小到没法训练的目标(比如 8×6 像素),哪些是几乎覆盖整图的大框(往往是标注员误把背景框进去了)。

3.2 颜色调度、线宽和文字遮挡的处理

颜色这块,千万别用随机色。同一张图刷新两次颜色不一样,做前后对比时会怀疑自己看错了;不同图之间颜色也不统一,回头看根本记不住哪个颜色对应哪类。正确做法是按类别索引取固定调色板:

PALETTE = [ (56, 56, 255), (151, 157, 255), (31, 112, 255), (29, 178, 255), (49, 114, 49), (0, 165, 255), (255, 128, 0), (128, 0, 128), (0, 200, 200), (200, 0, 100), ] def color_of(cls_id): return PALETTE[cls_id % len(PALETTE)]

注意这里给的是BGR 顺序,因为 OpenCV 内部就是 BGR,直接用 RGB 值喂进去,红色和蓝色会对调,看着也别扭但不容易意识到错在哪。调色板选色时尽量拉开色相差距,避免相邻两个类颜色接近——类别一多,10 个颜色必然不够分,取模复用是可以接受的妥协,但相邻索引一定要不同色。

线宽按图片尺寸动态定,公式大概是max(1, round(min(img_w, img_h) / 400))。1920 宽的图算出来 4~5 像素,640 宽的图算出来 1~2 像素,视觉上粗细一致。写死线宽 2 的话,大图上细得像头发丝,小图上粗得盖住目标。

文字标签最容易被忽略的是遮挡。默认往框上方写,框贴着图顶的时候文字就飞到画面外了。我的处理是:文字基线 y 坐标取max(y1 - 6, 14),如果框顶部空间不够就改写到框内部顶端。再给文字加一层深色实心底衬,用cv2.rectangle填一个矩形再写字,比加轮廓线可读性好得多,尤其是浅色背景图。

3.3 一个可直接复用的最小可视化函数

把上面这些揉成一个函数,日常验标注直接调它:

import cv2 def load_classes(yaml_path=None, txt_path=None): if txt_path: with open(txt_path, "r", encoding="utf-8") as f: return [line.strip() for line in f if line.strip()] if yaml_path: import yaml with open(yaml_path, "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) names = cfg.get("names", []) if isinstance(names, dict): # 有些 yaml 写成 {0: 'a', 1: 'b'} return [names[k] for k in sorted(names)] return list(names) return [] def draw_one(img_path, lbl_path, classes, save_path, min_vis=2): img = cv2.imread(str(img_path)) if img is None: print(f"[skip] 读不到图: {img_path}") return False H, W = img.shape[:2] lw = max(1, round(min(W, H) / 400)) fs = max(0.4, min(W, H) / 1200) n = 0 if lbl_path.exists(): with open(lbl_path, "r", encoding="utf-8") as f: for lineno, line in enumerate(f, 1): parts = line.strip().split() if len(parts) < 5: if line.strip(): print(f"[warn] {lbl_path.name} 第{lineno}行字段不足: {line.strip()}") continue try: c = int(float(parts[0])) xc, yc, w, h = map(float, parts[1:5]) except ValueError: print(f"[warn] {lbl_path.name} 第{lineno}行解析失败") continue x1, y1, x2, y2 = yolo_to_xyxy(xc, yc, w, h, W, H) if x2 - x1 < min_vis or y2 - y1 < min_vis: pass # 仍然画,但心里有数:这是极小目标 x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(W - 1, x2), min(H - 1, y2) col = color_of(c) cv2.rectangle(img, (x1, y1), (x2, y2), col, lw) name = classes[c] if 0 <= c < len(classes) else f"id_{c}" text = f"{name} {x2 - x1}x{y2 - y1}" (tw, th), base = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, fs, lw) ty = y1 - 6 if y1 - th - 6 > 0 else y1 + th + 6 cv2.rectangle(img, (x1, ty - th - 3), (x1 + tw + 4, ty + base), col, -1) cv2.putText(img, text, (x1 + 2, ty), cv2.FONT_HERSHEY_SIMPLEX, fs, (255, 255, 255), lw, cv2.LINE_AA) n += 1 save_path.parent.mkdir(parents=True, exist_ok=True) cv2.imwrite(str(save_path), img) return n

这个函数里我刻意做了两件事。一是所有异常行都跳过但不静默,打印文件名和行号,方便回头批量修;二是空标签文件也照样输出原图,因为空标签意味着这张图是负样本(纯背景),负样本在检测任务里是必要的,不能因为没框就跳过——恰恰相反,你要通过看图确认它确实没有目标,而不是标注员偷懒忘了标。

4. 从单张到整批:搭一条可视化流水线

4.1 单张快速验框:先确认坐标逻辑是对的

拿到新数据集,第一件事永远是挑一张图、画一遍、看一眼。别急着批量跑,坐标系搞错方向的话,批量跑完几千张再发现问题,返工成本高。

挑图有个技巧:不要挑最典型的那张,挑目标最多、目标最小、有大目标贴边的那张。目标多的能暴露遮挡和文字重叠问题,目标小的能暴露取整误差,贴边的能暴露边界夹取。一张图三种情况都覆盖,基本等于把绘制逻辑跑了一遍全路径测试。

画的时候顺手在控制台打印几行信息:图片实际宽高、标签行数、每个框的像素尺寸。这三个数和图对不上的时候,问题基本一眼就定位了。

img_path = Path("dataset/images/val/sample_001.jpg") lbl_path = Path("dataset/labels/val/sample_001.txt") classes = load_classes(txt_path="dataset/classes.txt") print("classes =", classes) n = draw_one(img_path, lbl_path, classes, Path("out/sample_001_vis.jpg")) print("drawn boxes:", n)

drawn boxes这个数字很关键。标签文件里有 7 行,打印出来是 6,说明有一行被跳过了,回去看 warn 日志;打印出来是 7 但图上只数出 5 个,说明有两个框完全重合或者跑到了画面外。

4.2 批量生成与目录规划

批量跑的逻辑不复杂,难点在图片和标签的配对。YOLO 数据集的标准结构是图片和标签分开放,目录名一一对应:

dataset/ ├── images/ │ ├── train/ │ │ ├── a.jpg │ │ └── b.png │ └── val/ └── labels/ ├── train/ │ ├── a.txt │ └── b.txt └── val/

配对规则是:把图片路径里的images段替换成labels,后缀换成.txt。这个规则看起来土,但比任何花哨的匹配都可靠,因为它是官方约定的结构。实现上我用Path.relative_to保结构,避免同名图片在不同子目录里撞车:

def pair_label(img_path, img_root, lbl_root): rel = img_path.relative_to(img_root) return lbl_root / rel.with_suffix(".txt")
from pathlib import Path def batch_visualize(img_dir, lbl_dir, classes, out_dir, exts=(".jpg", ".jpeg", ".png", ".bmp")): img_dir, lbl_dir, out_dir = Path(img_dir), Path(lbl_dir), Path(out_dir) imgs = [p for p in img_dir.rglob("*") if p.suffix.lower() in exts] print(f"found {len(imgs)} images") miss, empty, total = 0, 0, 0 for p in imgs: lbl = pair_label(p, img_dir, lbl_dir) if not lbl.exists(): miss += 1 print(f"[no-label] {p.name}") elif lbl.stat().st_size == 0: empty += 1 rel = p.relative_to(img_dir) draw_one(p, lbl, classes, out_dir / rel.with_suffix(".jpg")) total += 1 print(f"done={total} missing_label={miss} empty_label={empty}")

这里输出的三个统计量,其实已经是一次小规模的数据体检了。missing_label大于 0,说明有一批图没标注;empty_label占比过高,要想想是不是某批数据批量漏标;两个都为 0 但图里明显有目标没框,那就是标注质量问题,得进人工复核。

目录规划上,我习惯把输出镜像原目录结构(out/train/xxx.jpg),这样返回去和原图对照时路径完全对应,不用记忆映射关系。文件名后缀统一成.jpg也行,但要注意原图是 PNG 带透明通道的时候,cv2.IMREAD_COLOR读进来会丢掉 alpha,背景变黑,框在图上看不清,这种情况老实实保持原后缀。

4.3 类别名对不上时怎么办

data.yaml里的names写法有好几种变体,实际项目里我见过至少四种:列表、字典、带nc字段的、以及干脆没有 yaml 只有classes.txt。加载逻辑必须做兼容,否则一个KeyError就把整个批处理卡住。

names = cfg.get("names") if names is None and "nc" in cfg: names = [f"class_{i}" for i in range(int(cfg["nc"]))]

类别名缺失时,退化成class_0、class_1这样的占位名,比直接崩掉强得多。还有一种情况是标签里的类别索引超出了类别表长度,比如只有 5 个类,标签里出现了 7。这通常是两批数据集合并时索引没统一,绘制时用id_7兜底显示,比抛异常好,至少你能从图上看出哪些样本是脏数据。

4.4 分割标签和关键点标签的画法不一样

YOLO 分割格式一行是类别 + 一串 x y 坐标对,全部归一化,点数不固定:

0 0.312 0.441 0.338 0.462 0.371 0.498 0.355 0.521

画法是把坐标两两一组还原成像素,组装成numpy数组,然后cv2.polylines或cv2.fillPoly。关键是点数必须是偶数,奇数说明有一行被截断了,直接跳过:

pts = list(map(float, parts[1:])) if len(pts) % 2 != 0: continue arr = np.array(pts, dtype=np.float32).reshape(-1, 2) arr[:, 0] *= W arr[:, 1] *= H poly = arr.astype(np.int32).reshape((-1, 1, 2)) cv2.polylines(img, [poly], isClosed=True, color=col, thickness=lw)

想做半透明填充看覆盖效果,就复制一份图用fillPoly填色,再cv2.addWeighted叠回去,透明度取 0.3 左右,既能看清轮廓又不遮住底下的目标细节。

关键点格式更复杂一些,一行通常是类别 + 框的四个数 + N 组(x, y, 可见性):

0 0.52 0.61 0.18 0.42 0.501 0.503 2 0.512 0.548 2 0.498 0.601 1

那个可见性标志(0 未标注、1 被遮挡、2 清晰可见)一定要区别对待:值为 0 的点不要画,否则图上一堆点堆在原点位置,看着像标签全错了。我一般用不同形状区分——清晰点画实心圆,遮挡点画空心圆,颜色统一跟着类别走。

5. 常见问题速查与排查实录

5.1 框全挤在左上角或者整体缩小成一团

这是最高频的现象,几乎每个新手都会遇到一次。原因基本锁定在两类:坐标没乘回图片尺寸,或者标签本身是像素坐标却被当成归一化处理。

前者的表现是所有框集中在一个很小的区域,因为 0.5 这样的归一化值没乘宽高,直接当作像素用了,1280 宽的图上坐标 0.5 就落在左上角。后者的表现完全相反,框会大得离谱、超出画面,因为像素值 640 被当成了 0.64,乘回宽度后是 800 多像素,位置全跑偏。

排查方法很直接:打印一行标签的原始数值,看一眼四个数字是不是都在 0 到 1 之间。如果出现了 300、680 这种数,那就是像素坐标格式(有些转换脚本会把 VOC 转成这种),需要在换算函数里改成直接取左上右下,而不是中心宽高归一化。

5.2 框整体偏移或者转了 90 度

偏移通常是 EXIF 惹的祸。手机拍的图经常带旋转标记,标注工具按“显示方向”标了框,但你用 OpenCV 读进来是“存储方向”,两者差 90 度或 180 度。判断方法:看图片实际宽高和你的预期是否一致,一张竖拍的图读出来是 1280×720 还是 720×1280,一眼就能看出来。

稳妥的解法是固定用同一个库读图和取尺寸,并且在读图前统一把 EXIF 清掉、物理旋转过去。批量预处理时做一次这个动作,后续所有环节都用同一份规整过的图,能省掉大量玄学问题。

from PIL import Image, ImageOps def read_image_safe(path): with Image.open(path) as im: im = ImageOps.exif_transpose(im).convert("RGB") return cv2.cvtColor(np.array(im), cv2.COLOR_RGB2BGR)

还有一种偏移是letterbox 导致的比例错位。训练时图片会被等比缩放后填充到 640×640,两侧有灰边;而你可视化时直接按原始尺寸画,框看着是对的,但和模型看到的输入对不上,就会误判“模型检测偏了”。遇到这种比较,必须把 letterbox 的缩放比和填充偏移量带进换算里。

5.3 类别名显示成数字、显示成乱码或者文字重叠

显示数字说明类别表没加载成功,或者索引超范围走了兜底分支。先确认classes列表长度和标签里的最大索引是否匹配,再看 yaml 的names是不是字典格式被当列表用了。

显示成问号方块是字体问题。OpenCV 的putText只支持 ASCII,中文标签必然是问号。要标中文就换 Pillow 渲染,指定一个支持中文的字体文件路径,注意字号要按图片尺寸换算,别在小图上用 30 号字糊成一片。

文字重叠没有完美解,只有几个实用的缓解手段:一是文字只写类别名和序号,尺寸信息只在控制台输出;二是同一位置检测到已有文字占位就往上平移一行;三是类别多的时候干脆关闭文字,用颜色图例代替,另出一张图例说明图。我一般选第三种,尤其是做给非技术同事看的材料时,干净比信息全更重要。

5.4 一张表覆盖大部分排查场景

现象最可能的原因处理方式
框挤在左上角一小片归一化坐标当像素用了换算时乘回图片宽高
框巨大超出画面像素坐标当归一化用了改换算分支或重新导出标签
框整体偏半格用了 int 截断而非四舍五入改用 round 并夹取边界
框转了 90 度EXIF 方向未处理读图时统一 exif_transpose
框沿一个方向被压扁图片被非等比 resize用 letterbox,或按原比例画
图上框数量少于标签行数有解析失败的行看 warn 日志定位行号
图上框数量多于标签行数标签重复行或完全重合框去重后再画,或加偏移错开
类别名全是问号字体不支持该文字换 Pillow + 中文字体
所有图框都对但训练效果差漏标、错标、重复标抽查 + 统计框数分布
部分图黑底看不清框PNG 透明通道被丢弃保持原后缀,读 PNG 时注意 alpha

5.5 几个不写在文档里但很省时间的习惯

给输出图加文件名水印。批量跑几千张,返回去找原图时全靠文件名,把文件名直接写在图的角落(小字号、半透明),后续沟通时截图就能定位,省掉大量“你说的哪张图”的来回。

控制台一定要打印统计汇总,而不只是逐条日志。总数、缺标签数、空标签数、解析失败行数、每类目标数,五个数字一屏看完,比翻 200 行滚动日志有效得多。

采样抽检,不要全量看。几千张图全看完不现实。按类别分层抽样,每类抽 20 张,再额外把所有“框数特别多”和“框数特别少”的极端样本抽出来看。异常样本往往集中在长尾上,均匀随机抽反而容易漏。

保存一份原始标签的副本。清理重复框、修正越界坐标这类操作会改标签,改之前一定先复制一份。我吃过一次亏,批量去重脚本跑完发现阈值设错了,把正常的小目标框也当成重复框删了,只能从备份恢复。

注意:任何会修改标签文件的批量脚本,先在 100 张的临时目录上跑一遍,把它输出的图和原图并排看,确认没误伤,再上全量。

6. 让可视化产出更多价值

6.1 顺手把类别分布和框尺寸分布统计出来

画框的同时,那几行数字其实已经被解析出来了,多存一份统计结果几乎零成本。类别分布能立刻暴露出长尾问题:某一类占了 60% 的框,另一类只有 30 个,后者基本训不好,要么补充数据,要么在采样和损失加权上做文章。

框尺寸分布更值得看。把所有框的像素宽度排序,取 5%、50%、95% 分位数,你会很直观地知道这个数据集“难在哪”。如果 5% 分位数只有 6 个像素,说明大量目标小于模型的检测下限,得考虑切图推理、提高输入分辨率,或者干脆放弃这些样本。如果 95% 分位数接近整图宽度的 80%,那基本是标注错误,一个目标不可能占满画面还叫“目标”。

长宽比分布同样有用。同一个类别里长宽比从 0.2 横跨到 5.0,说明标注粒度不一致,有人在标整辆车,有人在标车的局部,这种数据会让回归头学得很混乱。

6.2 并排对比:原图、标注、模型预测三栏合一看

只画标注只能看标注质量,把模型预测叠上去,才是完整的问题定位。我习惯出一张三联图:左栏原图、中栏标注框、右栏预测框。看一眼就能区分是数据问题还是模型问题:

  • 标注框错位、预测框也跟着错位,那是数据的问题;
  • 标注框是对的、预测框偏了,那是模型或后处理的问题;
  • 标注框漏标了目标、预测框标出来了,说明模型在这个样本上比标注强,属于“被噪声拖累”的情况;
  • 预测框一大堆重叠,召回看着高但精度差,回头调 NMS 阈值。

用np.hstack横向拼三张同尺寸的图,输出成一张对比图,做周报和复盘材料特别省事。注意拼接前要把三张图统一到同一尺寸,不然hstack会直接报错。

6.3 输出带框视频做动态预览

静态图看久了会疲劳,做成视频能抓住一些时间维度上的问题:连续帧里同一目标框时有时无(标注漏帧),或者框在帧间跳动明显(人工标注手抖)。做法和图片完全一样,只是把imread换成VideoCapture逐帧读,标签按帧号匹配文件名(frame_000123.jpg对应frame_000123.txt),再VideoWriter写出去。

编码器选mp4v兼容性最好,但注意帧率要和原视频一致,否则预览看起来快进或慢放,容易误判“框漂移”。曾经有同事因为帧率设错,看着框一跳一跳的,怀疑标注质量有问题,实际是预览视频本身 30 帧写成了 15 帧。

6.4 可视化脚本本身也该进版本管理

最后提一件容易被忽略的事:可视化脚本不要随手写在临时文件里,用完就丢。它其实是数据质检流程的一部分,长期会用,而且会被不同的人复用。我现在的习惯是把load_classes、yolo_to_xyxy、draw_one、batch_visualize这几个函数放在一个固定的小模块里,参数用命令行传进去,参数默认值写死在代码里,谁拿到都能直接跑。这样换一批数据时,只需要改三个路径参数,几分钟就能出一轮体检结果。

我在实际使用中体会最深的一点是:可视化真正省下的时间不在画图这一步,而在它把问题暴露在了训练之前。以前是训完看指标不对,再回头一行行查标签,一轮排查两三天;现在跑一次批量可视化,十几分钟就能定位到具体的类别和样本,返工范围小得多。踩过几次“训练白跑”的坑之后,我现在的固定流程是:拿到任何一批新标注,先做可视化抽样,确认无误再进训练。这个习惯本身比任何脚本都值钱。

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

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

立即咨询