首页 / 资讯中心 / 文章详情

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

YOLOv8检测框绘制:PIL/numpy输入与RGB/BGR坐标还原 ★ FEATURED ARTICLE
做视觉项目时最容易被低估的往往不是模型推理而是“把结果画回去”这一步。标题里这个需求很具体YOLOv8 接受一个 numpy 数组cv2 风格或 PIL 图像在输入的 RGB 图像上绘制检测结果。听起来只是调个画框函数实际落地时会碰到一串问题cv2 读进来通常是 BGRPIL 默认是 RGBnumpy 数组可能是只读的、非连续的、四通道的检测框坐标还可能因为 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, conf0.25, iou0.45, input_colorBGR, return_same_typeTrue, class_namesNone, ): ...image接受 PIL 图像或 numpy 数组input_color告诉函数 numpy 数组的通道顺序return_same_type决定是否按入参类型返回class_names用于把类别 id 映射成可读文字。这个签名没有把“预处理”“后处理”“绘制”拆成三个公开函数是为了让调用方一行完成“输入图片到带框图片”的转换。但在内部我一定会拆成三个小步骤归一化输入、解析检测结果、绘制并还原输出类型。拆开之后排错时才能快速定位到底是颜色错了、坐标错了还是绘制参数错了。2. 输入归一化把 PIL 和 numpy 数组收拢到同一条流水线2.1 类型判断不要只看 isinstancePIL 图像用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_colorBGR): 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_typeTrue是更友好的默认值。但这里有一个陷阱如果输入是 cv2 的 BGR 数组你内部转成 RGB 绘制返回时要不要转回 BGR我的答案是如果调用方明确标了input_colorBGR就转回 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_namesNone, thickness2): 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是否大于x1y2是否大于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)的元组列表绘制层就稳定了。以后换模型、换版本只需要改解析层绘制层不用动。最后分享一个我自己的小习惯每次改完绘制函数先拿一张纯色图、一张小尺寸图、一张大尺寸图各跑一遍分别检查颜色、坐标和文字。三张图都过了再放进批量流程。这个检查不到一分钟但能省掉后面几个小时的排查。
阅读完成 · 觉得有帮助?
咨询建站