
第 026 期 | 图像处理算法入门系列 | 2026-09-04
关键词:分段线性变换、灰度窗、窗宽窗位、阶梯变换、密度分层、LUT、CT 影像、中间调拉伸
承接 025 期 → 026 期 :024 期是一条直线(g = α·f + β),025 期把直线弯成曲线(伽马/Log);今晚把控制权彻底交给你------直线折成几段,每段斜率自己定。医学影像 CT 的"窗宽窗位"正是这套数学的巅峰应用:同一张 CT,医生切换一下窗口,就能分别看肺、看骨、看软组织。
一、引言:曲线也有管不到的地方
025 期结尾我们验证了一个结论:暗图的"第一步粗调"优先用伽马。但伽马有一个先天盲区------它只有一个参数 γ,无法指定"拉伸哪一段、压缩哪一段"。
用一个实验说话(第六节详述):一张内容集中在 90~165 灰度区间的"中间调挤压"图(mean=128,std=18),我们分别用三种方法增强:
text
实测对比(std = 标准差,越大对比越强):
原图: mean=128 std=18
自动伽马: mean=127 std=18 ← 自动算出 γ = 1.011,等于什么都没做!
min-max 线性拉伸: mean=99 std=41 ← 有一点效果,但被极值"绑架"
三段线性(90,30/165,235):
mean=135 std=49 ← 中间调被猛拉,两端直接冻结
自动伽马失效的原因很直白:它的目标是"把均值调到 128",而这张图均值本来就是 128------伽马是亮度工具,不是对比度工具。min-max 拉伸能干活,但它只能"整体拉",一个亮斑离群点就能把拉伸倍数压垮。
想要"只拉 90~165 这一段,其余冻结"?只有折线做得到。
二、核心原理:三段线性、灰度窗与阶梯
2.1 三段线性变换
设两个断点 (x₁, y₁) 和 (x₂, y₂),两端锚定 (0, 0) 和 (255, 255):
text
三段线性:
f ≤ x₁: g = (y₁ / x₁) · f ← 段1:斜率小,压缩暗部
x₁ < f ≤ x₂: g = y₁ + (f − x₁) · (y₂ − y₁)/(x₂ − x₁) ← 段2:斜率大,拉伸中间调
f > x₂: g = y₂ + (f − x₂) · (255 − y₂)/(255 − x₂) ← 段3:斜率小,压缩高光
| 段 | 斜率 | 作用 | 直觉 |
|---|---|---|---|
| 段 1(0 → x₁) | < 1 | 暗部压缩 | 反正看不清,挤一挤 |
| 段 2(x₁ → x₂) | > 1 | 中间调拉伸 | 主体内容在这里,拉开 |
| 段 3(x₂ → 255) | < 1 | 高光压缩 | 已经够亮,不用动 |
text
和 024 期直线的关系:
直线 = 三段线性的退化形式(所有断点落在同一条直线上)
折线 = 直线的推广(每个断点都是自由度)
断点数越多 → 曲线越自由 → 但交互/调参也越复杂(PS 曲线工具就是 N 断点折线)
2.2 灰度窗:窗宽窗位(CT 的核心显示技术)
灰度窗是三段线性的一个极端特例------段 1 和段 3 的斜率直接压到 0:
text
灰度窗(窗宽 W,窗位 L):
f < L − W/2: g = 0 (窗下 → 全黑)
L − W/2 ≤ f ≤ L + W/2: g = 255 · (f − (L − W/2)) / W (窗内 → 线性拉满)
f > L + W/2: g = 255 (窗上 → 全白)
| 参数 | 别名 | 控制 | 直觉 |
|---|---|---|---|
| 窗宽 W | Window Width | 窗内对比度 | W 越小,窗内拉伸越猛,但覆盖的灰度范围越窄 |
| 窗位 L | Window Level | 观察哪一段 | L 平移窗口中心,对准感兴趣的灰度段 |
text
CT 的物理背景(帮助理解,不影响代码):
CT 值用 Hounsfield 单位(HU):水 = 0,空气 = −1000,骨 ≈ +1000,
动态范围横跨 2000 HU,而显示器只有 256 级灰度。
所以 CT 必须"开窗":一次只显示 2000 HU 中的一小段,其余全部黑/白饱和。
医生调窗宽窗位,就是在调今晚这两个公式参数。
2.3 阶梯变换:密度分层
把连续灰度量化成 n 级台阶,是分段线性"斜率取 0 或 ∞"的极限玩法:
text
阶梯变换(n 级):
g = floor( f · n / 256 ) · 255 / (n − 1)
n = 2 → 二值化(020 期的老朋友)
n = 4~16 → 海报化效果(美术/设计)
灰度级映射到颜色 → 伪彩色/密度分层(101 期彩色处理的铺垫)

