2. 对比度拉伸:从线性到自适应

图像预处理里,对比度拉伸是我用得最多的操作之一。说白了,就是把图像里太暗和太亮的像素拉开,让细节能看清楚。你想想看,一张雾蒙蒙的图,经过拉伸之后,纹理就全出来了。

这一节我们聊四种方法:线性拉伸、分段线性拉伸、直方图均衡化、自适应直方图均衡化。每种都有它的脾气,我一个个说。

核心思路:对比度拉伸的本质是重新映射像素的灰度值,让原本集中在狭窄区间的像素分布到更宽的区间。

对比度拉伸方法体系 对比度拉伸 线性拉伸 分段线性拉伸 直方图均衡化 自适应均衡化 全局映射 简单快速 易受极值影响 局部控制 灵活分段 参数需调优 自动增强 效果显著 可能过增强 局部自适应 抑制噪声 计算量大

2.1 线性拉伸:最直接的映射

线性拉伸的原理很简单。假设原始图像的最小灰度值是 \(I_{min}\),最大是 \(I_{max}\),你想把它拉伸到 \([0, 255]\) 区间。公式就是:

O = (I - I_min) * 255 / (I_max - I_min)

嗯,这里要注意。如果图像里有一个孤立的噪声点,比如一个纯白像素,那 \(I_{max}\) 就被拉到了 255,其他像素反而被压缩了。我在项目中遇到过这种情况——一张 CT 图像,因为传感器有个坏点,线性拉伸后整张图都发灰。

我的习惯:做线性拉伸前,先去掉上下 1% 的极值。用百分位截断,效果会稳很多。

代码实现也很直接:

import numpy as np
import cv2

def linear_stretch(img, low_percent=1, high_percent=99):
    # 计算百分位
    p_low = np.percentile(img, low_percent)
    p_high = np.percentile(img, high_percent)
    
    # 截断并拉伸
    img_clipped = np.clip(img, p_low, p_high)
    img_stretched = (img_clipped - p_low) / (p_high - p_low) * 255
    
    return img_stretched.astype(np.uint8)

2.2 分段线性拉伸:想拉哪里拉哪里

线性拉伸是全局的,但有时候我们只想增强某个灰度区间。比如医学图像里,骨骼和软组织分布在不同的灰度范围,你想让骨骼更清晰,同时保留软组织细节。这时候分段线性拉伸就派上用场了。

分段线性拉伸把灰度轴分成几段,每段用不同的斜率拉伸。斜率大于 1 的区间会被拉伸,小于 1 的会被压缩。

区间 斜率 效果
[0, r1] k1 压缩暗部
[r1, r2] k2 拉伸目标区域
[r2, 255] k3 压缩亮部

我曾经处理过一批工业检测的 X 光图像。缺陷区域集中在灰度 80-150 之间,背景在 200 以上。我设了三个分段:暗部压缩、中间拉伸、亮部压缩。效果立竿见影,缺陷的对比度提升了 3 倍。

def piecewise_linear(img, r1, s1, r2, s2):
    # 三段映射
    lut = np.zeros(256, dtype=np.uint8)
    for i in range(256):
        if i < r1:
            lut[i] = int(s1 / r1 * i)
        elif i < r2:
            lut[i] = int((s2 - s1) / (r2 - r1) * (i - r1) + s1)
        else:
            lut[i] = int((255 - s2) / (255 - r2) * (i - r2) + s2)
    return cv2.LUT(img, lut)

避坑指南:我曾经把斜率设得太大,结果目标区域出现了明显的量化伪影——灰度级不够用了,图像出现条带。分段拉伸时,输出灰度级最好保留 256 级,不要过度拉伸。

2.3 直方图均衡化:自动增强的利器

直方图均衡化(HE)是另一种思路。它不依赖人工设定参数,而是根据图像的直方图分布,自动生成一个映射函数。目标是让输出图像的直方图尽可能均匀分布。

原理其实不复杂。先计算累积分布函数(CDF),然后用 CDF 作为映射函数。直方图里像素多的灰度级会被拉伸,像素少的会被压缩。

我举个例子。一张背光拍摄的照片,人脸区域集中在暗部,背景在亮部。均衡化之后,人脸的细节会变得非常清楚。但有个问题——背景可能会过曝。

def histogram_equalization(img):
    # 如果是彩色图,先转 YUV 只处理亮度通道
    if len(img.shape) == 3:
        img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV)
        img_yuv[:,:,0] = cv2.equalizeHist(img_yuv[:,:,0])
        return cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR)
    else:
        return cv2.equalizeHist(img)

关键点:直方图均衡化是全局操作。如果图像光照不均匀,比如半边亮半边暗,均衡化后暗部噪声会被放大,亮部细节可能丢失。

2.4 自适应直方图均衡化:解决光照不均

全局均衡化的短板很明显。我处理过一张显微图像,左上角有强光源,右下角是阴影。全局均衡化之后,阴影区域的噪声被放大了好几倍,根本没法看。

自适应直方图均衡化(AHE)就是来解决这个问题的。它把图像分成一个个小窗口(tile),在每个窗口内独立做均衡化。这样每个局部都能得到合适的增强。

但 AHE 有个毛病——它容易放大噪声。尤其是在均匀区域,比如天空或皮肤,噪声会被明显增强。

后来有了限制对比度自适应直方图均衡化(CLAHE)。它在每个窗口做均衡化时,会限制对比度的放大倍数。超过阈值的部分会被裁剪并重新分布。

方法 窗口大小 对比度限制 适用场景
全局 HE 整图 无 光照均匀的图像
AHE 8×8 或 16×16 无 光照不均但噪声低
CLAHE 8×8 2-4 大多数场景
def clahe_enhance(img, clip_limit=2.0, tile_size=8):
    clahe = cv2.createCLAHE(
        clipLimit=clip_limit,
        tileGridSize=(tile_size, tile_size)
    )
    
    if len(img.shape) == 3:
        img_lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
        img_lab[:,:,0] = clahe.apply(img_lab[:,:,0])
        return cv2.cvtColor(img_lab, cv2.COLOR_LAB2BGR)
    else:
        return clahe.apply(img)

参数调优建议:clip_limit 我一般从 2.0 开始试。tile_size 默认 8×8 够用,如果图像纹理很细,可以降到 4×4,但计算量会翻倍。

2.5 四种方法对比总结

方法 优点 缺点 我常用的场景
线性拉伸 简单、速度快 受极值影响大 传感器标定后的原始数据
分段线性拉伸 灵活控制局部 需要人工调参 工业检测、医学图像
直方图均衡化 自动增强、效果好 全局操作、可能过增强 背光照片、监控图像
CLAHE 自适应、抑制噪声 计算量大、参数敏感 显微图像、遥感图像

我个人习惯是:先试线性拉伸,不行就上 CLAHE。分段线性拉伸虽然灵活,但调参太费时间,除非场景非常固定,否则我不太推荐在生产环境里用。

最后提醒一句:对比度拉伸不是万能的。如果原始图像的信噪比很低,拉伸只会让噪声更明显。这时候先做去噪,再做拉伸,顺序别搞反了。