简介:一份面向Python开发者与计算机视觉初学者的选择性搜索实现示例,聚焦如何通过超像素分割、区域生长与特征排序生成目标候选区域,为后续接入RCNN、Fast RCNN等检测模型打下基础。压缩包共19个文件,包含3个Python脚本(分别对应搜索、过滤、提取步骤)、14张示例图像(12张png与2张jpg)、1份markdown说明文档及license文件,整体仅53KB,结构精简且目录层级清晰,适合快速上手。已有283人学习,代码中附有可视化中间结果,可直观看到超像素合并与候选框生成过程;脚本还演示了图像预处理、SLIC超像素分割及颜色直方图等特征提取方法。通过阅读搜索、过滤、提取三个脚本,能学习如何用PIL和numpy实现完整流程,并进一步理解候选区域输入到检测模型进行集成与推理的思路,是入门目标检测区域提议的实用示例。
1. 选择性搜索到底是什么:区域提议算法为什么值得自己动手写一遍
做目标检测的人迟早会遇到这个词:selective_search,选择性搜索。它的历史地位很明确——在 Faster R-CNN 把区域提议网络(RPN)做进模型之前,R-CNN 系列全靠它来生成候选区域,先找出图上“可能含有目标”的几千个框,再逐个交给分类器确认。今天虽然端到端检测器是主流,但选择性搜索依然是理解检测流程、做无类别目标定位、或者快速验证一个分类器能不能用于检测任务时最顺手的起点工具。
这篇笔记想解决的不只是“跑通一个示例”,而是帮你在本机把候选框真正生成出来、画到图上、接到后续检测管线里,顺带告诉你参数该怎么调、坐标系为什么总翻车、同一张图为什么不同库跑出来的框不一样。适合正在入门检测算法的人,也适合需要区域提议但不想依赖重型检测框架的工程同学。一句话:它解决的是“目标可能在哪儿”的问题,接下来才轮到“这是什么”。
2. 算法核心拆解:相似度度量与层次分组的选择逻辑
2.1 从分割到分组:Felzenszwalb初始区域的生成逻辑
选择性搜索的第一步不是滑动窗口,而是先用基于图的图像分割算法生成一批初始小区域。这一步常用的就是 Felzenszwalb 的分割方法,它把像素看作图上的节点,相邻像素之间的颜色差异作为边的权重,然后按最小生成树的思路合并相似区域。合并的关键是一个判断准则:两个区域是否能合并,取决于它们内部差异和边界差异的相对大小。
这一设计让初始分割对噪声有一定容忍度。参数 scale 会直接影响合并的宽松程度——scale 越大,越倾向于合并成大块区域;scale 越小,碎片越多。sigma 则是分割前高斯平滑的标准差,平滑越多,小纹理越容易被抹掉。一般我用 scale 在 300 到 600 之间起步,sigma 取 0.8 左右,min_size 设 10 到 20,过滤掉像素数极少的碎片。
# 用 skimage 感受一下 Felzenszwalb 分割的粒度变化 import cv2 import numpy as np from skimage.segmentation import felzenszwalb img = cv2.imread('sample.jpg') img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) segments = felzenszwalb( img_rgb, scale=500, sigma=0.8, min_size=20 ) print('分割出的独立区域数量:', segments.max() + 1)分割后的每个整数标签就是一个初始区域,选择性搜索之后做的所有层次分组,都是在这批小区域上进行的。注意 felzenszwalb 返回的标签并不保证满足物体边界,它更偏“超像素式”的颜色一致块,所以后面才需要用相似度度量反复合并。
2.2 贪心层次分组:相似度函数怎么把碎片合成语义块
拿到初始区域之后,选择性搜索进入核心阶段:贪心层次分组。它不断计算相邻两个区域的相似度,把最相似的一对合并,更新邻接关系,重复直到整张图只剩一个区域。这个过程会把碎小的块逐步聚成能对应“一只猫”“一辆车”的中等尺度块,也会保留从单个超像素到全图的各种尺度,最终形成候选区域的金字塔。
相似度由四个分量加权求和得到。颜色相似度比较两个区域在 Lab 空间下的颜色直方图(每通道 25 个 bin),用直方图交集衡量重合程度;纹理相似度用局部二值模式或 SIFT 风格的高斯导数响应直方图做同样比较。尺寸相似度鼓励小区域先合并:两个区域的面积之和越小,得分越高,这避免某个大块吞掉周围所有小块。填充相似度衡量合并后外接框能多大程度包住内部空洞,奖励那些形状贴合目标的合并。
# 伪代码:层次分组主循环,理解结构比背库函数重要 def hierarchical_grouping(regions): adjacency = build_adjacency(regions) while len(adjacency) > 1: # 找到相似度最高的相邻区域对 i, j, max_score = find_most_similar(regions, adjacency) if max_score <= 0: break # 合并并更新特征与邻接表 merged = merge_features(regions[i], regions[j]) adjacency = rebuild_adjacency(adjacency, i, j, merged) regions = replace(regions, i, j, merged) return regions这里的 merge_features 需要同步更新颜色直方图、纹理直方图、尺寸和外接框信息。实际工程中很少有人手写这层,但理解主循环后,你才能明白为什么提示图一旦出现一条被切断的车灯,候选框质量差时,问题多半出在相似度权重或初始分割上。
2.3 为什么选选择性搜索而非滑动窗口或暴力网格
滑动窗口在图像金字塔上暴力滑,理论上穷尽,但候选框数量爆炸、速度慢、且大量窗口落在背景上。选择性搜索的意义是把“可能的位置”先筛到一两千个,质量还明显好于随机采样。相比后来 Faster R-CNN 的 RPN,它不依赖训练好的网络,也就意味着你可以在没有任何检测模型的情况下,只靠图像自身结构生成候选框。这在冷启动、数据探索、或者给弱监督任务找初始框时非常实用。
它的主要代价是速度:CPU 上单张图约 0.5 到 2 秒,视参数与图片尺寸而定。好在多数场景下候选框只离线生成一次,存下来后续复用。另一个代价是候选框质量上限受初始分割制约,小到像远处一只鸟那么小的目标,如果初始分割直接把它并入背景,后面层次分组基本捞不回来。这点在后文参数调优里我会详细说怎么规避。
3. 跑通第一个示例:环境准备、最小代码与结果可视化
3.1 环境准备与实现选择:两种常用实现库的取舍
目前最容易上手的实现有两个:Python 的 selectivesearch 库,以及 OpenCV contrib 模块里的 ximgproc 选择性搜索接口。selectivesearch 完全基于 skimage 和 numpy,纯 Python 实现,代码短、可读性好、参数直观,适合学习与定制,缺点是慢。OpenCV 版本底层用 C++,速度快不少,适合批量处理,但 API 较绕,且 OpenCV 版本之间行为不完全一致。
我的建议是:学习阶段用 selectivesearch,跑通逻辑、看懂参数;进入批量生产再换成 OpenCV。两个版本都要求图像通道顺序是 RGB 还是 BGR,要特别小心。selectivesearch 按 skimage 惯例吃 RGB,你把 OpenCV 读出来的 BGR 图直接丢进去,候选框方向性差异可能不大,但颜色相似度分量的统计就会对不上。
# 最小安装清单 pip install selectivesearch scikit-image opencv-python# 验证环境 import selectivesearch import skimage import cv2 print('selectivesearch OK:', selectivesearch.__version__ if hasattr(selectivesearch, '__version__') else 'unknown') print('skimage OK:', skimage.__version__) print('OpenCV OK:', cv2.__version__)3.2 最小示例代码:用 selectivesearch 库跑出候选框
下面这段是完整可跑的示例:读图、转 RGB、执行选择性搜索、把结果整理成 Nx4 数组。注意 selectivesearch 返回的 regions,每个元素是一个字典,包含 rect、labels 和 size。rect 的格式是 [x, y, w, h],x 和 y 是左上角坐标,这个格式非常容易在后续忘掉。
import cv2 from selectivesearch import selective_search # 1. 读图并转 RGB img = cv2.imread('sample.jpg') img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 2. 执行选择性搜索 _, regions = selective_search( img_rgb, scale=500, sigma=0.9, min_size=20 ) # 3. 转化为 [x1, y1, x2, y2] 格式候选框 candidates = [] for r in regions: x, y, w, h = r['rect'] if w * h < 500: # 过滤过小区域,避免全是噪声 continue candidates.append([x, y, x + w, y + h]) print(f'候选框数量: {len(candidates)}') print('前 5 个候选框:', candidates[:5])scale 控制初始分割的粗糙度,值越大,初始区域越少,最终候选框也越少,适合图中目标大而少的情况。sigma 影响分割前的平滑程度,0.9 是常用起步值。min_size 会过滤掉分割时的小块,这里设 20。第三个返回值 regions 里还带 label 信息,如果后面想做区域特征提取,可以按 label 取掩膜,但大多数检测任务只用到 rect。
3.3 可视化候选框:把区域画在原图上确认效果
光有数值没法判断质量,画框是确认效果的第一步。把前 100 个候选框画在原图上,你立刻能看出框的尺度分布是否合理。好的效果是:多个不同尺度框围绕目标分布,背景区域也有所覆盖但不会占满全图。
# 画前 100 个候选框,直观确认覆盖效果 vis = img.copy() for x1, y1, x2, y2 in candidates[:150]: cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 255, 0), 1) cv2.imwrite('candidates_top150.jpg', vis) # 再画一张全量候选框图,感受数量规模 vis_all = img.copy() for x1, y1, x2, y2 in candidates: cv2.rectangle(vis_all, (x1, y1), (x2, y2), (255, 0, 0), 1) cv2.imwrite('candidates_all.jpg', vis_all)如果画出来的框几乎全是整张图的外接框加几个随机碎片,多半是初始分割没拆开,需要调大 min_size 或者降低 scale。如果大量极端瘦长条框出现,说明纹理相似度权重过强或者平滑不足。可视化不是可选项,它是你后续调参数的“眼睛”,每次改参都要存一张对比图。
4. 四个必调参数:从候选框数量到质量的工程取舍
4.1 scale 与 sigma:先调整哪一档影响最大
多数人第一次跑完示例,看到几千个候选框,第一反应是怀疑模型写错了。其实候选框数量正常区间就是 1000 到 3000,关键看你要喂给后续的模型还是仅做定位探索。scale 是影响最直接的一档:它不直接限制候选框数量,而是决定初始分割的尺度,进而影响整棵合并树的结构。
我常用的调参顺序是先固定 sigma=0.8、min_size=20,只动 scale。用 300、500、800 各生成一遍候选框,打印数量并可视化,观察目标上的覆盖。scale 过低时初始碎片太多,候选框数量暴涨但大量是小碎块;scale 过高时初始区域已经贴近整个目标,合并树深度不够,候选框数量骤降但误检率也降低。sigma 的作用相对温和,主要影响边缘粘连问题,对线条状物体更明显,一般不需要频繁动它。
# 对同一张图测试多个 scale for scale in 300 500 800; do python selectivesearch_demo.py --scale $scale --out scale_${scale}.jpg done不同 scale 生成的结果甚至可以合并起来:把两次或三次不同 scale 的候选框做并集,能提升多尺度目标的召回。这是选择性搜索的“多策略”思想,官方论文里也提到组合不同颜色空间和参数能得到更多样化的候选框,但代价是候选框数量线性增长,后续 NMS 压力更大。
4.2 min_size 与 min_region_size:候选框数量控制的关键
min_size 在分割阶段起作用,过滤掉初始分割中像素少于该值的区域,直接决定了碎片总量。min_region_size 是 selectivesearch 库的特有参数,它过滤合并结果中过小的最终区域,这个值设得太大会丢掉真正的小目标。经验上,对常规 800x600 图片,min_size 取 20 到 50,min_region_size 取 500 到 2000 都算合理区间。
这两个参数影响最大的是边界:把 min_size 设成 200,边缘的小目标、远处行人、小动物都会因为初始分割阶段就被并掉而无法出现在任何候选框里。反过来,设得太小会让颜色直方图稀疏,相似度计算不稳定,候选框大量重叠。我的习惯是先看目标面积占图面积比例:目标约占 5% 到 10% 时,min_size 取 20 左右;目标较大时再提高。
| 参数 | 作用阶段 | 典型范围 | 调大后的影响 |
|---|---|---|---|
| scale | 初始分割 | 300-800 | 候选框变少,大目标更完整 |
| sigma | 分割前平滑 | 0.4-1.0 | 弱化纹理,边缘更钝 |
| min_size | 初始分割过滤 | 10-50 | 碎片减少,小目标丢失 |
| min_region_size | 合并结果过滤 | 500-2500 | 最终候选框总数下降 |
4.3 候选框数量压缩:NMS阈值与坐标归一化的配合
几千个候选框直接喂给分类器,每张图跑一次前向几百毫秒,数据集大一点就等不起。压缩数量主要有两步:先做 NMS 去掉高度重叠的框,再做得分或尺寸筛选保留 top-N。NMS 的 IoU 阈值一般取 0.5 到 0.7,阈值越低框越少但可能漏目标;阈值越高保留的框越接近原始分布。
import numpy as np def nms(rects, thresh=0.5): rects = np.asarray(rects, dtype=np.float32) if len(rects) == 0: return [] x1, y1, x2, y2 = rects[:, 0], rects[:, 1], rects[:, 2], rects[:, 3] areas = (x2 - x1 + 1) * (y2 - y1 + 1) order = areas.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) inter = np.maximum(0.0, xx2 - xx1 + 1) * np.maximum(0.0, yy2 - yy1 + 1) iou = inter / (areas[i] + areas[order[1:]] - inter) inds = np.where(iou <= thresh)[0] order = order[inds + 1] return rects[keep]执行完 NMS 后再按面积排序,取前 1000 到 1500 个框,基本能满足 R-CNN 风格训练的数据需求。坐标归一化这一步容易忽略:如果 GT 框的坐标已经除以了图像宽高,候选框还是像素坐标,后面算 IoU 就会彻底乱掉。统一办法是候选框也除以原图宽高,存成 0 到 1 的相对坐标,逻辑上更安全。
5. 避坑指南:选择性搜索实战中的常见锅与排查办法
5.1 坐标格式翻车:bbox 到底是左上角宽高还是中心点
现象:用候选框去算 IoU,结果数值小得离谱,或者画框时框整体错位到左下角。 原因:selectivesearch 返回的 rect 是 [left, top, width, height],而 OpenCV 的 rectangle 需要 [x1, y1, x2, y2],很多人直接拿 w、h 当 x2、y2 用,框自然歪了。更隐蔽的是有些教程把 rect 转成了中心点格式,混用时全部错位。 解决:写一个统一的正反向转换函数,入口和出口都走同一个;在代码里加上断言,确保 x2 > x1 且 y2 > y1,数值异常时立刻打印排查。
def xywh_to_xyxy(rect): x, y, w, h = rect return [x, y, x + w, y + h]5.2 候选框质量差:目标只出一半框怎么办
现象:可视化时发现目标被一个框框住了上半身,下半身被另一个更大框包含,或者目标整体没被任何框完整覆盖。 原因:最常见的是 scale 太大,初始分割已经把目标分成两块,合并阶段又没有某个相邻对能让它们合在一起;另一种是目标和背景颜色接近,颜色相似度误判导致过早与背景合并。 解决:先降低 scale 到 300 试试,观察是否出现更完整的包围盒;再把 sigma 调到 0.6 到 0.8 减少边缘干扰;若仍是老样子,把图片长边缩到 500 左右再跑,很多分割问题在大尺寸图上反而更明显。
5.3 内存与速度告急:大批量图片如何避免卡死
现象:几百张图跑下来越来越慢,内存从几百 MB 涨到几 GB,甚至直接 OOM。 原因:selectivesearch 把每张图的所有中间区域全部保留返回,数据集一大,Python 对象开销叠加,内存就爆了。另一个隐性原因是没有及时释放过大的原始图,循环里不断读图叠加内存引用。 解决:用 OpenCV 版替代纯 Python 版,速度提升明显;图片先缩放到长边 1000 以内;候选框生成后立即转成紧凑的 numpy 数组并丢弃 regions;每个进程处理完一批就 gc.collect()。
import gc def process_batch(image_paths): result = [] for path in image_paths: img = cv2.imread(path) img = cv2.resize(img, (800, int(800 * img.shape[0] / img.shape[1]))) # 执行 selective search 并提取候选框 result.append(np.asarray(candidates, dtype=np.float32)) gc.collect() return result5.4 OpenCV版与selectivesearch库结果对不上
现象:同一张图,OpenCV 跑出来 800 个候选框,selectivesearch 跑出来 2600 个,目标覆盖情况也不一致,以为是哪个库装错了。 原因:两个实现的相似度度量细节不同,OpenCV 的 ximgproc 模块使用了不同的特征初化与合并策略,行为并不保证完全一致。 解决:选一个版本固定下来,训练和推理全程用同一个。对结果做对比时,只比较“数量级”和“是否覆盖目标”这两个层面,不要逐框对齐。如果一定要在论文或报告中保持一致性,建议在代码里封装统一接口,换库时只改一行。
5.5 IoU匹配奇怪,正负样本数量异常
现象:用候选框和 GT 做正负样本分配时,正样本数几乎为 0,或者负样本全是背景垃圾框。 原因:坐标系统不统一(候选框像素坐标对 GT 归一化坐标)、IoU 阈值设得太高、候选框数量不够导致没有框与 GT 达到阈值。 解决:先确认两边坐标都在同一体系中;正样本 IoU 阈值建议从 0.5 起步;如果候选框覆盖不到 GT,优先检查 scale 和 min_size,而不是调阈值掩盖问题。
# 快速验证一条 GT 是否被候选框覆盖 gt = np.array([120, 80, 320, 280], dtype=np.float32) for cand in candidates: iou = compute_iou(gt, cand) if iou > 0.5: print('找到覆盖框:', cand, 'IoU:', iou) break else: print('该 GT 没有被任何候选框覆盖,请调整 scale')6. 进阶一步:把候选框接到目标检测管线里的验证技巧
6.1 用召回率曲线验证候选框质量
候选框数量只是表面指标,真正该看的是“目标有没有被框到”。正确做法是取二十张带 GT 的验证图,对每张图生成候选框,计算每个 GT 与所有候选框的最大 IoU,统计达到 0.5 和 0.7 的 GT 比例。这个数字就是区域提议召回率,它能直观反映参数调优的价值。
def proposal_recall(gts, candidates, iou_thresh=0.5): recalled = 0 for gt in gts: ious = [compute_iou(gt, cand) for cand in candidates] if max(ious) >= iou_thresh: recalled += 1 return recalled / len(gts)我一般会分别打印候选框数量与召回率,画一张表格:scale 300、500、800 各对应多少框、多少召回。召回率到 0.9 以上时,候选框数量通常在 2000 附近,再多就对检测精度帮助不大了。
6.2 把候选框接到分类器,做一个最简 R-CNN 验证
如果只到“生成框”为止,你没法确认这些框是否真的有用。最常见的验证方式是把候选框剪裁出来,缩放成 224x224,送进一个预训练分类器(比如 ResNet),取置信度最高的类别作为框标签,再 NMS 一下,你就得到了一个不训练的简易检测器。这个验证能快速暴露两个问题:候选框是否对目标提供了足够的上下文,以及分类器对小目标的敏感度。
from torchvision import models, transforms from PIL import Image model = models.resnet18(pretrained=True) model.eval() transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) for x1, y1, x2, y2 in candidates[:50]: crop = Image.open('sample.jpg').crop((x1, y1, x2, y2)) tensor = transform(crop).unsqueeze(0) with torch.no_grad(): probs = torch.softmax(model(tensor), dim=1) print(f'框 {x1},{y1},{x2},{y2}: {probs.max().item():.2f}')这种验证不用训练任何检测头,半小时就能跑完,是检验区域提议质量性价比最高的一步。我自己的习惯是永远先把候选框可视化打印到本地,再检查召回率,最后才引入分类器——因为候选框阶段出的错误,到了分类阶段会被放大成“假正例”,到时候查错成本高得多。
最后一个血泪经验:不要相信“调好一组参数就能跑全部数据”这种话。换数据集、换分辨率、甚至换摄像头角度,候选框分布都会变。每次换数据先跑一遍召回率和可视化,确认没问题再往下走。这样做虽然慢几步,但能省下大量排错时间。希望帮到你。
本文还有配套的精品资源,点击获取