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_BINARY | dst = src>T ? 255 : 0 | 黑白分明 |
| 反二值化 | THRESH_BINARY_INV | dst = src>T ? 0 : 255 | 反转黑白 |
| 截断 | THRESH_TRUNC | dst = src>T ? T : src | 压制高亮 |
| 置零 | THRESH_TOZERO | dst = src>T ? src : 0 | 只保留亮部 |
| 反置零 | THRESH_TOZERO_INV | dst = 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
)
自适应阈值参数
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| blockSize | 3-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、自适应阈值三种分割方法,学会了腐蚀、膨胀、开闭运算等形态学操作,以及连通域分析用于物体计数和标记。