1. 为什么先读这个子模块:一个被大多数人忽略的“最后三公里”
刚开始接触某个视觉检测框架的源码时,我和大多数人的习惯一样,一头扎进模型结构、损失函数、数据增强这些“核心地带”。直到有一天,我需要把一批验证集的检测结果批量导出成带标注的图片,才发现自己连plotting.py里有哪些工具函数都说不清——现搜现用,磕磕绊绊,白白浪费了一个下午。
那次之后我彻底意识到一个事:一个框架的“好用”程度,往往不取决于它的模型有多强,而取决于它把结果“翻译”成人能看懂的东西时有多顺手。对普通用户来说,训练曲线、混淆矩阵、带框的可视化图,就是他们感知模型效果的“最后三公里”。plotting.py这个指标评估子模块,承担的就是这“最后三公里”的运输任务。
这篇文章就把我读plotting.py的过程、关键函数的设计思路、以及实际调用时踩过的坑完整捋一遍。整体基于常见的框架版本代码结构展开,不同版本间函数名和签名可能有微调,但核心设计思想是通用的。适合想把框架吃透、想自己写可视化工具、或者想改框架源码做定制化输出的同学参考。
这个模块的一大特点是:它几乎没有复杂的算法逻辑,全是“把数据变成图形元素”的脏活累活。但恰恰是这些脏活累活,最能体现一个框架对用户体验的考量。读懂了它,你以后自己写训练日志可视化、写自定义评估报告时,会省掉大量重复造轮子的时间。
2. 模块定位与整体设计思路:为什么可视化代码要单独成模块
2.1 模块在整个框架中的位置
先说结论:plotting.py不是核心训练链路的一环,而是服务于评估阶段和调试阶段的外围工具集合。它做的事可以归纳为三类:
- 把模型预测结果(坐标框、类别、置信度)画到原图上,供人眼检查。
- 把训练过程指标(损失、mAP、学习率等)绘制成曲线,供趋势分析。
- 把评估结果(混淆矩阵、F1曲线、PR曲线等)渲染成规范图表,供论文或报告使用。
在框架的目录结构里,它通常挂在ultralytics/utils/下,和loss.py、metrics.py、tal.py这些评估相关模块平级。从依赖关系看,它不依赖模型定义,但 metrics 模块算出的数据会交给它来呈现,所以它和metrics.py的耦合比较紧密。
2.2 设计上最值得借鉴的一点:把“画图”和“数据处理”解耦
第一次通读这个模块时,我最大的感受是:作者刻意把“计算结果”和“如何呈现”分开了。
比如,混淆矩阵的数值计算在metrics.py里完成,但矩阵的绘图和颜色映射逻辑放在plotting.py里。这样做的直接好处是:如果你想换一套视觉风格,完全不用动计算逻辑;如果你想在计算过程中加一个指标,也不用关心它最后长什么样。
这个设计思路放到自己的工作里同样适用。我以前写训练评估脚本时,总喜欢在训练循环里直接调 matplotlib 画图,结果每次想调整字体、配色、保存格式,都要在训练代码里翻来翻去。后来参照这个模块的做法,把“画图”全部抽成独立函数,输入统一为数据对象,输出统一为图片文件或 ndarray,代码清爽了一截。
2.3 模块内部有哪些“角色”
通读下来,可以把类和方法归成几组:
| 角色分组 | 代表性内容 | 作用 |
|---|---|---|
| 颜色与样式管理 | Colors类 | 维护并分配边框、标签、曲线的颜色,保证多次调用颜色一致 |
| 基础绘布 | Annotator类 | 封装图片级绘制操作,如画框、写字、画点、画箭头 |
| 曲线绘制 | plot_results、plot_tune_results等 | 从训练 results 文件读取数据并绘制损失与指标曲线 |
| 评估图表 | plot_pr_curve、plot_mconf、plot_metric等 | 渲染 PR 曲线、置信度分布、自定义指标图 |
| 辅助功能 | feature_visualization、save_one_dir等 | 特征图可视化、单帧结果保存等扩展能力 |
这几组角色各司其职,也正是这篇分析文章的整体线索。下面我逐一展开讲。
3. 关键类与核心方法拆解:从Colors到Annotator
3.1Colors类:一套低调却极为重要的颜色系统
Colors类的核心作用是为画框、画点、画文字提供统一的颜色来源。它内部维护了一组固定的调色板,通过索引取色,保证同一类别在不同图片上颜色一致,这是人眼评估模型时的一个基本需求。
实际调用时,最常用的方法是__call__,传入idx和bgr参数:
from ultralytics.utils.plotting import Colors colors = Colors() # 取第 0 号颜色,返回 BGR 格式元组,供 OpenCV 直接使用 col = colors(idx=0, bgr=True)关于这个类,有几个设计细节值得注意:
- 为什么用 BGR 而不是 RGB:因为底层绘图用的是 OpenCV,OpenCV 的默认通道顺序是 BGR。如果直接返回 RGB,画上去的颜色会整体偏蓝偏黄,肉眼可见的“违和”。这个细节提醒我们:与 OpenCV 配合的工具函数,通道顺序要心里有数。
- 颜色索引越界处理:当类别数超过调色板长度时,类内部会循环取色。我数过,默认调色板大约包含 60 个颜色,实际应用中类别超过 60 的数据集不多;但如果你的自定义数据集有 100 类,那就得留意同色问题。
- 对自定义调色板的开放度:类支持传入自定义
names或修改色板长度来扩展。我没有改过默认色板,但尝试过传入另一组工业界常用的高对比色板,效果立竿见影,尤其在类别多、框密集的检测结果上区分度改善明显。
3.2Annotator类:真正干活的画布工具
如果要评一个“全模块最常用类”,Annotator排第二,没人敢排第一。它的实例化方式很简单:
from ultralytics.utils.plotting import Annotator # img 是 numpy 数组(BGR 格式) annotator = Annotator(img, line_width=2, font_size=0.5, pil=False)实例化时,它会自动完成两件小事:检查图片尺寸是否适合显示,必要时做缩放;初始化字体对象(默认是 OpenCV 的HersheyFonts,但如果pil=True,会用 PIL 的字体渲染,效果更细腻)。
初始化之后,Annotator提供了一组方法,我按使用频率列一下:
box_label(xyxy, label, color, txt_color):画一个检测框,并在框的左上方或内部写类别和置信度文本。这是评估时最常用的方法。rectangle(xyxy, fill=None, outline=None, width=1):只画矩形,不写字。适用于画感兴趣区域、画锚框、画 ROI。text(xy, text, txt_color, anchor):在任意位置写字。可用于在图片上标序号、标样本ID。circle(xy, radius=5, color=(255,255,255)):画圆点标注。polyline(xy1, xy2, ..., color):画折线。通常用于画出道路边界、车道线、或者运动轨迹。draw_arrow(x1,y1,x2,y2):画带箭头的线。我常用于标注预测方向或特征流向。
这里有一个踩坑回忆:早期我直接调用box_label,没有传txt_color,结果在某些暗色图片上标签文字完全看不清。后来查源码发现,box_label内部会做一次颜色亮度判断——如果框色偏亮,文字会自动用黑色;如果框色偏暗,文字用白色。这个设计很贴心,但如果你的自定义颜色比较“中间态”,判断可能不够理想,最好手动指定txt_color。
3.3 核心绘制流程的底层逻辑:为什么box_label的效果比“自己画框再写字”好
很多人拿Annotator只当工具调用,但其实box_label内部隐藏了一个值得学习的绘制流程:
- 先把输入的 xyxy 坐标从模型输出空间映射回原图坐标空间(如果原图被缩放)。
- 在框的左上角预留文本高度,若框的上边界超出图片区域,则把文本放到框内部。
- 调用
rectangle画框线,用传入的color描边。 - 计算文本框的背景色与文字颜色,用
cv2.rectangle填充小矩形,再调用cv2.putText写文字。 - 如果传入的是
PIL模式,则相应地使用 PIL 的ImageDraw与ImageFont完成绘制。
这个顺序大体上就是“先画框、再补文字背景、再写字”。从效果端看,box_label画出的标签会自动避开图片边缘,文字不会溢出画面。这是它比我“在脚本里直接写两个cv2.rectangle再putText”好用得多的重要原因。
4. 评估图表绘制函数逐组拆解:从plot_results到混淆矩阵
4.1plot_results:一条曲线背后的文件解析与多子图布局
训练过程中,框架会把每个 epoch 的指标写入一个results.csv文件。plot_results的作用就是读取这个文件,把里面的 metrics 全部画成曲线图并保存为results.png。
这个函数的实现套路值得细读,我把它拆成几步:
- 第一步,通过
Path构造结果文件路径,检查文件是否存在。若不存在,直接返回空串,不报错。 - 第二步,调用
pd.read_csv读取 CSV。它会跳过空行,并把列名映射成标准化指标名。 - 第三步,逐列绘图,每个子图对应一个指标。布局上通常每行 2-3 个子图,根据总指标数动态计算行数。
- 第四步,对每个子图设置标题、坐标轴标签、网格线,最后整体调整布局并保存。
实际用下来,plot_results有一个需要留意的小细节:它读的 CSV 列名是固定的一套(比如train/box_loss、metrics/precision等),如果你用自定义训练循环,并且保存的列名不一样,直接调用这个函数是画不出完整曲线的。为此,我在自己项目里做了一层薄封装,先把训练日志转成标准列名的 DataFrame,再喂给它。
另外一个高频需求是“只画某几个指标”。我平时会直接构造一个子集 DataFrame,调用内部绘制逻辑,而不是改框架源码。
4.2plot_tune_results:超参搜索时用得到的分数分布图
超参调优(tune)是很多人忽略的环节,但一旦涉及,就需要一种快速观察“哪个超参组合更优”的图。plot_tune_results做的事正是这个:它读取调优过程中保存的 CSV 数据,把所有候选结果按得分排序,然后绘制出类似“分数分布 + 最佳值标注”的图。
画这类图时,比较关键的是排序方向:分数越高越好,那就降序排列;越低越好,则升序排列。框架在这个函数里默认按“得分(score)”列排序,逻辑上对应越高越好这个常见约定。
我自己在这个函数基础上改过一版,用来把每个超参数组合的 mAP 值高亮出来,效果比直接看终端日志直观太多。如果你做过超参网格搜索,你会明白一图胜千言。
4.3plot_metric与plot_pr_curve:评估报告中最高频的两类图
plot_metric是一个通用的单指标曲线绘制函数。它接收metric数据、y_label、x_label等参数,画出带网格的曲线,并保留一个可选的best_fit拟合线。我在芯片部署前的量化评估里,常用它画不同阈值下 mAP 的变化曲线,非常顺手。plot_pr_curve则是 PR 曲线的专用绘制函数。它会读取每个类别的 precision-recall 数据,画出全类别的 PR 曲线,并在图上标出 AP 值。
plot_pr_curve内部做得比较好的地方是:它会自动处理“某一类完全没有召回样本”的情况,曲线不会因为空数据而崩溃。这类边界情况在自建数据集上特别容易出现,值得在自己写评估工具时参考。
4.4 混淆矩阵绘制:从ConfusionMatrix类到 heatmap 渲染
框架的ConfusionMatrix类在 metrics 子模块中维护,但它的可视化落在了 plotting 模块。绘图时,矩阵会通过plot方法渲染成一张背景带色块、文字带数值的热力图。
我这里想分享两个给自己项目做过定制时的体会:
- 归一化设置很重要:原始代码默认显示归一化后的数值(0~1 之间的小数),但很多人看混淆矩阵更习惯看计数。改一行参数,把
normalize=True改成False,色块数值立刻变成真实数量。 - 类别名过长会挤压版面:类别一多、名字一长,色块里的文字容易重叠。解决办法是把
fontsize调小,或者在传给绘图函数之前把类别名截短。
5. 从函数签名到实际调用:Annotator在不同场景下的配置建议
5.1 检测结果可视化:参数怎么配,效果才好看
做检测结果可视化时的标准配置,我建议按下面这样设置:
from ultralytics.utils.plotting import Annotator, Colors def visualize_predictions(img_bgr, detections, class_names, save_path): annotator = Annotator(img_bgr, line_width=3, font_size=0.6, pil=True) colors = Colors() for i, det in enumerate(detections): x1, y1, x2, y2, conf, cls = det label = f"{class_names[int(cls)]} {conf:.2f}" color = colors(idx=int(cls), bgr=True) # 手动指定文字颜色,避免自动判断导致对比度不足 annotator.box_label( (x1, y1, x2, y2), label=label, color=color, txt_color=(255, 255, 255) ) annotated_img = annotator.result() # result() 返回的是 numpy 数组,可用 cv2.imwrite 保存 cv2.imwrite(str(save_path), annotated_img)这里有一个非常实用的参数组合:pil=True加line_width=3。pil=True能让中文字体正常渲染,line_width=3在常见 720p 图上不会显得太细。如果你处理的是 4K 大图,line_width可以调到 5~6,字体会更适配大分辨率。
5.2 视频流逐帧标注:如何复用同一个Annotator实例
做视频流实时检测时,图片是一帧接一帧来的。每次都重新实例化Annotator会反复创建字体、做图片缩放检查,虽然开销不大,但帧率紧张时还是能省则省。我的做法是:在循环外创建一个Annotator,每帧只替换内部图像数据。
annotator = Annotator(np.zeros((720, 1280, 3), dtype=np.uint8), pil=True) cap = cv2.VideoCapture("input.mp4") while True: ret, frame = cap.read() if not ret: break # 每帧检测,假设得到 detections detections = model(frame)[0].boxes.data.cpu().numpy() annotator.im = frame.copy() # 直接替换内部图像 # 绘制过程同上 ... out_frame = annotator.result() writer.write(out_frame)这里需要注意:Annotator内部默认会做一次缩放检查,但如果你传入的帧尺寸一直在变化,可能引发不必要的插值处理。我在视频流场景下会先统一下发到固定分辨率,避免这个问题。
5.3 无界面服务器场景:保存格式与字体渲染注意点
服务器上通常没有显示器,matplotlib 默认后端是Agg,可以直接保存图片。框架的绘图函数大多自己处理了后端设置,一般不报错。但有一个隐性问题容易被忽略:如果系统缺少中文字体,PIL 模式下的中文标签会显示为方框。这时候要么手动注册一个支持中文的字体,要么在标签中先用英文。
我踩过的坑是,某次把类别名改成中文后,在本地 Windows 跑没问题,部署到某公司的精简版 Linux 容器上,所有中文全变“□□□”。排查到最后就是字体缺失。后来我在代码里加了动态检测,如果系统没有中文字体,就自动把标签替换为英文,才彻底解决。
6. 常见报错与排查方法实录:照着抄能少走弯路
6.1 “字体文件不存在”类错误
这类错误最常见于使用pil=True并且指定了自定义字体,但字体路径不对。框架内部如果找不到默认字体,会尝试回退到系统自带字体;如果都找不到,会抛出异常。
排查路径:
- 检查
font_path是否存在,用Path(font_path).exists()。 - 检查系统是否安装了
DejaVu Sans或类似开源字体。 - 如果跑在容器里,直接用
apt-get install fonts-dejavu-core补一下。
6.2 “颜色空间或数据类型不匹配”类错误
Annotator接收的图片必须是 numpy 数组,且通常是uint8、三通道。曾经有人把torch.Tensor直接传进去,结果 OpenCV 直接报错。
排查路径:
- 确认
.numpy()已经调用,并且dtype是uint8。 - 确认张量经过反归一化,像素值在 0~255 之间。如果模型输出是 0~1 的浮点数,画出来的图会是全黑的,或者颜色完全失真。
- 确认从
result()取回的结果可以继续作为Annotator输入。
6.3 类别索引越界导致颜色错乱
如果模型预测的cls数值大于names长度,box_label里的类别文本会显示为names[cls]不存在的内容,颜色也会重复。
排查路径:
- 打印检测结果的
cls最大值,和len(names)对比。 - 注意模型是
nc类还是nc+1类(背景类处理差异)。 - 如果是从旧权重迁移到新数据集,务必检查
data.yaml中的类别顺序是否一致。
6.4 绘制结果偏暗或偏亮,肉眼无法判断
这个问题通常不是绘制函数的问题,而是输入图片本身的通道顺序与颜色空间。
排查路径:
- 确认读取图片用
cv2.imread,它返回 BGR。 - 确认模型输入前没有把 BGR 转成 RGB 后又不转回来。
- 如果绘图结果比原图明显偏蓝偏红,基本就是通道顺序混了。
下面整理一份快速排查表,方便日后对照:
| 问题现象 | 可能原因 | 排查方式 |
|---|---|---|
| 中文显示为方框 | 系统缺字体 | 检查字体注册与系统字体包 |
| 图片整体偏蓝偏红 | RGB/BGR 顺序错误 | 确认输入数组通道顺序 |
| 标签文字看不见 | 文字颜色与框色相近 | 手动指定txt_color |
| 颜色全一样 | 类别索引越界 | 检查cls与names长度 |
| 保存的图片全黑 | 像素值范围为 0~1 | 乘 255 或转uint8并反归一化 |
| 画框位置偏移 | 原图与推理图尺寸不一致 | 检查 letterbox 缩放参数 |
7. 如何根据需求扩展这个子模块:两个亲测有效的改造方向
7.1 方向一:批量生成带自定义水印的评估报告图
我实际遇到的场景是,需要给客户提供评估报告,每张图上要有项目代号和日期。直接改源码不可取,我选择在plot_results的外面包一层:
def plot_results_with_watermark(csv_path, save_path, watermark_text): # 先生成标准图 standard_path = plot_results(csv_path, save_path=save_path) if not standard_path: return None img = cv2.imread(str(standard_path)) # 在右下角添加半透明水印 overlay = img.copy() cv2.putText(overlay, watermark_text, (img.shape[1] - 250, img.shape[0] - 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (50, 50, 50), 2) cv2.addWeighted(overlay, 0.6, img, 0.4, 0, img) cv2.imwrite(str(save_path), img) return save_path这个方案的好处是不动框架内部逻辑,未来框架升级时依然兼容。
7.2 方向二:把Annotator结果直接输出为视频
评估模型时,有时需要把一整个视频的检测结果拼在一起便于人工审查。我平时会这样搭配:
- 用
Annotator逐帧标注。 - 用
cv2.VideoWriter把标注帧写入 H.264 或无损 AVI。 - 按 1~2 秒一帧的关键帧抽样方式生成缩略图集。
这样生成的视频文件可以直接在播放器里倍速浏览,比一张张翻图片高效很多。尤其做夜间场景、遮挡密集场景评估时,视频连续帧能看出很多单张图暴露不出的问题。
7.3 方向三:自定义混淆矩阵的配色与数值展示
框架默认的混淆矩阵配色是蓝色系热力图,但在某些灰度打印场景下,区分度不够。我改造过一次:
- 把背景色从“低值为深蓝”改为“低值为白色、高值为红色”的红白渐变。
- 在单元格里同时显示“计数 + 百分比”,方便不同审查视角。
- 增加了列归一化与行归一化的切换逻辑。
改造的关键点是:不要动数值计算类,只替换绘图函数内部的imshow与text部分。这样既保留原有输出结构,又满足定制需求。
8. 实用经验总结:读完这个模块后,我的代码习惯发生了哪些变化
读完整份源码,我对“如何编写可视化工具”这件事有了新的判断。以前我写可视化脚本,总是把画图逻辑揉进评估循环里,导致改参数就要重新跑一遍评估。现在我会固定这样的结构:
- 评估阶段只产生数据,并序列化到磁盘。
- 可视化阶段独立读取数据,并生成图片。
- 图片的样式、尺寸、字体、水印全部作为函数参数。
这套思路带来的直接好处是:我可以为同一份评估数据生成多种风格的可视化图,而不必重新推理。如果模型迭代了,也只要重新生成数据,画图代码一行都不用改。
另外,我还学到一个很有用的习惯:绘图函数的参数要尽量给全默认值,但核心返回结果要保持单一类型。比如plot_results无论成功还是失败,都返回一个路径字符串或空字符串,而不是抛异常中断整个训练。这种“容错但可感知”的设计,在实际训练脚本里比“严格报错”更友好。
还有一个容易忽略的点:模块内大量使用Path对象而非字符串拼接路径。这提醒我在自己的项目里也应该统一用pathlib,避免 Windows 与 Linux 路径分隔符问题,也方便做路径存在性检查。
9. 我个人在实际使用中的最终体会
从第一遍粗略浏览,到后来为了自定义评估报告精读,这个模块我至少读了三遍。前两遍只是在找“哪个函数能画哪个图”,直到第三遍把Annotator内部的绘制流程和plot_results的数据读取链完整梳理后,才对框架的“可视化生态”有了整体认知。
一个常常被忽略的事实是:很多所谓的“评估困难”,不是模型精度不够,而是结果可视化不直观。有了这套工具,你能在几分钟内把一批检测结果、混淆矩阵、PR 曲线全部导成图片,省下的时间远比读代码花费的时间多。
如果你也想彻底掌握这个模块,我建议从Colors和Annotator这两个类入手,先跑通一个“输入图片 + 输出带框图片”的最小例子,然后逐步扩展到曲线和矩阵。不要一上来就研究所有绘图函数,那样容易迷失在参数海洋里。
最后分享一个小技巧:善用Annotator.result()返回的 numpy 数组,你可以把它当作普通图像数据继续做裁剪、拼接、加蒙版、做视频流读取。它不只是“保存图片”这一步的终点,更是其他图像处理流程的起点。我正是在这点上尝到甜头后,才真正觉得plotting.py设计得相当有远见。