强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

OpenCV 计算机视觉完全教程 / 第 07 章 — 阈值处理与形态学

第 07 章 — 阈值处理与形态学

7.1 阈值处理概述

阈值处理(Thresholding)将灰度图转换为二值图像,是图像分割的基础方法。

阈值处理分类

阈值处理
├── 全局阈值(Global Thresholding)
│   ├── 固定阈值
│   ├── 二值化
│   ├── 反二值化
│   ├── 截断
│   └── Otsu 自动阈值
└── 自适应阈值(Adaptive Thresholding)
    ├── 均值方法
    └── 高斯方法

7.2 全局阈值

import cv2
import numpy as np

img = cv2.imread("document.jpg", cv2.IMREAD_GRAYSCALE)

# 基本二值化
# pixel > threshold ? maxval : 0
ret, thresh_binary = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)

# 反二值化
ret, thresh_inv = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)

# 截断(超过阈值的像素设为阈值)
ret, thresh_trunc = cv2.threshold(img, 127, 255, cv2.THRESH_TRUNC)

# 低于阈值置零
ret, thresh_tozero = cv2.threshold(img, 127, 255, cv2.THRESH_TOZERO)

# 高于阈值置零
ret, thresh_tozero_inv = cv2.threshold(img, 127, 255, cv2.THRESH_TOZERO_INV)

print(f"使用的阈值: {ret}")

阈值类型速查

类型常量公式效果
二值化THRESH_BINARYdst = src>T ? 255 : 0黑白分明
反二值化THRESH_BINARY_INVdst = src>T ? 0 : 255反转黑白
截断THRESH_TRUNCdst = src>T ? T : src压制高亮
置零THRESH_TOZEROdst = src>T ? src : 0只保留亮部
反置零THRESH_TOZERO_INVdst = src>T ? 0 : src只保留暗部
// C++ 阈值处理
cv::Mat img = cv::imread("document.jpg", cv::IMREAD_GRAYSCALE);
cv::Mat thresh;
double ret = cv::threshold(img, thresh, 127, 255, cv::THRESH_BINARY);

7.3 Otsu 自动阈值

Otsu 方法自动计算最优阈值,使类间方差最大化。

import cv2
import numpy as np

img = cv2.imread("document.jpg", cv2.IMREAD_GRAYSCALE)

# Otsu 自动阈值
ret_otsu, thresh_otsu = cv2.threshold(
    img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
)
print(f"Otsu 自动阈值: {ret_otsu:.1f}")

# 先高斯模糊再 Otsu(推荐,减少噪声干扰)
blurred = cv2.GaussianBlur(img, (5, 5), 0)
ret, thresh = cv2.threshold(
    blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
)
print(f"去噪后 Otsu 阈值: {ret:.1f}")

# 可视化直方图 + Otsu 阈值线
import matplotlib.pyplot as plt

hist = cv2.calcHist([img], [0], None, [256], [0, 256])
plt.figure(figsize=(10, 4))
plt.plot(hist)
plt.axvline(x=ret_otsu, color='r', linestyle='--',
            label=f'Otsu = {ret_otsu:.0f}')
plt.xlabel('灰度值')
plt.ylabel('像素数')
plt.legend()
plt.title('灰度直方图与 Otsu 阈值')
plt.savefig("otsu_histogram.png", dpi=150)

Otsu 适用条件

条件说明
双峰分布直方图有两个明显峰(前景/背景)
图像质量好噪声低,对比度适中
均匀光照光照不均匀时效果差

注意: Otsu 仅适用于单峰或双峰分布的图像。多峰分布或光照不均匀时应使用自适应阈值。


7.4 自适应阈值

自适应阈值对每个像素计算不同的阈值,适合光照不均匀的场景。

import cv2
import numpy as np

img = cv2.imread("document.jpg", cv2.IMREAD_GRAYSCALE)

# 自适应均值阈值
# 每个像素的阈值 = 邻域均值 - C
thresh_mean = cv2.adaptiveThreshold(
    img, 255,
    cv2.ADAPTIVE_THRESH_MEAN_C,     # 均值方法
    cv2.THRESH_BINARY,
    blockSize=11,                     # 邻域大小(奇数)
    C=2                               # 偏移常数
)

# 自适应高斯阈值
# 每个像素的阈值 = 邻域高斯加权均值 - C
thresh_gauss = cv2.adaptiveThreshold(
    img, 255,
    cv2.ADAPTIVE_THRESH_GAUSSIAN_C,  # 高斯方法
    cv2.THRESH_BINARY,
    blockSize=11,
    C=2
)

