2、图像读取与显示:使用OpenCV读取热成像原始数据、灰度图显示、直方图均衡化

好,咱们正式开始动手了。上一章聊了热成像的原理和数据类型,这一章咱们直接上代码。说白了,图像处理的第一步就是把数据从文件里拽出来,然后想办法让它能看。热成像的原始数据跟普通照片不太一样,你直接用看图软件打开,大概率是一片漆黑或者一片惨白。为什么?因为它的像素值范围跟我们肉眼习惯的0-255不一样。

我个人习惯先把原始数据读成16位灰度图,然后再做映射。这样能保留最多的细节。你想想看,热成像传感器采集到的温度数据,往往是10位、12位甚至14位的,直接转成8位会丢掉很多信息。嗯,这里要注意,我们后面做的直方图均衡化,就是为了把这些被压缩的信息重新“撑开”。

2.1 读取热成像原始数据

OpenCV读取图像最常用的函数就是cv2.imread()。但热成像数据通常以两种形式存在:一种是标准的图像文件(比如PNG、TIFF),但像素值是16位的;另一种是厂商自定义的原始二进制格式(.raw、.bin等)。咱们先讲第一种,也是最常见的情况。

核心要点:读取时一定要指定 cv2.IMREAD_UNCHANGED 标志,否则OpenCV默认会帮你转成8位,数据就废了。

import cv2
import numpy as np
import matplotlib.pyplot as plt

# 读取16位热成像原始数据
# 注意:一定要用 IMREAD_UNCHANGED 保持原始位深
img_16bit = cv2.imread('thermal_raw.png', cv2.IMREAD_UNCHANGED)

# 检查是否读取成功
if img_16bit is None:
    print("文件读取失败,请检查路径")
    exit()

# 查看数据类型和形状
print(f"数据类型: {img_16bit.dtype}")   # 应该是 uint16
print(f"图像尺寸: {img_16bit.shape}")  # (height, width) 或 (height, width, channels)
print(f"像素值范围: {img_16bit.min()} ~ {img_16bit.max()}")

我在项目中遇到过一种情况:客户给的文件后缀是.png,但实际上是12位数据打包在16位容器里。直接用cv2.imread()读出来,最大值才4095,而不是65535。这时候你就知道,它的有效数据位是12位。这个细节在做后续映射时特别重要。

小技巧:如果读取后 img_16bit.dtype 显示 uint8,说明OpenCV自动做了截断。赶紧检查 cv2.IMREAD_UNCHANGED 有没有写对。

2.2 灰度图显示

16位数据直接显示是不行的。大多数显示器只支持8位,所以我们需要把16位映射到0-255范围。最简单的办法就是线性拉伸。

说白了,就是把原始数据的最小值映射到0,最大值映射到255。但这样做有个问题:如果数据中有几个异常噪点,整个图像的对比度就会被拉得很差。我曾经在野外测试时遇到过这种情况——一个热斑把整个画面的动态范围都占满了,周围细节全成了黑色。

# 方法一:简单线性拉伸(不推荐,容易受噪点影响)
def linear_stretch(img):
    min_val = img.min()
    max_val = img.max()
    # 防止除零
    if max_val == min_val:
        return np.zeros_like(img, dtype=np.uint8)
    stretched = ((img.astype(np.float32) - min_val) / (max_val - min_val) * 255)
    return stretched.astype(np.uint8)

# 方法二:百分比截断拉伸(推荐,抗噪能力强)
def percentile_stretch(img, low_perc=2, high_perc=98):
    # 计算指定百分位的像素值
    low_val = np.percentile(img, low_perc)
    high_val = np.percentile(img, high_perc)
    # 截断并拉伸
    clipped = np.clip(img, low_val, high_val)
    stretched = ((clipped.astype(np.float32) - low_val) / (high_val - low_val) * 255)
    return stretched.astype(np.uint8)

# 显示结果
img_8bit = percentile_stretch(img_16bit)
cv2.imshow('Thermal Gray Image', img_8bit)
cv2.waitKey(0)
cv2.destroyAllWindows()

你可能会问:为什么用2%和98%这两个值?这是我个人习惯。在大多数热成像场景中,图像边缘或者传感器坏点会产生极端的亮暗值,去掉头尾2%的像素,基本就能把这些干扰排除掉。当然,具体数值可以根据你的数据质量调整。

