线性灰度变换:全尺度拉伸、分段线性拉伸、饱和线性拉伸

灰度变换这块,说白了就是给图像做「亮度整形」。你拍了一张灰蒙蒙的照片,或者一张过曝到细节全无的图,靠的就是这几种线性变换来救场。我最早接触这个是在做工业缺陷检测的时候,当时相机拍出来的金属表面图像对比度极低,不用线性拉伸根本看不出来划痕在哪。

线性变换的核心公式其实就一行:

g(x,y) = a * f(x,y) + b

其中 f(x,y) 是输入像素值,a 控制对比度(斜率),b 控制亮度(截距)。就这么简单,但不同的 a 和 b 组合,能玩出三种完全不同的花样。

全尺度拉伸

全尺度拉伸,也叫「Min-Max 归一化」。它的想法很直接:把图像里最暗的像素映射到 0,最亮的映射到 255,中间的全部线性铺开。

公式长这样:

g(x,y) = (f(x,y) - f_min) * (255 / (f_max - f_min))

我个人的习惯是,拿到一张新图像,先看一眼它的直方图。如果直方图两端都有空白区域(像素值没铺满 0-255),全尺度拉伸基本就是首选。

适用场景: 低对比度图像、雾天图像、曝光不足或过度的照片。

代码实现也很直接:

import cv2
import numpy as np

def full_scale_stretch(img):
    f_min = np.min(img)
    f_max = np.max(img)
    # 防止除零
    if f_max - f_min == 0:
        return img
    stretched = (img - f_min) * (255.0 / (f_max - f_min))
    return np.clip(stretched, 0, 255).astype(np.uint8)

# 读图,直接调用
img = cv2.imread('low_contrast.jpg', cv2.IMREAD_GRAYSCALE)
result = full_scale_stretch(img)
注意: 如果图像里有一个孤立的噪点(比如一个纯白像素),f_max 会被这个噪点拉满,导致整体拉伸效果变差。我遇到过这种情况,后来加了百分位截断才解决。

分段线性拉伸

全尺度拉伸虽然简单,但它一视同仁。有时候我们只想增强某个灰度区间的对比度,比如把暗部细节提亮,同时保持亮部不变。这时候就得用分段线性拉伸。

它的思路是把灰度轴切成几段,每段用不同的斜率。最常见的做法是分三段:暗部、中间调、亮部。

公式(三段式):

g(x,y) = 
  (r1 / s1) * f(x,y),                    0 ≤ f < s1
  (r2 - r1) / (s2 - s1) * (f - s1) + r1, s1 ≤ f < s2
  (255 - r2) / (255 - s2) * (f - s2) + r2, s2 ≤ f ≤ 255

其中 (s1, s2) 是输入分段点,(r1, r2) 是对应的输出分段点。

举个例子:我想把暗部(0-80)提亮到 0-120,中间(80-160)保持不变,亮部(160-255)压暗到 160-220。这样暗部细节就出来了,亮部也不会过曝。

我的经验: 分段点的选择最好结合直方图来看。直方图的两个波谷位置,往往是天然的分段点。我曾经在医学 X 光片增强时,就是靠直方图波谷定位骨骼和软组织的分界。

代码实现:

def piecewise_linear_stretch(img, s1, s2, r1, r2):
    img = img.astype(np.float32)
    result = np.zeros_like(img)
    
    # 第一段:暗部
    mask1 = img < s1
    result[mask1] = (r1 / s1) * img[mask1]
    
    # 第二段:中间
    mask2 = (img >= s1) & (img < s2)
    slope = (r2 - r1) / (s2 - s1)
    result[mask2] = slope * (img[mask2] - s1) + r1
    
    # 第三段:亮部
    mask3 = img >= s2
    slope = (255 - r2) / (255 - s2)
    result[mask3] = slope * (img[mask3] - s2) + r2
    
    return np.clip(result, 0, 255).astype(np.uint8)

# 使用示例
result = piecewise_linear_stretch(img, s1=80, s2=160, r1=120, r2=200)

饱和线性拉伸

饱和线性拉伸,说白了就是全尺度拉伸的「稳健版」。它不直接用 min 和 max,而是用百分位来定义拉伸范围。比如,把 2% 的像素和 98% 的像素作为拉伸端点,两端的极端值直接截断。

这样做的好处很明显:不会被几个噪点带偏。我做过一个对比实验,同一张带椒盐噪声的图像,全尺度拉伸后整体偏暗,而饱和线性拉伸效果就好得多。

公式:

g(x,y) = (f(x,y) - p_low) * (255 / (p_high - p_low))
g(x,y) = clip(g(x,y), 0, 255)

其中 p_low 和 p_high 是低百分位和高百分位的像素值。

关键参数: 百分位值一般取 1%-5%。取 1% 更接近全尺度拉伸,取 5% 更稳健但可能损失细节。我通常从 2% 开始试。

代码实现:

def saturate_stretch(img, low_percent=2, high_percent=98):
    # 计算百分位值
    p_low = np.percentile(img, low_percent)
    p_high = np.percentile(img, high_percent)
    
    # 防止除零
    if p_high - p_low == 0:
        return img
    
    stretched = (img.astype(np.float32) - p_low) * (255.0 / (p_high - p_low))
    return np.clip(stretched, 0, 255).astype(np.uint8)

# 使用示例
result = saturate_stretch(img, low_percent=2, high_percent=98)
避坑: 如果图像是 16 位深度的(比如医学 DICOM 图像),百分位计算要小心。16 位图的像素范围是 0-65535,直接套用上面的代码会出问题。记得先归一化到 0-1 再处理。

三种方法对比

方法 原理 优点 缺点 典型场景
全尺度拉伸 Min-Max 线性映射 实现简单,充分利用动态范围 易受噪点影响 低对比度自然图像
分段线性拉伸 多段不同斜率映射 可针对特定灰度区间增强 需要手动调参 医学影像、工业检测
饱和线性拉伸 百分位截断后线性映射 抗噪能力强,稳健 可能丢失两端细节 含噪声图像、遥感图像

知识结构图

下面这张图帮你理清三种线性变换的关系和选择逻辑:

线性灰度变换知识结构图 线性灰度变换三种方法 输入图像 f(x,y) 全尺度拉伸 分段线性拉伸 饱和线性拉伸 核心公式 g = (f - min) * 255 / (max - min) 易受噪点影响 核心公式 多段线性映射 需手动选分段点 核心公式 g = (f - p_low) * 255 / (p_high - p_low) 抗噪能力强 选择建议 无噪声 → 全尺度拉伸 | 需局部增强 → 分段线性 | 有噪声 → 饱和线性

实际项目中的选择策略

这三种方法没有的好坏,关键看场景。我总结了一个简单的决策流程:

  1. 先看直方图 —— 如果直方图两端有空白,优先全尺度拉伸。
  2. 再看噪声 —— 如果图像有明显噪点,换饱和线性拉伸,百分位从 2% 开始试。
  3. 最后看需求 —— 如果只想增强某个灰度区间(比如暗部细节),用分段线性拉伸。
小技巧: 在实际项目中,我经常把三种方法的结果并排显示,让用户(或者下游算法)来选。有时候人眼觉得好的,算法不一定认。比如我做车牌识别时,饱和线性拉伸的效果就比全尺度拉伸好,因为车牌字符的对比度更均匀。

嗯,线性灰度变换就这些内容。代码都很短,但用好了能解决 80% 的图像对比度问题。下次你拿到一张灰蒙蒙的图,不妨先试试这三种方法,看看哪个效果最对味。