OpenCV ROI截取指南:NumPy切片、视图副本与工程避坑
很多人在写roi = img[y1:y2, x1:x2]这行代码的时候,脑子里默认的想法是"我从大图上抠了一块小图下来",就像用剪刀从照片上剪下一角。但 OpenCV 加 NumPy 的组合给出的答案其实不是这样:你拿到的往往只是一个指向原图内存的视图,不是一张新图。这个认知差,是 ROI(Region of Interest,感兴趣区域)相关的 bug 里出现频率最高的一类来源——你在 ROI 上画了个框,回头一看原图也被画上了;你把 ROI 送去缩放,原图某块莫名其妙糊了;你把 ROI append 进列表,最后发现列表里十个元素长得一模一样。
这篇东西就是围绕"OpenCV 用不同的方法截取 ROI 区域"这件事展开的。我把日常真正会用到的几种截取方式拆开讲:最基础的 NumPy 切片、需要独立副本时的复制写法、非矩形区域的掩膜法、亚像素级的getRectSubPix、配合坐标变换的裁剪、以及交互式和批量的框选方案。每一种我都会说清楚它解决什么问题、在什么场景下会咬人、以及我踩过的具体坑。适合刚上手 OpenCV 做图像处理的朋友,也适合已经写过不少裁剪代码但总觉得"有时候对有时候不对"的人拿来对一遍思路。
1. 先分清"视图"和"副本":ROI 这一步最容易翻车的地方
1.1 一行切片代码背后的内存真相
NumPy 的切片操作是基本切片(basic slicing),它返回的永远是一个 view,不是拷贝。这一点在纯数值计算里是优点——省内存、省时间;但放到图像处理流程里,就变成了一把双刃剑。
import cv2 import numpy as np img = cv2.imread('scene.jpg') # shape: (H, W, 3) roi = img[120:420, 200:500] # 这是视图,不是新图 print(roi.base is img) # True print(np.shares_memory(roi, img)) # Trueroi.base is img为True这件事说明:roi没有自己的数据缓冲区,它只是给img的一段内存起了个新名字。你往roi里写一个像素,img对应位置立刻同步变化——因为它们物理上是同一块内存。
这个设计本身没错,OpenCV 的 C++ 接口里Mat的 ROI 也是同样的语义(Mat roi = img(rect);也是共享数据,靠引用计数管理)。问题在于,很多从 PIL、从其他语言转过来的人,会本能地以为切片等于复制。PIL 的img.crop(box)返回的确实是新图像对象,行为差异就在这儿。
我一般会建议在项目里养成一个习惯:凡是拿到 ROI 之后要写操作的,一律加.copy()。读操作不加无所谓,写操作不加就是在给自己埋雷。
1.2 三个因为我当年不懂共享内存写出来的 bug
说几个具体场景,都是我自己或者身边人真实遇到过的,你对照看看有没有中招。
第一个:批量画框,框全部叠在左上角。有一批图要标检测框,我写了个循环,每次取出 ROI,在 ROI 上cv2.rectangle,然后cv2.imwrite保存 ROI。结果保存出来的小图全是带框的,但主流程最后汇总的那张大图上,所有框都叠在了同一块区域。原因是我复用了同一个变量名,而每一步的 ROI 都指向原图的不同位置——画框画到了原图上,原图被反复污染,最后看起来就是一团乱。
第二个:resize 之后原图变糊。这个更隐蔽。代码里写roi = img[y1:y2, x1:x2],然后roi = cv2.resize(roi, (224, 224))。这里 resize 返回的是新数组,所以赋值之后roi已经不再是视图了,这一步没问题。但如果中间插了一步原地操作,比如roi[:, :, 0] = 0(把蓝通道清零),原图那块区域就真的被清零了。我当时是在做通道实验,做完发现原图上多了一块"缺色"的矩形,排查了半天才反应过来。
第三个:列表里十个 ROI 全一样。这种写法你一定见过:
rois = [] for box in boxes: roi = img[box[1]:box[3], box[0]:box[2]] rois.append(roi)如果boxes里有坐标写错的重复项,或者循环里roi变量被覆盖成了同一个视图,最后rois里就会出现多个指向同一块内存的引用。更麻烦的是,如果你后面统一对rois做归一化、做 padding,改一个等于改一串。
提示:判断两个数组是否共享内存,用
np.shares_memory(a, b)最靠谱。a.base is b只在一层视图时成立,多级切片之后base会指向最原始的数组,容易误判。
理解了视图和副本的区别,后面所有方法的选择就有了判断依据:只读就用视图,要改就复制。这句话可以当口诀记。
2. NumPy 切片:最常用也最容易越界的那条路
2.1 行列与宽高的方向陷阱
img[y1:y2, x1:x2]这个写法里,第一个维度是行(对应 y,高度方向),第二个维度是列(对应 x,宽度方向)。这个顺序和 OpenCV 大量函数的参数顺序是反的。
比如cv2.rectangle(img, (x1, y1), (x2, y2), color, 2)用的是(x, y);cv2.resize(img, (w, h))用的是(宽, 高);但img.shape返回的是(高, 宽, 通道)。这三个顺序各不相同,代码里混着写,出错几乎是必然的。
我的做法是:所有从外部来的坐标,统一先拆成x1, y1, x2, y2四个变量,绝不直接写进切片里。切片那一步只做一次转换:
x1, y1, x2, y2 = 200, 120, 500, 420 roi = img[y1:y2, x1:x2].copy() # 注意 y 在前,x 在后多写一行变量赋值,换来的是三个月后回来看代码还能一眼看懂。这个投入产出比极高。
另一件事:切片是左闭右开的。img[120:420, 200:500]得到的形状是(300, 300, 3),也就是高度 420-120=300,宽度 500-200=300。如果你的框坐标是"包含右下角"的语义(很多标注工具导出的就是这种),你需要写成img[y1:y2+1, x1:x2+1],否则会少一行一列。这个 off-by-one 在人脸对齐、OCR 字符切割这类任务里影响特别明显——切出来的字边缘总差一点。
2.2 OpenCV 对越界切片的"沉默降级"
NumPy 切片遇到越界索引时不会报错,它会自动把范围截断到合法区间。这个特性在数组运算里很方便,但在 ROI 场景下就是个隐形杀手。
img = np.zeros((480, 640, 3), np.uint8) roi = img[400:900, 500:1200] print(roi.shape) # (80, 140, 3),被悄悄截断了你以为自己拿到了 500×700 的图,实际拿到的是 80×140 的一小块。如果后面直接喂给模型,模型的输入尺寸校验可能过了(因为你 resize 了),但内容完全是错的。
更极端的情况是完全越界:
roi = img[600:900, 800:1200] print(roi.shape) # (0, 0, 3)形状是 0,不报错。这时候你调cv2.resize(roi, (224, 224))就会抛error: (-215:Assertion failed) !ssize.empty()。报错位置在 resize,但根因在裁剪,中间隔了好几层调用,排查起来就很烦。
还有一个更阴的:负索引。NumPy 支持负数索引,img[-200:-100, -300:-100]是合法的,它取的是右下角一块。如果检测模型输出了负坐标(在图像边缘截断的框很常见),你的切片代码不会报错,但取到的是完全不相干的位置。这种 bug 特别难发现,因为程序不崩,只是结果飘。
我的建议是写一个统一的裁切函数,把边界处理集中在一处:
def safe_crop(img, x1, y1, x2, y2): """严格裁切:超出部分丢弃,返回 None 表示无效框""" h, w = img.shape[:2] x1, x2 = int(x1), int(x2) y1, y2 = int(y1), int(y2) x1, x2 = max(0, min(x1, w)), max(0, min(x2, w)) y1, y2 = max(0, min(y1, h)), max(0, min(y2, h)) if x2 <= x1 or y2 <= y1: return None return img[y1:y2, x1:x2].copy()注意这里返回值用None表示无效,而不是返回空数组。空数组传给下游容易被误当成有效数据,None是显式的失败信号,调用方必须处理。
2.3 什么情况下必须用"严格裁切"而不是"padding"
上面这个函数是丢弃越界部分。但有些场景你必须保留原图尺寸,比如要做 1:1 的特征对齐,或者要做 padding 到固定尺寸。这时候就换成带边界填充的版本:
def crop_with_pad(img, x1, y1, x2, y2, mode=cv2.BORDER_REPLICATE): h, w = img.shape[:2] pad_l, pad_t = max(0, -x1), max(0, -y1) pad_r, pad_b = max(0, x2 - w), max(0, y2 - h) if any([pad_l, pad_t, pad_r, pad_b]): img = cv2.copyMakeBorder(img, pad_t, pad_b, pad_l, pad_r, mode) x1, x2 = x1 + pad_l, x2 + pad_l y1, y2 = y1 + pad_t, y2 + pad_t return img[y1:y2, x1:x2].copy()cv2.copyMakeBorder的 padding 模式有几种,选择依据是内容连续性的假设:
| 模式 | 行为 | 适合场景 |
|---|---|---|
BORDER_REPLICATE | 复制最边缘的像素 | 自然图像边缘外扩,最常用 |
BORDER_REFLECT_101 | 镜像反射(不含边缘像素) | 纹理类图像,边缘无明显边界感时 |
BORDER_CONSTANT | 填充固定颜色 | 需要明确"这里是空白"的场合,比如 letterbox 用灰色 |
BORDER_WRAP | 循环填充 | 极少用,除非图像本身是周期性的 |
我默认用REPLICATE,因为它在目标检测和关键点任务里最不容易引入突兀的假边缘。CONSTANT填黑或填灰适合喂给神经网络,能明确告诉网络"这块不是内容"。
注意:
rotation之后做裁剪,坐标一定要用变换后重新计算的值,不能沿用原图坐标。同理,如果你有多个来源的坐标(手工标注 + 模型预测 + 跟踪器输出),最好在入口处就统一成一种语义,标注清楚是"包含边界"还是"不包含边界"。
3. 需要独立副本时:copy 与 np.array 到底差在哪
3.1 几种复制写法实测对比
一旦确定要复制,写法其实有几种,性能和行为上有些细微差别。
roi_view = img[y1:y2, x1:x2] a = roi_view.copy() # ndarray.copy() b = np.copy(roi_view) # 等价于 a c = roi_view[...] # 省略号索引 + 基础切片,仍是视图 d = roi_view[::1, ::1] # 步长为1的切片,仍是视图 e = np.array(roi_view) # 默认 copy=True f = np.ascontiguousarray(roi_view) # 保证 C 连续这里c和d看着像复制,实际上还是视图。这是 NumPy 里很容易误判的一点:只要用的是基础切片(不含花式索引),结果就是视图。想强制复制,得显式调用.copy()或者用np.array(..., copy=True)。
np.array()比起.copy()多了一个动作:它会先检查输入是不是 ndarray,是的话再复制。这个额外的类型判断在大批量小数组操作里有一点点开销,但差异在微秒级别,常规场景可以忽略。
真正值得关注的是np.ascontiguousarray。如果 ROI 是从原图切出来的,它本身在行方向是连续的(因为原图是 C 连续的),列方向需要跨行跳跃。经过转置、cv2.transpose、或者某些步长操作之后,数组可能变成非连续的。非连续数组传给 OpenCV 的某些函数会出问题,或者内部默默复制一份导致性能下降。
print(roi_view.flags['C_CONTIGUOUS']) # 通常 True print(cv2.transpose(roi_view).flags['C_CONTIGUOUS']) # 可能 False我在处理一批图像做特征提取时遇到过这个问题:数据来源是cv2.transpose之后的数组,喂给某个第三方库死活不对,日志里全是形状正确但数值错乱的警告,最后定位到连续性。用np.ascontiguousarray包一层就解决了。
3.2 什么场景必须复制
给几个明确的判断标准,遇到以下情况,一律.copy():
| 场景 | 为什么必须复制 |
|---|---|
| ROI 要进列表 / 字典缓存 | 视图会跟着原图变,缓存失效 |
| ROI 上要做绘制、原地算术 | 会污染原图 |
| ROI 要跨线程 / 跨进程传递 | 共享内存引发竞态 |
| 原图要释放,但 ROI 还要用 | 视图持有原图引用,内存不释放 |
| ROI 尺寸可能为 0 或需要判断空 | 副本便于显式校验 |
最后一条值得展开说。视图的base属性持有原数组的引用,这意味着只要 ROI 还活着,整张原图就无法被 GC 回收。如果你从一批 4K 图里切了几万个 ROI 存进列表,每个 ROI 都拽着一张 4K 原图不放,内存会以肉眼可见的速度涨上去。这种内存泄漏不会报错,只会让你的程序在跑到第 N 张时突然被系统杀进程。
而.copy()出来的 ROI 是独立的小数组,原图用完就能释放。所以在"批量裁剪后长期持有"这个场景里,复制不仅是正确性问题,更是内存问题。
反过来,如果你的流程是"切一小块 → 立刻处理 → 丢弃",那么用视图反而更好,省一次内存分配。这种短生命周期的小块处理,在实时视频流里能省下可观的分配开销。
4. 非矩形 ROI:掩膜法的完整实现与它不适用的地方
4.1 掩膜的两种落地方式
前面讲的都是矩形裁剪,本质上是对数组做切片。但真实业务里,ROI 经常不是矩形——车牌是斜的,人脸是椭圆的,文档是梯形透视的,工业检测里的目标形状完全没规律。
处理非矩形区域,标准做法是掩膜(mask)。核心思路:生成一张和原图同尺寸的单通道图,目标区域填 255,其余填 0,然后用bitwise_and把无关区域清零。
def mask_polygon(img, points): h, w = img.shape[:2] mask = np.zeros((h, w), dtype=np.uint8) pts = np.array(points, dtype=np.int32).reshape(-1, 1, 2) cv2.fillPoly(mask, [pts], 255) return cv2.bitwise_and(img, img, mask=mask), maskcv2.fillPoly支持任意多边形,pts必须是int32且形状为(N, 1, 2)。这个形状要求坑过很多人——直接传(N, 2)的数组会报错,必须reshape一次。
cv2.bitwise_and(img, img, mask=mask)这个写法看着奇怪,为什么要传两次img?因为bitwise_and是二元运算,mask的作用是限制参与运算的像素范围。传两次相同的图,等价于"原图和它自己做与运算",结果还是原图,只是掩膜外的部分被置 0。这是 OpenCV 里掩膜应用的惯用写法。
还有一种更轻量的方式,直接用布尔索引:
mask_bool = mask.astype(bool) result = img.copy() result[~mask_bool] = 0这种写法在纯 NumPy 环境下更直观,而且支持三通道(result[~mask_bool]会广播到所有通道)。但如果后面还要做形态学、找轮廓,还是用cv2的掩膜更顺手。
4.2 掩膜法保留原尺寸带来的后续麻烦
掩膜法的第一个特点是:输出尺寸和原图一样大。掩膜外的区域变成了黑色(0),但空间还在。
这带来两个实际问题。
问题一:后续计算被大量零值污染。如果你要对 ROI 算均值、算直方图、算梯度,掩膜外的零会严重拉偏结果。算均值要用cv2.mean(img, mask=mask)带掩膜参数,算直方图要用cv2.calcHist的 mask 参数。这些细节不看文档很容易漏。
mean_val = cv2.mean(img, mask=mask)[:3] # 只统计掩膜内 hist = cv2.calcHist([gray], [0], mask, [256], [0, 256])问题二:想拿到紧凑的子图得再切一次。掩膜法得到的还是大图,如果下游需要一个小尺寸的紧凑 ROI(比如喂给分类网络),你得再算外接矩形切一刀:
x, y, w, h = cv2.boundingRect(pts) roi_compact = cv2.bitwise_and(img, img, mask=mask)[y:y+h, x:x+w].copy() mask_compact = mask[y:y+h, x:x+w].copy()注意这里切完还要把 mask 也切一份一起带走。如果你只带图像不带 mask,下游就再也分不清哪部分是有效内容、哪部分是补黑的边角了。这个信息丢失在做语义分割、实例分割的预处理时是致命的。
我个人更推荐的做法是双输出:一个紧凑图像 + 一个同尺寸的紧凑掩膜,打包成一个 namedtuple 或者 dict 往下传。别嫌麻烦,后面每用一个地方都会感谢自己。
from collections import namedtuple CropResult = namedtuple('CropResult', ['image', 'mask', 'offset']) def polygon_crop(img, points): mask = np.zeros(img.shape[:2], np.uint8) pts = np.array(points, np.int32).reshape(-1, 1, 2) cv2.fillPoly(mask, [pts], 255) x, y, w, h = cv2.boundingRect(pts) roi = cv2.bitwise_and(img, img, mask=mask)[y:y+h, x:x+w].copy() m = mask[y:y+h, x:x+w].copy() return CropResult(roi, m, (x, y))offset这个字段很重要,它是紧凑 ROI 左上角在原图里的坐标。后面要把检测结果映射回原图坐标,全靠它。
4.3 掩膜法不适用的场景
掩膜法不是万能的。有一个场景要特别提醒:如果你只是想要一个旋转的矩形区域,用掩膜法会浪费大量空间。比如车牌倾斜 30 度,用旋转矩形掩膜之后,外接矩形可能比车牌本身大好几倍,有效像素占比很低。这时候正确做法是用仿射变换把区域"摆正"再切,见下面第 5 节。
另外,掩膜法的计算量随图像尺寸增长。在 4K 图上做几十个小区域的掩膜,每个都要分配一张全尺寸的单通道图,内存和耗时不划算。这种场景更适合先把外接矩形切出来,再在小子图上做掩膜——坐标减去 offset 就行。
5. 变换之后的 ROI:坐标怎么跟着一起走
5.1 先裁剪后缩放,还是先缩放后裁剪
这个顺序问题在预处理流水线里出现频率极高,而且两种做法结果不同。
先裁剪后缩放:几何关系清晰,缩放时只处理感兴趣区域,计算量小。缺点是如果裁剪区域本身分辨率很低,放大后会糊。
先缩放后裁剪:坐标需要按比例换算,x_new = x_old * scale_x。优势是可以一次性把大图缩到工作分辨率,后续所有操作都在小图上做,内存友好。
我一般按数据源决定:如果是高分辨率工业相机图,先缩放(用INTER_AREA)再裁剪,能显著降低处理时间;如果是手机拍摄,本身分辨率不高,先裁剪再缩放更清晰。
# 先裁后缩 roi = img[y1:y2, x1:x2] roi_resized = cv2.resize(roi, (224, 224), interpolation=cv2.INTER_LINEAR) # 先缩后裁 h, w = img.shape[:2] scale = 0.5 small = cv2.resize(img, (int(w*scale), int(h*scale)), interpolation=cv2.INTER_AREA) roi_resized = small[int(y1*scale):int(y2*scale), int(x1*scale):int(x2*scale)]注意INTER_AREA和INTER_LINEAR的区别:缩小图像用INTER_AREA(区域重采样,等效于对像素块求平均),放大用INTER_LINEAR或INTER_CUBIC。用错了会有明显的摩尔纹或者边缘锯齿。这个知识点很基础,但我在代码审查里见过太多次默认参数不写的情况。
还有一个隐藏的坑:int()截断 vsround()四舍五入。坐标乘完比例之后如果是200.7,int得 200,round得 201,差一个像素。单张图看不出来,批量做数据增强、做标签对齐的时候,一个像素的漂移会在 IoU 上体现出来。我倾向于统一用round然后int,或者干脆用cv2.resize配套的缩放比例反向计算。
5.2 用 getRectSubPix 处理亚像素中心
有一种场景,切片写不出来:中心点带小数。比如关键点检测或者跟踪器给的中心是(320.4, 240.7),你想以它为中心抠一个 200×150 的块。
cv2.getRectSubPix就是干这个的:
patch = cv2.getRectSubPix(img, (200, 150), (320.4, 240.7))三个参数:源图、patchSize(注意是(宽, 高))、center(浮点(x, y))。它会用双线性插值在浮点位置采样,得到的块在亚像素意义上更准确。
这个函数有两个我很喜欢的特点:一是边界自动处理,超出图像范围时按边缘像素复制补齐,不会报错也不会返回空;二是尺寸恒定,永远返回你请求的尺寸。
但正因为"边界自动补齐"这个特性,它也会掩盖坐标错误。如果你的中心点算错了,跑到图像外面去了,getRectSubPix不会告诉你,只会默默地返回一张大部分是边缘像素复制的图。调试时要留意那种"四周糊成一片"的裁剪结果,八成是中心点或尺寸算错了。
getRectSubPix还有一个限制:只支持 8 位和 32 位浮点类型。如果图像是 16 位深度(医学影像、工业检测常见),得先转换类型。这个限制在文档里写得不显眼,我第一次用 16 位图时直接报错。
5.3 旋转 ROI:仿射变换矩阵的正反用
倾斜矩形区域的正确截法分两步:先算旋转矩阵,把区域摆正;再在摆正后的坐标系里做普通矩形裁剪。
def crop_rotated(img, center, size, angle): """center: (cx, cy), size: (w, h), angle: 逆时针角度""" M = cv2.getRotationMatrix2D(center, angle, 1.0) h, w = img.shape[:2] rotated = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) cx, cy = center bw, bh = size[0] / 2, size[1] / 2 x1, y1 = int(cx - bw), int(cy - bh) x2, y2 = int(cx + bw), int(cy + bh) return rotated[max(0, y1):y2, max(0, x1):x2].copy(), M这里M必须返回出去。因为你在摆正后的图上切出来的坐标,要映射回原图,得用M的逆。OpenCV 没有现成的invertAffineTransform反变换点的方法,但可以这样算:
def apply_affine_to_point(pt, M): p = np.array([[pt[0], pt[1]]], dtype=np.float32).reshape(-1, 1, 2) return cv2.transform(p, M).reshape(-1, 2)[0]cv2.transform对点做的是M * [x, y, 1],返回齐次坐标的前两维。要把变换后的点还原,构造M的逆矩阵再调一次即可。
M_inv = cv2.invertAffineTransform(M) orig_pt = apply_affine_to_point(new_pt, M_inv)这套正反变换逻辑,在文档矫正、车牌识别、票据识别里是标配。写的时候一定要在脑子里画清楚"当前坐标是哪个坐标系下的",最好在变量名里带上_rot、_crop、_orig这类后缀。我吃过的最大的亏,就是把摆正后的坐标当成原图坐标存进了数据库,几百条标注全部作废重标。
6. 交互式与批量 ROI:把坐标从硬编码里解放出来
6.1 用 selectROI 和鼠标回调两种方式框选
调试阶段最烦的就是硬编码坐标。改一次跑一次,改一次跑一次。OpenCV 自带交互式框选,能省掉大量来回。
最省事的是cv2.selectROI:
r = cv2.selectROI('pick', img, showCrosshair=True, fromCenter=False) cv2.destroyWindow('pick') x, y, w, h = map(int, r) roi = img[y:y+h, x:x+w].copy()这里有个特别容易踩的坑:返回值是(x, y, w, h),不是(x1, y1, x2, y2)。我第一次用的时候直接当x1, y1, x2, y2用了,切出来的区域大小完全不对,还以为是 OpenCV 有 bug。文档里其实写得很清楚,但那个元组的四个数字乍看之下就是坐标对。
另外selectROI返回的r是(0,0,0,0)表示用户按了 ESC 或者直接回车没选。这个返回值必须判断,否则你会得到一张空图。
selectROI只支持单个区域。要框多个,用cv2.selectROIs,它会返回一个数组,但窗口交互体验一般——每次框完要按空格确认,按 ESC 结束。批量标数据时我更喜欢自己写鼠标回调,控制力更强:
class ROISelector: def __init__(self, img, win='roi'): self.img = img.copy() self.disp = img.copy() self.pt = [] self.boxes = [] self.win = win cv2.namedWindow(win) cv2.setMouseCallback(win, self._on_mouse) def _on_mouse(self, event, x, y, flags, param): if event == cv2.EVENT_LBUTTONDOWN: self.pt = [(x, y)] elif event == cv2.EVENT_MOUSEMOVE and self.pt: self.disp = self.img.copy() cv2.rectangle(self.disp, self.pt[0], (x, y), (0, 255, 0), 1) elif event == cv2.EVENT_LBUTTONUP and self.pt: self.pt.append((x, y)) x1, y1 = self.pt[0] x2, y2 = self.pt[1] self.boxes.append((min(x1,x2), min(y1,y2), max(x1,x2), max(y1,y2))) self.disp = self.img.copy() for b in self.boxes: cv2.rectangle(self.disp, b[:2], b[2:], (0, 0, 255), 1) self.pt = [] cv2.imshow(self.win, self.disp) def run(self): while True: k = cv2.waitKey(20) & 0xFF if k == 27: # ESC 结束 break cv2.destroyWindow(self.win) return self.boxes用类封装的好处是状态干净。用全局变量写回调,多个窗口或者多次调用时状态互相污染,是典型的难调 bug。回调函数里那个self.pt = [(x, y)]用重新赋值而不是self.pt.append,也是为了每次按下左键时清空上次的起点。
注意回调里的self.disp每次 mousemove 都重新copy()一次原图,这是必须的。如果直接在self.disp上叠加画框,框会越画越多。用一张干净的底图每次重绘是最稳妥的做法。代价是拖动时有点卡,图像大的话可以考虑只重绘变化区域,但一般几十万像素的图完全够用。
6.2 归一化坐标:多分辨率兼容的关键
一个项目里图像来源往往不止一种:手机拍的、相机拍的、截屏的、扫描的。分辨率从 640×480 到 4000×3000 都有。如果标注存的是绝对像素坐标,换一批图就得全部重标。
解法是存归一化坐标(0 到 1 的比例),用的时候再乘回实际尺寸:
def norm_to_abs(box, w, h): x1, y1, x2, y2 = box return (int(round(x1*w)), int(round(y1*h)), int(round(x2*w)), int(round(y2*h))) def abs_to_norm(box, w, h): x1, y1, x2, y2 = box return (x1/w, y1/h, x2/w, y2/h)用归一化坐标还有个额外好处:数据增强(随机缩放、随机裁剪)之后,坐标会自动跟着变,不用重新算。这也是主流检测框架(YOLO 系列)都用归一化坐标的原因之一。
但要注意宽高比的问题。如果原始图像是 16:9,训练时 letterbox 到 1:1,那坐标就不是简单乘一下了,还必须考虑 padding 的偏移:
def letterbox(img, new_size=(640, 640), color=114): h, w = img.shape[:2] scale = min(new_size[0]/w, new_size[1]/h) nw, nh = int(round(w*scale)), int(round(h*scale)) resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA) canvas = np.full((new_size[1], new_size[0], 3), color, np.uint8) dx, dy = (new_size[0]-nw)//2, (new_size[1]-nh)//2 canvas[dy:dy+nh, dx:dx+nw] = resized return canvas, scale, dx, dy反算回去就是x_orig = (x_letter - dx) / scale。dx和dy这两个偏移量是整个 letterbox 流程里最容易漏传的参数。写函数时我强烈建议把 scale、dx、dy 一起返回,不要只返回图像。少返回一个,下游就得自己重算,重算就容易和实际不一致,最后坐标整体偏移几个像素,调半天。
6.3 批量裁剪的封装与命名
真正跑批的时候,代码要处理的是几千上万张图。这时候几个工程细节就很重要了。
第一是失败要留痕。哪张图裁失败了、为什么失败(框越界、图像读不出来、尺寸为 0),都得记下来,不然跑完一堆图你根本不知道少了什么。
第二是输出命名要可追溯。我一般用{原图名}_{序号}_{x1}_{y1}_{x2}_{y2}.jpg,虽然长,但出问题时能直接定位回去。纯自增编号看着清爽,一旦发现某张图有问题,就得翻日志找对应关系。
第三是不要一次性把所有 ROI 读进内存。一边遍历一边写盘,内存占用是常数级的。前面提过,视图会拖住原图,即使你用.copy(),GPU 显存和 CPU 内存也扛不住上万张图同时驻留。
import os, glob def batch_crop(img_dir, out_dir, boxes_map, ext='.jpg'): os.makedirs(out_dir, exist_ok=True) failed = [] for p in sorted(glob.glob(os.path.join(img_dir, '*' + ext))): name = os.path.splitext(os.path.basename(p))[0] img = cv2.imread(p) if img is None: failed.append((name, 'read_fail')) continue for i, box in enumerate(boxes_map.get(name, [])): roi = safe_crop(img, *box) if roi is None: failed.append((name, f'box{i}_invalid')) continue cv2.imwrite(os.path.join(out_dir, f'{name}_{i}.jpg'), roi) return failedcv2.imread返回None而不抛异常,这是 OpenCV 的一贯作风。判断if img is None是必须的,尤其处理混合格式(jpg、png、webp)或者跨平台路径时。Windows 路径里的反斜杠、中文路径,都会让imread静默失败。中文路径的解法是用cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR),这个技巧值得记一下。
7. 排查清单:ROI 出问题时按这个顺序看
踩坑踩多了,会形成一套固定的排查顺序。下面这张表是我自己的清单,遇到"裁剪结果不对"时从上往下过一遍,八成能定位到。
| 现象 | 最可能原因 | 快速验证方式 |
|---|---|---|
| 裁剪区域是黑的 | 坐标越界,切片返回空或极小区 | print(x1,y1,x2,y2, img.shape) |
| 裁剪位置整体偏移 | x/y 顺序写反,或(w,h)当(h,w)用 | 打印roi.shape对比期望宽高 |
| 修改 ROI 后原图变了 | 切片是视图,没.copy() | np.shares_memory(roi, img) |
| 保存的图全一样 | 缓存的是视图,原图被后续覆盖 | 检查列表元素是否共享内存 |
| 尺寸比预期小一截 | 左闭右开,右上角差一个像素 | 对比y2-y1与roi.shape[0] |
| 出现不相关内容 | 模型输出负坐标,负索引取到了右下角 | 检查坐标最小值是否 < 0 |
| 缩放后坐标错乱 | letterbox 的 scale/dx/dy 没传下游 | 反算一遍坐标看能否回到原图 |
| 内存一路涨 | 长期持有视图,原图无法释放 | 检查 ROI 是否有.base属性 |
报!ssize.empty() | 上游裁剪拿到空数组 | 在 resizing 前加尺寸断言 |
| 边缘出现明显边框 | padding 模式选错或 fill 值突兀 | 试BORDER_REPLICATE |
这张表里最值得多说两句的是第三和第四行。它们表面上是两个问题,根子上是同一件事:视图语义。很多人第一次遇到"改了 ROI 原图也变了"会觉得是灵异事件,理解了 NumPy 的视图机制之后就再也不会犯。这也是为什么我在这篇开头就先讲内存模型——不是卖弄原理,是因为后面所有的坑都能从这个根上解释清楚。
另外补一个不在表里但经常遇到的:彩色图和灰度图混用。灰度图shape是(H, W),只有两个维度,切片写img[y1:y2, x1:x2]没问题。但如果代码里某处假设了三通道,写了个img[y1:y2, x1:x2, 0],灰度图上就直接报IndexError。更麻烦的是反过来——灰度图只有两维,某些函数接受,某些不接受,报错信息还各不相同。我的做法是在流程入口统一转成三通道(cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)),或者反过来统一转灰度,选定一种然后全流程不改。
最后分享一个我自己养成的习惯:每写一个裁剪函数,先在确认了(H, W)的图上看一眼roi.shape。别急着往下跑,就这么一眼,能挡掉后面 80% 的尺寸问题。花两秒钟打一行print,比花半小时排查Assertion failed划算太多。