简介这份资源是面向计算机视觉初学者与目标检测入门者的选择性搜索Selective SearchPython 实践示例帮助读者理解如何通过超像素分割与区域生长策略生成候选目标区域从而替代传统滑动窗口、降低后续检测的计算负担。压缩包共 19 个文件以 12 张 png 与 2 张 jpg 图像素材、3 个 Python 脚本、1 份 README 说明及 1 份 LICENSE 为主整体约 53KB体积轻便便于快速下载与本地运行。代码按步骤拆分为搜索、过滤、特征提取等模块配合示例图片可直观观察候选区域的生成与筛选过程适合对照学习算法流程、调试参数并理解与 R-CNN 系列模型的衔接思路。目前已有 283 人学习可作为图像分割与目标检测方向入门练手的参考素材。1. 选择性搜索到底在解决什么问题如果你做过传统目标检测一定遇到过这个场景一张图里可能有猫、有狗、有远处的车但模型只认识固定尺寸的输入物体大小位置全都不固定。滑窗法是最直觉的方案——用不同大小的窗口在图上逐像素扫一遍但计算量会爆炸到无法接受。选择性搜索Selective Search就是在这个背景下被提出来的它不盲目扫描而是先用图像分割把图切成一小块一小块再按相似度不断合并最终生成一批「可能是物体」的候选区域。这个思路在 R-CNN 时代是标配后来虽然被端到端检测器取代但它的核心思想——用区域合并代替穷举——在今天的区域提议、显著性检测、甚至某些图像分割预处理里仍然能看到影子。这篇文章面向两类人一是想搞懂候选区域生成原理的算法工程师二是需要在没有深度学习环境时快速做物体定位的开发者。我会用一个可复现的 Python 示例把选择性搜索从参数到代码到排错讲透让你能直接跑起来并调出适合自己数据的候选框。2. 选择性搜索的核心机制与参数选择2.1 从像素到区域分层合并的直觉选择性搜索的第一步不是合并而是「过度分割」。常见做法是用 Felzenszwalb 算法或者简单的区域生长把图像切成几百个小块。这些小块内部颜色、纹理基本一致但边界往往贴着真实物体的边缘。为什么不用边缘检测直接找轮廓因为边缘检测对纹理复杂的区域会碎成渣而过度分割能保证每个小块是「干净」的。拿到初始小块后算法计算每两个相邻区域的相似度。相似度不是单一指标而是颜色直方图、纹理梯度、区域大小和区域填充度的加权组合。颜色相似度用 HSV 空间的直方图交叠纹理用 SIFT -like 的梯度方向直方图大小相似度鼓励小区域先合并填充度则防止合并出细长条。这四个维度各有权重通常颜色和纹理占大头。合并过程是贪心的每次找相似度最高的一对相邻区域合并更新相似度表重复直到整张图变成一个区域。合并过程中产生的所有中间区域都被记录下来按面积和多样性筛选后就是候选框。这里有个关键点候选框不是最终检测结果它只是「可能包含物体」的提议后续还要靠分类器打分。2.2 四个必调参数与它们的影响实际用的时候选择性搜索暴露出来的参数不多但每一个都会显著改变候选框的数量和质量。我一般会重点关注下面四个参数含义典型范围调大后果调小后果scale初始分割的粒度100~500小块变大候选框变少但可能漏小物体小块变细候选框暴增速度下降sigma分割前高斯模糊核0.5~1.5边缘被抹平分割块更规整噪声保留分割碎片化min_size最小区域像素数20~200过滤掉小碎块减少候选框保留小物体但噪声框增多max_boxes最终候选框上限1000~5000召回率高后续分类慢速度快但可能漏检这些参数没有万能值。比如做街景检测远处行人只有几十像素min_size 就得压到 20 左右做商品图检测物体占画面一半以上scale 可以设到 300 以上来减少冗余框。我的习惯是先固定 sigma0.8、min_size50然后根据目标尺寸调 scale最后用 max_boxes 控制总量。2.3 用 Python 跑通最小示例下面这段代码不依赖深度学习框架只用 OpenCV 和 scikit-image 就能跑。它会读一张图生成候选框并画出前 50 个框。你可以直接复制到本地运行。import cv2 import numpy as np from skimage.segmentation import felzenszwalb from skimage.feature import local_binary_pattern def selective_search_demo(image_path, scale200, sigma0.8, min_size50, max_boxes2000): # 读取图像并转 RGB img cv2.imread(image_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w img_rgb.shape[:2] # 第一步Felzenszwalb 过度分割 segments felzenszwalb(img_rgb, scalescale, sigmasigma, min_sizemin_size) num_segments segments.max() 1 print(f初始分割区域数: {num_segments}) # 第二步为每个区域计算颜色直方图和纹理特征 # 这里用简化的 HSV 直方图 LBP 纹理 hsv cv2.cvtColor(img_rgb, cv2.COLOR_RGB2HSV) gray cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY) lbp local_binary_pattern(gray, P8, R1, methoduniform) region_feats {} for seg_id in range(num_segments): mask (segments seg_id) if mask.sum() 0: continue # 颜色直方图H 通道 16 bin hist_h np.histogram(hsv[:, :, 0][mask], bins16, range(0, 180))[0].astype(float) hist_h / (hist_h.sum() 1e-6) # 纹理直方图LBP 10 bin hist_t np.histogram(lbp[mask], bins10, range(0, 10))[0].astype(float) hist_t / (hist_t.sum() 1e-6) # 区域面积和边界框 ys, xs np.where(mask) bbox (xs.min(), ys.min(), xs.max(), ys.max()) region_feats[seg_id] { color: hist_h, texture: hist_t, area: mask.sum(), bbox: bbox } # 第三步构建邻接关系简化版用膨胀判断相邻 # 实际选择性搜索会用区域邻接图这里用形态学膨胀近似 from scipy import ndimage adj_pairs set() for seg_id in range(num_segments): mask (segments seg_id).astype(np.uint8) dilated ndimage.binary_dilation(mask, iterations2) neighbors np.unique(segments[dilated (segments ! seg_id)]) for nb in neighbors: if nb seg_id: adj_pairs.add((seg_id, nb)) # 第四步贪心合并 # 相似度 0.5*颜色 0.3*纹理 0.2*大小 def similarity(a, b): fa, fb region_feats[a], region_feats[b] sim_color np.minimum(fa[color], fb[color]).sum() sim_texture np.minimum(fa[texture], fb[texture]).sum() sim_size 1.0 - abs(fa[area] - fb[area]) / (fa[area] fb[area] 1e-6) return 0.5 * sim_color 0.3 * sim_texture 0.2 * sim_size # 用并查集维护合并 parent list(range(num_segments)) def find(x): while parent[x] ! x: parent[x] parent[parent[x]] x parent[x] return x # 记录所有历史区域合并前的原始区域 合并后的区域 all_boxes [] for seg_id, feat in region_feats.items(): all_boxes.append(feat[bbox]) # 按相似度排序所有邻接对 scored_pairs [] for a, b in adj_pairs: if a in region_feats and b in region_feats: scored_pairs.append((similarity(a, b), a, b)) scored_pairs.sort(reverseTrue) for sim, a, b in scored_pairs: ra, rb find(a), find(b) if ra rb: continue # 合并区域 parent[rb] ra # 更新合并后区域的特征简单加权平均 fa, fb region_feats[ra], region_feats[rb] total_area fa[area] fb[area] new_color (fa[color] * fa[area] fb[color] * fb[area]) / total_area new_texture (fa[texture] * fa[area] fb[texture] * fb[area]) / total_area # 合并 bbox x1 min(fa[bbox][0], fb[bbox][0]) y1 min(fa[bbox][1], fb[bbox][1]) x2 max(fa[bbox][2], fb[bbox][2]) y2 max(fa[bbox][3], fb[bbox][3]) region_feats[ra] { color: new_color, texture: new_texture, area: total_area, bbox: (x1, y1, x2, y2) } all_boxes.append((x1, y1, x2, y2)) # 第五步去重并限制数量 # 简单按面积排序取前 max_boxes 个 all_boxes list(set(all_boxes)) all_boxes.sort(keylambda b: (b[2]-b[0])*(b[3]-b[1]), reverseTrue) all_boxes all_boxes[:max_boxes] # 画框 vis img.copy() for (x1, y1, x2, y2) in all_boxes[:50]: cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(selective_search_result.jpg, vis) print(f生成候选框数量: {len(all_boxes)}) return all_boxes if __name__ __main__: boxes selective_search_demo(test.jpg, scale200, sigma0.8, min_size50, max_boxes2000)这段代码的逻辑分五步过度分割、特征提取、邻接构建、贪心合并、去重截断。felzenszwalb的scale参数控制分割粒度值越大区域越少sigma是分割前的高斯模糊用来抑制噪声min_size过滤掉太小的碎块。相似度函数里颜色权重 0.5、纹理 0.3、大小 0.2这个比例来自原始论文的实验实际调的时候如果目标颜色区分度不高可以把纹理权重提到 0.4。合并部分用并查集维护区域归属每次合并后更新颜色和纹理直方图按面积加权平均同时扩展边界框。注意这里为了简化邻接关系用膨胀近似真实场景建议用区域邻接图RAG来避免重复计算。最后按面积排序取前 N 个框是因为大框更可能包含完整物体但如果你检测的是小目标应该改成按面积升序或者保留更多小框。跑完之后你会得到一张画了 50 个绿框的图以及控制台输出的候选框总数。如果框太多太乱先把scale调到 300 以上如果小物体没框住把min_size降到 20 并提高max_boxes。3. 候选框质量评估与筛选策略3.1 用 IoU 和召回率判断候选框够不够用生成一堆框只是第一步关键是要知道这些框里有没有包含真实物体。最直接的指标是召回率假设图里有 3 个真实物体候选框里至少有 1 个框与某个真实框的 IoU 大于 0.5就算这个物体被召回。选择性搜索的召回率通常能到 90% 以上但代价是框的数量可能上千。我一般会写一个小评估脚本把真实框和候选框做匹配统计不同 IoU 阈值下的召回率。如果召回率低于 80%说明参数太保守需要增大max_boxes或减小scale如果召回率够了但框数超过 3000就要考虑用后续的分类器打分来过滤而不是继续调选择性搜索本身。def evaluate_recall(gt_boxes, candidate_boxes, iou_thresh0.5): # gt_boxes: list of (x1,y1,x2,y2) # candidate_boxes: list of (x1,y1,x2,y2) def iou(b1, b2): x1 max(b1[0], b2[0]) y1 max(b1[1], b2[1]) x2 min(b1[2], b2[2]) y2 min(b1[3], b2[3]) if x2 x1 or y2 y1: return 0.0 inter (x2 - x1) * (y2 - y1) area1 (b1[2]-b1[0])*(b1[3]-b1[1]) area2 (b2[2]-b2[0])*(b2[3]-b2[1]) return inter / (area1 area2 - inter 1e-6) recalled 0 for gt in gt_boxes: for cand in candidate_boxes: if iou(gt, cand) iou_thresh: recalled 1 break return recalled / len(gt_boxes) if gt_boxes else 0.0这个函数很简单但能帮你快速判断当前参数是否合理。注意 IoU 阈值一般取 0.5如果物体形状不规则可以降到 0.4。3.2 按面积和长宽比做二次过滤选择性搜索输出的框里有很多明显不合理的比如细长条、面积占全图 90% 以上的、或者面积小于 100 像素的。这些框送进分类器只会浪费算力。我通常会在生成后加一层过滤面积过滤去掉面积小于min_area或大于0.9 * 全图面积的框。长宽比过滤去掉长宽比大于 5 或小于 0.2 的框除非你的目标就是细长物体。边缘过滤去掉紧贴图像边缘且宽度或高度小于 10 像素的框这些多半是分割噪声。def filter_boxes(boxes, img_w, img_h, min_area200, max_area_ratio0.9, max_aspect5.0): filtered [] for (x1, y1, x2, y2) in boxes: w x2 - x1 h y2 - y1 area w * h if area min_area or area max_area_ratio * img_w * img_h: continue aspect max(w / (h 1e-6), h / (w 1e-6)) if aspect max_aspect: continue filtered.append((x1, y1, x2, y2)) return filtered过滤之后框数通常能减少 30% 到 50%而召回率几乎不掉。这一步的阈值要根据你的数据来做人脸检测时min_area可以设到 400做文本检测时max_aspect要放宽到 10 以上。3.3 用 NMS 去掉高度重叠的框选择性搜索合并过程中会产生大量互相重叠的框比如一个物体可能被 20 个不同大小的框套住。非极大值抑制NMS是标准做法按面积或得分排序保留最大的框然后删掉与它 IoU 超过阈值的其他框。这里没有分类得分所以按面积排序大框优先。def nms_boxes(boxes, iou_thresh0.7): if not boxes: return [] boxes sorted(boxes, keylambda b: (b[2]-b[0])*(b[3]-b[1]), reverseTrue) keep [] while boxes: best boxes.pop(0) keep.append(best) boxes [b for b in boxes if iou(best, b) iou_thresh] return keepNMS 的阈值很关键0.7 比较温和保留更多框0.5 会删得更狠适合后续分类器很强的场景。注意 NMS 之后框数可能降到几百但召回率会掉几个点需要权衡。4. 避坑与常见问题排查4.1 候选框数量爆炸内存扛不住现象跑一张 1080p 的图候选框超过 10000 个程序直接卡死或者内存溢出。原因scale设得太小比如 50导致初始分割区域上千个合并过程中产生的中间区域数量是区域数的平方级。另外max_boxes设得太大没有及时截断。解决把scale提到 200 以上min_size提到 50 以上并且在合并循环里每合并 100 次就检查一次all_boxes长度超过max_boxes * 2就提前截断。更彻底的做法是用区域邻接图代替全量相似度计算只维护相邻区域的相似度内存占用能降一个数量级。4.2 小物体完全没框住现象图里有几个 30x30 像素的小目标但候选框里最小的也有 100x100小目标全漏了。原因min_size设得太大初始分割时小物体被合并到背景区域里了。另外相似度函数里大小相似度权重太低导致小区域被大区域吞并。解决把min_size降到 10 到 20scale降到 100 左右让分割更细。同时把相似度里的大小权重从 0.2 提到 0.3鼓励小区域先合并。如果还不行可以在过度分割后单独保留面积小于 50 像素的区域不参与后续合并直接作为候选框输出。4.3 候选框位置偏移总是差几个像素现象框大致套住了物体但边缘总是多出或缺少十几个像素IoU 卡在 0.4 上不去。原因Felzenszwalb 分割的边界本身就不精确加上合并时边界框直接取并集误差会累积。另外sigma太大导致边缘模糊分割块偏离真实边缘。解决把sigma降到 0.5 到 0.6减少模糊。合并时不要直接取并集而是用区域像素的均值漂移来微调边界。如果对精度要求高可以在候选框生成后加一个边缘对齐步骤在框边界附近做 Canny 边缘检测把框吸附到最近的强边缘上。4.4 颜色相似度在灰度图上失效现象处理黑白图像或低饱和度图像时候选框质量急剧下降很多物体被拆成多个框。原因颜色直方图用的是 HSV 的 H 通道灰度图上 H 通道全是 0颜色相似度恒为 1等于没有区分能力。解决检测图像饱和度如果平均饱和度低于阈值比如 30就把颜色权重降到 0.1纹理权重提到 0.6并加入梯度幅值直方图作为补充特征。更简单的做法是直接把图像转成灰度只用纹理和大小相似度颜色项去掉。4.5 合并顺序导致大区域吞并小区域现象一个中等大小的物体被旁边的背景区域提前合并导致最终框里物体只占一小部分。原因贪心合并每次选相似度最高的对如果背景区域颜色和物体接近背景会先和物体合并然后继续吞并周围。解决在相似度函数里加入边界吻合度惩罚如果两个区域的公共边界很长但颜色差异大降低相似度。具体做法是计算公共边界像素数除以两个区域周长之和再乘以颜色差异的倒数。这个惩罚项能有效阻止跨边界的错误合并。另外可以设置合并终止条件当最大相似度低于某个阈值比如 0.3时停止合并避免强行合并不相关的区域。5. 把选择性搜索接进自己的检测流程5.1 用候选框训练一个轻量分类器选择性搜索本身不分类它只负责提议。要完成检测你需要一个分类器给每个候选框打分。如果不想上深度学习可以用 HOG 特征加 SVM这是 R-CNN 之前的经典组合。流程是对每个候选框裁剪图像缩放到固定尺寸比如 64x64提取 HOG 特征送进预训练好的 SVM 得到类别得分最后用 NMS 按得分筛选。from skimage.feature import hog from sklearn.svm import SVC import joblib def extract_hog_features(img, boxes, size(64, 64)): features [] for (x1, y1, x2, y2) in boxes: patch img[y1:y2, x1:x2] if patch.size 0: continue patch cv2.resize(patch, size) gray cv2.cvtColor(patch, cv2.COLOR_RGB2GRAY) feat hog(gray, orientations9, pixels_per_cell(8, 8), cells_per_block(2, 2), feature_vectorTrue) features.append(feat) return np.array(features) # 假设已有训练数据 X_train, y_train # clf SVC(kernellinear, probabilityTrue) # clf.fit(X_train, y_train) # joblib.dump(clf, svm_detector.pkl)HOG 的orientations一般取 9pixels_per_cell取 8x8cells_per_block取 2x2这是行人检测的经典配置。如果你的物体纹理更细可以把pixels_per_cell降到 4x4但特征维度会翻倍。SVM 用线性核就够RBF 核在小数据集上容易过拟合。5.2 用得分排序替代面积排序做 NMS之前 NMS 是按面积排序接入分类器后应该按分类得分排序。得分高的框优先保留这样能保住那些面积小但置信度高的框。具体做法是把候选框和得分一起送进 NMS排序键改成得分。def nms_with_scores(boxes, scores, iou_thresh0.5): order np.argsort(scores)[::-1] keep [] while len(order) 0: i order[0] keep.append(i) rest order[1:] if len(rest) 0: break ious np.array([iou(boxes[i], boxes[j]) for j in rest]) order rest[ious iou_thresh] return [boxes[i] for i in keep], [scores[i] for i in keep]这个改动看起来小但实际效果很明显面积排序会保留很多背景大框得分排序则把真正的物体框顶上来。IoU 阈值可以设到 0.4 到 0.5比无分类器时的 0.7 更激进因为得分已经帮你过滤了一轮。5.3 一个容易忽略的细节候选框坐标越界选择性搜索生成的框有时候会超出图像边界比如 x2 大于图像宽度。如果直接拿去裁剪OpenCV 会报错或者返回空数组。我一般在生成后统一做一次坐标裁剪def clip_boxes(boxes, img_w, img_h): clipped [] for (x1, y1, x2, y2) in boxes: x1 max(0, min(x1, img_w - 1)) y1 max(0, min(y1, img_h - 1)) x2 max(0, min(x2, img_w - 1)) y2 max(0, min(y2, img_h - 1)) if x2 - x1 2 and y2 - y1 2: clipped.append((x1, y1, x2, y2)) return clipped这个函数还会顺便过滤掉宽高小于 2 像素的无效框。别小看这一步我至少有两次因为没做裁剪在批量处理时被一张边界异常的图卡了整个流程。5.4 批量处理时的内存控制技巧如果你要处理几千张图每张图生成 2000 个候选框内存很快会爆。我的做法是分块处理每处理完一张图只保留得分前 200 的框和对应特征其余全部释放。另外用生成器代替列表把候选框一个一个吐给分类器而不是一次性全生成再全送进去。def batch_process(image_paths, model, batch_size32): for path in image_paths: img cv2.imread(path) boxes selective_search_demo(path, max_boxes1000) boxes clip_boxes(boxes, img.shape[1], img.shape[0]) # 分批提取特征并预测 for i in range(0, len(boxes), batch_size): batch boxes[i:ibatch_size] feats extract_hog_features(img, batch) scores model.predict_proba(feats)[:, 1] # 只保留高分框 for box, score in zip(batch, scores): if score 0.5: yield box, score这个生成器模式能让内存占用稳定在几百 MB而不是随图片数量线性增长。batch_size取 32 或 64 都行取决于你的特征维度和内存大小。5.5 什么时候该放弃选择性搜索选择性搜索在 CPU 上跑一张图通常要 1 到 3 秒如果对速度有要求比如视频流处理它基本不可用。这时候要么换轻量级区域提议网络RPN要么直接用 YOLO 这类单阶段检测器。我的判断标准是如果每秒需要处理超过 5 帧或者候选框数量经常超过 3000就该考虑替代方案了。选择性搜索的价值在于它的可解释性和无需训练适合做原型验证、小批量离线处理或者作为教学工具理解候选区域生成的全过程。我自己的习惯是新数据集先跑一遍选择性搜索看看候选框的召回率上限在哪再决定要不要上深度学习。如果选择性搜索都召不回的目标后面的分类器再强也没用。这个前置检查帮我省过很多次盲目调模型的功夫。希望帮到你。本文还有配套的精品资源点击获取