ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv8检测框绘制:PIL/numpy输入与RGB/BGR坐标还原

YOLOv8检测框绘制:PIL/numpy输入与RGB/BGR坐标还原

做视觉项目时,最容易被低估的往往不是模型推理,而是“把结果画回去”这一步。标题里这个需求很具体:YOLOv8 接受一个 numpy 数组(cv2 风格)或 PIL 图像,在输入的 RGB 图像上绘制检测结果。听起来只是调个画框函数,实际落地时会碰到一串问题:cv2 读进来通常是 BGR,PIL 默认是 RGB,numpy 数组可能是只读的、非连续的、四通道的,检测框坐标还可能因为 letterbox 缩放而偏移。更麻烦的是,很多人把“推理输入的颜色顺序”和“绘制输出的颜色顺序”混在一起,最后框是画上了,红绿蓝却反了,排查半天找不到原因。这篇内容就围绕这个接口的完整链路展开,从输入归一化、推理坐标还原、绘制层设计到排错和工程化封装,尽量把每个“为什么”讲清楚,让不同基础的读者都能直接照着改。

1. 这个接口的边界:为什么“接受两种输入”比看起来麻烦

1.1 图像类型、通道顺序和颜色空间是三件事

很多人把“PIL 图像”和“numpy 数组”当成两种格式,其实它们背后至少有三层差异。第一层是容器类型:PIL 是图像对象,numpy 是内存数组。第二层是通道顺序:PIL 的convert("RGB")得到的是 RGB,而 cv2 的imread默认读出 BGR。第三层是内存布局:numpy 数组可能是 C 连续,也可能是切片后的非连续视图,甚至可能是只读数组。绘制函数通常要求可写、连续、uint8类型的三通道图像,少一个条件就可能报错或者画出奇怪结果。

我一般会把这三件事拆开处理,而不是在一个函数里随手cv2.cvtColor了事。因为一旦你接受了两种输入,就必须明确一个契约:函数内部统一用哪种颜色空间做推理和绘制,返回值又按哪种格式给调用方。比较稳妥的约定是:内部统一转成 RGB 的uint8numpy 数组,推理和绘制都在这个空间里完成;返回时再根据入参类型决定是否转回 PIL 或 BGR。这样做的好处是绘制颜色只需要按 RGB 给一次,不会出现“框的颜色一会儿对一会儿错”的情况。

这里还有一个容易被忽略的点:标题说的是“在输入的 RGB 图像上绘制检测结果”,但用户传进来的 cv2 数组很可能是 BGR。如果你的函数签名里没有input_color这样的开关,调用方就只能靠猜。我的做法是显式暴露颜色顺序参数,默认值可以按团队习惯来,但文档里一定写清楚:传 cv2 数组且没有转换过,就标BGR;传 PIL 或已经转好的数组,就标RGB。这个小参数能省掉大量沟通成本。

1.2 绘制结果到底画在谁身上:原图副本与坐标基准

第二个边界是“画在谁身上”。直接修改入参看起来省内存,但在批量处理和可视化对比时非常危险:调用方可能还要用原图做裁剪、保存或二次推理,你悄悄改了原图,后面所有逻辑都会受影响。所以绘制函数应该默认返回副本,除非调用方明确要求原地修改。复制一张 1080p 的三通道图大约几 MB,对多数检测场景来说完全可以接受,换来的是可预测的行为。

坐标基准也要说清楚。YOLOv8 的输出框一般用xyxy表示左上角和右下角,但有些封装给的是xywh,有些给的是归一化坐标,还有些在预处理时做了 letterbox,需要先减 padding 再除以缩放比例。绘制层不应该假设“拿到的就是原图坐标”,而应该在进入绘制函数前统一转成原图xyxy。我习惯在绘制函数里再加一道裁剪:把x1、y1、x2、y2限制在图像宽高范围内,避免因为浮点误差或异常框导致cv2.rectangle画出边界外的线。这个裁剪不解决根本偏移,但能防止程序因为极端坐标崩溃。