图 1:一张图看懂分段线性。左图为三段折线与 4 级阶梯的映射曲线------段 2 斜率 > 3,中间调被猛拉;右图为同一渐变条的实际效果,灰度窗窗内对比显著增强、窗外直接黑/白。
三、代码实践
3.1 核心函数:一切点运算都是 256 项 LUT
分段线性、灰度窗、阶梯变换,全部可以预计算成 256 项查找表(027 期专题预告):
python
import cv2
import numpy as np
def lut_piecewise(x1, y1, x2, y2):
"""
三段线性 LUT:断点 (x1, y1)、(x2, y2),两端锚定 (0,0)、(255,255)。
返回 (lut_uint8, ys_float)------ys 用于绘制映射曲线。
"""
xs = np.arange(256, dtype=np.float32)
ys = np.empty(256, dtype=np.float32)
s1 = y1 / max(x1, 1e-6) # 段1斜率:暗部压缩
m1 = xs <= x1
ys[m1] = xs[m1] * s1
s2 = (y2 - y1) / max(x2 - x1, 1e-6) # 段2斜率:中间调拉伸
m2 = (xs > x1) & (xs <= x2)
ys[m2] = y1 + (xs[m2] - x1) * s2
s3 = (255.0 - y2) / max(255.0 - x2, 1e-6) # 段3斜率:高光压缩
m3 = xs > x2
ys[m3] = y2 + (xs[m3] - x2) * s3
return np.clip(np.round(ys), 0, 255).astype(np.uint8), ys
def lut_window(width, level):
"""灰度窗 LUT(窗宽 W、窗位 L)。"""
lo = level - width / 2.0
xs = np.arange(256, dtype=np.float32)
vals = (xs - lo) * (255.0 / max(float(width), 1e-6))
return np.clip(np.round(vals), 0, 255).astype(np.uint8)
def lut_staircase(n):
"""阶梯变换 LUT:把 [0,255] 量化成 n 级台阶。"""
xs = np.arange(256, dtype=np.float32)
ys = np.floor(xs * n / 256.0) * (255.0 / (n - 1))
return np.clip(np.round(ys), 0, 255).astype(np.uint8)
text
关键细节:
1. 斜率分母要 max(·, 1e-6) 防除零(比如 x1=0、x2=255 的退化情况)。
2. 整个函数没有任何像素级运算------只在 256 项上预计算,
像素处理交给 cv2.LUT 一次查表完成(实测比 NumPy 逐像素快 32 倍,见第七节)。
3. lut_window 用 float 中间量计算再 clip,不存在 uint8 回绕问题。
应用只需一行:
python
out = cv2.LUT(img, lut_window(width=110, level=115)) # CT 软组织窗
3.2 测试图:合成"CT 断层"
真实 DICOM 数据不方便获取,我们合成一张类比图------灰度模拟组织密度:空气(≈8) → 肺(≈45) → 软组织(≈110) → 肝(≈135) → 骨(≈225):
python
def make_ct_phantom(size=(420, 560), seed=3):
h, w = size
rng = np.random.default_rng(seed)
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
ny, nx = yy / h, xx / w
img = np.full(size, 8.0, dtype=np.float32) # 背景 = 空气
body = ((nx - 0.50)/0.42)**2 + ((ny - 0.52)/0.44)**2 < 1
img[body] = 110.0 # 身体 = 软组织
for cx in (0.33, 0.67): # 双肺
lung = ((nx - cx)/0.13)**2 + ((ny - 0.42)/0.22)**2 < 1
img[lung] = 45.0
# ......肝 135 / 脊椎 230 / 肋骨环 225,加噪声与高斯模糊(完整代码见文末脚本)

图 2:断点网格实验。原图 std=18(中间调挤压在 90~165),6 组断点组合拉伸后 std 提升到 24~51------断点收得越窄、段 2 斜率越大,对比越猛,但离群像素越容易被"牺牲"到 0/255。
四、灰度窗深入:一次调窗 = 一次重新显示
4.1 四大窗口:同一张 CT 的四种看法

图 3:合成 CT 上的四种窗口。全范围对照(std=67)所有组织混在一起;软组织窗(W=110, L=115,std=86)肝与肌肉层次清晰、骨已饱和发白;肺窗(W=170, L=60)肺叶与背景层次拉开;骨窗(W=90, L=200)只剩肋骨环与脊椎,软组织全部归黑。
实测数据:
text
全范围对照(W=255, L=128): mean=77 std=67 ← 什么都能看到,什么都看不清
软组织窗(W=110, L=115): mean=74 std=86 ← 窗内对比 +29%,细节最清楚
肺窗(W=170, L=60): mean=141 std=79 ← 窗口对准肺的灰度段
骨窗(W=90, L=200): mean=20 std=59 ← 只有骨在窗内,其余全黑
4.2 W 与 L 的独立影响:两个旋钮各管各的

