轮廓检测与质心计算:从图像中提取目标的关键

各位同学,今天我们来聊聊目标跟踪里最基础、也最核心的一步——轮廓检测与质心计算。说白了,就是怎么把图像里的目标“抠”出来,然后找到它的中心点。我做了这么多年视觉算法,可以负责任地告诉你:这一步做不好,后面的跟踪全是白搭。

核心逻辑:二值化图像 → 轮廓检测 → 轮廓筛选 → 质心与边界框计算

二值化图像 findContours 轮廓筛选 质心 + 边界框 输入:灰度/阈值图 提取所有连通区域 面积/周长/宽高比 输出:目标位置

一、findContours 函数详解

OpenCV 里的 findContours 是我用得最多的函数之一。它的作用很简单:从二值图像中提取出所有物体的轮廓。嗯,这里要注意,输入图像必须是二值图——也就是只有黑白两色,没有中间灰度。

函数签名长这样:

contours, hierarchy = cv2.findContours(image, mode, method)

三个参数,我一个个说:

  • image:输入的二值图像。我个人习惯用 cv2.THRESH_BINARY 或者 cv2.adaptiveThreshold 处理后的结果。
  • mode:轮廓检索模式。常用的有:
    • RETR_EXTERNAL:只检测最外层轮廓。适合目标不重叠的场景。
    • RETR_LIST:检测所有轮廓,不建立层级关系。
    • RETR_TREE:检测所有轮廓,建立完整的层级树。我在做复杂场景时常用这个。
  • method:轮廓近似方法。推荐用 CHAIN_APPROX_SIMPLE,它只保存轮廓的端点,节省内存。

我的经验:刚开始学的时候,我总喜欢用 RETR_TREE,觉得信息越多越好。后来发现,大部分场景下 RETR_EXTERNAL 就够了,而且速度更快。你想想看,如果目标本身就不嵌套,干嘛要算那些没用的层级呢?

二、轮廓筛选——去掉噪声,留下真目标

检测完轮廓后,你会发现一堆乱七八糟的东西。有大的、有小的、有长的、有扁的。怎么把真正的目标挑出来?这就是轮廓筛选要做的事。

我一般用三个指标来筛选:

指标 函数 作用
面积 cv2.contourArea(contour) 过滤掉太小或太大的噪声
周长 cv2.arcLength(contour, True) 判断轮廓的复杂程度
宽高比 通过边界框计算 筛选特定形状的目标

举个例子,假设我们要跟踪一个圆形的目标:

# 假设 contours 是 findContours 的结果
valid_contours = []
for cnt in contours:
    area = cv2.contourArea(cnt)
    if area < 100:  # 面积太小,跳过
        continue
    
    perimeter = cv2.arcLength(cnt, True)
    if perimeter < 50:  # 周长太小,跳过
        continue
    
    # 计算宽高比
    x, y, w, h = cv2.boundingRect(cnt)
    aspect_ratio = w / h
    if 0.8 < aspect_ratio < 1.2:  # 接近正方形
        valid_contours.append(cnt)

避坑指南:我曾经在一个项目中,目标明明是圆形,但用宽高比筛选死活找不到。后来发现,因为光照不均匀,目标的轮廓被分割成了好几块。解决办法是先用形态学操作(膨胀/腐蚀)把断裂的轮廓连起来。嗯,这个坑我踩过,你们别踩了。

三、计算质心与边界框

筛选出目标轮廓后,下一步就是找到它的位置。这里有两个常用方法:

3.1 边界框

边界框有两种:

  • 矩形边界框:用 cv2.boundingRect(),返回 (x, y, w, h)。简单粗暴,但不带旋转。
  • 旋转矩形:用 cv2.minAreaRect(),返回带角度的矩形。适合跟踪有旋转的目标。

3.2 质心计算

质心,说白了就是轮廓的“重心”。计算方式有两种:

方法一:图像矩

M = cv2.moments(contour)
if M["m00"] != 0:
    cx = int(M["m10"] / M["m00"])
    cy = int(M["m01"] / M["m00"])

方法二:边界框中心

x, y, w, h = cv2.boundingRect(contour)
cx = x + w // 2
cy = y + h // 2

哪个更好?我个人更推荐用图像矩。为什么呢?因为边界框中心只考虑了矩形范围,而图像矩考虑了轮廓的实际形状。举个例子,一个L形的目标,边界框中心可能落在空白区域,但图像矩算出来的质心一定在目标内部。你想想看,哪个更靠谱?

四、完整代码示例

来,看一个完整的流程:

import cv2
import numpy as np

# 读取图像并预处理
img = cv2.imread('target.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)

# 轮廓检测
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

# 筛选与计算
for cnt in contours:
    area = cv2.contourArea(cnt)
    if area < 500:  # 过滤小噪声
        continue
    
    # 计算质心
    M = cv2.moments(cnt)
    if M["m00"] != 0:
        cx = int(M["m10"] / M["m00"])
        cy = int(M["m01"] / M["m00"])
    
    # 绘制边界框和质心
    x, y, w, h = cv2.boundingRect(cnt)
    cv2.rectangle(img, (x, y), (x+w, y+h), (0, 255, 0), 2)
    cv2.circle(img, (cx, cy), 5, (0, 0, 255), -1)

cv2.imshow('Result', img)
cv2.waitKey(0)

小技巧:如果你发现质心总是偏来偏去,可以试试对连续几帧的质心做平滑处理。我一般用指数移动平均:cx_smooth = alpha * cx + (1 - alpha) * cx_prev,alpha 取 0.3 左右效果不错。

五、实际项目中的注意事项

最后,分享几个我在项目中踩过的坑:

  1. 轮廓层级问题:如果目标内部有空洞,RETR_EXTERNAL 会丢失内部轮廓。这时候要用 RETR_TREE 并分析层级关系。
  2. 性能优化:高分辨率图像上跑轮廓检测很慢。我一般先缩小图像到 640x480 再处理,质心坐标再映射回去。
  3. 多目标跟踪:如果有多个目标,记得给每个目标分配一个 ID。我习惯用字典来存:{id: (cx, cy, bbox)}。

好了,轮廓检测和质心计算就讲到这里。这些内容看起来基础,但真的是目标跟踪的基石。你把这些吃透了,后面学卡尔曼滤波、光流法都会轻松很多。

可可精品资料库