1.3 一个最小可用的函数签名

如果只考虑核心需求,我会先定一个不花哨但能扩展的签名:

def draw_yolo_result( image, model, conf=0.25, iou=0.45, input_color="BGR", return_same_type=True, class_names=None, ): ...

image接受 PIL 图像或 numpy 数组;input_color告诉函数 numpy 数组的通道顺序;return_same_type决定是否按入参类型返回;class_names用于把类别 id 映射成可读文字。这个签名没有把“预处理”“后处理”“绘制”拆成三个公开函数,是为了让调用方一行完成“输入图片到带框图片”的转换。但在内部,我一定会拆成三个小步骤:归一化输入、解析检测结果、绘制并还原输出类型。拆开之后,排错时才能快速定位到底是颜色错了、坐标错了,还是绘制参数错了。

2. 输入归一化:把 PIL 和 numpy 数组收拢到同一条流水线

2.1 类型判断不要只看 isinstance

PIL 图像用isinstance(image, Image.Image)判断是可靠的,但 numpy 数组的判断不能只看isinstance(image, np.ndarray)就结束,因为数组的维度、通道数、数据类型都可能不合法。我见过有人把灰度图直接传进来,结果绘制函数按三通道处理,颜色元组写进去只改了第一个通道,框看起来像一条灰线;也有人传了四通道的 RGBA 图,绘制时 alpha 通道被一起改掉,保存出来背景变透明。更稳妥的做法是在归一化阶段就把这些情况处理掉:灰度图复制成三通道,四通道图取前三个通道,三通道图保留,其他维度直接抛ValueError。

数据类型也要检查。检测模型通常吃uint8的 0 到 255 图像,但 numpy 数组可能是float32的 0 到 1,也可能是int64。如果直接拿去绘制,cv2.rectangle可能不报错,但颜色值会被截断,画出来的框颜色很怪。我的习惯是统一np.clip到 0 到 255,再转uint8。这一步看起来粗暴,但对可视化接口是合理的:绘制结果最终是给人看的,保留 8 位精度足够了。如果调用方需要保留浮点精度做后续计算,应该把“绘制”和“数值处理”拆成两个函数,而不是让绘制函数承担不该承担的责任。

2.2 BGR 与 RGB 的转换时机

颜色顺序的转换时机比转换本身更重要。假设你拿到一个 cv2 读入的 BGR 数组,正确的顺序是:先确认它是 BGR,再转成 RGB,然后用 RGB 做推理和绘制,最后如果需要返回 cv2 风格,再转回 BGR。错误做法是“推理前转一次,绘制前又转一次”,或者“绘制用 BGR 颜色,但图像是 RGB”,最后红蓝颠倒。我的经验是:在函数入口处只转一次,并在变量名里带上颜色标记,比如rgb_image。后面所有绘制颜色都按 RGB 给,不要在中途再改。

这里有一个细节:rgb_image = bgr_image[:, :, ::-1]得到的是视图,步长为负,内存不连续。很多绘制函数能处理,但为了稳妥,最好加一句np.ascontiguousarray。这个操作会复制一份连续内存,代价很小,却能避免“某些环境下画不出来”的怪问题。如果你追求极致性能,可以先判断rgb_image.flags["C_CONTIGUOUS"],只在需要时复制。但在可视化接口里,我倾向于直接复制,代码更简单,行为更稳定。

2.3 内存连续性与只读数组的处理

只读数组是另一个常见坑。有些人从数据集里取出的图像是只读的,或者用np.broadcast_to构造的数组不可写。绘制函数要往数组里写像素,遇到只读数组就会报ValueError: assignment destination is read-only。解决办法不是让调用方去改,而是在归一化阶段统一copy()一份可写副本。复制之前可以先检查arr.flags.writeable,不可写就复制;这样可以避免每次都无脑复制大图。

