1. 从一张图说起:为什么宽高和矩形框标注是视觉项目的“地基”
做视觉项目的人都有一个共识:不管上层跑的是多复杂的模型,落到工程实现里,第一步永远是搞清楚“这张图到底多大”“我要处理的那块区域在哪”。cv2.imread读进来一个numpy.ndarray,它的shape属性就三个值——高度、宽度、通道数。听起来简单到不值一提,但我见过太多项目在这上面翻车:坐标越界、框画歪、缩放后标注错位、批量处理时把宽高写反导致整批数据报废。
这篇文章聊的就是这个“地基级”的操作:用 OpenCV 获取图片的宽度和高度,以及在此基础上做矩形框标注。它看起来是入门内容,但真正把它用扎实,涉及到坐标系约定、图像属性读取的多种方式、矩形绘制的参数细节、批量处理的健壮性设计,以及标注结果如何落地成可复用的数据。适合刚接触 OpenCV 的开发者打基础,也适合有一定经验但想把自己代码写得更稳的人对照检查。
我个人的习惯是:任何一个视觉项目,先写一个“图像体检”函数,把尺寸、通道、数据类型打印出来,再动手做后面的事。这个习惯帮我省下了无数次调试时间。下面就把这套东西完整拆开讲。
2. 图像尺寸获取:三种写法与它们背后的取舍
2.1 shape 属性:最直接也最容易被坑的方式
OpenCV 用cv2.imread读图,返回的是一个多维数组。对彩色图来说,它的结构是(高度, 宽度, 通道数)。注意这个顺序——高度在前,宽度在后。这是新手最容易搞反的地方,因为日常说话都是“宽高”,但数组里偏偏是“高宽”。
import cv2 img = cv2.imread("sample.jpg") print(img.shape) # 例如 (720, 1280, 3) height, width = img.shape[:2] print(f"width={width}, height={height}")为什么 OpenCV 要用这个顺序?因为图像在内存里是按行存储的,一行一行往下排,行数就是高度,每行的像素数就是宽度。numpy的索引习惯是img[row, col],也就是img[y, x]。理解了这一点,后面画矩形框时坐标顺序就不会乱。
注意:如果图片读取失败,
img会是None,此时访问.shape会直接抛AttributeError。生产代码里必须先判空。
2.2 用img.shape还是img.shape[:2]:一个实际影响
很多人写代码时习惯h, w, c = img.shape,这在彩色图上没问题,但一旦遇到灰度图(cv2.imread(path, cv2.IMREAD_GRAYSCALE)),shape 只有两个值,解包就会报“not enough values to unpack”。所以更稳的写法是:
h, w = img.shape[:2]这样无论彩色还是灰度都能正常工作。通道数需要时再单独取img.shape[2]并做判断。这个细节在写通用工具函数时特别重要,因为你永远不知道调用方传进来的是几通道的图。
2.3 尺寸获取的另外两条路:ndarray.size与图像元信息
除了 shape,还有两个信息值得知道:
img.size返回的是像素总数,等于h * w * c。它不能直接告诉你宽高,但可以用来做快速校验,比如判断两张图是否像素总量一致。- 如果只是想拿尺寸而不想真正解码整张图(大图场景下很实用),可以用
cv2.imread之外的方式读取文件头信息。不过在纯 OpenCV 体系里,最常用的还是 shape。
我实测过一个场景:批量处理几千张高分辨率图时,如果只是为了统计尺寸分布,先全部imread再取 shape 会非常慢。这时候可以先读文件头拿尺寸做筛选,再决定要不要真正解码。这个优化在数据清洗阶段能省下大量时间。
2.4 尺寸相关的常见坑与规避
| 问题现象 | 根本原因 | 解决方式 |
|---|---|---|
| 宽高写反,框画到图外 | shape 顺序是高在前 | 统一用h, w = img.shape[:2]并加注释 |
| 灰度图解包报错 | 通道数不存在 | 用切片[:2]而非全解包 |
| 读图返回 None 后崩溃 | 路径错误或格式不支持 | 读后立即判空并给出明确日志 |
| 缩放后坐标错位 | 标注坐标基于原图 | 缩放比例同步作用于坐标 |
这张表里的每一条,我都在真实项目里踩过至少一次。尤其是最后一条,做图像增强或统一尺寸时,如果只缩放图像不缩放标注框,框就会整体偏移,而且偏移量随缩放比例变化,非常隐蔽。
3. 矩形框标注:从坐标约定到绘制细节
3.1 坐标系约定:左上角是原点
OpenCV 的图像坐标系原点在左上角,x 轴向右,y 轴向下。一个矩形框用两个点表示:左上角(x, y)和右下角(x + w, y + h)。cv2.rectangle接受的就是这两个点。
x, y, w, h = 100, 80, 200, 150 cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)这里有个容易混淆的点:很多标注工具(比如某些数据集格式)用的是(x_min, y_min, x_max, y_max),而另一些用的是(x, y, w, h)。这两种表示法在转换时极易出错。我的做法是在项目里统一用一种内部格式,所有外部输入输出都写明确的转换函数,绝不混用。
3.2cv2.rectangle的参数逐个拆解
cv2.rectangle(img, pt1, pt2, color, thickness, lineType, shift)img:目标图像,函数是原地修改,会直接改变传入的数组。pt1、pt2:矩形的两个对角点,顺序无所谓,函数会自动处理。color:BGR 顺序的元组。注意是 BGR 不是 RGB,(0, 255, 0)是绿色,(255, 0, 0)是蓝色。这个坑几乎每个新手都踩过。thickness:线宽。设为-1或cv2.FILLED会填充整个矩形。lineType:线条类型,默认cv2.LINE_8。画细线时用cv2.LINE_AA抗锯齿,视觉上更平滑。shift:坐标的小数位数,用于亚像素精度。一般用不到,但做精细标注时有用。
提示:
cv2.rectangle会修改原图。如果你需要保留原图,先img.copy()一份再画。
3.3 颜色与线宽的实战选择
颜色选择不只是好看的问题。在标注场景里,颜色承担着“区分类别”的功能。比如检测任务里,人用绿色、车用蓝色、交通标志用红色。我一般会准备一个颜色表,按类别索引取色,避免每次手写 BGR 值。
线宽的选择和图像分辨率强相关。1080p 的图用 2 像素线宽刚好,4K 图就得用 4 到 6 像素,否则框细得几乎看不见。一个经验公式是:thickness = max(2, int(min(h, w) / 400)),让线宽随图像尺寸自适应。
3.4 在框上叠加文字标签
光有框还不够,通常还要在框旁边写上类别和置信度。cv2.putText可以做到,但要注意文字位置别超出图像边界。
label = "person 0.92" (tw, th), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2) cv2.rectangle(img, (x, y - th - baseline), (x + tw, y), (0, 255, 0), -1) cv2.putText(img, label, (x, y - baseline), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2)这段代码先画一个填充的小矩形作为文字背景,再在上面写黑字。这样无论背景是什么颜色,文字都清晰可读。getTextSize返回文字的宽高和基线偏移,用它来动态计算背景框大小,比写死数值靠谱得多。
4. 完整实操:一个可复用的标注工具函数
4.1 需求拆解与函数设计
把前面的知识点串起来,做一个实用的小工具:输入一张图和一个框列表,输出画好框的图,同时打印图像尺寸信息。框列表里每个元素包含坐标、类别名和可选置信度。
设计上要考虑几点:图像读取失败要能优雅退出;框坐标要做边界裁剪,防止画到图外;颜色按类别自动分配;文字背景自适应。这些都是在实际使用中逐渐补上的,一开始写得很简单,用着用着问题就冒出来了。
4.2 核心代码实现
import cv2 import numpy as np def get_image_size(image_path): img = cv2.imread(image_path) if img is None: raise FileNotFoundError(f"无法读取图像: {image_path}") h, w = img.shape[:2] c = img.shape[2] if img.ndim == 3 else 1 return w, h, c def draw_boxes(image_path, boxes, output_path=None): img = cv2.imread(image_path) if img is None: raise FileNotFoundError(f"无法读取图像: {image_path}") h, w = img.shape[:2] thickness = max(2, int(min(h, w) / 400)) color_map = {} for box in boxes: x, y, bw, bh = box["x"], box["y"], box["w"], box["h"] label = box.get("label", "obj") score = box.get("score") # 边界裁剪 x1 = max(0, min(x, w - 1)) y1 = max(0, min(y, h - 1)) x2 = max(0, min(x + bw, w - 1)) y2 = max(0, min(y + bh, h - 1)) if label not in color_map: idx = len(color_map) color_map[label] = ( int((37 * idx + 50) % 256), int((17 * idx + 120) % 256), int((29 * idx + 200) % 256), ) color = color_map[label] cv2.rectangle(img, (x1, y1), (x2, y2), color, thickness) text = f"{label} {score:.2f}" if score is not None else label font_scale = max(0.5, min(h, w) / 1200) (tw, th), baseline = cv2.getTextSize( text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness ) ty = y1 - th - baseline if ty < 0: ty = y2 cv2.rectangle(img, (x1, ty), (x1 + tw, ty + th + baseline), color, -1) cv2.putText( img, text, (x1, ty + th), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (255, 255, 255), thickness ) if output_path: cv2.imwrite(output_path, img) return img4.3 参数计算过程说明
线宽和字体大小都用了自适应公式,这里解释一下思路。min(h, w) / 400的含义是:图像短边每 400 像素对应 1 像素线宽。1080p 图短边 1080,算出来约 2.7,取整后 2;4K 图短边 2160,算出来约 5.4,取整后 5。这样不同分辨率下视觉粗细基本一致。
字体缩放用min(h, w) / 1200,同样是让文字大小随图像尺寸走。这两个系数不是理论推导出来的,是我在多种分辨率下反复调整后觉得比较舒服的值,你可以根据自己的审美微调。
颜色生成用了三个不同的线性同余公式,目的是让相邻类别的颜色差异明显。(37 * idx + 50) % 256这类写法能保证在 idx 递增时颜色值跳变较大,避免出现两个类别颜色接近的情况。这比随机取色更可控,因为随机取色可能连续两次取到相近的颜色。
4.4 调用示例与输出验证
boxes = [ {"x": 120, "y": 90, "w": 200, "h": 260, "label": "person", "score": 0.93}, {"x": 400, "y": 150, "w": 180, "h": 180, "label": "car", "score": 0.87}, ] draw_boxes("input.jpg", boxes, "output.jpg")跑完之后打开output.jpg,检查三件事:框的位置是否和目标准确对齐、文字是否清晰可读、颜色是否容易区分。如果框整体偏移,多半是坐标格式搞混了;如果文字被截断,检查ty的计算和边界处理。
5. 批量处理与数据落地:让标注结果真正可用
5.1 批量读取目录并统计尺寸分布
单个图处理只是开始,实际项目里往往是成百上千张。批量处理时,第一件事是统计尺寸分布,看看有没有异常尺寸的图。
import os from collections import Counter def scan_sizes(folder): counter = Counter() for name in os.listdir(folder): if not name.lower().endswith((".jpg", ".jpeg", ".png", ".bmp")): continue path = os.path.join(folder, name) img = cv2.imread(path) if img is None: print(f"[跳过] 无法读取: {name}") continue h, w = img.shape[:2] counter[(w, h)] += 1 return counter这个函数返回一个尺寸计数字典。如果发现某张图尺寸特别离谱(比如 1x1 或者几万像素宽),基本可以判定是损坏文件或异常数据,需要单独处理。我在一个项目里就遇到过一张 0 字节的图,imread返回 None,如果不判空直接取 shape 就会中断整个批处理流程。
5.2 标注结果的存储格式选择
画在图上只是给人看的,真正要喂给下游流程的,是结构化的标注数据。常见格式有几种:
| 格式 | 坐标表示 | 特点 |
|---|---|---|
| 内部字典 | x, y, w, h | 灵活,适合程序内部流转 |
| 通用交换格式 | x_min, y_min, x_max, y_max | 跨工具兼容性好 |
| 归一化格式 | 相对宽高的比例值 | 与分辨率解耦,适合训练 |
归一化格式特别值得说。它把坐标除以图像宽高,变成 0 到 1 之间的小数。这样无论原图多大,标注都能直接对应。训练模型时几乎都用这种格式,因为输入尺寸经常要统一缩放,归一化坐标不受缩放影响。
def to_normalized(box, img_w, img_h): return { "cx": (box["x"] + box["w"] / 2) / img_w, "cy": (box["y"] + box["h"] / 2) / img_h, "nw": box["w"] / img_w, "nh": box["h"] / img_h, }这里用的是中心点加宽高的表示法,也是很多检测框架的标准输入格式。转换时务必用浮点除法,Python 3 里/本身就是浮点除法,但如果你从其他语言转过来,要留意整数除法的陷阱。
5.3 批量标注的健壮性设计
批量跑的时候,最怕的是中途崩溃导致前面全白干。我的做法是每处理完一张就写一次结果,用追加模式写 JSON Lines 文件,一行一条记录。这样即使中途出错,已完成的部分也是安全的。
import json def batch_annotate(folder, boxes_map, out_jsonl): with open(out_jsonl, "w", encoding="utf-8") as f: for name, boxes in boxes_map.items(): path = os.path.join(folder, name) img = cv2.imread(path) if img is None: f.write(json.dumps({"file": name, "error": "read_failed"}) + "\n") continue h, w = img.shape[:2] record = { "file": name, "width": w, "height": h, "boxes": [to_normalized(b, w, h) for b in boxes], } f.write(json.dumps(record, ensure_ascii=False) + "\n")JSON Lines 的好处是流式可读,不用一次性加载整个文件,出错了也能快速定位到具体哪一行。相比一个大 JSON 数组,它在处理大量数据时更稳。
6. 常见问题排查与避坑经验
6.1 坐标越界与负值处理
标注数据来自人工或模型预测时,出现负坐标或超出图像范围的坐标是家常便饭。如果不做裁剪直接画,OpenCV 不会报错,但框会画到看不见的地方,或者只画出一部分,让人误以为标注丢了。
我的处理原则是:先裁剪再绘制,同时记录裁剪前的原始值。因为裁剪会改变框的实际范围,如果下游需要精确的原始坐标,得保留一份。裁剪逻辑就是前面代码里的max(0, min(x, w-1))那一套,简单但有效。
6.2 中文标签显示乱码
cv2.putText不支持中文,写中文会显示成一串问号。这是个老问题,解决办法有几种:一是用英文或拼音代替;二是用 PIL 库绘制文字再转回 OpenCV 格式;三是提前把文字渲染成图片贴上去。如果项目里必须显示中文,我一般用第二种,虽然多引入一个依赖,但最灵活。
6.3 图像通道顺序导致的颜色错乱
OpenCV 读图是 BGR,而很多其他库(比如 matplotlib)显示时按 RGB 解释。如果你用 matplotlib 显示 OpenCV 读的图,红色和蓝色会对调。画框时如果颜色定义来自 RGB 体系,也会出现同样的错乱。统一原则:在 OpenCV 体系内一律用 BGR,需要跨库时显式转换。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 框位置整体偏移 | 坐标格式混淆 | 确认是 xywh 还是 xyxy |
| 框大小不对 | 缩放比例未同步 | 检查图像与坐标是否同比例变换 |
| 颜色和预期不符 | BGR/RGB 混用 | 统一颜色空间 |
| 文字显示为问号 | 不支持中文 | 换英文或用 PIL 渲染 |
| 批处理中途崩溃 | 未判空/未捕获异常 | 加判空和 try 包裹 |
| 标注与图不对应 | 文件名匹配错误 | 检查文件与标注的对应关系 |
6.5 几个我踩过的坑
第一个坑是用img.shape[0]当宽度。这个错误在代码里非常隐蔽,因为两个值都是整数,不会报错,只是框画出来比例不对。后来我强制自己写h, w = img.shape[:2],并且变量名绝不简写成单个字母以外的东西,减少混淆。
第二个坑是在循环里反复imread同一张图。早期写代码没注意,一个函数里读了三遍同一张图,数据量一大就明显变慢。后来改成读一次传引用,速度提升很明显。
第三个坑是忽略图像方向信息。有些手机拍的图带有旋转元数据,imread读进来可能是旋转过的,导致标注坐标和实际显示对不上。处理这类图时需要先根据元数据做方向校正。
7. 从尺寸到标注:一个完整工作流的串接
把前面所有内容串起来,一个典型的图像标注工作流是这样的:先扫描目录统计尺寸分布,筛掉异常图;然后对每张图读取宽高,拿到标注框;对框做边界裁剪和格式转换;绘制可视化结果用于人工抽检;最后把归一化后的标注写入结构化文件供下游使用。
这个流程里,宽高获取是最基础的一环,但它贯穿始终——裁剪要用、归一化要用、绘制自适应线宽也要用。所以把它写成一个独立、健壮、可复用的函数,是整个项目质量的第一道保障。我现在的习惯是每个视觉项目都先建一个image_utils模块,把尺寸获取、格式转换、绘制这些基础操作放进去,后面所有代码都从这里调用,避免重复造轮子和各处实现不一致。
实际用下来,这套基础打得越扎实,后面接模型、做训练、跑推理时出的问题就越少。很多看似复杂的 bug,追根溯源都是坐标、尺寸、通道这些最基础的东西没处理好。把这一层做对,后面的路会顺很多。