自适应阈值参数

参数推荐范围说明
blockSize3-51(奇数)邻域大小,越大越平滑
C-10 到 10偏移常数,正值减少噪声,负值保留细节
方法MEAN_C / GAUSSIAN_C高斯通常效果更好

方法对比

场景全局阈值Otsu自适应阈值
均匀光照文档
不均匀光照文档
自然图像分割⚠️⚠️
手写文字⚠️
低对比度⚠️

7.5 形态学操作

形态学操作基于**形状(结构元素)**处理二值图像。

7.5.1 结构元素

import cv2
import numpy as np

# 矩形结构元素
kernel_rect = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))

# 椭圆结构元素
kernel_ellipse = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))

# 十字结构元素
kernel_cross = cv2.getStructuringElement(cv2.MORPH_CROSS, (5, 5))

# 自定义结构元素
kernel_custom = np.array([
    [0, 1, 0],
    [1, 1, 1],
    [0, 1, 0]
], dtype=np.uint8)

print("矩形核:\n", kernel_rect)
print("椭圆核:\n", kernel_ellipse)
print("十字核:\n", kernel_cross)

7.5.2 腐蚀(Erosion)

腐蚀使物体缩小,消除小噪点,分离连接物体。

import cv2
import numpy as np

img = cv2.imread("binary.png", cv2.IMREAD_GRAYSCALE)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))

# 腐蚀
# 只有当核覆盖的所有像素都为 1 时,中心像素才保留为 1
eroded = cv2.erode(img, kernel, iterations=1)

# 多次腐蚀(更强的收缩)
eroded_3x = cv2.erode(img, kernel, iterations=3)

7.5.3 膨胀(Dilation)

膨胀使物体扩大,填补小孔洞,连接断开区域。

# 膨胀
# 只要核覆盖的任一像素为 1,中心像素就设为 1
dilated = cv2.dilate(img, kernel, iterations=1)

# 多次膨胀
dilated_3x = cv2.dilate(img, kernel, iterations=3)

7.5.4 开运算(Opening)

先腐蚀后膨胀 — 去除小噪点,保持物体大小基本不变。

# 开运算
opened = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel)

# 等价于:
# opened = cv2.dilate(cv2.erode(img, kernel), kernel)

7.5.5 闭运算(Closing)

先膨胀后腐蚀 — 填补小孔洞,连接邻近物体。

# 闭运算
closed = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel)

7.5.6 形态学梯度

膨胀 - 腐蚀 = 物体轮廓

# 形态学梯度
gradient = cv2.morphologyEx(img, cv2.MORPH_GRADIENT, kernel)

7.5.7 顶帽与黑帽

# 顶帽 = 原图 - 开运算(提取亮细节)
tophat = cv2.morphologyEx(img, cv2.MORPH_TOPHAT, kernel)

# 黑帽 = 闭运算 - 原图(提取暗细节)
blackhat = cv2.morphologyEx(img, cv2.MORPH_BLACKHAT, kernel)

形态学操作总结

操作公式效果应用
腐蚀A ⊖ B缩小物体去噪、分离
膨胀A ⊕ B扩大物体填孔、连接
开运算(A ⊖ B) ⊕ B去除亮噪点小物体去除
闭运算(A ⊕ B) ⊖ B填补暗孔洞连接邻近区域
梯度(A ⊕ B) - (A ⊖ B)提取边界轮廓提取
顶帽A - open(A)亮细节背景不均匀校正
黑帽close(A) - A暗细节文字检测

7.6 连通域分析

import cv2
import numpy as np

img = cv2.imread("objects.png", cv2.IMREAD_GRAYSCALE)

# 二值化
_, binary = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)

# 连通域标记
# num_labels: 连通域数量(含背景)
# labels: 标记图(每个像素标记所属连通域)
# stats: 每个连通域的统计信息 [x, y, w, h, area]
# centroids: 每个连通域的质心
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
    binary, connectivity=8
)

print(f"连通域数量: {num_labels - 1}")  # 减去背景