还有一个隐蔽问题:图像可能是非连续的切片,比如image[100:500, 200:800]。这种数组在内存里不是整块连续,某些底层函数处理起来会变慢,甚至在某些版本里行为不一致。用np.ascontiguousarray统一成连续数组,可以同时解决负步长和切片不连续的问题。代价是复制,但可视化场景通常不是性能瓶颈;如果真的是高帧率视频流,可以把绘制层换成更底层的写法,或者复用缓冲区,但那是另一个话题了。

2.4 统一成 RGB numpy 数组的代码

下面这个函数是我常用的入口归一化逻辑,核心是把 PIL、numpy、灰度、四通道、BGR 都收拢成 RGBuint8连续数组:

import numpy as np from PIL import Image def to_rgb_numpy(image, input_color="BGR"): if isinstance(image, Image.Image): rgb = np.asarray(image.convert("RGB")) elif isinstance(image, np.ndarray): arr = image if arr.ndim == 2: arr = np.stack([arr, arr, arr], axis=-1) elif arr.ndim == 3 and arr.shape[2] == 4: arr = arr[:, :, :3] elif arr.ndim == 3 and arr.shape[2] == 3: pass else: raise ValueError(f"不支持的数组形状: {arr.shape}") if input_color.upper() == "BGR": arr = arr[:, :, ::-1] rgb = np.ascontiguousarray(arr) else: raise TypeError("image 必须是 PIL.Image 或 numpy.ndarray") if rgb.dtype != np.uint8: rgb = np.clip(rgb, 0, 255).astype(np.uint8) if not rgb.flags.writeable: rgb = rgb.copy() return rgb

这段代码没有追求“最短”,而是把每个分支都写清楚。实际项目里,我还会在input_color不是BGR或RGB时直接抛错,避免拼写错误导致颜色悄悄错掉。归一化函数越明确,后面的绘制和推理越省心。

3. 推理与坐标还原:检测框为什么总在“漂”

3.1 预处理里的 letterbox 与缩放系数

YOLOv8 推理时通常会把输入图像缩放到模型要求的尺寸,为了保持宽高比,常见做法是 letterbox:按比例缩放后,在短边补灰边。这个过程会引入两个参数:缩放比例scale和填充量pad_x、pad_y。如果后处理时没有把这两个参数还原回去,检测框就会整体偏移,或者框的大小和实际目标对不上。表现是:小图上看不出来,大图上框偏到左上角;或者框的宽高比不对,目标在右边,框却画在中间。

我排查这类问题时,会先找一张有明显边缘目标的图,比如目标贴近画面右下角。如果框整体往左上偏,大概率是 padding 没减;如果框整体偏小,可能是缩放比例用错了;如果框只在某个方向偏,检查pad_x和pad_y是不是写反了。最稳的做法是不要自己重复实现 letterbox 的逆变换,而是优先使用推理接口返回的原图坐标。很多封装在内部已经做了还原,boxes.xyxy就是原图坐标。如果你确实要手动预处理,那就把scale、pad_x、pad_y和预处理代码放在同一个函数里,后处理时直接复用,不要靠全局变量传递。

3.2 从模型输出到原图坐标

拿到检测结果后,先确认坐标格式。xyxy是左上角和右下角,xywh是中心点和宽高,归一化坐标还要乘以原图宽高。绘制函数只认xyxy,所以解析阶段就要统一转换:

def xywh_to_xyxy(box): x, y, w, h = box return x, y, x + w, y + h

如果结果是归一化的,还要先乘(width, height, width, height)。转换完不要急着画,先做一次裁剪:

x1 = max(0, min(int(round(x1)), width - 1)) y1 = max(0, min(int(round(y1)), height - 1)) x2 = max(0, min(int(round(x2)), width - 1)) y2 = max(0, min(int(round(y2)), height - 1))