图 4:上排固定 L=128 变 W------窗宽是"对比度旋钮",W 越小窗内越猛(std 随 W 单调变化);下排固定 W=110 变 L------窗位是"平移旋钮",决定观察哪一段灰度,图像整体亮度随之变化。
text
一句话记住:
窗宽 W = 拉伸倍数的分母(255/W)------管"看多清楚"
窗位 L = 窗口中心的位置------管"看哪里"
临床调窗顺序:先转 L 对准目标组织,再收 W 增强对比。
五、三方案对比:为什么只有折线能"只拉中间调"

图 5:中间调挤压图(std=18)上三方案对决。自动伽马算出 γ=1.011 近似恒等(均值已达标,无活可干);min-max 拉伸 std=41 但被极值绑架;分段线性 std=49 且直方图两端被钉死在 0/255。
text
选型口诀:
· 亮度不对(整体偏暗/过曝) → 伽马校正(025 期)
· 对比度整体不足、内容满幅分布 → min-max / 百分位拉伸
· 内容挤在某一段、两端是废话 → 分段线性 / 灰度窗(本期)
· 要的是全局对比度重新分配 → HE / CLAHE(022/023 期)
分段线性直方图的独有特征:中间部分被拉开,两端出现 0 和 255 上的"冲天柱"(被冻结的像素堆在那里)------这是伽马和 HE 都不会产生的形状。
六、阶梯变换与密度分层

图 6:合成 CT 在 2/4/8/16 级阶梯量化下的效果。2 级即二值化(020 期),级数越少色块感越强------设计领域的"海报化"滤镜正是这个数学。
text
阶梯变换的三张面孔:
图像增强视角:密度分层------把淹没在连续灰度里的密度差异显式分开
美术设计视角:海报化(Posterize)------PS 的"色调分离"就是逐通道阶梯变换
压缩视角:颜色量化(099 期预告)------中位切割/八叉树是它的"智能选台阶"版本
七、性能基准:折线再复杂,查表只要 0.24 ms

图 7:480×640 灰度图三方案性能(对数坐标)。LUT=0.236ms,比 NumPy 逐像素插值(7.55ms)快 32 倍,比 Python 双层循环(70.9ms)快约 300 倍;三方案输出逐像素一致(max|Δ|=0)。
text
本期最重要的性能结论:
无论映射是直线(024)、曲线(025)还是折线/阶梯(026),
只要它是"每个输入灰度对应唯一输出"的点运算,
就一定能写成 256 项 LUT------cv2.LUT 一律 0.2~0.3ms。
这也是为什么医学影像设备能实时调窗:调窗 = 换一张 256 项表。
八、参数网格:等效双滑杆预览