# 分析每个连通域
result = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
for i in range(1, num_labels):  # 跳过背景(label 0)
    x, y, w, h, area = stats[i]
    cx, cy = centroids[i]

    # 过滤过小的区域
    if area < 100:
        continue

    # 绘制边界框
    cv2.rectangle(result, (x, y), (x + w, y + h), (0, 255, 0), 2)
    cv2.putText(result, f"#{i} area={area}", (x, y - 5),
                cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 0), 1)
    cv2.circle(result, (int(cx), int(cy)), 3, (0, 0, 255), -1)

    print(f"  连通域 #{i}: 位置=({x},{y}), 大小={w}×{h}, "
          f"面积={area}, 质心=({cx:.0f},{cy:.0f})")

颜色标记连通域

# 为每个连通域分配随机颜色
output = np.zeros((*img.shape, 3), dtype=np.uint8)
colors = [(0, 0, 0)]  # 背景为黑色
for i in range(1, num_labels):
    colors.append(tuple(np.random.randint(0, 255, 3).tolist()))

for y in range(labels.shape[0]):
    for x in range(labels.shape[1]):
        output[y, x] = colors[labels[y, x]]

7.7 实战:文档二值化

"""
document_binarize.py — 文档图像自适应二值化
"""
import cv2
import numpy as np

def binarize_document(image_path, output_path="output.png"):
    """文档图像二值化,自动选择最佳方法"""
    img = cv2.imread(image_path)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # 1. 评估图像质量
    mean_brightness = gray.mean()
    std_brightness = gray.std()
    print(f"平均亮度: {mean_brightness:.0f}, 标准差: {std_brightness:.0f}")

    # 2. 选择二值化方法
    if std_brightness > 60:
        # 对比度好 — 使用 Otsu
        print("使用 Otsu 全局阈值")
        blurred = cv2.GaussianBlur(gray, (5, 5), 0)
        _, binary = cv2.threshold(blurred, 0, 255,
                                  cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    else:
        # 对比度差或光照不均 — 使用自适应阈值
        print("使用自适应高斯阈值")
        binary = cv2.adaptiveThreshold(
            gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
            cv2.THRESH_BINARY, blockSize=15, C=8
        )

    # 3. 形态学后处理
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
    binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)

    cv2.imwrite(output_path, binary)
    print(f"结果已保存: {output_path}")
    return binary

# 使用
# binarize_document("scan.jpg")

7.8 实战:细胞计数

"""
cell_counter.py — 显微镜细胞图像计数
"""
import cv2
import numpy as np

def count_cells(image_path):
    img = cv2.imread(image_path)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # 1. 高斯模糊
    blurred = cv2.GaussianBlur(gray, (9, 9), 2)

    # 2. Otsu 二值化
    _, binary = cv2.threshold(blurred, 0, 255,
                              cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # 3. 形态学操作
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
    opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=2)
    closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations=2)

    # 4. 连通域分析
    num_labels, labels, stats, centroids = \
        cv2.connectedComponentsWithStats(closed, connectivity=8)

    # 5. 过滤并计数
    valid_count = 0
    result = img.copy()
    for i in range(1, num_labels):
        area = stats[i, cv2.CC_STAT_AREA]
        if 50 < area < 5000:  # 合理面积范围
            valid_count += 1
            cx, cy = centroids[i]
            cv2.circle(result, (int(cx), int(cy)), 5, (0, 0, 255), -1)
            cv2.putText(result, str(valid_count),
                        (int(cx) + 5, int(cy) - 5),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.3, (0, 255, 0), 1)

    print(f"检测到 {valid_count} 个细胞")
    return result, valid_count

7.9 阈值处理选型指南

图像特征评估
├─ 光照均匀?
│  ├─ 是 → 全局阈值 / Otsu
│  └─ 否 → 自适应阈值
├─ 对比度高?
│  ├─ 是 → Otsu
│  └─ 否 → 自适应 + 预处理
├─ 噪声多?
│  └─ → 先滤波(高斯/中值)再阈值
└─ 需要分割多个物体?
   └─ → 形态学后处理 + 连通域

7.10 扩展阅读

资源链接说明
OpenCV 阈值教程docs.opencv.org/4.x/d7/d4d/tutorial_py_thresholding官方教程
形态学操作docs.opencv.org/4.x/d9/d61/tutorial_py_morphological_ops形态学详解
Otsu 论文“A Threshold Selection Method from Gray-Level Histograms” (1979)原始论文
下一章第 08 章 — 轮廓分析查找/绘制/层级

本章小结: 掌握了全局阈值、Otsu、自适应阈值三种分割方法,学会了腐蚀、膨胀、开闭运算等形态学操作,以及连通域分析用于物体计数和标记。