这个裁剪看起来只是防越界,实际还能暴露坐标问题。如果你发现大量框都被裁到 0 或width - 1,说明坐标尺度不对,可能是把归一化坐标当成了像素坐标,或者把xywh当成了xyxy。与其让绘制函数画出满屏边框,不如在裁剪前加一句断言或日志,把异常框的数量打出来。

3.3 置信度过滤与类别名映射

YOLOv8 的输出通常包含置信度和类别 id。绘制前要不要再过滤一次?我的建议是:如果推理接口已经按conf和iou过滤过,绘制层就不要再重复过滤,否则容易出现“阈值传了但结果没变”的困惑。如果推理接口返回的是原始输出,那就在解析阶段统一过滤,并保留过滤后的数组,不要一边画一边判断,代码会很乱。

类别名映射也要提前准备好。最常见的错误是类别 id 从 0 开始,但class_names列表从 1 开始,结果所有标签都错一位。我的做法是用字典而不是列表,或者在使用前打印一次len(class_names)和最大类别 id 做校验。标签文字不要只写类别名,最好带上置信度,比如person 0.87。这样在调试时能快速判断是分类错了还是框错了。如果类别很多,文字会显得拥挤,可以只对高置信度目标显示文字,低置信度只画框,或者把文字缩小一号。

3.4 绘制前的坐标裁剪

坐标裁剪不只是防越界,还能减少绘制层的异常输入。cv2.rectangle对整数坐标更友好,浮点数虽然能隐式转换,但不同版本行为可能有差异。我通常会在解析阶段就把坐标转成int,并保证x2 > x1、y2 > y1。如果出现宽高为 0 的框,直接跳过,不要画一条看不见的线。这个细节在密集小目标场景里很重要,因为模型可能输出退化的框,绘制函数如果不去重,会在同一个位置反复画线,最后标签糊成一团。

4. 绘制层的取舍:为什么我宁愿多写一个适配器

4.1 返回同类型还是统一 numpy

返回值类型是一个产品决策,不是纯技术问题。如果调用方传 PIL,通常希望拿回 PIL,方便直接save或送进下游;如果传 numpy,通常希望拿回 numpy,方便继续做矩阵运算。所以return_same_type=True是更友好的默认值。但这里有一个陷阱:如果输入是 cv2 的 BGR 数组,你内部转成 RGB 绘制,返回时要不要转回 BGR?我的答案是:如果调用方明确标了input_color="BGR",就转回 BGR;如果标的是RGB,就返回 RGB。这样调用方不用猜,行为也可预测。

如果团队内部约定“所有可视化结果统一返回 RGB numpy”,那就不要提供return_same_type,直接返回 RGB,并在文档里写死。最怕的是半吊子兼容:有时返回 PIL,有时返回 numpy,有时 RGB,有时 BGR,调用方只能靠试。接口可以简单,但契约必须稳定。

4.2 颜色、线宽和文字可读性

绘制颜色不要用纯随机色。类别多的时候,随机色容易撞色,而且深色背景上的深色框几乎看不见。我一般会用一组高对比度的固定调色板,按类别 id 取模。线宽根据图像尺寸动态调整:小图用 1 到 2 像素,大图用 3 到 5 像素。文字背景比文字本身更重要,白字直接写在浅色背景上会糊,所以我会在文字下面画一个填充矩形,再用白字或黑字。下面是一个简化版绘制函数:

import cv2 import numpy as np def draw_boxes_rgb(rgb, boxes, class_names=None, thickness=2): out = rgb.copy() h, w = out.shape[:2] for item in boxes: x1, y1, x2, y2, conf, cls_id = item x1 = max(0, min(int(round(x1)), w - 1)) y1 = max(0, min(int(round(y1)), h - 1)) x2 = max(0, min(int(round(x2)), w - 1)) y2 = max(0, min(int(round(y2)), h - 1)) if x2 <= x1 or y2 <= y1: continue color = (0, 200, 0) cv2.rectangle(out, (x1, y1), (x2, y2), color, thickness) name = str(cls_id) if class_names is not None and 0 <= int(cls_id) < len(class_names): name = class_names[int(cls_id)] label = f"{name} {conf:.2f}" font = cv2.FONT_HERSHEY_SIMPLEX scale = 0.5 text_thickness = 1 (tw, th), baseline = cv2.getTextSize(label, font, scale, text_thickness) top = max(0, y1 - th - baseline - 4) cv2.rectangle(out, (x1, top), (x1 + tw + 4, y1), color, -1) cv2.putText( out, label, (x1 + 2, y1 - baseline - 2), font, scale, (255, 255, 255), text_thickness, cv2.LINE_AA ) return out

注意这里是在 RGB 数组上绘制,颜色元组按 RGB 给。cv2的绘制函数不会帮你做颜色空间转换,它只是往数组里写值。如果你把 BGR 的颜色元组用在 RGB 数组上,红色和蓝色会互换。这个问题在绿色框上不明显,一旦换成红色框就暴露了。

4.3 小目标密集场景的绘制策略

小目标密集时,框和标签会互相遮挡。我的经验是:先画框,再画标签,标签只给置信度最高的前 N 个目标,或者只给面积大于阈值的框。如果所有框都带文字,画面会变成一堵字墙。另一个技巧是把标签放在框内顶部,而不是框外上方;框外上方容易超出图像边界,被裁掉。对于非常小的框,可以只画一个点或者加粗边框,不要强行塞文字。

还有一点:小目标的框可能只有几个像素宽,线宽设成 3 就会把目标本身盖住。这种场景下我通常把线宽降到 1,或者用半透明填充代替粗边框。半透明绘制需要先画到临时图层再混合,代码会多几行,但视觉效果好很多。如果只是做调试,粗线宽反而更明显,所以线宽应该作为参数暴露,而不是写死。

4.4 性能:复制一张图到底贵不贵

绘制函数默认复制图像,很多人担心性能。实际测下来,一张 1920×1080 的三通道uint8图大约 6 MB,复制一次在现代机器上是毫秒级。相比之下,模型推理通常几十毫秒到几百毫秒,复制开销可以忽略。真正影响性能的是在循环里反复转换颜色空间、反复创建大数组、反复astype。我的做法是把归一化、推理、绘制拆成三个阶段,每个阶段只做一次必要转换,不在循环里做重复工作。

如果是视频流场景,可以复用输出缓冲区,或者只复制 ROI 区域。但那是优化阶段的事,不要一上来就为了省几 MB 把代码写得很难维护。先保证颜色对、坐标对、返回类型对,再谈性能。

5. 排错链路:从颜色错乱到框体偏移的逐层定位

5.1 先检查通道顺序:用一张纯色小图验证

颜色问题最难靠肉眼判断,因为很多自然图像红绿差异不明显。我习惯用一张纯色小图做验证:生成一个 100×100 的纯红 RGB 数组,分别用RGB和BGR两种模式走一遍函数。如果返回的图变成蓝色,说明中间多转了一次或者少转了一次。这个方法比拿真实照片猜要快得多。纯色图还能验证绘制颜色:在 RGB 图上画一个(255, 0, 0)的框,应该看到红色;如果看到蓝色,就是颜色元组按 BGR 给了。

5.2 再检查尺寸映射:letterbox 参数是否一致

框偏移的排查要从预处理开始。先打印原图宽高、模型输入宽高、缩放比例和 padding。然后拿一个已知坐标的框,手动算一遍还原后的坐标,和实际画出来的位置对比。如果手动算的对、画出来的不对,问题在绘制层;如果手动算的也不对,问题在预处理或后处理。不要同时改多个地方,一次只改一个变量,否则你无法判断是哪个改动生效了。

5.3 最后检查绘制坐标:xywh 与 xyxy 混用