注意:百分比截断拉伸会改变像素间的相对温差关系。如果你需要做精确的温度测量,请保留原始16位数据,只在显示时做映射。

2.3 直方图均衡化

好,现在图像能看了,但你可能发现一个问题:画面整体偏暗或者偏亮,对比度不够。这是因为热成像数据的直方图往往集中在某个狭窄的区间内。直方图均衡化就是用来解决这个问题的——它把像素值重新分布,让直方图尽可能平坦。

OpenCV提供了现成的函数cv2.equalizeHist(),但它只接受8位单通道图像。所以我们需要先做拉伸,再做均衡化。

# 先拉伸到8位
img_8bit = percentile_stretch(img_16bit)

# 直方图均衡化
img_eq = cv2.equalizeHist(img_8bit)

# 对比显示
plt.figure(figsize=(12, 4))

plt.subplot(1, 3, 1)
plt.imshow(img_8bit, cmap='gray')
plt.title('原始拉伸')
plt.axis('off')

plt.subplot(1, 3, 2)
plt.imshow(img_eq, cmap='gray')
plt.title('直方图均衡化')
plt.axis('off')

plt.subplot(1, 3, 3)
plt.hist(img_8bit.ravel(), 256, [0, 256], alpha=0.5, label='原始')
plt.hist(img_eq.ravel(), 256, [0, 256], alpha=0.5, label='均衡化')
plt.legend()
plt.title('直方图对比')

plt.tight_layout()
plt.show()

你想想看,均衡化后的图像是不是细节更丰富了?原本隐藏在暗区的纹理,现在能看清楚了。但凡事都有代价——均衡化会放大噪声。我在处理一些低端热成像仪的数据时,均衡化后整个画面出现了明显的颗粒感。这时候就需要考虑用自适应直方图均衡化(CLAHE)来替代。

# 自适应直方图均衡化(CLAHE)—— 推荐用于热成像
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
img_clahe = clahe.apply(img_8bit)

# 对比三种方法
plt.figure(figsize=(15, 4))

plt.subplot(1, 4, 1)
plt.imshow(img_8bit, cmap='gray')
plt.title('原始拉伸')
plt.axis('off')

plt.subplot(1, 4, 2)
plt.imshow(img_eq, cmap='gray')
plt.title('全局均衡化')
plt.axis('off')

plt.subplot(1, 4, 3)
plt.imshow(img_clahe, cmap='gray')
plt.title('CLAHE')
plt.axis('off')

plt.subplot(1, 4, 4)
# 显示局部细节放大
roi = img_clahe[100:200, 100:200]
plt.imshow(roi, cmap='gray', extent=[100, 200, 200, 100])
plt.title('CLAHE细节')
plt.axis('off')

plt.tight_layout()
plt.show()

经验总结:

  • 全局均衡化:适合直方图分布均匀的场景,速度快,但容易过增强噪声
  • CLAHE:适合热成像这种局部对比度差异大的场景,效果好,但参数需要调
  • clipLimit:我一般从2.0开始试,值越大对比度越强,但噪声也越明显
  • tileGridSize:默认(8,8)够用,如果图像分辨率高可以适当增大

2.4 本章知识体系

下面这张图总结了本章的核心流程。你可以把它当作一个操作清单,以后处理热成像数据时照着走就行。

热成像图像读取与增强流程 步骤1:读取原始数据 cv2.imread(UNCHANGED) 步骤2:检查数据属性 dtype / shape / 位深 步骤3:映射到8位 百分比截断拉伸 选择增强方法 全局直方图均衡化 equalizeHist() CLAHE自适应均衡化 createCLAHE() 直接显示(不增强) 仅做线性拉伸 输出增强后的8位灰度图

我的建议:在实际项目中,不要一上来就用CLAHE。先试试简单的百分比拉伸,如果效果不满意再上均衡化。我曾经在一个项目中,客户觉得CLAHE处理后的图像“太假了”,反而喜欢原始拉伸那种“自然”的感觉。嗯,审美这东西,有时候比技术还难搞。

好了,这一章的内容就到这里。代码不多,但每一步都有坑。你回去练习的时候,可以找几张不同场景的热成像图试试——比如白天拍的建筑、夜晚拍的人脸、还有工业设备的热图。对比一下不同拉伸方法和均衡化策略的效果差异。只有亲手调过参数,才能真正理解这些算法的脾气。


公众号:蓝海资料掘金营,微信deep3321