
简介选择性搜索是目标检测中生成候选区域的关键技术这份资源面向Python初学者与计算机视觉开发者演示了在PIL、numpy等库支持下完成超像素分割、区域合并与候选框输出的完整过程能有效规避传统滑动窗口带来的大量冗余计算。包体精简共19个文件包含3个Python步骤脚本、12张PNG测试图、2张JPG素材以及1份README说明文档和LICENSE许可文件压缩包仅53KB便于直接下载运行。目前已有283人学习。借助三段式Python脚本读者可以完整经历输入图像预处理、候选区域筛选过滤、特征提取与结果可视化等环节尤其适合目标检测入门和RCNN系列模型的学习实验同时包内自带十余张测试图片可对比不同场景下的分区效果帮助理解颜色、纹理等特征的合并策略是快速上手选择性搜索的实用参考。1. 选择性搜索比滑动窗口聪明在哪先圈出 2000 个候选框再做识别如果你训练过自己的第一个目标检测器大概率经历过对着滑动窗口发呆的时刻宽高比怎么枚举、步长定多少、窗口数量怎么控选择性搜索Selective Search就是用来终结这种穷举的经典方案它不负责“认出目标”只负责把图像里“像目标”的位置尽可能全地圈出来。而项目标题里的 selective_search正是把这个算法打包成几行代码就能调用的最小示例。这篇文章我会按“原理 → 跑通 → 训练 → 踩坑 → 验证”的顺序把它讲透适合正在读 R-CNN 源码、做检测数据预处理、或者在面试中被问到区域提议的从业者。读完你能独立跑出候选框也知道它们能不能喂给模型、参数崩了往哪排查。2. 选择性搜索的可复现原理超像素分割起点与四条相似度规则2.1 为什么用 Felzenszwalb 图分割做起点而不是 SLIC选择性搜索的核心思路很简单先把图像切成很多小区域再把“长得像同一个物体”的小区域一层层合并最后把合并过程里出现过的所有边界框都当作候选框。第一步的过分割质量决定了后面候选框的上限所以分割算法不能随便选。常见做法是用 Felzenszwalb 图分割作为起点而不是 SLIC 或分水岭。原因有两个第一它基于图割在颜色相近但边界不明显的区域之间更容易贴合物体边缘第二它支持把分割结果和后续的层次化合并天然搭在一起每一步合并都是在上一步的图上进行的。SLIC 虽然快但超像素的规整形状反而会让候选框碎得厉害后续合并成本更大。跑通 selective_search 之前你可以先用一个十几行的脚本单独看看这个分割起点的效果这样后面调参时心里有数。import cv2 from skimage.segmentation import felzenszwalb img cv2.imread(demo.jpg) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # scale 越大初始分割块越粗糙sigma 是高斯模糊方差先平滑再分割 seg felzenszwalb(img_rgb, scale200, sigma0.8, min_size50) print(分割块数量:, seg.max() 1)这段代码做的事就是选择性搜索的第一步。scale200控制的是“多像才能并成一个块”值越大结果越粗候选框数量越少sigma0.8是分割前高斯模糊的平滑强度主要用来抑制纹理噪声对细小边缘保留有直接影响min_size50是小于这个像素数的碎片会被合并掉可以理解为分割的“最小面积门槛”。我一般会用seg.max() 1去判断一张图切了多少块如果这个数和你图像的复杂度完全不匹配后面选择性搜索的候选框质量基本不会好。2.2 四条相似度规则颜色、纹理、尺寸与形状兼容分割完成之后选择性搜索进入层次合并阶段。它要做的不只是把相邻区域叠起来而是每次先计算所有相邻区域的相似度合并最像的那一对然后更新区域池和相似度循环到只剩一个区域为止。关键就在这里相似度不能只看颜色。论文里定义了四条相似度组合起来才是最终得分。颜色相似度是基于每个通道的 25 桶直方图做直方图相交。纹理相似度是每个通道上高斯导数滤波结果的直方图相交可以理解为“表面粗糙程度像不像”。尺寸相似度是让面积小的区域优先合并防止某个大区域一路吞掉所有邻居。形状兼容度看的是合并后的包围框能省下多少空余面积越紧凑越优先。四条加起来保证合并出的候选框既不是纯色块堆积又不至于让边界惨不忍睹。为了看清这个合并过程的形状不必直接啃源码可以用一个简化循环理解它的骨架import numpy as np # region_ids 是当前区域编号列表sim_matrix 存两两之间相似度 while len(region_ids) 1: i, j np.unravel_index(np.argmax(sim_matrix), sim_matrix.shape) if i j: sim_matrix[i, j] -1 continue new_id region_ids.pop(max(i, j)) region_ids.pop(min(i, j)) region_ids.append(new_id) sim_matrix update_sim_matrix(sim_matrix, i, j)这个循环虽然忽略了优先队列和区域更新细节但它的遍历逻辑和 selective_search 是一致的每一轮找相似度最高的一对合并重算。update_sim_matrix在真正的库里会重新计算新区域和所有相邻区域的四条相似度这里只用函数名代表。理解这个循环的价值在于你能立刻明白为什么候选框数量会随参数变化scale越大最开始的块就越少合并轮数越少候选框自然更少。对比滑动窗口区域提议的方式把“搜索所有位置和尺度”这个问题换成了“在分割层次上挑边界框”。一张常见图片用滑动窗口可能要跑几十万个窗口选择性搜索一般只生成千到两千量级的候选框R-CNN 这一代检测器就是靠这个数量级优势才跑得动。这也是为什么 2014 年之后 region proposal 几乎变成检测管线的标准前缀。3. 跑通示例代码安装 selective_search、调用候选框并可视化3.1 安装依赖并确认读图正常先排除两个最基础的变量selective_search 的使用门槛很低但我见过不少人在环境这步就翻车。这个库底层依赖 OpenCV 和 scikit-image而这两个包互相之间有过不少版本摩擦。常见做法是先建一个干净的虚拟环境再一次性装齐不要用系统自带的 Python 环境硬跑。pip install selectivesearch opencv-python scikit-image安装完成后先不要急着调算法用这段代码确认 OpenCV 能正常读图以及 skimage 有没有被意外覆盖import cv2 import skimage print(opencv:, cv2.__version__) print(skimage:, skimage.__version__) img cv2.imread(demo.jpg) print(img.shape if img is not None else 读图失败)这里值得多说一句cv2.imread对中文路径支持很差如果你把图片放在“测试数据”这类文件夹里img会直接变成None而 selective_search 完全没有办法给你有意义的报错只会崩在一堆底层的 numpy 计算里。所以建议先用这张验证脚本确认读图成功再往后走。3.2 调用 selective_search 得到候选框返回值结构先打印再解包接下来是最核心的一步。selective_search 的调用方式在不同版本里存在一个非常坑的差异有的版本返回的是区域字典列表有的版本返回的是候选框数组加其他中间量。我吃过这个亏现在习惯先print(type(regions))看看结构再做保护性解包而不是直接按网上老代码一口气解包。import cv2 import selectivesearch img cv2.imread(demo.jpg) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # scale 越大候选框越少sigma 越小边缘越碎min_size 过滤过小区域 regions selectivesearch.selective_search( img_rgb, scale200, sigma0.8, min_size50 ) print(type(regions)) candidates [] for item in regions: if isinstance(item, dict): rect item.get(rect) size item.get(size, 0) else: rect item size 0 if rect is None: continue x, y, w, h rect candidates.append((x, y, w, h))这段代码的要点在于“不假设返回值长什么样”。如果regions是字典列表就从rect字段里取坐标如果是普通列表就直接当作坐标元组处理。这样无论库的版本怎么变只要候选框信息还在代码都能跑。size字段在字典结构里表示该候选框对应的区域像素总数后面的去重和过滤会用到它。拿到候选框后我们一般会按面积排序因为面积大的候选框通常更接近完整物体而碎片化的候选框大量集中在面积几像素到几十像素的范围。参数作用调参方向scale初始分割粒度越大候选框越少目标小就调小目标大就调大sigma分割前高斯模糊力度图像噪声多就加大纹理清晰就减小min_size过滤过小区域目标太小要调低否则全被吞掉这张表不是精确公式更多是经验方向。尤其scale它对候选框数量的影响几乎是线性的我后面会专门讲它的玄学属性。3.3 可视化候选框并理解坐标格式是 xywh不是 xyxy候选框跑出来之后最直观的验证方式就是画图。直接把前一两百个框画在原图上你就能一眼看出算法有没有产生合理的“目标可能位置”。import random boxes sorted(candidates, keylambda b: b[2] * b[3], reverseTrue) for x, y, w, h in boxes[:100]: color (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) cv2.rectangle(img, (x, y), (x w, y h), color, 2) cv2.imwrite(candidates.jpg, img)这里有一个非常容易被新手忽略的坑selective_search 输出的坐标是(x, y, w, h)其中w和h是宽和高不是右下角坐标。如果你把它当成(x1, y1, x2, y2)去画框或者去算 IoU框的位置会整体偏移尤其是长宽比悬殊的目标偏移量肉眼可见。我给一个简单的验证方法画框时先自己宽高转换一次再和直接画的对比确认坐标系没搞反。x1, y1 x w, y h cv2.rectangle(img, (x, y), (x1, y1), (0, 0, 255), 1)如果你看到红色框和绿色框完全重合说明你的坐标系理解是对的。这一步做扎实了后面喂数据给模型时才不会出“框全歪了但没报错”这种最磨人的问题。4. 把候选框转成训练样本IoU 采样、回归目标与多尺度合并4.1 用 IoU 给候选框打标正样本、负样本与忽略区候选框本身不带语义必须和真实标注框Ground Truth比较之后才能确定“这个框里有没有目标”。这里最常用的工具是 IoU也就是交集除以并集。R-CNN 那一代的做法是设定两个阈值与某个真实框的 IoU 大于 0.5 的候选框当作正样本小于 0.3 的当作负样本中间部分直接忽略。def iou_xywh(box_a, box_b): ax1, ay1 box_a[0], box_a[1] ax2, ay2 ax1 box_a[2], ay1 box_a[3] bx1, by1 box_b[0], box_b[1] bx2, by2 bx1 box_b[2], by1 box_b[3] inter_w max(0, min(ax2, bx2) - max(ax1, bx1)) inter_h max(0, min(ay2, by2) - max(ay1, by1)) inter inter_w * inter_h union box_a[2] * box_a[3] box_b[2] * box_b[3] - inter return inter / union if union 0 else 0这段代码里的box_a和box_b都是(x, y, w, h)所以先要转成左上角和右下角坐标再算交集。inter_w和inter_h用max(0, ...)防住两个框不相交的情况避免出现负面积。union用两个框面积之和减去交集避免重复计算。拿到 IoU 之后正负样本的分配逻辑就很直白了positive [] negative [] for cand in candidates: max_iou max(iou_xywh(cand, gt) for gt in gt_boxes) if max_iou 0.5: positive.append(cand) elif max_iou 0.3: negative.append(cand)这里要注意正负样本数量通常极不平衡。一张图目标可能只有几个但负样本候选框成百上千。常见做法是先收集所有正样本再从负样本里随机抽一部分让比例维持在 1:2 或 1:3 附近。不要试图全量塞给模型训练稳定性和速度都会好很多。4.2 回归目标为什么用“偏移 log”把框预测变成小数值回归正样本不是直接拿去训练“输出四个坐标”的。目标检测里的习惯是让模型预测候选框到真实框的偏移量而不是绝对坐标。直接回归坐标的问题在于不同尺度的目标坐标相差太远模型要同时拟合大框和小框损失会不稳定。import numpy as np def make_reg_target(proposal, gt): px proposal[0] proposal[2] / 2 py proposal[1] proposal[3] / 2 gx gt[0] gt[2] / 2 gy gt[1] gt[3] / 2 tx (gx - px) / proposal[2] ty (gy - py) / proposal[3] tw np.log(gt[2] / proposal[2]) th np.log(gt[3] / proposal[3]) return [tx, ty, tw, th]代码里出现了两组不同性质的量tx和ty是中心点偏移量除以候选框宽高做了归一化让不同尺寸的候选框偏移范围落到同一量级tw和th是宽高比的对数因为比例关系是乘性的取 log 才能把“宽变成两倍”和“宽变成四倍”映射成等差数值。如果不加 log模型很可能在大目标的宽高预测上疯狂震荡小目标又被压得学不动。实际训练时这个函数的输入是上一节筛选出的正样本候选框和它对应的真实框。每个正样本单独算一组目标值和一个类别标签一起打包成训练样本。4.3 单尺度候选框不够用多尺度合并后再做去重selective_search 原版最常被诟病的一点是它对小目标不够友好。scale200可能漏掉几十像素的小物体scale100又让候选框数量暴涨。一个成熟的做法是跑多个尺度把所有候选框合并成一个候选池再用去重逻辑裁掉重复框。def multi_scale_proposals(img_rgb, scales(100, 200, 500)): pool [] for s in scales: regions selectivesearch.selective_search( img_rgb, scales, sigma0.8, min_size20 ) for item in regions: rect item[rect] if isinstance(item, dict) else item pool.append(rect) print(fscale{s}: {len(pool)} 个候选框) return pool多尺度合并后同一目标大概率被几个尺度的候选框同时覆盖所以去重不能只看坐标是否完全相等而要看 IoU。两个框 IoU 超过 0.7 且面积接近就可以保留其中一个。我一般按面积从大到小排序优先保留大框再逐一对剩下的框做 NMS 式过滤这样能保留最完整的覆盖又不会让候选框数量失控。参数选择上小目标占比高的数据集不要舍不得降低min_size。它的作用是过滤分割碎片但当目标本身就只有三五十像素时把min_size设成 100 等于把目标直接埋了。后面我会专门讲这个坑。5. selective_search 避坑现场版本冲突、返回值结构与中文路径5.1 现象分割结果变成大片马赛克候选框数量冲到几万个原因绝大多数出在 scikit-image 升级后felzenszwalb的参数语义变化或者 OpenCV 的findContours在新版本里返回结构不同导致分割结果没有被正确传递。这类问题不会直接抛异常而是让候选框质量断崖式下跌。解决不要盲目升级依赖。先把skimage和opencv-python锁死在当前测试通过的版本上用pip freeze requirements.txt固定环境。然后单独跑一遍felzenszwalb看分割块数量如果明显异常就降级或升级其中一个包再测。5.2 现象TypeError: cannot unpack non-iterable原因网上大量教程写的是老版本返回结构你把selective_search的返回值直接解包成boxes, labels而当前库返回的是一个字典列表结构对不上。这不是你的代码写错了是库改过接口。解决不要在解包前假设结构。先print(type(regions))再取第一个元素看它是什么类型然后按类型做兼容处理。我在第三章给的兼容代码可以直接拿去用它保证无论返回字典还是元组都能提取坐标。5.3 现象cv2.imread 返回 None后面报错全是 numpy 维度问题原因OpenCV 的imread在 Windows 上对中文路径兼容极差“测试图片.jpg”这类路径会让它直接失败。而 selective_search 内部拿到None后不会给清晰提示错误会堆在下游计算里排查成本很高。解决用np.fromfile加cv2.imdecode读图或者所有路径都用英文。import numpy as np import cv2 def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)5.4 现象同一套参数在 A 数据集效果不错换到 B 数据集候选框质量骤降原因scale和min_size是绝对像素值不是相对图像比例。一张 1920×1080 的航拍图和一张 640×480 的街景图最优参数差出好几倍很正常。很多人把这两个参数当作“调一次就能通用”的东西这是选择性搜索里最普遍的误用。解决把scale当作“相对分割粒度”来理解。先看图像分辨率再根据目标大小做粗调。小目标多的图scale往小调并同步降低min_size大目标少的图scale往大调可以减少大量无意义的碎片框。多试几组组合不要盯一个参数硬调。5.5 现象按坐标去重后重叠框依然成片出现原因selective_search 的层次合并会产生同一位置不同尺寸的候选框它们来自不同的合并层级。单独看(x, y, w, h)可能不完全相等但 IoU 非常高坐标去重根本拦不住。解决用 IoU 做去重而不是坐标相等。我习惯的做法是所有候选框按面积降序排列逐个和已保留框算 IoU超过 0.7 就跳过否则加入保留列表。这一步能让候选框数量减少一半以上而不影响召回。6. 验证候选框质量的技巧用 MABO 筛选参数告别玄学调参参数调优是选择性搜索最容易变成“玄学”的环节因为人眼看图判断候选框好坏太主观。一个可复现的客观指标是 MABO即平均最佳重叠度。它的计算方式很简单对每个真实标注框找出所有候选框中 IoU 最高的那个记录这个最大 IoU最后对所有真实框取平均。MABO 越接近 1说明候选框设计得越贴合目标。把之前的 IoU 函数串起来就能得到一个简单的评估脚本def compute_mabo(proposals, gt_boxes): total 0.0 for gt in gt_boxes: best max(iou_xywh(p, gt) for p in proposals) total best return total / len(gt_boxes)然后在调参时做网格搜索把scale、min_size各取三到四个值跑一遍 MABO 对比。best_params None best_mabo 0.0 for scale in [100, 200, 500]: for min_size in [10, 50, 100]: proposals multi_scale_proposals(img_rgb, scales(scale,)) mabo compute_mabo(proposals, gt_boxes) print(scale, min_size, round(mabo, 4)) if mabo best_mabo: best_mabo mabo best_params (scale, min_size)这套评估逻辑我不只在 selective_search 上用过后来换 Faster R-CNN 里的 RPN 时也沿用了“框的召回优先于分类精度”的验证习惯。我自己的一个教训是某个航拍数据集上所有人都盯着scale从 200 往大调MABO 始终在 0.4 徘徊。后来把min_size从 100 降到 10小目标候选框立刻多出来MABO 直接抬到 0.6。那时候才明白选择性搜索的容量其实一开始就在只是被一个过大的碎片过滤阈值堵住了。从那以后我调任何区域提议参数都会先跑一轮 MABO再回去看可视化结果。希望这个习惯也能帮到你——先数说话再靠眼睛判断就能少走很多弯路。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。