xywh和xyxy混用是经典错误。表现是框的左上角看起来对,但宽高只有实际的一半,或者框整体偏移半个宽高。排查时直接打印前几个框的四个数,看看x2是否大于x1,y2是否大于y1。如果大量框出现x2 < x1,基本可以确定格式搞错了。另一个信号是框的宽高比和目标明显不符,比如人形目标被画成横条。

5.4 常见异常对照表

现象更可能的原因处理方式
红蓝颠倒RGB/BGR 转换次数不对,或绘制颜色元组顺序错入口只转一次,变量名带颜色标记,纯色图验证
框整体偏左上letterbox 的 padding 没减后处理减pad_x、pad_y,再除以scale
框整体偏小缩放比例用错,或把归一化坐标当像素坐标打印scale和原图尺寸,逐框手算验证
框画在图像外坐标未裁剪,或xywh当xyxy绘制前裁剪,检查x2 > x1、y2 > y1
报错数组只读入参是不可写视图归一化阶段copy()成可写数组
返回 PIL 报错数组不是uint8或通道数不对统一uint8三通道,再Image.fromarray
标签文字太小字体比例固定,未按图像尺寸调整按短边尺寸动态计算scale

这张表不是万能答案,但它能帮你快速缩小范围。排错最重要的是顺序:先颜色,再坐标,再类型,最后才是绘制样式。颜色和坐标错了,样式调得再好看也没用。

6. 工程化落地:让这个函数经得起批量调用

6.1 类型注解与文档字符串

可视化函数很容易被到处复制,最后每个文件里都有一个“差不多”的版本。我的建议是把它收进一个独立模块,加上类型注解和简短文档字符串。类型注解不一定能拦住运行时错误,但它能让调用方一眼看出该传什么、会返回什么。文档字符串里重点写三件事:input_color的含义、返回值是否与入参同类型、坐标格式要求。不要写长篇大论,把契约写清楚就够了。

from typing import Optional, Sequence, Union import numpy as np from PIL import Image ImageLike = Union[Image.Image, np.ndarray] def draw_yolo_result( image: ImageLike, model, conf: float = 0.25, iou: float = 0.45, input_color: str = "BGR", return_same_type: bool = True, class_names: Optional[Sequence[str]] = None, ) -> ImageLike: ...

6.2 批量推理与单张绘制的拆分

批量场景下,不要把“推理”和“绘制”绑死在一个循环里。更好的结构是:先批量推理,拿到每张图的检测结果;再按需绘制。这样调试时可以只跑推理不画图,性能测试时可以只测推理,导出数据时可以跳过绘制。绘制函数只负责“给一张图和一个结果列表,返回带框图”,不负责加载模型、不负责读文件。职责越单一,复用越容易。

6.3 异常处理和日志

异常处理不要只写try/except: pass。图像处理里最常见的异常是形状不合法、类型不支持、坐标越界。我的做法是:形状和类型错误直接抛出带上下文的异常,比如ValueError(f"不支持的数组形状: {arr.shape}");坐标越界不抛异常,而是裁剪并计数,如果异常框比例过高,打一条警告日志。这样既不会因为个别坏框中断整个批次,又能及时发现系统性问题。

6.4 版本升级时的兼容点

模型和推理库升级时,最容易变的是输出结构:以前是列表,现在是对象;以前boxes直接是数组,现在要.cpu().numpy();以前坐标是xyxy,现在可能多了一层。绘制函数应该尽量只依赖“标准化后的结果列表”,而不是直接依赖某个版本的输出对象。在解析层做一次转换,把结果统一成(x1, y1, x2, y2, conf, cls_id)的元组列表,绘制层就稳定了。以后换模型、换版本,只需要改解析层,绘制层不用动。

最后分享一个我自己的小习惯:每次改完绘制函数,先拿一张纯色图、一张小尺寸图、一张大尺寸图各跑一遍,分别检查颜色、坐标和文字。三张图都过了,再放进批量流程。这个检查不到一分钟,但能省掉后面几个小时的排查。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进