线性灰度变换:全尺度拉伸、分段线性拉伸、饱和线性拉伸
灰度变换这块,说白了就是给图像做「亮度整形」。你拍了一张灰蒙蒙的照片,或者一张过曝到细节全无的图,靠的就是这几种线性变换来救场。我最早接触这个是在做工业缺陷检测的时候,当时相机拍出来的金属表面图像对比度极低,不用线性拉伸根本看不出来划痕在哪。
线性变换的核心公式其实就一行:
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)
分段线性拉伸
全尺度拉伸虽然简单,但它一视同仁。有时候我们只想增强某个灰度区间的对比度,比如把暗部细节提亮,同时保持亮部不变。这时候就得用分段线性拉伸。
它的思路是把灰度轴切成几段,每段用不同的斜率。最常见的做法是分三段:暗部、中间调、亮部。
公式(三段式):
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。这样暗部细节就出来了,亮部也不会过曝。
代码实现:
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 是低百分位和高百分位的像素值。
代码实现:
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)
三种方法对比
| 方法 | 原理 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 全尺度拉伸 | Min-Max 线性映射 | 实现简单,充分利用动态范围 | 易受噪点影响 | 低对比度自然图像 |
| 分段线性拉伸 | 多段不同斜率映射 | 可针对特定灰度区间增强 | 需要手动调参 | 医学影像、工业检测 |
| 饱和线性拉伸 | 百分位截断后线性映射 | 抗噪能力强,稳健 | 可能丢失两端细节 | 含噪声图像、遥感图像 |
知识结构图
下面这张图帮你理清三种线性变换的关系和选择逻辑:
实际项目中的选择策略
这三种方法没有的好坏,关键看场景。我总结了一个简单的决策流程:
- 先看直方图 —— 如果直方图两端有空白,优先全尺度拉伸。
- 再看噪声 —— 如果图像有明显噪点,换饱和线性拉伸,百分位从 2% 开始试。
- 最后看需求 —— 如果只想增强某个灰度区间(比如暗部细节),用分段线性拉伸。
嗯,线性灰度变换就这些内容。代码都很短,但用好了能解决 80% 的图像对比度问题。下次你拿到一张灰蒙蒙的图,不妨先试试这三种方法,看看哪个效果最对味。