红外图像预处理:基于直方图分位数裁剪的灰度拉伸实现
简介面向图像处理学习者、算法工程师以及需要快速改善图像质量的开发人员这套灰度拉伸算法代码合集主要解决红外照相机、夜间监控、医学影像等低对比度原始图像的灰度范围狭窄问题通过直方图统计实现对比度增强适用场景广泛、调用门槛较低尤其适合需要快速提升图片对比度的场景。资源以zip压缩包形式发布共6个文件含2个Python脚本、2个Matlab脚本、1张raw原始数据图像及1张bmp输出结果图整体仅564KB轻量紧凑便于直接集成至现有图像处理项目。目前已有1042人学习下载。算法核心基于直方图统计提供Matlab与Python两个版本的完整函数封装使用者只需传入图像即可完成灰度拉伸无需自行理解统计细节或从零编码。同时配套两份测试脚本分别演示两种语言下的调用流程并利用raw原图和bmp效果图直观展示拉伸前后的视觉对比帮助初学者掌握直方图拉伸原理也为二次开发提供了可直接扩展的基础代码。1. 红外原图黑乎乎灰度拉伸是第一步预处理拿到红外相机或者低照度CMOS的raw图直接显示时通常是一团黑灰度直方图挤在低值区间。灰度拉伸Matlab代码和灰度拉伸Python代码我都在实际项目里跑过按直方图统计出像素累计分布再对灰度做线性映射把窄区间拉开到0-255图片对比度增强的效果比直接调亮度稳定得多。这种直方图拉伸方法不需要训练或标定基本参数只有两端裁剪比例适合做红外图像、工业相机图像的第一级预处理。无论你是用Matlab做算法验证还是用Python做批量部署都能从这套代码里直接复用核心函数和测试流程。2. 直方图统计与分位数裁剪拉伸参数的来源2.1 为什么 min-max 拉伸会被孤立点带偏很多人第一次做对比度增强时直接用img.min()和img.max()作为拉伸端点。这在对曝光正常的自然图像上没问题但红外raw图像几乎总带着坏点、本底噪声或者高反光点只要有一个像素是65535或者极端暗值整个灰度范围就被拉满中间你要看的温差区域反而压缩在十几个灰阶里。这时就需要先做直方图统计。统计每个灰度级出现的次数计算累计分布函数然后取低比例和高比例作为拉伸端点。比如low_ratio0.01, high_ratio0.99表示把最暗的1%像素和最亮的1%像素裁剪掉只对中间98%的像素做线性映射。这样比 min-max 拉伸更稳的原因在于端点的确定来自全局像素分布而不是孤立极值。这份代码里的函数名clip_linear_gray_scale里的 clip 就是“裁剪”的意思强调它不是简单找最小最大值而是通过直方图累积概率来确定区间。三种常见图片对比度增强方法的对比如下表方便你在选型时判断灰度拉伸适合用在哪个环节。方法端点/映射计算优点缺点全局线性拉伸min(img)、max(img) 作为端点实现简单零参数受坏点或噪声影响大主体对比度可能被压缩直方图分位数裁剪线性拉伸直方图累计分布中的低比例/高比例分位抗孤立噪声保留主体层次参数可调极端分布时比例参数不够直观直方图均衡化用 CDF 本身作为映射函数自动铺满动态范围适合观察会放大噪声非线性改变灰度相对关系不适合定量分析2.2 Python 和 Matlab 计算直方图拉伸边界的写法2.2.1 Python 直方图统计实现先用一个不依赖图像处理库的小函数展示如何从直方图得到裁剪端点。这个函数就是灰度拉伸内部的第一步你可以在命令行里直接验证import numpy as np def get_stretch_bounds(img, low_ratio0.01, high_ratio0.99, max_val65535): # 统计直方图bins 数量由图像位深决定 hist, edges np.histogram( img, binsmax_val 1, range(0, max_val) ) # 计算累计分布概率 cdf np.cumsum(hist) / np.sum(hist) # 找到累计概率首次超过 low_ratio / high_ratio 的位置 low_idx np.searchsorted(cdf, low_ratio) high_idx np.searchsorted(cdf, high_ratio) low edges[min(low_idx, len(edges) - 1)] high edges[min(high_idx 1, len(edges) - 1)] return low, highnp.histogram返回两个数组hist 是每个灰度级的像素计数edges 是 bin 的边界长度比 hist 多 1。np.cumsum(hist) / np.sum(hist)得到累计概率向量 cdfnp.searchsorted返回第一个不小于目标数值的位置。这里 low 取的是分位点所在 bin 的左边界high 取右边界这样能够覆盖所有从暗到亮的有效像素。参数方面max_val 需要和图像位深匹配uint8 图像传 25516bit raw 传 65535。如果位深传小了高位灰度会被合并拉伸后容易出现条纹传大了则直方图大部分 bin 是空的计算量增加但边界定位仍准确。low_ratio 和 high_ratio 的选择要看图像中背景噪声占比红外图像我一般从 0.01/0.99 起步如果背景占比超过 5%低比例要相应降低否则会把有效目标裁成死黑。2.2.2 Matlab 直方图统计实现Matlab 版本对应资源里的.m文件计算逻辑与 Python 一致只是语法换成了histcounts和cumsum。function [low, high] get_stretch_bounds(img, low_ratio, high_ratio) img double(img(:)); [counts, edges] histcounts(img, BinLimits, [min(img) max(img)], NumBins, 256); cdf cumsum(counts) / sum(counts); low_idx find(cdf low_ratio, 1); high_idx find(cdf high_ratio, 1); low edges(low_idx); high edges(high_idx 1); end这里用double统一数据类型避免 uint8 计算累计分布时溢出。find(cdf low_ratio, 1)返回第一个累计概率超过阈值的索引high 取edges(high_idx 1)也就是分位点所在 bin 的右边界语义上与 Python 版本对齐。注意histcounts默认的 BinLimits 会按数据范围自动扩展 1%如果你对边界敏感需要显式传BinLimits。对于 16bit 数据建议把NumBins改为 65536否则 16bit 输入的灰阶会被粗暴合并进 256 个 bin拉伸出来的边界误差可能达到几十个灰度级。3. Python代码实现与调用从raw文件到增强后的bmp3.1 clip_linear_gray_scale.py 核心函数封装把上一章的边界计算再推进一步就得到资源里的clip_linear_gray_scale函数。它把“计算分位端点”和“线性映射”封装在一起输出自动夹紧在 0-255。精简后的核心逻辑如下与资源里的 python 代码在思路上保持一致import numpy as np def clip_linear_gray_scale(img, low_ratio0.01, high_ratio0.99, max_val255): # 如果是多通道图先按通道平均转成灰度 if img.ndim 3: img img.mean(axis2) hist, edges np.histogram(img, binsmax_val 1, range(0, max_val)) cdf np.cumsum(hist) / np.sum(hist) low_idx int(np.searchsorted(cdf, low_ratio)) high_idx int(np.searchsorted(cdf, high_ratio)) low edges[low_idx] high edges[min(high_idx 1, len(edges) - 1)] # 防止高、低端相等时除零 if high low: high low 1 out (img.astype(np.float32) - low) * (max_val / (high - low)) out np.clip(out, 0, max_val).astype(np.uint8) return out函数第一步判断输入是否为多通道常见做法是对三通道取均值转成灰度这对红外伪彩图足够用。接着计算直方图和累计概率定位 low 和 high。真正做拉伸的只有一行(img - low) * (max_val / (high - low))相当于把直方图从 [low, high] 线性搬移到 [0, max_val]。为了避免 uint8 在减法时出现负值错误这里先转成np.float32计算最后用np.clip把越界像素截断再转回 uint8。参数默认值说明img无单通道 numpy 数组或多通道灰度图low_ratio0.01暗部裁剪比例越大暗部越黑high_ratio0.99亮部裁剪比例越小亮部越白max_val255输出动态范围16bit 可改成 65535如果要把结果保留为 16bit最后一行应改成astype(np.uint16)同时 max_val 传 65535。但注意普通 bmp 格式对 16bit 支持不统一建议保存为 PNG 或 TIFF。3.2 测试脚本 test.py 怎样把 01.raw 变成增强图资源里的 test.py 主要演示完整调用链读 raw 文件、重排成图像、调用拉伸函数、保存结果。raw 格式没有统一文件头必须知道宽、高和数据位深。我通常会在测试脚本里这样组织命令行参数import sys import numpy as np from clip_linear_gray_scale import clip_linear_gray_scale # 命令行参数脚本路径 宽 高 位深 raw_path sys.argv[1] if len(sys.argv) 1 else 01.raw width int(sys.argv[2]) if len(sys.argv) 2 else 640 height int(sys.argv[3]) if len(sys.argv) 3 else 480 bit_depth int(sys.argv[4]) if len(sys.argv) 4 else 8 dtype np.uint8 if bit_depth 8 else np.uint16 raw np.fromfile(raw_path, dtypedtype) frame raw[:width * height].reshape(height, width) stretched clip_linear_gray_scale(frame, 0.01, 0.99) from PIL import Image Image.fromarray(stretched).save(output.bmp)执行命令python test.py 01.raw 640 480 8np.fromfile按二进制读出数据后用reshape重新排列成二维矩阵。这里默认 raw 是行优先存储也就是第一行数据连续存放在文件开头如果你的 raw 来自工业相机 SDK有时是列优先输出读入后需要执行frame np.ascontiguousarray(frame.T)再做后续处理。提示如果输出的 output.bmp 与资源里的 01.bmp 在内容上对不上优先检查宽高是否填反其次检查位深是否写错。raw 本身没有长度标识宽高填错后图像会呈现撕裂或错位。4. Matlab代码实现与test.m用imhist验证直方图拉伸4.1 clip_linear_gray_scale.m 的写法Matlab 版本适合算法验证封装成 function 后可以在 test.m 里直接调用。为了避免数据类型溢出函数内部统一转成 double 计算function out clip_linear_gray_scale(img, low_ratio, high_ratio) if nargin 2 low_ratio 0.01; end if nargin 3 high_ratio 0.99; end img double(img); min_val min(img(:)); max_val max(img(:)); if max_val min_val max_val min_val 1; end [counts, edges] histcounts(img(:), BinLimits, [min_val max_val], NumBins, 256); cdf cumsum(counts(:)) / sum(counts); low_idx find(cdf low_ratio, 1); high_idx find(cdf high_ratio, 1); low edges(low_idx); high edges(high_idx 1); out (img - low) * (255 / (high - low)); out max(0, min(255, out)); out uint8(out); end这段代码用histcounts统计直方图cumsum计算累计概率。low 取第一个累计概率超过low_ratio的 bin 左边界high 取 bin 右边界。out (img - low) * (255 / (high - low))是向量化线性拉伸所有像素一次性完成映射。最后用max(0, min(255, out))做截断避免浮点计算产生的越界值。当输入是 16bit raw 时当前NumBins256会损失灰度细节。对于 14bit 或 16bit 数据我一般会把NumBins改成65536并把BinLimits显式设为[0 65535]同时将输出端从 255 改为 65535这样拉伸后的数据仍保留原始温度梯度。Matlab 函数的默认参数如下参数默认值作用low_ratio0.01暗部裁剪比例high_ratio0.99亮部裁剪比例NumBins256直方图分箱数8bit 默认16bit 建议 65536输出动态范围2558bit 输出保留 16bit 改成 655354.2 在 test.m 中读取 raw 并查看增强效果test.m 一般完成三步读入 raw 或 bmp、调用上面的函数、显示前后对比。最容易出错的是fread的读取顺序Matlab 按列优先填充矩阵和 Python 的行优先相反。正确的读取方式如下width 640; height 480; fid fopen(01.raw, rb); raw fread(fid, [width height], *uint8); fclose(fid); raw raw; % 转置成 height x width out clip_linear_gray_scale(raw, 0.01, 0.99); figure; subplot(2,2,1); imshow(raw); title(Raw 原图); subplot(2,2,2); imhist(raw); title(Raw 直方图); subplot(2,2,3); imshow(out); title(拉伸后); subplot(2,2,4); imhist(out); title(拉伸后直方图); imwrite(out, matlab_output.bmp);fread的第一个参数是维度写成[width height]意味着先按列方向读满 width 个数据再换下一列。读取结果是一个 width x height 的矩阵转置后才对应通常的 height x width 图像矩阵。如果你的 raw 是按行优先存储的应该改成fread(fid, [height width])这样读取后就是行优先排列不需要转置。imhist在这里不是为了好看而是用来验证拉伸是否有效拉伸前直方图集中在低灰度段拉伸后直方图应铺满 0-255。如果拉伸后直方图在两端出现大量堆积说明low_ratio或high_ratio设置不当。资源里的01.bmp可以当作参考用imread读入后和matlab_output.bmp做imshowpair并排对照能快速判断参数是否合适。5. 拉伸效果验证与三个容易被忽略的细节5.1 用直方图对照判断参数是否合适跑完一份测试数据后建议把处理前后的直方图画在一起。Python 示例如下import matplotlib.pyplot as plt plt.hist(img.ravel(), bins256, range(0, 255), alpha0.5, labelbefore) plt.hist(out.ravel(), bins256, range(0, 255), alpha0.5, labelafter) plt.legend(locupper right)理想状态是拉伸后的直方图分布在 0-255 之间不再像原来那样挤在一侧同时不能出现中间大片空洞。如果拉伸后低端大量像素压到 0说明low_ratio超过了实际有效像素占比如果整体偏灰、没有真正的黑色和白色说明 high 与 low 的差值太小映射斜率不足需要调大high_ratio或调小low_ratio。5.2 三个细节位深混用、除零保护、动态范围漂移风险点现象处理方式16bit raw 被 uint8 截断图像出现死白/死黑细节丢失读取时用 uint16拉伸计算中用 float输出时再转 uint8high low输出全黑或全白甚至除零在映射前加判断if high low: high low 1视频每帧独立统计端点画面亮度闪烁拉伸效果不稳定先统计前 N 帧固定端点或使用滑动平均更新 LUT位深混用是红外处理里最常见的坑。很多 14bit 传感器把数据包装在 16bit 变量里直接astype(np.uint8)会先截断低字节结果就是整幅图像明显偏色或出现等高线状条纹。正确做法是读入时保持原始位深拉伸系数用浮点计算最后输出时才转换。针对视频流的动态范围漂移实时对比度增强时我一般不会每帧重算直方图。常见做法是取前 30 帧的平均累计分布确定一次 low/high之后每 20 帧再更新一次这样拉伸端点不会因为某一帧出现强噪声而跳变。如果平台计算资源紧张可直接把这组端点生成一段 256 或 65536 项的 LUT每个像素通过查表完成映射。另一个实用技巧当拉伸后的图像要送进深度学习检测模型前用这套分位数拉伸替代 mean/std 归一化能保留更多边缘梯度对红外小目标检测尤其明显。你可以在同一份测试数据上分别处理再对比目标边缘的保留程度通常拉伸版本的召回率会更稳定。本文还有配套的精品资源点击获取