图 8:窗宽 × 窗位 3×3 网格。横向看 W:越往左窗内越猛;纵向看 L:越往下观察的灰度段越亮。本地可跑 Trackbar 交互版,双滑杆实时调窗(代码见文末)。
Trackbar 交互版(本地运行,按 q 退出):
python
import cv2
import numpy as np
img = cv2.imread('ct.png', 0) # 换成你的图
cv2.namedWindow('windowing')
def on_change(_):
w = cv2.getTrackbarPos('W', 'windowing') or 1
l = cv2.getTrackbarPos('L', 'windowing')
lo = l - w / 2.0
xs = np.arange(256, dtype=np.float32)
lut = np.clip(np.round((xs - lo) * (255.0 / w)), 0, 255).astype(np.uint8)
cv2.imshow('windowing', cv2.LUT(img, lut))
cv2.createTrackbar('W', 'windowing', 110, 255, on_change)
cv2.createTrackbar('L', 'windowing', 115, 255, on_change)
on_change(0)
while cv2.waitKey(30) != ord('q'):
pass
九、可视化实验总览
本次共 8 个实验,对应 8 张可视化图:
| # | 实验 | 图文件 | 验证目标 |
|---|---|---|---|
| 1 | 折线/阶梯原理 + 渐变条 | 026_curves_principle.png |
一张图看懂折线形状 |
| 2 | 断点网格 | 026_piecewise_grid.png |
断点收窄 → 对比增强 |
| 3 | 灰度窗四大窗口 | 026_window_basics.png |
同图四看法 |
| 4 | W/L 独立影响 | 026_window_wl_effect.png |
W 管对比、L 管位置 |
| 5 | 阶梯变换 | 026_staircase.png |
密度分层/海报化 |
| 6 | 三方案对比 | 026_compare_three.png |
折线是中间调拉伸唯一解 |
| 7 | 性能与一致性 | 026_benchmark.png |
LUT 快 32 倍 |
| 8 | W×L 参数网格 | 026_wl_grid.png |
等效双滑杆 |
代码运行后,上述 PNG 全部自动生成到 code/output/ 目录。
十、产品推荐
| 层级 | 移动端 App | 专业级工具 | 开源/免费 | 适用场景 |
|---|---|---|---|---|
| 医学影像调窗 | OsiriX HD(iOS DICOM 查看器)、MIM Mobile | RadiAnt DICOM Viewer(Windows)、OsiriX MD(macOS) | Horos(macOS)、3D Slicer、MicroDicom(Windows 免费版) | DICOM 影像窗宽窗位浏览 |
| 曲线工具(N 断点折线) | Snapseed(曲线)、Lightroom Mobile(色调曲线) | Adobe Photoshop(曲线)、Capture One、Affinity Photo | GIMP(曲线)、Darktable、RawTherapee | 修图:中间调精修 |
| 海报化/色调分离 | PicsArt(海报效果)、Prisma | Photoshop(色调分离)、Affinity Photo | GIMP(Posterize) | 设计/美术风格化 |
| 科研分析 | --- | ImageJ/Fiji(Brightness/Contrast 调窗)、MATLAB | ImageJ(免费) | 显微/科学图像 |
text
选择建议:
· 想亲手体验"调窗":手机装 MicroDicom / OsiriX HD 打开任意 DICOM 文件,
拖动窗宽窗位滑杆------就是今晚实验 3/4 的交互版。
· 通用修图:Snapseed "曲线"工具点两个控制点拉折线,等价于今晚的 lut_piecewise。
· 影像科/科研:Horos 与 3D Slicer 免费专业,窗宽窗位、CT 值测量全套支持。
十一、常见问题 FAQ
Q1:分段线性、灰度窗、阶梯变换,三者是什么关系?
A:灰度窗是三段线性的特例(段 1/段 3 斜率 = 0,段 2 端点对齐 0/255);阶梯变换是"斜率在台阶处为 0、台阶沿垂直"的极端分段函数。三者都是"逐灰度映射"的点运算,都能写成 256 项 LUT。
Q2:窗宽窗位里的 L(窗位)和 024 期的 β(亮度)是一回事吗?
A:形似神不似。β 是整条映射直线平移;L 平移的是"窗口"(哪一段灰度被拉伸),窗内像素的输出不受 L 直接加减,而是被重新映射到 0~255。L 增大时图像看起来"变暗",本质是窗口移向了更亮的输入段。
Q3:分段线性会把信息压没吗?
A:窗外像素直接饱和到 0/255,窗内外的灰度差信息确实丢失(不可逆)。这正是 CT 的设计取舍:一次只看清一种组织,需要看别的组织就换窗口------所以医学影像原始数据永远保存全动态范围的 DICOM,显示层才调窗。
Q4:自动伽马为什么在这期实验里完全失效?
A:伽马的优化目标是均值(亮度),不改变直方图形状的"宽窄"(对比度)。实验图的均值恰好是 128,自动伽马算出 γ=1.011 直接躺平。教训:先诊断图像问题属于亮度还是对比度,再选工具。
Q5:断点应该怎么选?有没有自动方法?
A:经验法是看直方图:把内容密集区两端设为断点(如百分位 1% 和 99%)。自动方法包括按百分位自动选断点、最小值-最大值忽略离群点等。更彻底的自适应方案是 022/023 期的 HE/CLAHE------它们相当于"直方图数据驱动的自动分段变换"。
Q6:彩色图能做分段线性吗?
A:能,而且 Photoshop 的"曲线"就是逐通道(RGB/单独通道)分段折线。工程上与 024/025 期结论一致:整图风格调整推荐转 Lab 只动 L,或 HSV 只动 V,避免色相漂移;创意调色才逐通道拉曲线。
十二、课后练习
- 基础练习 :给
lut_window加一个"反窗"模式:窗内显示 0/255 两级、窗外线性(用于突出"不在目标灰度段"的像素),想想这能用于什么质检场景。 - 进阶练习:实现"百分位自动断点":取累计直方图 1% 和 99% 处的灰度作为 x₁、x₂,自动做三段拉伸,在 10 张不同图上对比 min-max 拉伸的鲁棒性。
- 挑战题 :用 OpenCV Trackbar 实现四窗口联动:一个窗口显示原图+当前窗宽窗位指示线(叠加在直方图上),另一个窗口实时显示调窗结果(提示:
cv2.line画指示线)。
十三、完整可运行脚本
bash
# 环境要求
Python 3.8+
OpenCV 4.5+ / 5.x
NumPy 1.20+
Matplotlib 3.4+
# 运行
python code/026_piecewise_windowing.py
运行后 code/output/ 下会生成 8 张 PNG,对应本文全部实验。
下期预告(第 027 期):查找表(LUT)加速------点运算优化的统一答案。三期的直线、曲线、折线全部归位:为什么 256 项查表能通吃一切点运算?LUT 在滤镜引擎、美颜 SDK、实时调色里的工业级用法。