第一章:图像相似度计算概述
1.1 什么是图像相似度?
图像相似度,顾名思义,是指衡量两幅或多幅图像在视觉内容或语义信息上相似程度的指标。它是一个介于0(完全不相似)和1(完全相同或高度相似)之间的数值,或者是一个可以反映相似程度的距离度量(距离越小越相似)。在计算机视觉领域,图像相似度计算是诸多高级应用的基础。
1.1.1 定义与重要性
定义:
图像相似度的定义可以从多个层面进行。
像素层面:直接比较两幅图像对应位置像素值的差异。这种定义方式简单直观,但对图像的微小变化(如平移、旋转、光照改变)非常敏感。
特征层面:提取图像中能够表征其内容的特征(如颜色分布、纹理特征、边缘信息、关键点等),然后比较这些特征之间的相似性。这种方式更能抵抗图像的几何变换和光照变化,更接近人类的视觉感知。
语义层面:理解图像所表达的语义内容(如图中有什么物体、场景是什么等),并基于语义的相似性进行判断。这是最具挑战性但也是最接近人类理解的方式,通常需要借助深度学习等复杂模型。
1.1.2 应用场景
下面我们更详细地展开一些典型的应用场景,并思考其中对相似度计算的具体需求。
图像检索 (Content-Based Image Retrieval, CBIR)
描述:用户提供一张查询图像,系统从大规模图像数据库中找出与查询图像内容最相似的一批图像。例如,搜索引擎的“以图搜图”功能,电商网站的“相似商品推荐”。
相似度需求:需要对图像的整体视觉内容(颜色、纹理、形状、物体布局等)进行综合度量。对旋转、缩放、光照变化、轻微遮挡等具有一定的鲁棒性。计算速度也是一个关键因素,尤其是在大型数据库中。
Pillow的应用可能:可以利用Pillow进行图像预处理(如统一尺寸、色彩空间转换),并实现一些基础的相似度算法(如直方图比较、感知哈希)作为初步筛选或简单场景的应用。
重复图像检测 (Duplicate Image Detection)
描述:在给定的图像集合中,找出完全相同或几乎完全相同的图像。常用于云存储服务、社交媒体平台、个人相册管理,以减少冗余存储,提升用户体验。
相似度需求:要求能够精确识别出内容上一致的图像,即使它们的文件格式、压缩率、分辨率、元数据等可能不同。对于微小的编辑(如裁剪边缘、轻微调色、添加小水印)也需要有判断能力。
Pillow的应用可能:感知哈希算法(如aHash, dHash, pHash)配合Pillow进行图像处理是此场景下的常用且高效的方案。通过比较图像的哈希值,可以快速判断其相似性。
人脸识别 (Facial Recognition – 概念关联)
描述:识别或验证图像中人物的身份。虽然完整的人脸识别系统远比单纯的图像相似度计算复杂,涉及到人脸检测、特征点定位、特征提取等多个步骤,但其核心比对环节依然是衡量两个人脸特征向量的相似度。
相似度需求:对表情变化、姿态变化、光照变化、年龄增长、遮挡(如眼镜、口罩)等具有高度鲁棒性。
Pillow的应用可能:Pillow可用于人脸图像的预处理,如裁剪、灰度化、尺寸归一化。但核心的人脸特征提取和比对通常依赖更专业的库(如Dlib, OpenCV, face_recognition)或深度学习模型。
目标跟踪 (Object Tracking – 概念关联)
描述:在视频序列中,持续定位特定目标的位置。跟踪算法通常需要在连续帧之间比较目标区域的相似性,以预测目标下一帧的位置。
相似度需求:实时性要求高,需要对目标的外观变化、部分遮挡、运动模糊等具有鲁棒性。
Pillow的应用可能:Pillow可以用于从视频帧中提取目标区域的图像块,并进行基础的预处理。但跟踪算法的核心,如相关滤波、卡尔曼滤波、深度学习跟踪器,则超出了Pillow的主要功能范畴。
医学图像分析 (Medical Image Analysis)
描述:比较患者的医学影像(如X光、CT、MRI)与标准图谱或历史影像,以检测异常、评估病情进展或辅助手术规划。
相似度需求:精度要求极高,对细微的结构和纹理变化敏感。可能需要考虑三维空间的相似性。不同模态(如CT与MRI)图像间的相似度比较也是一个挑战。
Pillow的应用可能:Pillow可用于医学影像的读取(如果格式支持,如DICOM需要借助pydicom等库,但Pillow可以处理转换后的常见格式如PNG, TIFF)、显示、基本增强(对比度调整)。但专业的医学影像分析通常依赖ITK, VTK等专用库和深度学习方法。
版权保护 (Copyright Protection)
描述:在互联网上检测是否有未经授权的图片被使用或传播。这可能涉及到将受版权保护的图片与网络上的大量图片进行比对。
相似度需求:需要能够识别出经过裁剪、缩放、调色、添加水印或文字、甚至部分重绘的盗版图片。
Pillow的应用可能:感知哈希算法是此场景下的有力工具,Pillow用于实现哈希值的计算。结合网络爬虫和大规模哈希比对系统,可以构建盗版检测服务。
视觉质量评估 (Visual Quality Assessment)
描述:评估图像在压缩、传输或处理过程中引入的失真程度,或者比较不同算法生成的图像质量。
相似度需求:这里的“相似度”通常指与原始无失真图像的相似程度。指标如SSIM(结构相似性)、PSNR(峰值信噪比)常被用来量化这种相似性。
Pillow的应用可能:Pillow可以用于实现这些像素级别的质量评估指标,通过加载原始图像和处理后的图像,进行逐像素的计算。
1.2 图像相似度计算的挑战
尽管图像相似度计算的目标看似简单,但在实际应用中却面临诸多挑战。这些挑战源于图像内容的多样性以及成像、传输、编辑过程中可能引入的各种变化。理想的相似度算法应该对内容本身的微小差异敏感,而对与内容无关的外部因素具有鲁棒性。
1.2.1 光照变化 (Illumination Changes)
描述:同一场景或物体在不同光照条件下拍摄的图像,其像素值会有显著差异,即使物体本身的颜色和结构没有改变。例如,户外阳光下、阴天、室内灯光下拍摄的同一物体。
影响:直接基于像素值比较的方法(如MSE)会因为光照变化而错误地判断图像不相似。颜色直方图也会受到严重影响。
应对策略:
直方图均衡化:尝试扩展图像的动态范围,减少全局光照不均的影响。
颜色恒常性算法:尝试恢复物体表面的真实颜色,去除光源颜色的影响。
使用对光照变化不敏感的颜色空间:如HSV色彩空间中的H(色相)通道相对光强变化较为稳定。
局部归一化:在计算特征或比较时,对局部区域进行亮度归一化。
基于梯度的特征:如SIFT、HOG等特征,它们依赖于局部强度变化率,对整体光照变化不那么敏感。
深度学习模型:通过大量数据学习,可以隐式地学习到对光照变化的鲁棒性。
1.2.2 尺度变化 (Scale Changes)
描述:同一物体在图像中占据的大小不同。例如,远景拍摄和近景特写。
影响:基于像素的比较方法在尺度不一致时完全失效。固定大小的模板匹配也会失败。全局直方图对尺度变化有一定鲁棒性,但物体大小变化过大时也会失效。
应对策略:
图像金字塔/多尺度分析:在多个尺度上提取特征并进行比较。
尺度不变特征变换 (SIFT):SIFT等特征点检测与描述算法被设计为尺度不变的。
图像归一化:在比较前,尝试将图像或感兴趣区域缩放到统一的尺寸,但这可能丢失细节或引入形变。
感知哈希算法:如pHash,在计算哈希前会将图像缩放到一个固定的小尺寸(如32×32),这使其对原始图像的尺寸变化具有一定的鲁棒性。
1.2.3 旋转与视角变化 (Rotation and Viewpoint Changes)
描述:
平面内旋转:图像围绕其中心在二维平面内旋转。
平面外旋转/视角变化:物体相对于相机的三维姿态发生改变,导致图像发生透视形变。
影响:基于像素的比较方法和简单的直方图方法对旋转和视角变化非常敏感。
应对策略:
旋转不变特征:如SIFT特征描述子考虑了主方向,使其具有旋转不变性。ORB特征也具有旋转不变性。
数据增强:在训练模型(尤其是深度学习模型)时,通过对训练图像进行随机旋转来提升模型的旋转鲁棒性。
图像对齐/配准:在比较前,尝试将一幅图像通过几何变换对齐到另一幅图像。
矩不变量 (Moment Invariants):如Hu矩,对平移、旋转、尺度变化具有不变性,可以作为全局形状描述子。
感知哈希算法:aHash和dHash对小角度旋转不敏感,但大角度旋转会显著改变哈希值。pHash由于基于DCT的低频系数,对旋转的鲁棒性也有限。
1.2.4 遮挡 (Occlusion)
描述:目标物体的一部分被其他物体遮挡。
影响:遮挡会改变图像的像素内容和局部特征,使得相似度计算变得困难。全局描述子受影响较大。
应对策略:
局部特征匹配:基于局部特征点(如SIFT, SURF, ORB)的方法,即使部分特征点被遮挡,只要有足够数量的未遮挡特征点匹配成功,仍然可以判断相似性。
部件模型 (Part-based models):将物体分解为多个部件,分别比较部件的相似性,并综合判断。
鲁棒的统计方法:如RANSAC,在特征匹配后可以剔除由遮挡或噪声引起的误匹配点。
深度学习模型:一些注意力机制和图神经网络模型可以学习处理遮挡情况。
1.2.5 形变 (Deformation)
描述:非刚性物体的形状发生改变,或者柔性物体发生扭曲。例如,布料的褶皱、人脸表情的变化。
影响:严重依赖形状信息的相似度算法会受到显著影响。
应对策略:
弹性匹配算法:如薄板样条模型(Thin Plate Splines)。
基于骨架或轮廓的描述子,并允许一定的形变容忍度。
学习形变模型:通过大量样本学习物体可能的形变模式。
局部描述子的组合:即使物体整体形变,其局部区域的纹理或结构可能保持相对稳定。
1.2.6 噪声 (Noise)
描述:图像在采集或传输过程中可能引入各种噪声,如高斯噪声、椒盐噪声。
影响:噪声会改变像素值,干扰特征提取,从而影响相似度计算的准确性。
应对策略:
图像去噪:在计算相似度之前,使用滤波器(如高斯滤波、中值滤波)对图像进行去噪处理。Pillow的ImageFilter模块提供了这些功能。
鲁棒的特征描述子:一些特征描述子本身对轻微噪声具有一定的抵抗能力。
感知哈希算法:通常会对图像进行平滑或缩放操作,这在一定程度上可以抑制噪声的影响。例如,pHash使用DCT的低频分量,而高频分量更容易受到噪声影响。
1.2.7 计算效率与资源消耗 (Computational Efficiency and Resource Consumption)
描述:在处理大规模图像数据库或实时应用(如视频分析)时,相似度计算的效率和所需计算资源(CPU、内存)成为关键瓶颈。
影响:复杂的算法虽然可能准确度更高,但如果计算耗时过长,则不适用于实际系统。
应对策略:
选择合适的算法:根据应用需求在精度和效率之间进行权衡。例如,感知哈希算法通常比复杂的特征点匹配算法快得多。
算法优化:优化代码实现,利用并行计算(多线程、多进程、GPU加速)。
索引结构:对于大规模检索,使用高效的索引结构(如k-d树、LSH)来加速相似哈希或特征向量的查找过程,避免暴力比较。
硬件加速:利用GPU等专用硬件进行计算密集型操作。
克服这些挑战是图像相似度研究领域的核心目标。没有一种万能的方法可以完美解决所有问题,通常需要根据具体的应用场景和数据特点选择或组合多种技术。
1.3 图像相似度计算方法分类
图像相似度计算方法多种多样,可以根据其基本原理和所依赖的图像信息大致分为以下几类:
1.3.1 基于像素的方法 (Pixel-based Methods)
原理:直接比较两幅图像对应位置的像素值。通常要求图像具有相同的尺寸和颜色模式。
典型方法:
绝对差和 (Sum of Absolute Differences, SAD)
平方差和 (Sum of Squared Differences, SSD)
均方误差 (Mean Squared Error, MSE):SSD的均值。
峰值信噪比 (Peak Signal-to-Noise Ratio, PSNR):常用于衡量图像压缩或重建的质量,与MSE相关。
结构相似性指数 (Structural Similarity Index, SSIM):试图从结构信息、亮度和对比度三个方面来衡量图像相似性,比MSE更符合人类视觉感知。
优点:计算简单,直观易懂。
缺点:对图像的几何变换(平移、旋转、缩放)、光照变化、噪声非常敏感。两张在人类看来内容相似但有轻微位移的图片,其MSE可能很大。
Pillow应用:Pillow可以方便地加载图像,获取像素数据,进行尺寸和模式转换,为实现这些方法提供基础。
1.3.2 基于直方图的方法 (Histogram-based Methods)
原理:计算图像的颜色直方图、灰度直方图或梯度方向直方图等,然后比较这些直方图的相似性。直方图描述了图像中不同像素强度(或颜色、梯度方向)的统计分布。
典型方法:
直方图相交 (Histogram Intersection)
相关性 (Correlation)
卡方距离 (Chi-Squared Distance)
巴氏距离 (Bhattacharyya Distance)
地球移动距离 (Earth Mover’s Distance, EMD)
优点:
对图像的平面内旋转和整体平移不敏感。
对物体在图像中的尺度变化(若物体本身颜色分布不变)有一定鲁棒性。
计算相对简单。
缺点:
丢失了像素的空间位置信息,两张颜色分布相同但内容完全不同的图像可能有相似的直方图。
对光照变化比较敏感(除非使用归一化直方图或对光照不敏感的颜色空间)。
难以区分具有相似颜色但不同纹理或结构的图像。
Pillow应用:Pillow的Image.histogram()方法可以直接计算图像的直方图,为后续的比较提供了便利。
1.3.3 基于特征的方法 (Feature-based Methods)
这类方法的核心思想是先从图像中提取能够有效表征其内容的“特征”,然后通过比较这些特征来判断图像的相似性。特征可以是全局的,也可以是局部的。
A. 局部特征 (Local Features)
原理:检测图像中的关键点(如角点、斑点等对光照、旋转、尺度变化具有一定不变性的点),并为每个关键点计算一个描述其局部邻域信息的向量(描述子)。通过匹配两幅图像中的局部特征描述子来评估相似性。
典型算法:
SIFT (Scale-Invariant Feature Transform):对尺度、旋转、光照变化具有良好鲁棒性的经典算法。
SURF (Speeded Up Robust Features):SIFT的加速版本,性能略有下降。
ORB (Oriented FAST and Rotated BRIEF):结合了FAST角点检测和BRIEF描述子,并加入了旋转不变性,计算速度快,且免专利。
AKAZE (Accelerated KAZE Features):非线性尺度空间中的特征检测与描述。
优点:对尺度、旋转、光照、视角变化和一定程度的遮挡具有较好的鲁棒性。能够处理图像间的复杂变换。
缺点:计算量相对较大,特别是SIFT和SURF。特征点的数量和质量受图像内容影响。
Pillow应用:Pillow本身不直接提供这些高级特征提取算法。通常需要结合OpenCV (cv2) 等专门的计算机视觉库来实现。Pillow可用于图像的加载、预处理以及与OpenCV图像格式(通常是NumPy数组)的转换。
B. 全局特征 (Global Features)
原理:将整幅图像用一个或一组数值向量来表示,这个向量捕获了图像的整体特性。
典型方法:
颜色矩 (Color Moments):使用颜色通道的均值、标准差、偏度等低阶矩作为特征。
纹理描述子:如Gabor滤波器响应、局部二值模式 (Local Binary Patterns, LBP)。
形状描述子:如Hu矩不变量、傅里叶描述子(用于描述闭合轮廓)。
基于深度学习的特征:使用预训练的卷积神经网络(CNN)的中间层或最后一层输出作为图像的全局特征表示。
优点:特征维度通常较低,比较速度快。
缺点:对图像的裁剪、遮挡、物体位置变化等比较敏感,因为它们改变了图像的整体统计特性。
Pillow应用:Pillow可以辅助计算某些简单的全局特征,如颜色均值。LBP等也可以用Pillow结合NumPy实现。但更复杂的全局特征或深度学习特征通常依赖其他库。
1.3.4 基于感知哈希的方法 (Perceptual Hashing Methods)
原理:将图像通过一系列处理步骤(如缩小、灰度化、频率变换等)映射为一个简短的二进制串(哈希值)。其核心思想是,内容相似的图像会产生相同或非常相似(汉明距离小)的哈希值,而内容不同的图像产生的哈希值差异较大。它不同于密码学哈希(如MD5, SHA1)追求的雪崩效应。
典型算法:
平均哈希 (Average Hash, aHash)
差异哈希 (Difference Hash, dHash)
感知哈希 (Perceptual Hash, pHash):基于离散余弦变换 (DCT)。
小波哈希 (Wavelet Hash, wHash):基于离散小波变换 (DWT)。
优点:
计算速度快,哈希值存储空间小,比较高效(计算汉明距离)。
对图像的缩放、轻微的颜色变化、不同的压缩格式具有较好的鲁棒性。
非常适用于大规模图像库的去重和相似图像检索。
缺点:
鲁棒性有限,对于旋转、大幅度裁剪、严重形变等可能失效。
哈希值的区分度不如复杂的特征描述子。
pHash 和 wHash 计算相对 aHash 和 dHash 复杂一些。
Pillow应用:Pillow是实现这些感知哈希算法的理想工具,因为它提供了图像缩放、灰度转换、像素访问等所有必要的基础操作。对于pHash和wHash,可能需要结合scipy.fftpack (DCT) 或 PyWavelets (DWT)。
1.3.5 基于深度学习的方法 (Deep Learning-based Methods)
原理:利用深度神经网络(尤其是卷积神经网络CNN)学习图像的层次化特征表示。这些模型可以通过有监督学习(如分类任务的副产品,或专门为相似性学习设计的网络如Siamese网络、Triplet网络)或无监督/自监督学习来获得强大的图像嵌入(embeddings)。比较图像的相似性就转化为比较它们在高维特征空间中的嵌入向量的距离(如余弦相似度、欧氏距离)。
典型方法:
使用预训练CNN模型提取特征:如VGG, ResNet, Inception, EfficientNet等在ImageNet上预训练的模型,取其全连接层之前或池化层的输出作为特征向量。
Siamese Networks:输入两张图片,网络输出它们的相似度得分。通过成对的相似/不相似样本进行训练。
Triplet Networks:输入一个锚点图像 (anchor)、一个正例图像 (positive, 与锚点相似) 和一个负例图像 (negative, 与锚点不相似),网络学习使锚点与正例的距离小,与负例的距离大。
对比学习方法:如SimCLR, MoCo等,通过自监督的方式学习图像表示。
优点:
通常能达到当前最高的准确率,对各种复杂变换具有非常强的鲁棒性。
能够学习到更抽象和语义相关的特征。
端到端学习,无需手动设计特征。
缺点:
需要大量的标注数据进行训练(除非使用预训练模型)。
模型训练和推理计算成本较高,需要GPU支持。
模型的可解释性较差。
Pillow应用:Pillow主要用于图像的加载、预处理(如缩放、归一化、数据增强),将图像转换为深度学习框架(如PyTorch, TensorFlow)所需的张量格式。
每种方法都有其适用范围和优缺点。在实际项目中,往往需要根据具体需求(如精度要求、速度要求、鲁棒性要求、数据规模等)来选择最合适的方法,或者将多种方法结合起来使用。在接下来的章节中,我们将重点使用Pillow库来深入探索和实现其中一些经典且实用的方法。
第二章:Pillow (PIL Fork) 图像处理基础
要使用Python进行图像相似度计算,Pillow库是一个不可或缺的基础工具。它提供了丰富的图像文件格式支持以及强大的图像处理能力。本章将详细介绍Pillow库的基础知识,为后续的相似度计算算法实现打下坚实的基础。
2.1 Pillow 库简介
2.1.1 PIL 与 Pillow 的历史
PIL (Python Imaging Library):是Python中一个历史悠久且功能强大的图像处理库。它由Fredrik Lundh在1995年左右开始开发,并迅速成为Python进行图像操作的标准库之一。PIL支持多种文件格式,提供了图像的打开、操作和保存功能,包括像素操作、掩模和路径操作、图像增强、滤镜等。
PIL的停止维护:PIL的最后一个官方版本是1.1.7,发布于2009年。之后,由于各种原因,PIL的开发和维护逐渐停滞。这给Python社区带来了不便,因为新的Python版本和操作系统可能无法与旧的PIL良好兼容。
Pillow的诞生:为了解决PIL停止维护的问题,Alex Clark和其他贡献者创建了Pillow项目。Pillow是PIL的一个友好分支 (friendly fork)。它继承了PIL的所有功能,并在此基础上进行了持续的改进和扩展。
Pillow的优势:
积极维护:Pillow团队保持着活跃的开发和维护,及时修复bug,增加新功能,并确保与最新Python版本的兼容性。
更易安装:Pillow的安装过程比原始PIL更加简便,尤其是在Windows等平台上,通常可以通过pip轻松安装预编译的二进制包。
扩展支持:支持更多的图像格式,并改进了对现有格式的支持。
持续集成:采用现代化的开发实践,如持续集成测试,保证了库的稳定性和质量。
社区活跃:拥有一个活跃的社区,方便用户获取支持和贡献代码。
2.1.2 安装 Pillow
安装Pillow非常简单,通常只需要使用Python的包管理器pip即可。
基本安装:
打开你的终端或命令行提示符,输入以下命令:
pip install Pillow
这条命令会从PyPI (Python Package Index) 下载最新稳定版的Pillow并自动安装其依赖。
验证安装:
安装完成后,可以打开Python解释器,尝试导入Pillow的核心模块Image来验证安装是否成功:
# verify_pillow.py
try:
from PIL import Image # 尝试从Pillow库导入Image模块
print("Pillow (PIL Fork) 安装成功!") # 打印成功信息
print(f"Pillow 版本: {
Image.__version__}") # 打印Pillow的版本号
except ImportError:
print("Pillow (PIL Fork) 安装失败或未安装。") # 打印失败信息
print("请尝试使用 'pip install Pillow' 命令进行安装。") # 提示安装命令
运行此脚本,如果看到成功信息和版本号,则表示Pillow已正确安装。
安装特定格式支持的依赖:
Pillow的核心功能不需要太多外部依赖,但对于某些特定的图像格式(如JPEG, PNG, TIFF, WebP等)的完全支持,Pillow可能会依赖一些外部的C库(如libjpeg, libpng, libtiff, libwebp等)。
在Linux上,通常可以使用系统的包管理器(如apt-get for Debian/Ubuntu, yum for CentOS/RHEL)安装这些依赖库。例如,在Ubuntu上:
sudo apt-get install libjpeg-dev libpng-dev libtiff-dev libwebp-dev zlib1g-dev
(具体的包名可能因发行版而异)
在macOS上,可以使用Homebrew:
brew install libjpeg libpng libtiff libwebp
在Windows上,通过pip安装的Pillow预编译轮子包 (wheel) 通常已经内置了对常用格式的支持,一般无需手动安装这些C库。
如果在安装或使用Pillow时遇到关于特定格式支持的问题(例如 “decoder … not available”),可能需要检查并安装相应的依赖库,然后重新安装Pillow (pip install --no-cache-dir --force-reinstall Pillow),以便Pillow在编译时能够链接到这些库。
从源码安装:
在某些特殊情况下,如果需要从源码编译安装Pillow(例如,为了启用特定的编译选项或在不支持二进制包的平台上),可以从PyPI下载源码包或从GitHub克隆Pillow的仓库,然后按照官方文档的指示进行编译和安装。这通常需要C编译器和相关的开发工具。
对于绝大多数用户而言,pip install Pillow 已经足够。
2.1.3 Pillow 的核心模块
Pillow库由多个模块组成,每个模块负责一部分特定的功能。了解这些核心模块有助于更好地使用Pillow。
Image 模块:
核心:这是Pillow中最核心的模块,几乎所有的图像操作都围绕它展开。
功能:
定义了Image类,Pillow中图像对象的基本类型。
提供了加载图像文件的函数,如 Image.open()。
包含了创建新图像的函数,如 Image.new(), Image.frombytes(), Image.effect_noise()等。
提供了图像对象的基本属性(如尺寸size、模式mode、格式format)和方法(如保存save()、裁剪crop()、缩放resize()、旋转rotate()、格式转换convert()等)。
示例:
from PIL import Image # 从Pillow库导入Image模块
# 打开一张图片
img = Image.open("example.jpg") # 使用Image.open()方法打开名为example.jpg的图片文件,返回一个Image对象
# 获取图片信息
print(f"图片格式: {
img.format}") # 打印图片的原始文件格式
print(f"图片尺寸: {
img.size}") # 打印图片的宽度和高度 (width, height)
print(f"图片模式: {
img.mode}") # 打印图片的色彩模式 (例如 "RGB", "L" 等)
# 显示图片 (通常会调用系统默认的图片查看器)
# img.show() # 调用Image对象的show()方法显示图片
# 关闭图片对象 (Pillow通常使用延迟加载,但在某些情况下显式关闭有助于释放资源)
img.close() # 调用Image对象的close()方法关闭图片,释放相关资源
ImageChops 模块 (Channel Operations):
功能:提供了一系列用于图像通道操作和算术运算的函数。“Chops” 是 “Channel Operations” 的缩写。这些操作通常是逐像素进行的。
典型函数:
ImageChops.add(image1, image2, scale=1.0, offset=0): 两图相加。result = (image1 + image2) / scale + offset
ImageChops.subtract(image1, image2, scale=1.0, offset=0): 两图相减。result = (image1 - image2) / scale + offset
ImageChops.multiply(image1, image2): 两图相乘(逐像素)。
ImageChops.screen(image1, image2): 屏幕混合模式。
ImageChops.difference(image1, image2): 计算两图差异的绝对值。常用于比较图像。
ImageChops.logical_and(image1, image2), logical_or, logical_xor: 逻辑运算(图像需为二值模式”1″或”L”模式,结果为二值)。
ImageChops.invert(image): 反色。
ImageChops.lighter(image1, image2): 比较两图中对应像素,取较亮者。
ImageChops.darker(image1, image2): 比较两图中对应像素,取较暗者。
应用:图像融合、差异检测、创建特殊视觉效果等。
ImageStat 模块 (Image Statistics):
功能:用于计算图像的统计信息。
核心类:ImageStat.Stat(image, mask=None)
可计算的统计量:
Stat.extrema: 最小/最大像素值 (min, max),对每个通道分别计算。
Stat.count: 总像素数,对每个通道分别计算。
Stat.sum: 像素值总和,对每个通道分别计算。
Stat.sum2: 像素值平方和,对每个通道分别计算。
Stat.mean: 平均像素值。
Stat.median: 中值像素值。
Stat.rms: 均方根值。
Stat.var: 方差。
Stat.stddev: 标准差。
应用:图像分析、特征提取(如颜色均值和标准差)、图像质量评估。
ImageFilter 模块 (Image Filters):
功能:提供了一系列预定义的图像增强滤镜,可以应用于Image对象。
核心方法:image.filter(filter)
预定义滤镜:
ImageFilter.BLUR: 模糊滤镜。
ImageFilter.CONTOUR: 轮廓滤镜。
ImageFilter.DETAIL: 细节增强滤镜。
ImageFilter.EDGE_ENHANCE, ImageFilter.EDGE_ENHANCE_MORE: 边缘增强滤镜。
ImageFilter.EMBOSS: 浮雕滤镜。
ImageFilter.FIND_EDGES: 边缘检测滤镜。
ImageFilter.SMOOTH, ImageFilter.SMOOTH_MORE: 平滑滤镜。
ImageFilter.SHARPEN: 锐化滤镜。
ImageFilter.GaussianBlur(radius=2): 高斯模糊。
ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3): USM锐化。
ImageFilter.MedianFilter(size=3): 中值滤波(去噪)。
ImageFilter.ModeFilter(size=3): 众数滤波。
ImageFilter.MinFilter(size=3), ImageFilter.MaxFilter(size=3): 最小/最大值滤波。
ImageFilter.Kernel(size, kernel, scale=None, offset=0): 自定义卷积核。
应用:图像去噪、锐化、模糊、边缘检测、艺术效果等。
ImageEnhance 模块 (Image Enhancement):
功能:提供了用于调整图像视觉效果的类,如色彩、亮度、对比度和锐度。
核心类:
ImageEnhance.Color(image): 调整色彩饱和度。
ImageEnhance.Brightness(image): 调整亮度。
ImageEnhance.Contrast(image): 调整对比度。
ImageEnhance.Sharpness(image): 调整锐度。
使用方法:每个类都有一个enhance(factor)方法,factor为1.0表示原始图像,小于1.0减弱效果,大于1.0增强效果。factor为0通常会得到灰色图像(Color)、黑色图像(Brightness)或灰色图像(Contrast)。
应用:图像后期处理、改善图像视觉质量。
ImageDraw 模块 (Drawing on Images):
功能:提供了在图像上绘制2D图形(如点、线、矩形、椭圆、多边形)和文本的功能。
核心类:ImageDraw.Draw(image)
常用方法:
draw.point(xy, fill=None)
draw.line(xy, fill=None, width=0)
draw.rectangle(xy, fill=None, outline=None, width=0)
draw.ellipse(xy, fill=None, outline=None, width=0)
draw.polygon(xy, fill=None, outline=None)
draw.text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None)
draw.multiline_text(...)
字体支持:可以使用ImageFont模块加载TrueType和OpenType字体 (.ttf, .otf)。
ImageFont.truetype(font=None, size=10, index=0, encoding='', layout_engine=None)
应用:添加水印、标注图像、生成包含文本的图像、可视化。
ImageFont 模块 (Font Handling):
功能:与ImageDraw配合,用于加载和使用字体文件,以便在图像上绘制具有特定样式的文本。
核心函数:ImageFont.load(filename), ImageFont.load_path(filename), ImageFont.truetype(font, size, ...)。推荐使用truetype来加载.ttf或.otf字体。
ImageOps 模块 (Image Operations):
功能:提供了一些“即用型”的图像处理操作,很多是基于Image对象的方法或其他模块功能的封装,使用更便捷。
典型函数:
ImageOps.autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): 自动对比度。
ImageOps.colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoint=127): 将灰度图着色。
ImageOps.contain(image, size, method=Image.Resampling.BICUBIC): 将图像缩放到指定尺寸内,保持宽高比,多余部分用黑色填充(默认)。
ImageOps.cover(image, size, method=Image.Resampling.BICUBIC): 缩放并裁剪图像以完全覆盖指定尺寸,保持宽高比。
ImageOps.crop(image, border=0): 裁剪图像边缘。
ImageOps.deform(image, deformer, resample=Image.Resampling.BILINEAR): 几何形变。
ImageOps.equalize(image, mask=None): 直方图均衡化。
ImageOps.expand(image, border=0, fill=0): 扩展图像边缘,添加边框。
ImageOps.fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): 智能裁剪和缩放,类似contain但更灵活。
ImageOps.flip(image): 垂直翻转。
ImageOps.grayscale(image): 转换为灰度图(image.convert("L")的快捷方式)。
ImageOps.invert(image): 反色(针对”L”, “RGB”模式)。
ImageOps.mirror(image): 水平翻转。
ImageOps.pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5, 0.5)): 将图像缩放到指定尺寸内,并用指定颜色填充空白区域,保持宽高比。
ImageOps.posterize(image, bits): 减少每个颜色通道的位数(色调分离)。
ImageOps.solarize(image, threshold=128):曝光效应,反转高于阈值的像素值。
应用:快速图像调整和效果处理。
其他模块:
ImageColor: 用于颜色名称字符串和RGB元组之间的转换。
ImageGrab: 用于在特定平台(如Windows, macOS)截取屏幕内容。
ImagePath: 用于处理路径对象,可用于ImageDraw。
ImageSequence: 用于处理图像序列(如GIF动画)。
TiffImagePlugin, JpegImagePlugin, PngImagePlugin等:这些是Pillow内部用于支持特定文件格式的插件模块,用户通常不需要直接与它们交互,而是通过Image.open()和image.save()来使用。
对这些核心模块有所了解后,我们就可以开始学习Pillow提供的具体图像操作功能了。在后续章节中,我们将大量运用这些模块来实现各种图像相似度算法。
2.2 图像的基本操作
2.2.1 图像的打开与保存
这是使用Pillow最基本也是最频繁的操作。
Image.open(fp, mode='r', formats=None) 详解
功能:打开一个图像文件,进行解码,并返回一个Image对象。Pillow支持延迟加载(lazy loading),即它只读取图像的头部信息(如格式、尺寸、模式),直到需要处理像素数据时才会真正将整个图像加载到内存中。
参数:
fp:
字符串:表示图像文件的路径。
文件对象:一个已经以二进制读取模式 ('rb') 打开的文件对象 (e.g., open('image.jpg', 'rb'))。Pillow会从文件对象的当前位置开始读取。
类文件对象:任何具有read, seek, tell方法的对象。
mode: 文件打开模式,默认为 'r' (读取)。通常不需要修改此参数。
formats: 一个包含期望图像格式字符串的列表或元组 (e.g., ['JPEG', 'PNG'])。如果提供此参数,Pillow将只尝试用指定的格式解码器打开图像。如果图像格式不匹配,会引发IOError。省略此参数则Pillow会自动检测图像格式。
返回值:一个Image对象。
异常:
FileNotFoundError: 如果fp是路径字符串且文件不存在。
PIL.UnidentifiedImageError: 如果Pillow无法识别图像文件格式,或者文件已损坏无法打开。这个异常是IOError的子类。
IOError: 其他I/O相关错误。
支持的格式:Pillow支持非常广泛的图像格式,包括但不限于:
常见格式:JPEG (JPG), PNG, GIF, BMP, TIFF, WebP
其他格式:PPM, PCX, ICO, CUR, PSD (有限支持,通常是扁平化图层), EPS (需要Ghostscript), PDF (有限支持,通常是第一页或指定页面,也可能需要Ghostscript或MuPDF), SVG (需要svglib和reportlab等外部库进行光栅化), DDS, TGA, XBM, XPM 等。
可以通过 PIL.features.pilinfo() 或检查 PIL.Image.OPEN 和 PIL.Image.SAVE 字典来查看当前Pillow安装支持的具体格式。
代码示例:
from PIL import Image # 从Pillow库导入Image模块
import io # 导入io模块,用于处理内存中的字节流
# 示例1: 从文件路径打开图像
try:
img_from_path = Image.open("images/example.jpg") # 尝试打开指定路径的JPG图片
print(f"从路径打开成功: {
img_from_path.format}, {
img_from_path.size}, {
img_from_path.mode}") # 打印图片信息
# ... 在这里可以对 img_from_path 进行操作 ...
img_from_path.close() # 操作完成后关闭图片对象,释放资源
except FileNotFoundError:
print("错误: 图片文件 'images/example.jpg' 未找到。") # 文件未找到时的错误处理
except Image.UnidentifiedImageError:
print("错误: 无法识别 'images/example.jpg' 的图像格式或文件已损坏。") # 无法识别格式时的错误处理
except Exception as e:
print(f"打开图片时发生未知错误: {
e}") # 其他未知错误处理
# 示例2: 从已打开的文件对象打开图像
try:
with open("images/example.png", "rb") as f: # 以二进制读取模式打开PNG图片文件
img_from_file_object = Image.open(f) # 从文件对象打开图片
print(f"从文件对象打开成功: {
img_from_file_object.format}, {
img_from_file_object.size}, {
img_from_file_object.mode}") # 打印图片信息
# ... 操作 ...
img_from_file_object.close() # 关闭图片对象
except FileNotFoundError:
print("错误: 图片文件 'images/example.png' 未找到。") # 文件未找到错误
except Image.UnidentifiedImageError:
print("错误: 无法识别 'images/example.png' 的图像格式或文件已损坏。") # 无法识别格式错误
except Exception as e:
print(f"从文件对象打开图片时发生未知错误: {
e}") # 其他错误
# 示例3: 从内存中的字节流打开图像 (例如,从网络下载的图像数据)
try:
# 假设 image_bytes 是从某个来源获取的图像的字节数据
# 这里我们用一个简单的PNG图像的字节数据做示例
# (实际应用中,这可能是 response.content)
# 创建一个极简的1x1像素的PNG字节流
from PIL import Image, ImageDraw # 额外导入ImageDraw用于创建示例字节
temp_img = Image.new('RGB', (1, 1), color = 'red') # 创建一个1x1的红色图片
byte_io = io.BytesIO() # 创建一个BytesIO对象,用于在内存中操作字节
temp_img.save(byte_io, format='PNG') # 将临时图片保存为PNG格式到BytesIO对象
image_bytes = byte_io.getvalue() # 获取PNG图片的字节数据
temp_img.close() # 关闭临时图片
img_from_bytes = Image.open(io.BytesIO(image_bytes)) # 将字节数据包装在BytesIO中,然后传递给Image.open
print(f"从字节流打开成功: {
img_from_bytes.format}, {
img_from_bytes.size}, {
img_from_bytes.mode}") # 打印信息
# ... 操作 ...
img_from_bytes.close() # 关闭图片
except Image.UnidentifiedImageError:
print("错误: 无法从字节流识别图像格式或数据已损坏。") # 字节流错误
except Exception as e:
print(f"从字节流打开图片时发生未知错误: {
e}") # 其他错误
# 示例4: 使用 formats 参数指定解码器
try:
# 假设我们知道它是一个JPEG,但文件名可能是通用的
img_specific_format = Image.open("images/example.jpg", formats=['JPEG']) # 指定只尝试使用JPEG解码器
print(f"指定JPEG格式打开成功: {
img_specific_format.format}, {
img_specific_format.size}, {
img_specific_format.mode}") # 打印信息
img_specific_format.close() # 关闭图片
except FileNotFoundError:
print("错误: 图片文件 'images/example.jpg' 未找到。") # 文件未找到
except Image.UnidentifiedImageError:
# 如果文件不是JPEG,即使是有效的其他格式图片,也会触发这个错误
print("错误: 'images/example.jpg' 不是指定的JPEG格式或无法解码。") # 格式不匹配错误
except Exception as e:
print(f"指定格式打开图片时发生未知错误: {
e}") # 其他错误
注意:Image.open() 返回的图像对象在使用完毕后,调用其 close() 方法是一个好习惯,尤其是在处理大量图像或长时间运行的脚本中。这可以帮助Pillow及时释放文件句柄和相关内存。如果图像对象是通过 with Image.open(...) as img: 语句创建的,则在退出 with 块时会自动调用 img.close()。
image.save(fp, format=None, **params) 详解
功能:将Image对象保存到文件或类文件对象。
参数:
fp:
字符串:保存图像的文件路径。Pillow会根据文件扩展名自动推断要使用的图像格式 (e.g., “.jpg” -> JPEG, “.png” -> PNG)。
文件对象:一个已经以二进制写入模式 ('wb') 打开的文件对象。
类文件对象:任何具有write方法的对象。
format: 字符串,显式指定保存的图像格式 (e.g., “JPEG”, “PNG”, “GIF”)。如果fp是文件名字符串且没有扩展名,或者扩展名无法识别,或者你想覆盖自动推断的格式,则必须提供此参数。如果fp是文件对象,通常也建议提供此参数。
**params: 特定于图像格式的保存选项。这些选项以关键字参数的形式传递。不同的格式支持不同的选项。
JPEG 特定选项:
quality (int, 1-95, 默认约75): 压缩质量,越高图像质量越好,文件越大。95以上通常不推荐,可能导致文件异常增大而质量提升不明显。
optimize (bool, 默认False): 如果为True,进行额外的哈夫曼编码优化,可能减小文件大小,但编码稍慢。
progressive (bool, 默认False): 如果为True,保存为渐进式JPEG,加载时会先显示模糊轮廓再逐渐清晰。
subsampling (int or str): 色度二次采样设置。0 for 4:4:4 (no subsampling), 1 for 4:2:2, 2 for 4:2:0 (common default). Can also be a string like “4:4:4”. Affects color detail and file size.
icc_profile (bytes): 嵌入ICC颜色配置文件。
PNG 特定选项:
optimize (bool, 默认False): 如果为True,尝试多种压缩策略以找到最小的文件大小,编码较慢。
compress_level (int, 0-9, 默认通常是6或zlib默认): PNG压缩级别,0表示不压缩,1最快但压缩率低,9最慢但压缩率高。
icc_profile (bytes): 嵌入ICC颜色配置文件。
transparency (int or bytes or Image): 设置透明度。对于”L”或”P”模式,可以是调色板索引或灰度值;对于”RGB”模式,可以是RGB元组。
bits (int): 对于”L”或”P”模式,可以减少存储位数。
GIF 特定选项 (用于保存动画GIF):
save_all (bool, 默认False): 如果为True且图像包含多帧 (通过ImageSequence模块加载或手动构建),则保存所有帧为动画GIF。
append_images (list of Image objects): 要附加到当前图像后形成动画的帧列表。
duration (int or list of ints, 默认100): 每帧的持续时间(毫秒)。
loop (int, 默认0): 动画循环次数,0表示无限循环。
transparency (int): 透明色索引 (用于”P”模式)。
disposal (int): 帧处置方法。
TIFF 特定选项:
compression (str): 压缩方法,如 “tiff_jpeg”, “tiff_lzw”, “packbits”。
save_all (bool): 保存多页TIFF。
WebP 特定选项:
quality (int, 0-100, 默认80): 有损压缩质量。
lossless (bool, 默认False): 是否使用无损压缩。
method (int, 0-6, 默认4): 编码器速度与压缩率的权衡,0最快,6最慢但压缩更好。
icc_profile (bytes): 嵌入ICC配置文件。
exact (bool): 对于RGB图像,如果为True,则精确保留RGB值,可能会禁用一些有损优化。
代码示例:
from PIL import Image # 从Pillow库导入Image模块
import os # 导入os模块,用于文件和目录操作
# 创建一个输出目录 (如果不存在)
output_dir = "output_images" # 定义输出目录名
if not os.path.exists(output_dir): # 检查目录是否存在
os.makedirs(output_dir) # 如果不存在,则创建目录
# 假设我们已经有一个名为 img 的 Image 对象
try:
img_to_save = Image.open("images/example.png") # 打开一张示例图片用于保存
# 示例1: 保存为JPEG,自动推断格式,使用默认质量
save_path_jpg_default = os.path.join(output_dir, "saved_example_default.jpg") # 定义保存路径和文件名
img_to_save.save(save_path_jpg_default) # 保存为JPG,Pillow根据扩展名推断格式
print(f"图片已保存到: {
save_path_jpg_default}") # 打印保存成功信息
# 示例2: 保存为JPEG,指定格式和高质量、优化
save_path_jpg_high_q = os.path.join(output_dir, "saved_example_high_quality.jpg") # 定义保存路径
img_to_save.save(save_path_jpg_high_q, format="JPEG", quality=90, optimize=True) # 指定格式为JPEG,质量90,开启优化
print(f"图片已保存到: {
save_path_jpg_high_q}") # 打印信息
# 示例3: 保存为PNG,指定格式和高压缩级别
save_path_png_compressed = os.path.join(output_dir, "saved_example_compressed.png") # 定义PNG保存路径
# 如果原始图像是RGBA,保存为PNG会保留alpha通道
# 如果原始图像是RGB,需要先转换为RGBA才能保存带透明度的PNG (如果需要透明背景)
# img_rgba = img_to_save.convert("RGBA") # 转换为RGBA模式
# img_rgba.save(...)
img_to_save.save(save_path_png_compressed, format="PNG", compress_level=9, optimize=True) # 指定PNG格式,压缩级别9,开启优化
print(f"图片已保存到: {
save_path_png_compressed}") # 打印信息
# 示例4: 保存为GIF (单帧)
save_path_gif = os.path.join(output_dir, "saved_example.gif") # 定义GIF保存路径
# GIF是调色板图像,如果原图颜色过多,Pillow会自动进行量化
# 为了更好的GIF质量,可能需要先将图像转换为 "P" (palette) 模式
# img_p_mode = img_to_save.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) # 转换为P模式,自适应调色板,256色
# img_p_mode.save(save_path_gif, format="GIF")
img_to_save.save(save_path_gif, format="GIF") # 直接保存为GIF,Pillow会自动处理
print(f"图片已保存到: {
save_path_gif}") # 打印信息
# 示例 5: 保存到内存中的 BytesIO 对象
byte_io_buffer = io.BytesIO() # 创建一个BytesIO对象
img_to_save.save(byte_io_buffer, format="WEBP", lossless=True, quality=80) # 保存为WebP格式到内存,启用无损压缩,质量80
webp_bytes = byte_io_buffer.getvalue() # 从BytesIO对象获取字节数据
print(f"图片已保存到内存中的BytesIO对象,WebP格式,大小: {
len(webp_bytes)} 字节") # 打印信息
# webp_bytes 可以后续用于网络传输或写入其他地方
# 示例 6: 保存多帧GIF (动画)
# 首先创建一些帧
frames = [] # 初始化一个空列表用于存放帧
width, height = 100, 100 # 定义帧的宽度和高度
for i in range(10): # 创建10帧
frame = Image.new("RGB", (width, height), color=(i * 25, 50, 100)) # 创建一个新图像作为一帧,颜色随i变化
draw = ImageDraw.Draw(frame) # 获取该帧的Draw对象
draw.text((10, 10), f"Frame {
i}", fill=(255, 255, 255)) # 在帧上绘制文字
frames.append(frame) # 将创建的帧添加到列表中
save_path_animated_gif = os.path.join(output_dir, "animated_example.gif") # 定义动画GIF的保存路径
if frames: # 确保帧列表不为空
frames[0].save(
save_path_animated_gif, # 保存路径
save_all=True, # 关键参数:保存所有帧
append_images=frames[1:], # 附加从第二帧开始的所有帧
duration=200, # 每帧持续时间200毫秒
loop=0 # 0表示无限循环
)
print(f"动画GIF已保存到: {
save_path_animated_gif}") # 打印信息
img_to_save.close() # 关闭最初打开的图片
except FileNotFoundError:
print("错误: 原始图片 'images/example.png' 未找到。") # 文件未找到错误
except Exception as e:
print(f"保存图片时发生错误: {
e}") # 其他保存错误
# 清理创建的示例图片 (可选)
# for item in os.listdir(output_dir):
# if item.startswith("saved_example_") or item.startswith("animated_example_"):
# os.remove(os.path.join(output_dir, item))
# if not os.listdir(output_dir):
# os.rmdir(output_dir)
重要提示:
当保存为有损格式(如JPEG)时,每次保存都会导致图像质量的损失(除非质量设置为100,但这通常不推荐且不一定无损)。应避免重复打开JPEG再保存为JPEG,除非绝对必要。
某些格式转换可能会导致信息丢失。例如,将带有Alpha透明通道的PNG保存为不支持透明的JPEG时,透明区域通常会被替换为某种纯色(默认为黑色,或可以通过image.convert("RGB")控制背景色)。
确保你有写入指定路径的权限,否则save()会失败。
正确地打开和保存图像是进行任何图像处理任务的第一步。Pillow通过简洁的API使得这些操作非常方便。
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance, ImageChops, ImageStat, ImageOps # 从Pillow库导入所有会用到的模块
import os # 导入os模块,用于文件和目录操作
import io # 导入io模块,用于处理内存中的字节流
import math # 导入math模块,用于数学运算
import numpy # 导入numpy模块,用于数值计算,特别是在处理像素数据时非常有用
# from scipy import fftpack # 对于pHash,可能需要scipy.fftpack (按需导入)
# import PyWavelets # 对于wHash,可能需要PyWavelets (按需导入)
# 为确保代码可复现和路径正确,我们先定义一些基础路径
# 假设在当前工作目录下有一个名为 'images' 的文件夹存放输入图片
# 以及一个名为 'output_images' 的文件夹存放处理后的图片
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) if '__file__' in locals() else os.getcwd() # 获取当前脚本或工作目录的绝对路径
IMAGE_DIR = os.path.join(BASE_DIR, "images") # 定义输入图片目录路径
OUTPUT_DIR = os.path.join(BASE_DIR, "output_images") # 定义输出图片目录路径
# 创建示例图片目录和输出目录(如果它们不存在)
if not os.path.exists(IMAGE_DIR): # 检查输入图片目录是否存在
os.makedirs(IMAGE_DIR) # 如果不存在,则创建该目录
print(f"已创建目录: {
IMAGE_DIR}") # 打印创建目录信息
# 可以考虑在这里添加一些示例图片到IMAGE_DIR,或者提示用户添加
try:
# 创建一个简单的示例图片并保存到IMAGE_DIR
sample_img_path = os.path.join(IMAGE_DIR, "example.png") # 定义示例图片路径
if not os.path.exists(sample_img_path): # 如果示例图片不存在
img_sample = Image.new('RGB', (200, 150), color = 'skyblue') # 创建一个200x150的天蓝色图片
draw_sample = ImageDraw.Draw(img_sample) # 获取该图片的Draw对象
try:
# 尝试加载一个系统字体,如果失败则使用Pillow默认字体
font_path = "arial.ttf" # 尝试使用arial字体
font_sample = ImageFont.truetype(font_path, 20) # 加载20号arial字体
except IOError:
font_sample = ImageFont.load_default() # 如果arial加载失败,使用默认字体
print(f"警告: 无法加载字体 {
font_path}, 使用默认字体。") # 打印警告信息
draw_sample.text((10, 10), "Sample Image
For Pillow Test", font=font_sample, fill='black') # 在图片上绘制文字
img_sample.save(sample_img_path) # 保存示例图片
print(f"已创建并保存示例图片: {
sample_img_path}") # 打印创建成功信息
img_sample.close() # 关闭示例图片对象
sample_jpg_path = os.path.join(IMAGE_DIR, "example.jpg") # 定义另一个示例图片路径(JPEG)
if not os.path.exists(sample_jpg_path): # 如果JPEG示例图片不存在
img_jpg_sample = Image.new('RGB', (300, 200), color = (255, 128, 128)) # 创建一个300x200的浅红色图片
draw_jpg_sample = ImageDraw.Draw(img_jpg_sample) # 获取Draw对象
draw_jpg_sample.rectangle([50, 50, 250, 150], outline="blue", width=5, fill="yellow") # 绘制一个黄底蓝边的矩形
img_jpg_sample.save(sample_jpg_path, "JPEG", quality=85) # 以JPEG格式保存,质量85
print(f"已创建并保存示例图片: {
sample_jpg_path}") # 打印创建成功信息
img_jpg_sample.close() # 关闭图片对象
except Exception as e_create_sample:
print(f"创建示例图片时发生错误: {
e_create_sample}") # 打印创建示例图片时的错误
if not os.path.exists(OUTPUT_DIR): # 检查输出图片目录是否存在
os.makedirs(OUTPUT_DIR) # 如果不存在,则创建该目录
print(f"已创建目录: {
OUTPUT_DIR}") # 打印创建目录信息
print("--- 第一章:图像相似度计算概述 ---") # 打印章节标题
print("1.1 什么是图像相似度?") # 打印小节标题
print(" 1.1.1 定义与重要性: 图像相似度是衡量图像间视觉或语义相似性的指标,对图像检索、去重、版权保护等至关重要。") # 打印子小节内容
print(" 1.1.2 应用场景: CBIR, 重复检测, 人脸识别辅助, 目标跟踪辅助, 医学图像分析, 版权保护, 视觉质量评估等。") # 打印子小节内容
print("
1.2 图像相似度计算的挑战") # 打印小节标题
print(" 1.2.1 光照变化: 不同光照导致像素值巨大差异。") # 打印子小节内容
print(" 1.2.2 尺度变化: 物体在图中大小不一。") # 打印子小节内容
print(" 1.2.3 旋转与视角变化: 物体姿态改变。") # 打印子小节内容
print(" 1.2.4 遮挡: 物体部分被遮挡。") # 打印子小节内容
print(" 1.2.5 形变: 非刚性物体形状改变。") # 打印子小节内容
print(" 1.2.6 噪声: 图像采集或传输中引入的干扰。") # 打印子小节内容
print(" 1.2.7 计算效率与资源消耗: 大规模或实时应用对性能要求高。") # 打印子小节内容
print("
1.3 图像相似度计算方法分类") # 打印小节标题
print(" 1.3.1 基于像素的方法: 如MSE, SSIM。简单但敏感。") # 打印子小节内容
print(" 1.3.2 基于直方图的方法: 比较颜色/灰度分布。对旋转平移不敏感,但丢失空间信息。") # 打印子小节内容
print(" 1.3.3 基于特征的方法: 局部特征(SIFT, ORB)和全局特征(颜色矩, LBP)。鲁棒性更好,计算更复杂。") # 打印子小节内容
print(" 1.3.4 基于感知哈希的方法: aHash, dHash, pHash。快速,适用于去重和粗略相似搜索。") # 打印子小节内容
print(" 1.3.5 基于深度学习的方法: CNN提取特征, Siamese网络等。精度高,鲁棒性强,但需数据和算力。") # 打印子小节内容
print("
--- 第二章:Pillow (PIL Fork) 图像处理基础 ---") # 打印章节标题
print("2.1 Pillow 库简介") # 打印小节标题
print(" 2.1.1 PIL 与 Pillow 的历史: Pillow是PIL的友好分支,积极维护,易于安装。") # 打印子小节内容
print(" 2.1.2 安装 Pillow: 通常使用 'pip install Pillow'。") # 打印子小节内容
# 验证 Pillow 安装的代码块
try:
from PIL import Image # 从Pillow库导入Image模块
print(f" Pillow (PIL Fork) 已成功导入!版本: {
Image.__version__}") # 打印成功信息和版本号
except ImportError:
print(" 错误: Pillow (PIL Fork) 未安装或导入失败。请使用 'pip install Pillow' 安装。") # 打印失败信息
print(" 2.1.3 Pillow 的核心模块: Image, ImageChops, ImageStat, ImageFilter, ImageEnhance, ImageDraw, ImageFont, ImageOps等。") # 打印子小节内容
print("
2.2 图像的基本操作") # 打印小节标题
print(" 2.2.1 图像的打开与保存") # 打印子小节内容
# 图像打开示例
print(" 图像打开示例:") # 打印提示信息
img_path_example_png = os.path.join(IMAGE_DIR, "example.png") # 定义PNG示例图片的路径
img_path_example_jpg = os.path.join(IMAGE_DIR, "example.jpg") # 定义JPG示例图片的路径
# 确保示例图片存在
if not os.path.exists(img_path_example_png): # 检查PNG示例图片是否存在
print(f" 警告: 示例图片 {
img_path_example_png} 不存在,部分打开示例可能失败。") # 打印警告
if not os.path.exists(img_path_example_jpg): # 检查JPG示例图片是否存在
print(f" 警告: 示例图片 {
img_path_example_jpg} 不存在,部分打开示例可能失败。") # 打印警告
# 示例1: 从文件路径打开图像
if os.path.exists(img_path_example_jpg): # 检查JPG图片是否存在
try:
with Image.open(img_path_example_jpg) as img_from_path: # 使用with语句打开图片,自动关闭
print(f" 从路径 '{
img_path_example_jpg}' 打开成功: 格式={
img_from_path.format}, 尺寸={
img_from_path.size}, 模式={
img_from_path.mode}") # 打印图片信息
# img_from_path 对象在此 'with' 块内可用
except FileNotFoundError:
print(f" 错误: 图片文件 '{
img_path_example_jpg}' 未找到。") # 文件未找到错误
except Image.UnidentifiedImageError:
print(f" 错误: 无法识别 '{
img_path_example_jpg}' 的图像格式或文件已损坏。") # 无法识别格式错误
except Exception as e:
print(f" 打开图片 '{
img_path_example_jpg}' 时发生未知错误: {
e}") # 其他未知错误
else:
print(f" 跳过从路径打开JPG示例,因为文件 '{
img_path_example_jpg}' 不存在。") # 打印跳过信息
# 示例2: 从已打开的文件对象打开图像
if os.path.exists(img_path_example_png): # 检查PNG图片是否存在
try:
with open(img_path_example_png, "rb") as f_obj: # 以二进制读取模式打开PNG图片文件
with Image.open(f_obj) as img_from_file_object: # 从文件对象打开图片
print(f" 从文件对象 ('{
img_path_example_png}') 打开成功: 格式={
img_from_file_object.format}, 尺寸={
img_from_file_object.size}, 模式={
img_from_file_object.mode}") # 打印图片信息
except FileNotFoundError:
print(f" 错误: 图片文件 '{
img_path_example_png}' 未找到。") # 文件未找到错误
except Image.UnidentifiedImageError:
print(f" 错误: 无法识别 '{
img_path_example_png}' 的图像格式或文件已损坏。") # 无法识别格式错误
except Exception as e:
print(f" 从文件对象打开图片 '{
img_path_example_png}' 时发生未知错误: {
e}") # 其他错误
else:
print(f" 跳过从文件对象打开PNG示例,因为文件 '{
img_path_example_png}' 不存在。") # 打印跳过信息
# 示例3: 从内存中的字节流打开图像
try:
# 创建一个简单的10x10像素的PNG字节流作为示例
temp_img_for_bytes = Image.new('L', (10, 10), color = 128) # 创建一个10x10的灰色图片 ('L'模式)
byte_io_stream = io.BytesIO() # 创建一个BytesIO对象
temp_img_for_bytes.save(byte_io_stream, format='PNG') # 将图片保存为PNG格式到BytesIO对象
image_byte_data = byte_io_stream.getvalue() # 获取PNG图片的字节数据
temp_img_for_bytes.close() # 关闭临时图片
byte_io_stream.close() # 关闭BytesIO流
with Image.open(io.BytesIO(image_byte_data)) as img_from_bytes: # 将字节数据包装在BytesIO中,然后传递给Image.open
print(f" 从字节流打开成功: 格式={
img_from_bytes.format}, 尺寸={
img_from_bytes.size}, 模式={
img_from_bytes.mode}") # 打印图片信息
except Image.UnidentifiedImageError:
print(" 错误: 无法从字节流识别图像格式或数据已损坏。") # 字节流错误
except Exception as e:
print(f" 从字节流打开图片时发生未知错误: {
e}") # 其他错误
# 图像保存示例
print("
图像保存示例:") # 打印提示信息
if os.path.exists(img_path_example_png): # 检查PNG图片是否存在,用它作为保存操作的源
try:
with Image.open(img_path_example_png) as img_to_save_ops: # 打开PNG图片用于后续保存操作
# 示例1: 保存为JPEG,自动推断格式,使用默认质量
save_path_jpg_default_ops = os.path.join(OUTPUT_DIR, "saved_ops_default.jpg") # 定义JPG保存路径
img_to_save_ops.save(save_path_jpg_default_ops) # 保存为JPG
print(f" 图片已保存到: {
save_path_jpg_default_ops} (JPEG, 默认质量)") # 打印保存信息
# 示例2: 保存为JPEG,指定格式和高质量、优化
save_path_jpg_high_q_ops = os.path.join(OUTPUT_DIR, "saved_ops_high_quality.jpg") # 定义JPG高质量保存路径
# 对于JPEG,如果原图有Alpha通道(RGBA),直接保存会丢失Alpha。通常需要先转为RGB。
img_rgb_for_jpeg = img_to_save_ops # 初始化用于保存JPEG的图像
if img_to_save_ops.mode == 'RGBA' or 'A' in img_to_save_ops.mode: # 检查图像模式是否包含Alpha通道
# 创建一个白色背景
background = Image.new("RGB", img_to_save_ops.size, (255, 255, 255)) # 创建一个与原图同尺寸的白色背景图
background.paste(img_to_save_ops, mask=img_to_save_ops.split()[3]) # 将原图(带Alpha)粘贴到白色背景上,使用Alpha通道作为遮罩
img_rgb_for_jpeg = background # 更新用于保存JPEG的图像为处理后的RGB图像
print(f" 原图模式为 {
img_to_save_ops.mode},已转换为RGB模式并合成到白色背景上以保存为JPEG。") # 打印转换信息
elif img_to_save_ops.mode != 'RGB': # 如果不是RGBA也不是RGB
img_rgb_for_jpeg = img_to_save_ops.convert('RGB') # 转换为RGB模式
print(f" 原图模式为 {
img_to_save_ops.mode},已转换为RGB模式以保存为JPEG。") # 打印转换信息
img_rgb_for_jpeg.save(save_path_jpg_high_q_ops, format="JPEG", quality=92, optimize=True, progressive=True) # 保存为JPEG,质量92,开启优化和渐进式
print(f" 图片已保存到: {
save_path_jpg_high_q_ops} (JPEG, 质量92, 优化, 渐进式)") # 打印保存信息
if img_rgb_for_jpeg != img_to_save_ops : # 如果创建了新的RGB图像对象
img_rgb_for_jpeg.close() # 关闭这个临时创建的RGB图像对象
# 示例3: 保存为PNG,指定格式和高压缩级别
save_path_png_compressed_ops = os.path.join(OUTPUT_DIR, "saved_ops_compressed.png") # 定义PNG压缩保存路径
img_to_save_ops.save(save_path_png_compressed_ops, format="PNG", compress_level=9, optimize=True) # 保存为PNG,压缩级别9,开启优化
print(f" 图片已保存到: {
save_path_png_compressed_ops} (PNG, 压缩级别9, 优化)") # 打印保存信息
# 示例4: 保存为WebP (有损和无损)
save_path_webp_lossy = os.path.join(OUTPUT_DIR, "saved_ops_lossy.webp") # 定义有损WebP保存路径
img_to_save_ops.save(save_path_webp_lossy, format="WEBP", quality=80) # 保存为有损WebP,质量80
print(f" 图片已保存到: {
save_path_webp_lossy} (WebP, 有损, 质量80)") # 打印保存信息
save_path_webp_lossless = os.path.join(OUTPUT_DIR, "saved_ops_lossless.webp") # 定义无损WebP保存路径
img_to_save_ops.save(save_path_webp_lossless, format="WEBP", lossless=True, quality=90) # 保存为无损WebP,quality参数在无损时影响压缩速度/效率平衡
print(f" 图片已保存到: {
save_path_webp_lossless} (WebP, 无损, quality 90)") # 打印保存信息
# 示例5: 保存到内存中的 BytesIO 对象
byte_io_buffer_ops = io.BytesIO() # 创建BytesIO对象
img_to_save_ops.save(byte_io_buffer_ops, format="TIFF", compression="tiff_lzw") # 保存为TIFF格式到内存,使用LZW压缩
tiff_bytes_data = byte_io_buffer_ops.getvalue() # 获取TIFF图片的字节数据
print(f" 图片已保存到内存中的BytesIO对象,TIFF格式 (LZW压缩),大小: {
len(tiff_bytes_data)} 字节") # 打印信息
byte_io_buffer_ops.close() # 关闭BytesIO流
except FileNotFoundError:
print(f" 错误: 源图片 '{
img_path_example_png}' 未找到,无法执行保存操作。") # 源文件未找到错误
except Exception as e:
print(f" 保存图片时发生错误: {
e}") # 其他保存错误
else:
print(f" 跳过保存示例,因为源文件 '{
img_path_example_png}' 不存在。") # 打印跳过信息
# 2.2.2 图像的属性 (Image Attributes)
print("
2.2.2 图像的属性") # 打印小节标题
if os.path.exists(img_path_example_png): # 检查PNG图片是否存在
try:
with Image.open(img_path_example_png) as img_attr: # 打开PNG图片获取属性
print(f" 图片: {
img_path_example_png}") # 打印图片路径
print(f" Format (文件格式): {
img_attr.format}") # 打印图片的文件格式 (如 PNG, JPEG)
print(f" Size (尺寸 WxH): {
img_attr.size}") # 打印图片的宽度和高度 (width, height) 元组
width_attr, height_attr = img_attr.size # 解包宽度和高度
print(f" 宽度: {
width_attr} 像素, 高度: {
height_attr} 像素") # 分别打印宽度和高度
print(f" Mode (模式): {
img_attr.mode}") # 打印图片的色彩模式 (如 "L", "RGB", "RGBA", "CMYK", "P")
print(f" 可能的模式解释:") # 打印模式解释提示
print(f" '1': 1位像素,黑和白,存为一个字节的每个像素。") # 1模式解释
print(f" 'L': 8位像素,黑白 (灰度)。") # L模式解释
print(f" 'P': 8位像素,使用调色板映射到其他模式。") # P模式解释
print(f" 'RGB': 3x8位像素,真彩色。") # RGB模式解释
print(f" 'RGBA': 4x8位像素,带透明通道的真彩色。") # RGBA模式解释
print(f" 'CMYK': 4x8位像素,分色。") # CMYK模式解释
print(f" 'YCbCr': 3x8位像素,彩色视频格式。") # YCbCr模式解释
print(f" 'I': 32位有符号整数像素。") # I模式解释
print(f" 'F': 32位浮点像素。") # F模式解释
if img_attr.mode == 'P': # 如果是P模式(调色板模式)
palette = img_attr.getpalette() # 获取调色板数据
if palette: # 如果调色板存在
print(f" Palette (调色板数据): 前15个值 {
palette[:15]}... (R1,G1,B1,R2,G2,B2...)") # 打印调色板前15个值
else:
print(f" Palette (调色板数据): 无") # 打印无调色板信息
print(f" Info (元信息字典): {
img_attr.info}") # 打印图片的元信息字典,内容因格式而异
# .info 字典可能包含 DPI, 软件信息, EXIF数据 (需要专门解析) 等
if 'dpi' in img_attr.info: # 检查是否存在DPI信息
print(f" DPI (每英寸点数): {
img_attr.info['dpi']}") # 打印DPI信息
if 'icc_profile' in img_attr.info: # 检查是否存在ICC Profile
print(f" ICC Profile: 存在,长度 {
len(img_attr.info['icc_profile'])} 字节") # 打印ICC Profile存在信息
# 获取帧信息 (主要用于GIF等动图)
print(f" Number of frames (帧数): {
getattr(img_attr, 'n_frames', 1)}") # 打印图片帧数,默认为1
print(f" Is animated (是否为动画): {
getattr(img_attr, 'is_animated', False)}") # 打印是否为动画,默认为False
except Exception as e:
print(f" 获取 '{
img_path_example_png}' 属性时发生错误: {
e}") # 打印获取属性时的错误
else:
print(f" 跳过图像属性示例,因为文件 '{
img_path_example_png}' 不存在。") # 打印跳过信息
# 2.2.3 图像的显示 (image.show())
print("
2.2.3 图像的显示 (`image.show()`)") # 打印小节标题
print(" `image.show(title=None, command=None)` 方法会尝试使用外部程序显示图像。") # 打印show方法说明
print(" 在Windows上,它通常调用默认的图片查看器。在Linux上,可能使用 `xdg-open` 或 `display` (ImageMagick)。") # 打印不同系统下的行为
print(" 注意: 在无GUI环境或某些远程服务器上,`show()` 可能无法工作或报错。") # 打印注意事项
print(" 它主要用于调试和快速查看,不适用于生产环境的GUI应用。") # 打印适用场景
# 示例 (通常在脚本中,执行show后脚本会暂停直到查看器关闭,或在某些环境下直接继续)
# if os.path.exists(img_path_example_jpg):
# try:
# with Image.open(img_path_example_jpg) as img_to_show:
# print(f" 尝试显示图片: {img_path_example_jpg} (如无反应,请检查是否有图片查看器弹出)")
# img_to_show.show(title="Pillow Show Test") # 尝试显示图片,并设置标题
# except Exception as e:
# print(f" 显示图片 '{img_path_example_jpg}' 时发生错误: {e}")
# else:
# print(f" 跳过图像显示示例,因为文件 '{img_path_example_jpg}' 不存在。")
print(" (为避免脚本中断或依赖GUI,实际的show()调用在此被注释掉)") # 打印注释信息
# 2.2.4 图像格式转换 (image.convert())
print("
2.2.4 图像格式转换 (`image.convert(mode=None, matrix=None, dither=None, palette=Image.Palette.WEB, colors=256)`)") # 打印小节标题
print(" 功能: 将图像转换为指定的色彩模式。") # 打印功能说明
if os.path.exists(img_path_example_rgb_source := os.path.join(IMAGE_DIR, "example.jpg")): # 定义RGB源图片路径并检查是否存在
try:
with Image.open(img_path_example_rgb_source) as img_orig_rgb: # 打开RGB源图片
print(f" 原始图片 ({
img_path_example_rgb_source}):模式={
img_orig_rgb.mode}, 尺寸={
img_orig_rgb.size}") # 打印原始图片信息
# 转换为灰度图 ('L'模式)
img_l = img_orig_rgb.convert("L") # 将图片转换为L模式(灰度)
save_path_l = os.path.join(OUTPUT_DIR, "converted_to_L.png") # 定义灰度图保存路径
img_l.save(save_path_l) # 保存灰度图
print(f" 转换为灰度图 ('L'): 新模式={
img_l.mode}。已保存到 {
save_path_l}") # 打印转换和保存信息
img_l.close() # 关闭灰度图对象
# 转换为RGBA (如果原始是RGB,则添加一个不透明的Alpha通道)
if img_orig_rgb.mode == "RGB": # 如果原始模式是RGB
img_rgba = img_orig_rgb.convert("RGBA") # 将图片转换为RGBA模式
save_path_rgba = os.path.join(OUTPUT_DIR, "converted_to_RGBA.png") # 定义RGBA图保存路径
img_rgba.save(save_path_rgba) # 保存RGBA图
print(f" 转换为RGBA图: 新模式={
img_rgba.mode}。已保存到 {
save_path_rgba}") # 打印转换和保存信息
# 从RGBA转回RGB (会丢失Alpha通道)
img_rgb_from_rgba = img_rgba.convert("RGB") # 将RGBA图转换回RGB模式
save_path_rgb_from_rgba = os.path.join(OUTPUT_DIR, "converted_RGBA_to_RGB.jpg") # 定义转回RGB图的保存路径
img_rgb_from_rgba.save(save_path_rgb_from_rgba) # 保存转回的RGB图
print(f" 从RGBA转回RGB: 新模式={
img_rgb_from_rgba.mode}。已保存到 {
save_path_rgb_from_rgba}") # 打印转换和保存信息
img_rgb_from_rgba.close() # 关闭转回的RGB图对象
img_rgba.close() # 关闭RGBA图对象
else:
print(f" 原始图片模式不是RGB,跳过RGBA转换示例。") # 打印跳过信息
# 转换为调色板模式 ('P')
# 使用 dither 参数可以控制抖动算法,减少颜色量化时的色带效应
# Image.Palette.WEB: 使用Web安全调色板 (216色)
# Image.Palette.ADAPTIVE: 创建一个自适应调色板,colors参数指定颜色数量
img_p_web = img_orig_rgb.convert("P", palette=Image.Palette.WEB, dither=Image.Dither.FLOYDSTEINBERG) # 转换为P模式,使用WEB调色板和Floyd-Steinberg抖动
save_path_p_web = os.path.join(OUTPUT_DIR, "converted_to_P_web.gif") # 定义P模式(WEB调色板)保存路径
img_p_web.save(save_path_p_web) # 保存P模式图片
print(f" 转换为调色板图 ('P', Web调色板, Floyd-Steinberg抖动): 新模式={
img_p_web.mode}。已保存到 {
save_path_p_web}") # 打印转换和保存信息
img_p_web.close() # 关闭P模式图片对象
img_p_adaptive = img_orig_rgb.convert("P", palette=Image.Palette.ADAPTIVE, colors=64, dither=Image.Dither.FLOYDSTEINBERG) # 转换为P模式,使用自适应调色板(64色)和Floyd-Steinberg抖动
save_path_p_adaptive = os.path.join(OUTPUT_DIR, "converted_to_P_adaptive64.png") # 定义P模式(自适应调色板)保存路径
img_p_adaptive.save(save_path_p_adaptive) # 保存P模式图片
print(f" 转换为调色板图 ('P', 自适应64色调色板, Floyd-Steinberg抖动): 新模式={
img_p_adaptive.mode}。已保存到 {
save_path_p_adaptive}") # 打印转换和保存信息
img_p_adaptive.close() # 关闭P模式图片对象
# 转换为二值图 ('1')
# 通常先转为灰度图,然后可以设定一个阈值 (Pillow的convert('1')会自动处理)
# dither=Image.Dither.NONE 可以得到更清晰的二值化,但可能有锯齿
# dither=Image.Dither.FLOYDSTEINBERG 会用误差扩散来模拟中间色调
img_bw_no_dither = img_orig_rgb.convert("1", dither=Image.Dither.NONE) # 转换为1模式(二值图),无抖动
save_path_bw_no_dither = os.path.join(OUTPUT_DIR, "converted_to_1_nodither.png") # 定义二值图(无抖动)保存路径
img_bw_no_dither.save(save_path_bw_no_dither) # 保存二值图
print(f" 转换为二值图 ('1', 无抖动): 新模式={
img_bw_no_dither.mode}。已保存到 {
save_path_bw_no_dither}") # 打印转换和保存信息
img_bw_no_dither.close() # 关闭二值图对象
img_bw_dither = img_orig_rgb.convert("1", dither=Image.Dither.FLOYDSTEINBERG) # 转换为1模式(二值图),使用Floyd-Steinberg抖动
save_path_bw_dither = os.path.join(OUTPUT_DIR, "converted_to_1_dither.png") # 定义二值图(抖动)保存路径
img_bw_dither.save(save_path_bw_dither) # 保存二值图
print(f" 转换为二值图 ('1', Floyd-Steinberg抖动): 新模式={
img_bw_dither.mode}。已保存到 {
save_path_bw_dither}") # 打印转换和保存信息
img_bw_dither.close() # 关闭二值图对象
# 使用 matrix 进行自定义颜色空间转换 (例如,RGB到特定灰度版本的转换)
# matrix 是一个包含浮点数的元组。对于 "L" 和 "RGB" 之间的转换,它应该有4或12个元素。
# 例如,标准的RGB到L的转换 (ITU-R 601-2 luma transform): L = R * 299/1000 + G * 587/1000 + B * 114/1000
# Pillow的 .convert('L') 内部使用这个。
# matrix for RGB to L: (0.299, 0.587, 0.114, 0)
custom_gray_matrix = (0.299, 0.587, 0.114, 0) # 定义RGB到L的转换矩阵
img_l_matrix = img_orig_rgb.convert("L", matrix=custom_gray_matrix) # 使用矩阵转换为L模式
save_path_l_matrix = os.path.join(OUTPUT_DIR, "converted_to_L_matrix.png") # 定义L模式(矩阵转换)保存路径
img_l_matrix.save(save_path_l_matrix) # 保存图片
print(f" 使用matrix转换为灰度图 ('L'): 新模式={
img_l_matrix.mode}。已保存到 {
save_path_l_matrix}") # 打印转换和保存信息
img_l_matrix.close() # 关闭图片对象
except FileNotFoundError:
print(f" 错误: 源图片 '{
img_path_example_rgb_source}' 未找到,无法执行转换操作。") # 源文件未找到错误
except Exception as e:
print(f" 图像模式转换时发生错误: {
e}") # 其他转换错误
else:
print(f" 跳过图像模式转换示例,因为源文件 '{
img_path_example_rgb_source}' 不存在。") # 打印跳过信息
# 2.2.5 图像裁剪 (image.crop(box=None))
print("
2.2.5 图像裁剪 (`image.crop(box)`)") # 打印小节标题
print(" 功能: 从图像中提取一个矩形区域。") # 打印功能说明
print(" `box` 是一个4元组 (left, upper, right, lower),定义了裁剪框的左上角和右下角坐标。") # 打印box参数说明
print(" 坐标系原点 (0,0) 在左上角。") # 打印坐标系说明
if os.path.exists(img_path_example_png): # 检查PNG图片是否存在
try:
with Image.open(img_path_example_png) as img_to_crop: # 打开PNG图片用于裁剪
width_crop, height_crop = img_to_crop.size # 获取图片宽度和高度
print(f" 原始图片 ({
img_path_example_png}): 尺寸={
width_crop}x{
height_crop}") # 打印原始图片信息
if width_crop > 50 and height_crop > 50: # 确保图片尺寸足够大以便裁剪
# 裁剪中心区域,假设裁剪框大小为原图的一半,位于中心
crop_width = width_crop // 2 # 定义裁剪宽度为原图宽度的一半
crop_height = height_crop // 2 # 定义裁剪高度为原图高度的一半
left = (width_crop - crop_width) // 2 # 计算裁剪框左边界
upper = (height_crop - crop_height) // 2 # 计算裁剪框上边界
right = left + crop_width # 计算裁剪框右边界
lower = upper + crop_height # 计算裁剪框下边界
box_center = (left, upper, right, lower) # 定义中心裁剪框
img_cropped_center = img_to_crop.crop(box_center) # 执行裁剪操作
save_path_cropped_center = os.path.join(OUTPUT_DIR, "cropped_center.png") # 定义中心裁剪图保存路径
img_cropped_center.save(save_path_cropped_center) # 保存裁剪后的图片
print(f" 中心区域裁剪: Box={
box_center}, 新尺寸={
img_cropped_center.size}。已保存到 {
save_path_cropped_center}") # 打印裁剪信息和保存路径
img_cropped_center.close() # 关闭裁剪后的图片对象
# 裁剪左上角 50x50 区域 (如果图片够大)
if width_crop >= 50 and height_crop >= 50: # 再次确认图片尺寸是否大于等于50x50
box_top_left = (0, 0, 50, 50) # 定义左上角50x50裁剪框
img_cropped_tl = img_to_crop.crop(box_top_left) # 执行裁剪
save_path_cropped_tl = os.path.join(OUTPUT_DIR, "cropped_top_left_50x50.png") # 定义左上角裁剪图保存路径
img_cropped_tl.save(save_path_cropped_tl) # 保存裁剪图
print(f" 左上角50x50裁剪: Box={
box_top_left}, 新尺寸={
img_cropped_tl.size}。已保存到 {
save_path_cropped_tl}") # 打印裁剪信息和保存路径
img_cropped_tl.close() # 关闭裁剪图对象
else:
print(f" 图片尺寸小于50x50,跳过左上角裁剪。") # 打印跳过信息
else:
print(f" 图片尺寸过小 ({
width_crop}x{
height_crop}),无法进行有意义的裁剪示例。") # 打印尺寸过小信息
except FileNotFoundError:
print(f" 错误: 源图片 '{
img_path_example_png}' 未找到,无法执行裁剪操作。") # 源文件未找到错误
except Exception as e:
print(f" 图像裁剪时发生错误: {
e}") # 其他裁剪错误
else:
print(f" 跳过图像裁剪示例,因为文件 '{
img_path_example_png}' 不存在。") # 打印跳过信息
# 2.2.6 图像缩放 (image.resize(size, resample=None, box=None, reducing_gap=None))
print("
2.2.6 图像缩放 (`image.resize(size, resample=...)`)") # 打印小节标题
print(" 功能: 改变图像的尺寸。") # 打印功能说明
print(" `size` 是一个2元组 (width, height) 定义目标尺寸。") # 打印size参数说明
print(" `resample` 是插值算法,影响缩放质量和速度:") # 打印resample参数说明
print(" `Image.Resampling.NEAREST` (或 `Image.NEAREST` 老版本): 最近邻插值,速度快,质量差,易产生锯齿。") # NEAREST插值算法说明
print(" `Image.Resampling.BOX` (或 `Image.BOX`): 盒滤波,效果类似最近邻但更平滑一点点,用于缩小较多时。") # BOX插值算法说明
print(" `Image.Resampling.BILINEAR` (或 `Image.BILINEAR` 或 `Image.LINEAR`): 双线性插值,速度和质量的平衡。") # BILINEAR插值算法说明
print(" `Image.Resampling.HAMMING`: Hamming滤波,效果通常不如BICUBIC或LANCZOS。") # HAMMING插值算法说明
print(" `Image.Resampling.BICUBIC` (或 `Image.BICUBIC`): 双三次插值,质量较好,比双线性慢。") # BICUBIC插值算法说明
print(" `Image.Resampling.LANCZOS` (或 `Image.LANCZOS` 或 `Image.ANTIALIAS` 老版本): Lanczos插值,通常是质量最好的下采样/上采样算法,但计算量最大。") # LANCZOS插值算法说明
print(" 推荐: 缩小用 `LANCZOS` 或 `BICUBIC`,放大用 `BICUBIC` 或 `LANCZOS`。若追求速度,可用 `BILINEAR`。") # 推荐插值算法
if os.path.exists(img_path_example_jpg): # 检查JPG图片是否存在
try:
with Image.open(img_path_example_jpg) as img_to_resize: # 打开JPG图片用于缩放
print(f" 原始图片 ({
img_path_example_jpg}): 尺寸={
img_to_resize.size}") # 打印原始图片信息
target_size_half = (img_to_resize.width // 2, img_to_resize.height // 2) # 定义目标尺寸为原图的一半
# 使用LANCZOS缩小
img_resized_lanczos = img_to_resize.resize(target_size_half, resample=Image.Resampling.LANCZOS) # 使用LANCZOS算法缩小图片
save_path_resized_lanczos = os.path.join(OUTPUT_DIR, f"resized_lanczos_{
target_size_half[0]}x{
target_size_half[1]}.jpg") # 定义LANCZOS缩小图保存路径
img_resized_lanczos.save(save_path_resized_lanczos) # 保存缩小后的图片
print(f" 缩小到 {
target_size_half} (LANCZOS): 新尺寸={
img_resized_lanczos.size}。已保存到 {
save_path_resized_lanczos}") # 打印缩小信息和保存路径
img_resized_lanczos.close() # 关闭缩小后的图片对象
# 使用NEAREST缩小 (对比效果)
img_resized_nearest = img_to_resize.resize(target_size_half, resample=Image.Resampling.NEAREST) # 使用NEAREST算法缩小图片
save_path_resized_nearest = os.path.join(OUTPUT_DIR, f"resized_nearest_{
target_size_half[0]}x{
target_size_half[1]}.jpg") # 定义NEAREST缩小图保存路径
img_resized_nearest.save(save_path_resized_nearest) # 保存缩小后的图片
print(f" 缩小到 {
target_size_half} (NEAREST): 新尺寸={
img_resized_nearest.size}。已保存到 {
save_path_resized_nearest}") # 打印缩小信息和保存路径
img_resized_nearest.close() # 关闭缩小后的图片对象
target_size_double = (img_to_resize.width * 2, img_to_resize.height * 2) # 定义目标尺寸为原图的两倍
# 使用BICUBIC放大
img_resized_bicubic_up = img_to_resize.resize(target_size_double, resample=Image.Resampling.BICUBIC) # 使用BICUBIC算法放大图片
save_path_resized_bicubic_up = os.path.join(OUTPUT_DIR, f"resized_bicubic_up_{
target_size_double[0]}x{
target_size_double[1]}.jpg") # 定义BICUBIC放大图保存路径
img_resized_bicubic_up.save(save_path_resized_bicubic_up) # 保存放大后的图片
print(f" 放大到 {
target_size_double} (BICUBIC): 新尺寸={
img_resized_bicubic_up.size}。已保存到 {
save_path_resized_bicubic_up}") # 打印放大信息和保存路径
img_resized_bicubic_up.close() # 关闭放大后的图片对象
# 保持宽高比缩放 (例如,缩放到宽度为100像素,高度按比例)
target_width = 100 # 定义目标宽度
if img_to_resize.width > 0 : # 确保原始宽度大于0以避免除零错误
aspect_ratio = img_to_resize.height / img_to_resize.width # 计算宽高比
target_height_aspect = int(target_width * aspect_ratio) # 根据目标宽度和宽高比计算目标高度
size_aspect_preserved = (target_width, target_height_aspect) # 定义保持宽高比的缩放尺寸
img_resized_aspect = img_to_resize.resize(size_aspect_preserved, resample=Image.Resampling.LANCZOS) # 使用LANCZOS算法进行保持宽高比的缩放
save_path_resized_aspect = os.path.join(OUTPUT_DIR, f"resized_aspect_w{
target_width}.jpg") # 定义保持宽高比缩放图保存路径
img_resized_aspect.save(save_path_resized_aspect) # 保存缩放后的图片
print(f" 保持宽高比缩放到宽度 {
target_width} (LANCZOS): 新尺寸={
img_resized_aspect.size}。已保存到 {
save_path_resized_aspect}") # 打印缩放信息和保存路径
img_resized_aspect.close() # 关闭缩放后的图片对象
else:
print(f" 原始图片宽度为0,跳过保持宽高比缩放示例。") # 打印跳过信息
# `reducing_gap` 参数: Pillow 3.3.0+ 引入。用于优化多次缩小操作。
# 如果缩小比例很大(例如缩小到原尺寸的1/8以下),Pillow可能会分阶段进行,
# `reducing_gap=2.0` (默认) 表示每次最多缩小一半。
# `reducing_gap=3.0` 表示每次最多缩小到1/3。`None`则禁用此优化,可能导致质量下降或伪影。
# `box` 参数 (四元组 left, upper, right, lower): 从源图像的这个区域进行采样并缩放到目标尺寸。
# 这允许在缩放的同时进行裁剪,或者从图像的特定部分创建缩略图。
# 例如,从图像中心100x100区域创建50x50的缩略图
if img_to_resize.width >=100 and img_to_resize.height >=100: # 确保图片尺寸大于等于100x100
center_x, center_y = img_to_resize.width // 2, img_to_resize.height // 2 # 计算图片中心点坐标
box_for_resize = (center_x - 50, center_y - 50, center_x + 50, center_y + 50) # 定义源图像裁剪框(中心100x100)
thumbnail_size = (50, 50) # 定义目标缩略图尺寸
img_thumb_from_box = img_to_resize.resize(thumbnail_size, resample=Image.Resampling.LANCZOS, box=box_for_resize) # 从指定box区域缩放
save_path_thumb_from_box = os.path.join(OUTPUT_DIR, "resized_from_box_thumb.jpg") # 定义缩略图保存路径
img_thumb_from_box.save(save_path_thumb_from_box) # 保存缩略图
print(f" 从Box {
box_for_resize} 缩放到 {
thumbnail_size} (LANCZOS): 新尺寸={
img_thumb_from_box.size}。已保存到 {
save_path_thumb_from_box}") # 打印缩放信息和保存路径
img_thumb_from_box.close() # 关闭缩略图对象
else:
print(f" 图片尺寸小于100x100,跳过使用box参数的缩放示例。") # 打印跳过信息
except FileNotFoundError:
print(f" 错误: 源图片 '{
img_path_example_jpg}' 未找到,无法执行缩放操作。") # 源文件未找到错误
except Exception as e:
print(f" 图像缩放时发生错误: {
e}") # 其他缩放错误
else:
print(f" 跳过图像缩放示例,因为文件 '{
img_path_example_jpg}' 不存在。") # 打印跳过信息
# image.thumbnail(size, resample=Resampling.BICUBIC, reducing_gap=2.0)
print("
`image.thumbnail(size, resample=...)` (生成缩略图,原地修改)") # 打印thumbnail方法说明
print(" 功能: 与 `resize` 类似,但它会修改原 `Image` 对象,并且总是保持宽高比。") # 打印thumbnail功能说明
print(" 它会将图像缩小到不超过 `size` (width, height),同时保持原始宽高比。") # 打印thumbnail行为说明
print(" 由于是原地修改,通常建议先复制图像: `thumb_img = original_img.copy(); thumb_img.thumbnail(...)`") # 打印使用建议
if os.path.exists(img_path_example_png): # 检查PNG图片是否存在
try:
with Image.open(img_path_example_png) as img_for_thumb: # 打开PNG图片用于生成缩略图
print(f" 原始图片 ({
img_path_example_png}): 尺寸={
img_for_thumb.size}") # 打印原始图片信息
img_thumb_copy = img_for_thumb.copy() # 复制图像对象,避免修改原始对象
thumbnail_max_size = (100, 80) # 定义缩略图的最大尺寸
img_thumb_copy.thumbnail(thumbnail_max_size, resample=Image.Resampling.LANCZOS) # 生成缩略图(原地修改副本)
save_path_thumbnail = os.path.join(OUTPUT_DIR, f"thumbnail_max_{
thumbnail_max_size[0]}x{
thumbnail_max_size[1]}.png") # 定义缩略图保存路径
img_thumb_copy.save(save_path_thumbnail) # 保存缩略图
print(f" 生成缩略图 (最大尺寸 {
thumbnail_max_size}, LANCZOS): 新尺寸={
img_thumb_copy.size}。已保存到 {
save_path_thumbnail}") # 打印缩略图信息和保存路径
img_thumb_copy.close() # 关闭缩略图副本对象
except FileNotFoundError:
print(f" 错误: 源图片 '{
img_path_example_png}' 未找到,无法执行thumbnail操作。") # 源文件未找到错误
except Exception as e:
print(f" 生成缩略图时发生错误: {
e}") # 其他缩略图错误
else:
print(f" 跳过图像thumbnail示例,因为文件 '{
img_path_example_png}' 不存在。") # 打印跳过信息
# 2.2.7 图像旋转与翻转
print("
2.2.7 图像旋转与翻转") # 打印小节标题
# `image.rotate(angle, resample=Image.Resampling.NEAREST, expand=0, center=None, translate=None, fillcolor=None)`
print(" `image.rotate(angle, resample=..., expand=..., center=..., fillcolor=...)`") # 打印rotate方法说明
print(" `angle`: 逆时针旋转的角度 (单位: 度)。") # angle参数说明
print(" `resample`: 插值算法,同 `resize`。对于旋转,`BICUBIC` 或 `BILINEAR` 通常较好。") # resample参数说明
print(" `expand` (bool): 如果为True,输出图像会足够大以包含整个旋转后的图像。如果为False (默认),输出图像与输入图像大小相同,旋转后超出边界的部分会被切掉。") # expand参数说明
print(" `center` (2-tuple): 旋转中心点。默认为图像中心。") # center参数说明
print(" `translate` (2-tuple): 旋转后的平移量 (dx, dy)。") # translate参数说明
print(" `fillcolor`: 旋转后图像外部区域的填充颜色。对于 'RGB' 模式,可以是 '(R,G,B)' 元组;对于 'RGBA',可以是 '(R,G,B,A)';对于 'L',可以是整数。") # fillcolor参数说明
# `image.transpose(method)`
print("
`image.transpose(method)`") # 打印transpose方法说明
print(" `method` 可以是:") # method参数说明
print(" `Image.Transpose.FLIP_LEFT_RIGHT` (或 `Image.FLIP_LEFT_RIGHT`): 水平翻转。") # FLIP_LEFT_RIGHT说明
print(" `Image.Transpose.FLIP_TOP_BOTTOM` (或 `Image.FLIP_TOP_BOTTOM`): 垂直翻转。") # FLIP_TOP_BOTTOM说明
print(" `Image.Transpose.ROTATE_90` (或 `Image.ROTATE_90`): 逆时针旋转90度。") # ROTATE_90说明
print(" `Image.Transpose.ROTATE_180` (或 `Image.ROTATE_180`): 旋转180度。") # ROTATE_180说明
print(" `Image.Transpose.ROTATE_270` (或 `Image.ROTATE_270`): 逆时针旋转270度 (即顺时针90度)。") # ROTATE_270说明
print(" `Image.Transpose.TRANSPOSE` (或 `Image.TRANSPOSE`): 转置 (行变列,列变行),相当于沿左上到右下对角线翻转。") # TRANSPOSE说明
print(" `Image.Transpose.TRANSVERSE` (或 `Image.TRANSVERSE`): 横转,相当于沿右上到左下对角线翻转,也等同于先转置再旋转180度。") # TRANSVERSE说明
if os.path.exists(img_path_example_png): # 检查PNG图片是否存在
try:
with Image.open(img_path_example_png) as img_to_transform: # 打开PNG图片用于旋转和翻转
print(f" 原始图片 ({
img_path_example_png}): 尺寸={
img_to_transform.size}, 模式={
img_to_transform.mode}") # 打印原始图片信息
# 旋转45度,不扩展边界,超出部分裁剪
img_rotated_45_crop = img_to_transform.rotate(45, resample=Image.Resampling.BICUBIC, fillcolor=(200,200,200,0) if img_to_transform.mode == 'RGBA' else (200,200,200) ) # 旋转45度,使用BICUBIC插值,填充浅灰色(如果需要)
save_path_rotated_45_crop = os.path.join(OUTPUT_DIR, "rotated_45_crop.png") # 定义旋转45度(裁剪)保存路径
img_rotated_45_crop.save(save_path_rotated_45_crop) # 保存图片
print(f" 旋转45度 (不扩展, BICUBIC): 新尺寸={
img_rotated_45_crop.size}。已保存到 {
save_path_rotated_45_crop}") # 打印旋转信息和保存路径
img_rotated_45_crop.close() # 关闭图片对象
# 旋转30度,扩展边界以包含整个图像
img_rotated_30_expand = img_to_transform.rotate(30, resample=Image.Resampling.BICUBIC, expand=True, fillcolor='skyblue') # 旋转30度,扩展边界,使用BICUBIC插值,填充天蓝色
save_path_rotated_30_expand = os.path.join(OUTPUT_DIR, "rotated_30_expand.png") # 定义旋转30度(扩展)保存路径
img_rotated_30_expand.save(save_path_rotated_30_expand) # 保存图片
print(f" 旋转30度 (扩展, BICUBIC, 填充skyblue): 新尺寸={
img_rotated_30_expand.size}。已保存到 {
save_path_rotated_30_expand}") # 打印旋转信息和保存路径
img_rotated_30_expand.close() # 关闭图片对象
# 水平翻转
img_flipped_lr = img_to_transform.transpose(Image.Transpose.FLIP_LEFT_RIGHT) # 水平翻转图片
save_path_flipped_lr = os.path.join(OUTPUT_DIR, "flipped_left_right.png") # 定义水平翻转图保存路径
img_flipped_lr.save(save_path_flipped_lr) # 保存图片
print(f" 水平翻转 (FLIP_LEFT_RIGHT): 新尺寸={
img_flipped_lr.size}。已保存到 {
save_path_flipped_lr}") # 打印翻转信息和保存路径
img_flipped_lr.close() # 关闭图片对象
# 逆时针旋转90度
img_rotated_90 = img_to_transform.transpose(Image.Transpose.ROTATE_90) # 逆时针旋转90度
save_path_rotated_90 = os.path.join(OUTPUT_DIR, "rotated_90_transpose.png") # 定义旋转90度图保存路径
img_rotated_90.save(save_path_rotated_90) # 保存图片
print(f" 逆时针旋转90度 (ROTATE_90): 新尺寸={
img_rotated_90.size}。已保存到 {
save_path_rotated_90}") # 打印旋转信息和保存路径
img_rotated_90.close() # 关闭图片对象
except FileNotFoundError:
print(f" 错误: 源图片 '{
img_path_example_png}' 未找到,无法执行旋转/翻转操作。") # 源文件未找到错误
except Exception as e:
print(f" 图像旋转/翻转时发生错误: {
e}") # 其他旋转/翻转错误
else:
print(f" 跳过图像旋转/翻转示例,因为文件 '{
img_path_example_png}' 不存在。") # 打印跳过信息
# 2.2.8 图像像素操作
print("
2.2.8 图像像素操作") # 打印小节标题
print(" `image.getpixel(xy)`: 获取指定坐标 `xy` (一个2元组) 的像素值。") # getpixel说明
print(" `image.putpixel(xy, value)`: 设置指定坐标 `xy` 的像素值。") # putpixel说明
print(" `image.load()`: 返回一个像素访问对象 (PixelAccess),提供更高效的像素读写。") # load说明
print(" 对于 `PixelAccess` 对象 `px = image.load()`,可以用 `px[x, y]` 读取,`px[x, y] = value` 写入。") # PixelAccess使用说明
print(" 注意: 频繁调用 `getpixel/putpixel` 效率较低,处理大量像素建议用 `load()` 或将其转为NumPy数组。") # 效率提示
if os.path.exists(img_path_example_png): # 检查PNG图片是否存在
try:
with Image.open(img_path_example_png) as img_pixel_ops: # 打开PNG图片用于像素操作
width_px, height_px = img_pixel_ops.size # 获取图片宽度和高度
print(f" 原始图片 ({
img_path_example_png}): 尺寸={
width_px}x{
height_px}, 模式={
img_pixel_ops.mode}") # 打印原始图片信息
# 使用 getpixel 获取左上角像素
if width_px > 0 and height_px > 0: # 确保图片至少有1x1像素
top_left_pixel_value = img_pixel_ops.getpixel((0, 0)) # 获取(0,0)坐标的像素值
print(f" `getpixel((0,0))`: {
top_left_pixel_value}") # 打印获取到的像素值
else:
print(f" 图片尺寸为0,无法获取像素。") # 打印尺寸为0信息
# 使用 putpixel 修改一个像素 (先复制图像以免修改原图)
img_copy_for_putpixel = img_pixel_ops.copy() # 复制图像对象
if width_px > 10 and height_px > 10: # 确保图片尺寸大于10x10
# 根据模式确定要设置的值
if img_copy_for_putpixel.mode == 'L': # 如果是L模式
new_pixel_val = 0 # 设置为黑色
elif img_copy_for_putpixel.mode == 'RGB': # 如果是RGB模式
new_pixel_val = (255, 0, 0) # 设置为红色
elif img_copy_for_putpixel.mode == 'RGBA': # 如果是RGBA模式
new_pixel_val = (0, 255, 0, 255) # 设置为不透明绿色
else: # 其他模式
new_pixel_val = None # 未知模式,不修改
if new_pixel_val is not None: # 如果成功确定了新像素值
xy_to_change = (5, 5) # 定义要修改的坐标
original_val_at_xy = img_copy_for_putpixel.getpixel(xy_to_change) # 获取原像素值
img_copy_for_putpixel.putpixel(xy_to_change, new_pixel_val) # 修改像素值
changed_val_at_xy = img_copy_for_putpixel.getpixel(xy_to_change) # 获取修改后的像素值
save_path_putpixel = os.path.join(OUTPUT_DIR, "putpixel_modified.png") # 定义修改后图片保存路径
img_copy_for_putpixel.save(save_path_putpixel) # 保存图片
print(f" `putpixel({
xy_to_change}, {
new_pixel_val})`: 原值={
original_val_at_xy}, 新值={
changed_val_at_xy}。已保存到 {
save_path_putpixel}") # 打印修改信息和保存路径
else:
print(f" 未知图像模式 {
img_copy_for_putpixel.mode},跳过putpixel示例。") # 打印未知模式信息
else:
print(f" 图片尺寸小于10x10,跳过putpixel示例。") # 打印尺寸过小信息
img_copy_for_putpixel.close() # 关闭副本图片对象
# 使用 load() 高效访问像素 (示例: 将图像左半边变暗)
img_copy_for_load = img_pixel_ops.copy() # 复制图像对象
pixels = img_copy_for_load.load() # 获取PixelAccess对象
if width_px > 0 and height_px > 0: # 确保图片有像素
for x in range(width_px // 2): # 遍历图像左半边的x坐标
for y in range(height_px): # 遍历图像的y坐标
current_pixel = pixels[x, y] # 获取当前像素值
if img_copy_for_load.mode == 'L': # 如果是L模式
pixels[x, y] = max(0, current_pixel - 50) # 将像素值减50(变暗),但不小于0
elif img_copy_for_load.mode == 'RGB': # 如果是RGB模式
pixels[x, y] = tuple(max(0, c - 50) for c in current_pixel) # 对每个通道减50
elif img_copy_for_load.mode == 'RGBA': # 如果是RGBA模式
r, g, b, a = current_pixel # 解包RGBA值
pixels[x, y] = (max(0, r - 50), max(0, g - 50), max(0, b - 50), a) # RGB通道减50,Alpha不变
save_path_load_modified = os.path.join(OUTPUT_DIR, "load_modified_darker_left.png") # 定义修改后图片保存路径
img_copy_for_load.save(save_path_load_modified) # 保存图片
print(f" 使用 `load()` 将左半边变暗。已保存到 {
save_path_load_modified}") # 打印修改信息和保存路径
else:
print(f" 图片尺寸为0,跳过load()像素操作。") # 打印尺寸为0信息
img_copy_for_load.close() # 关闭副本图片对象
# 将Pillow图像转换为NumPy数组进行像素操作 (非常常用)
try:
img_np_array = numpy.array(img_pixel_ops) # 将Pillow Image对象转换为NumPy数组
print(f" 转换为NumPy数组: shape={
img_np_array.shape}, dtype={
img_np_array.dtype}") # 打印NumPy数组信息
# img_np_array 的形状通常是 (height, width) 或 (height, width, channels)
# 现在可以用NumPy的强大功能处理像素数据
# 例如,将所有像素值增加10 (注意溢出和数据类型)
# modified_np_array = numpy.clip(img_np_array.astype(numpy.int16) + 10, 0, 255).astype(numpy.uint8)
# 将NumPy数组转回Pillow图像
# img_from_np = Image.fromarray(modified_np_array, mode=img_pixel_ops.mode) # mode参数有时需要显式指定
# save_path_from_np = os.path.join(OUTPUT_DIR, "from_numpy_array.png")
# img_from_np.save(save_path_from_np)
# print(f" NumPy数组处理后转回Pillow图像并保存到 {save_path_from_np}")
# img_from_np.close()
except Exception as e_numpy:
print(f" 与NumPy转换或操作时发生错误: {
e_numpy}") # 打印NumPy操作错误
except FileNotFoundError:
print(f" 错误: 源图片 '{
img_path_example_png}' 未找到,无法执行像素操作。") # 源文件未找到错误
except Exception as e:
print(f" 图像像素操作时发生错误: {
e}") # 其他像素操作错误
else:
print(f" 跳过图像像素操作示例,因为文件 '{
img_path_example_png}' 不存在。") # 打印跳过信息
# 2.2.9 图像通道操作
print("
2.2.9 图像通道操作") # 打印小节标题
print(" `image.split()`: 将多通道图像 (如RGB, RGBA) 分离成单个通道的图像 (模式'L')。") # split方法说明
print(" `Image.merge(mode, bands)`: 将一组单通道图像合并成一个新的多通道图像。") # merge方法说明
if os.path.exists(img_path_example_jpg) and os.path.exists(img_path_example_png): # 检查JPG和PNG图片是否存在
try:
with Image.open(img_path_example_jpg) as img_rgb_channels,
Image.open(img_path_example_png) as img_rgba_channels_orig: # 打开JPG和PNG图片
print(f" 用于通道操作的RGB图片 ({
img_path_example_jpg}): 模式={
img_rgb_channels.mode}") # 打印RGB图片信息
print(f" 用于通道操作的RGBA图片 ({
img_path_example_png}): 模式={
img_rgba_channels_orig.mode}") # 打印RGBA图片信息
# 确保img_rgba_channels是RGBA模式
img_rgba_channels = img_rgba_channels_orig # 初始化RGBA图片
if img_rgba_channels_orig.mode != 'RGBA': # 如果不是RGBA模式
img_rgba_channels = img_rgba_channels_orig.convert('RGBA') # 转换为RGBA模式
print(f" '{
img_path_example_png}' 已从模式 {
img_rgba_channels_orig.mode} 转换为 RGBA") # 打印转换信息
# 分离RGB图像的通道
if img_rgb_channels.mode == 'RGB': # 如果是RGB模式
r_band, g_band, b_band = img_rgb_channels.split() # 分离RGB通道
save_path_r_band = os.path.join(OUTPUT_DIR, "channel_R.png") # 定义R通道保存路径
r_band.save(save_path_r_band) # 保存R通道图片
print(f" RGB图分离 - R通道 (模式'{
r_band.mode}') 已保存到 {
save_path_r_band}") # 打印R通道信息和保存路径
save_path_g_band = os.path.join(OUTPUT_DIR, "channel_G.png") # 定义G通道保存路径
g_band.save(save_path_g_band) # 保存G通道图片
print(f" RGB图分离 - G通道 (模式'{
g_band.mode}') 已保存到 {
save_path_g_band}") # 打印G通道信息和保存路径
save_path_b_band = os.path.join(OUTPUT_DIR, "channel_B.png") # 定义B通道保存路径
b_band.save(save_path_b_band) # 保存B通道图片
print(f" RGB图分离 - B通道 (模式'{
b_band.mode}') 已保存到 {
save_path_b_band}") # 打印B通道信息和保存路径
# 合并通道 (例如,交换R和B通道)
img_merged_bgr = Image.merge("RGB", (b_band, g_band, r_band)) # 合并B,G,R通道为新的RGB图像
save_path_merged_bgr = os.path.join(OUTPUT_DIR, "merged_BGR.jpg") # 定义BGR合并图保存路径
img_merged_bgr.save(save_path_merged_bgr) # 保存图片
print(f" RGB通道合并 (B,G,R -> RGB): 已保存到 {
save_path_merged_bgr}") # 打印合并信息和保存路径
img_merged_bgr.close() # 关闭合并后的图片对象
r_band.close(); g_band.close(); b_band.close() # 关闭分离的通道图片对象
else:
print(f" '{
img_path_example_jpg}' 不是RGB模式,跳过RGB通道分离/合并。") # 打印跳过信息
# 分离RGBA图像的通道
if img_rgba_channels.mode == 'RGBA': # 如果是RGBA模式
r_alpha, g_alpha, b_alpha, alpha_band = img_rgba_channels.split() # 分离RGBA通道
save_path_alpha_band = os.path.join(OUTPUT_DIR, "channel_Alpha.png") # 定义Alpha通道保存路径
alpha_band.save(save_path_alpha_band) # 保存Alpha通道图片
print(f" RGBA图分离 - Alpha通道 (模式'{
alpha_band.mode}') 已保存到 {
save_path_alpha_band}") # 打印Alpha通道信息和保存路径
# 创建一个仅保留红色通道,其他颜色通道为0的RGBA图像
zeros_band = Image.new('L', img_rgba_channels.size, 0) # 创建一个与原图同尺寸的全黑L模式图片
img_red_only_rgba = Image.merge("RGBA", (r_alpha, zeros_band, zeros_band, alpha_band)) # 合并R, 0, 0, Alpha通道为新的RGBA图像
save_path_red_only_rgba = os.path.join(OUTPUT_DIR, "merged_RedOnly_RGBA.png") # 定义仅红色通道RGBA图保存路径
img_red_only_rgba.save(save_path_red_only_rgba) # 保存图片
print(f" 创建仅红色通道的RGBA图像: 已保存到 {
save_path_red_only_rgba}") # 打印创建信息和保存路径
img_red_only_rgba.close() # 关闭图片对象
r_alpha.close(); g_alpha.close(); b_alpha.close(); alpha_band.close(); zeros_band.close() # 关闭分离的通道和零值通道图片对象
else:
print(f" '{
img_path_example_png}' (或转换后) 不是RGBA模式,跳过RGBA通道操作。") # 打印跳过信息
if img_rgba_channels_orig.mode != 'RGBA' and img_rgba_channels is not None: # 如果对原始PNG进行了模式转换
img_rgba_channels.close() # 关闭转换后的RGBA对象
except FileNotFoundError:
print(f" 错误: 源图片 ('{
img_path_example_jpg}' 或 '{
img_path_example_png}') 未找到,无法执行通道操作。") # 源文件未找到错误
except Exception as e:
print(f" 图像通道操作时发生错误: {
e}") # 其他通道操作错误
else:
print(f" 跳过图像通道操作示例,因为 '{
img_path_example_jpg}' 或 '{
img_path_example_png}' 不存在。") # 打印跳过信息
# 2.2.10 图像增强 (ImageEnhance module)
print("
2.2.10 图像增强 (`ImageEnhance` 模块)") # 打印小节标题
print(" 模块包含: `Color`, `Brightness`, `Contrast`, `Sharpness` 类。") # 模块包含类说明
print(" 每个类的构造函数接收一个Image对象,返回一个增强器对象。") # 构造函数说明
print(" 增强器对象有 `enhance(factor)` 方法: factor=1.0为原图,<1.0减弱,>1.0增强。0通常是特殊值。") # enhance方法说明
if os.path.exists(img_path_example_jpg): # 检查JPG图片是否存在
try:
with Image.open(img_path_example_jpg) as img_to_enhance: # 打开JPG图片用于增强
print(f" 原始图片 ({
img_path_example_jpg}): 尺寸={
img_to_enhance.size}") # 打印原始图片信息
# 调整亮度
enhancer_brightness = ImageEnhance.Brightness(img_to_enhance) # 创建亮度增强器对象
img_brighter = enhancer_brightness.enhance(1.5) # 亮度增加50%
img_darker = enhancer_brightness.enhance(0.5) # 亮度减少50%
save_path_brighter = os.path.join(OUTPUT_DIR, "enhanced_brighter.jpg") # 定义增亮图保存路径
save_path_darker = os.path.join(OUTPUT_DIR, "enhanced_darker.jpg") # 定义减亮图保存路径
img_brighter.save(save_path_brighter) # 保存增亮图
img_darker.save(save_path_darker) # 保存减亮图
print(f" 亮度增强 (factor 1.5): 已保存到 {
save_path_brighter}") # 打印增亮信息和保存路径
print(f" 亮度减弱 (factor 0.5): 已保存到 {
save_path_darker}") # 打印减亮信息和保存路径
img_brighter.close(); img_darker.close() # 关闭图片对象
# 调整色彩饱和度
enhancer_color = ImageEnhance.Color(img_to_enhance) # 创建色彩增强器对象
img_more_color = enhancer_color.enhance(2.0) # 色彩饱和度加倍
img_less_color_bw = enhancer_color.enhance(0.0) # 色彩饱和度为0 (变为灰度图)
save_path_more_color = os.path.join(OUTPUT_DIR, "enhanced_more_color.jpg") # 定义增色图保存路径
save_path_less_color = os.path.join(OUTPUT_DIR, "enhanced_less_color_bw.jpg") # 定义减色图保存路径
img_more_color.save(save_path_more_color) # 保存增色图
img_less_color_bw.save(save_path_less_color) # 保存减色图
print(f" 色彩增强 (factor 2.0): 已保存到 {
save_path_more_color}") # 打印增色信息和保存路径
print(f" 色彩减弱至灰度 (factor 0.0): 已保存到 {
save_path_less_color}") # 打印减色信息和保存路径
img_more_color.close(); img_less_color_bw.close() # 关闭图片对象
# 调整对比度
enhancer_contrast = ImageEnhance.Contrast(img_to_enhance) # 创建对比度增强器对象
img_more_contrast = enhancer_contrast.enhance(1.8) # 对比度增加80%
img_less_contrast = enhancer_contrast.enhance(0.6) # 对比度减少40%
save_path_more_contrast = os.path.join(OUTPUT_DIR, "enhanced_more_contrast.jpg") # 定义增对比度图保存路径
save_path_less_contrast = os.path.join(OUTPUT_DIR, "enhanced_less_contrast.jpg") # 定义减对比度图保存路径
img_more_contrast.save(save_path_more_contrast) # 保存增对比度图
img_less_contrast.save(save_path_less_contrast) # 保存减对比度图
print(f" 对比度增强 (factor 1.8): 已保存到 {
save_path_more_contrast}") # 打印增对比度信息和保存路径
print(f" 对比度减弱 (factor 0.6): 已保存到 {
save_path_less_contrast}") # 打印减对比度信息和保存路径
img_more_contrast.close(); img_less_contrast.close() # 关闭图片对象
# 调整锐度
enhancer_sharpness = ImageEnhance.Sharpness(img_to_enhance) # 创建锐度增强器对象
img_sharper = enhancer_sharpness.enhance(2.5) # 锐度增加150% (factor=0是模糊, 1是原图, 2是某种锐化效果)
# factor 很大的时候可能效果过于夸张
save_path_sharper = os.path.join(OUTPUT_DIR, "enhanced_sharper.jpg") # 定义锐化图保存路径
img_sharper.save(save_path_sharper) # 保存锐化图
print(f" 锐度增强 (factor 2.5): 已保存到 {
save_path_sharper}") # 打印锐化信息和保存路径
img_sharper.close() # 关闭图片对象
except FileNotFoundError:
print(f" 错误: 源图片 '{
img_path_example_jpg}' 未找到,无法执行增强操作。") # 源文件未找到错误
except Exception as e:
print(f" 图像增强时发生错误: {
e}") # 其他增强错误
else:
print(f" 跳过图像增强示例,因为文件 '{
img_path_example_jpg}' 不存在。") # 打印跳过信息
print("
--- Pillow 图像处理基础操作演示完毕 ---") # 打印结束信息
# 后续章节将基于这些基础操作来实现具体的图像相似度计算算法。
# 例如,计算MSE时需要load()或getpixel(),计算直方图需要histogram(),
# 感知哈希算法需要resize(), convert('L'), getpixel(), load()等。
# SSIM等更复杂的算法可能需要将Pillow图像转为NumPy数组,并结合scikit-image等库。
请注意:
以上代码假定您在脚本同级目录下创建了 images 文件夹,并且该文件夹下有 example.png 和 example.jpg 文件。如果文件不存在,代码中包含的示例图片创建逻辑会尝试创建它们。
所有处理后的图片会保存到脚本同级目录下自动创建的 output_images 文件夹中。
image.show() 的调用在示例中被注释掉了,因为它依赖GUI环境,可能会中断脚本执行。您可以取消注释以在本地查看效果。
部分高级功能(如特定格式支持、某些字体)可能依赖系统库,请确保Pillow已正确安装并包含所需依赖。
第三章:基于像素的图像相似度计算方法
基于像素的图像相似度计算方法是最直接的一类方法。它们直接比较两幅图像对应位置像素值的差异,而不去提取更抽象的特征。这类方法通常计算简单、速度较快,但在应对图像的几何变换、光照变化等方面表现不佳。然而,在某些特定场景下,或者作为更复杂算法的组成部分,它们依然有其应用价值。
3.1 均方误差 (Mean Squared Error, MSE)
均方误差(MSE)是一种常用的衡量两幅图像之间差异的指标。它的基本思想是计算两幅图像对应像素值之差的平方的平均值。MSE值越小,表示两幅图像越相似。
3.1.1 原理与数学公式
假设我们有两幅图像,(I_1) 和 (I_2),它们的尺寸均为 (M imes N)(即宽度为 (N) 像素,高度为 (M) 像素)。对于彩色图像,通常会先将其转换为灰度图像,或者分别计算每个颜色通道的MSE再取平均。这里我们先以灰度图像为例。
令 (I_1(i, j)) 和 (I_2(i, j)) 分别表示图像 (I_1) 和 (I_2) 在坐标 ((i, j)) 处的像素值(其中 (0 le i < M, 0 le j < N))。
两幅图像之间的均方误差定义为:
[ ext{MSE}(I_1, I_2) = frac{1}{M imes N} sum_{i=0}^{M-1} sum_{j=0}^{N-1} [I_1(i, j) – I_2(i, j)]^2 ]
公式解读:
([I_1(i, j) – I_2(i, j)]):计算在同一位置 ((i, j)) 上,两幅图像像素值的差。
([I_1(i, j) – I_2(i, j)]^2):将这个差值平方。平方操作有两个目的:
确保差异值为正,避免正负差异相互抵消。
对较大的差异值给予更大的权重(惩罚)。
(sum_{i=0}^{M-1} sum_{j=0}^{N-1} dots):将所有像素位置上的平方差累加起来,得到总的平方差和 (Sum of Squared Differences, SSD)。
(frac{1}{M imes N} dots):将总的平方差和除以像素总数((M imes N)),得到平均值,即均方误差。
MSE的特性:
取值范围:MSE (ge 0)。
完美匹配:如果 (I_1) 和 (I_2) 完全相同,则所有位置的像素差均为0,此时 MSE = 0。
差异度量:MSE值越大,表示两幅图像的差异越大,相似度越低。
单位:MSE的单位是像素值单位的平方。例如,如果像素值范围是0-255,MSE的单位就是 (像素强度单位)(^2)。
对于彩色图像:
计算彩色图像的MSE通常有几种方式:
转换为灰度图后计算MSE:这是最常见的方式,因为它将颜色信息降维,简化了比较。Pillow的convert('L')可以实现灰度转换。
[ L = 0.299 imes R + 0.587 imes G + 0.114 imes B ]
分别计算各颜色通道的MSE,然后取平均:
假设是RGB图像,可以分别计算R通道的MSE (( ext{MSE}_R)),G通道的MSE (( ext{MSE}_G)),B通道的MSE (( ext{MSE}B))。
[ ext{MSE}{RGB}(I_1, I_2) = frac{ ext{MSE}_R(I_1, I_2) + ext{MSE}_G(I_1, I_2) + ext{MSE}_B(I_1, I_2)}{3} ]
这种方法保留了颜色信息,但计算量更大。
将彩色图像视为多维向量,计算向量差的模长的平方和的平均:
对于RGB图像,每个像素 ((i, j)) 可以看作一个三维向量 ((R_{ij}, G_{ij}, B_{ij}))。
[ ext{MSE}{color}(I_1, I_2) = frac{1}{M imes N} sum{i=0}^{M-1} sum_{j=0}^{N-1} left[ (R_{1,ij} – R_{2,ij})^2 + (G_{1,ij} – G_{2,ij})^2 + (B_{1,ij} – B_{2,ij})^2
ight] ]
这实际上等同于将每个通道的SSD加起来再除以总像素数,如果再除以通道数3,则和第二种方法的思路接近(只是平均方式略有不同,这里是先加总所有通道的平方差再平均,方法二是先平均每个通道的MSE再平均)。
在实际应用中,为了简化和统一比较基准,通常优先采用第一种方法(转换为灰度图)。
3.1.2 Pillow 与 NumPy 实现步骤
要使用Pillow和NumPy计算两幅图像的MSE,通常遵循以下步骤:
加载图像:使用Pillow的 Image.open() 方法加载两张待比较的图像。
预处理图像:
统一尺寸:确保两幅图像具有完全相同的尺寸(宽度和高度)。如果尺寸不同,MSE无法直接计算。可以使用Pillow的 image.resize() 方法将其中一张或两张图像调整到相同尺寸。选择合适的插值算法(如 Image.Resampling.LANCZOS 或 Image.Resampling.BICUBIC)以保证缩放质量。
统一模式:将两幅图像转换为相同的色彩模式,通常是灰度模式 ('L'),以简化计算并已关注亮度差异。使用Pillow的 image.convert('L') 方法。如果选择计算彩色MSE,则确保它们都是例如 'RGB' 模式。
转换为NumPy数组:将Pillow的Image对象转换为NumPy数组,以便进行高效的像素级数值运算。可以使用 numpy.array(image)。转换后,灰度图像的NumPy数组形状通常是 (height, width),RGB图像的形状是 (height, width, 3)。
计算像素差的平方:利用NumPy的数组运算能力,直接计算两个数组对应元素的差,然后平方。例如,diff = image_array1.astype(numpy.float64) - image_array2.astype(numpy.float64),然后 squared_diff = numpy.square(diff)。这里将数组类型转换为浮点型 (numpy.float64 或 numpy.float32) 是一个好习惯,可以避免整数运算可能导致的溢出问题,尤其是在计算差值时可能出现负数。
计算均值:使用NumPy的 numpy.mean() 方法计算平方差数组的平均值,即得到MSE。
对于灰度图,直接对 squared_diff 求均值。
对于彩色图(例如,形状为 (height, width, 3) 的 squared_diff 数组),可以 squared_diff.mean() 直接得到所有通道所有像素的平方差的均值,这对应于上面提到的第三种彩色MSE计算方式(但未除以通道数)。如果想实现第二种方式(各通道MSE的平均),则需要对每个通道分别计算MSE再平均,或者 numpy.mean(squared_diff, axis=(0,1)) 得到各通道的MSE,然后再对这些MSE求平均。最简单的方式是直接对所有元素的平方差求均值。
关键考量点:
图像对齐:MSE对像素位置非常敏感。如果图像内容相同但有轻微的平移、旋转或缩放,MSE值可能会很大。因此,MSE适用于比较那些预期空间对齐的图像,例如视频压缩中原始帧与重建帧的比较。
数据类型:在计算差值和平方时,要注意数据类型的范围,防止溢出。将图像数据转换为浮点数进行计算是安全的做法。Pillow读取的8位图像像素值范围是0-255。差值范围是-255到255,平方后是0到 (255^2 = 65025)。如果使用uint8直接相减,负数会因取模而变成大的正数,导致错误。
3.1.3 代码示例与逐行解释
下面是一个使用Pillow和NumPy计算两幅图像(灰度MSE)的完整代码示例。
from PIL import Image # 从Pillow库导入Image模块,用于图像的打开、操作等
import numpy # 导入numpy库,用于高效的数值数组运算,别名为np通常更常见,但这里保持全名
import os # 导入os模块,用于处理文件路径和目录
# --- 准备工作:确保示例图片和输出目录存在 ---
# (这部分代码与上一章类似,用于确保脚本可以独立运行并找到/创建所需文件)
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) if '__file__' in locals() else os.getcwd() # 获取当前脚本或工作目录的绝对路径
IMAGE_DIR = os.path.join(BASE_DIR, "images_for_similarity") # 定义存放相似度测试图片的目录
OUTPUT_DIR = os.path.join(BASE_DIR, "output_similarity_results") # 定义存放相似度计算结果相关文件的目录
def ensure_dir(directory_path): # 定义一个函数确保目录存在
"""确保指定的目录存在,如果不存在则创建它"""
if not os.path.exists(directory_path): # 检查目录是否存在
os.makedirs(directory_path) # 如果不存在,则创建目录
print(f"已创建目录: {
directory_path}") # 打印创建目录的信息
ensure_dir(IMAGE_DIR) # 调用函数确保图片输入目录存在
ensure_dir(OUTPUT_DIR) # 调用函数确保输出结果目录存在
def create_sample_image_if_not_exists(path, size=(200,150), color="skyblue", text="Sample"): # 定义创建示例图片的函数
"""如果指定路径的图片不存在,则创建一个简单的示例图片"""
if not os.path.exists(path): # 检查图片文件是否存在
try:
img_sample = Image.new('RGB', size, color=color) # 创建一个指定大小和颜色的RGB图片
draw_sample = ImageDraw.Draw(img_sample) # 获取该图片的Draw对象,用于绘图
try:
font_sample = ImageFont.truetype("arial.ttf", 20) # 尝试加载arial字体,字号20
except IOError:
font_sample = ImageFont.load_default() # 如果arial加载失败,使用Pillow的默认字体
# 计算文本位置使其大致居中
text_bbox = draw_sample.textbbox((0,0), text, font=font_sample) # 获取文本的边界框
text_width = text_bbox[2] - text_bbox[0] # 计算文本宽度
text_height = text_bbox[3] - text_bbox[1] # 计算文本高度
text_x = (size[0] - text_width) // 2 # 计算文本x坐标
text_y = (size[1] - text_height) // 2 # 计算文本y坐标
draw_sample.text((text_x, text_y), text, font=font_sample, fill='black') # 在图片上绘制黑色文本
img_sample.save(path) # 保存创建的示例图片
print(f"已创建并保存示例图片: {
path}") # 打印创建成功信息
img_sample.close() # 关闭图片对象
except Exception as e:
print(f"创建示例图片 {
path} 时发生错误: {
e}") # 打印创建图片时的错误信息
# 为MSE计算准备一些示例图片
# 图片1: 基础图片
img1_path = os.path.join(IMAGE_DIR, "mse_img1_original.png") # 定义图片1的路径
create_sample_image_if_not_exists(img1_path, size=(100, 80), color=(100, 150, 200), text="Img1") # 创建图片1
# 图片2: 与图片1完全相同的图片 (预期MSE=0)
img2_path_identical = os.path.join(IMAGE_DIR, "mse_img2_identical.png") # 定义图片2(与1相同)的路径
create_sample_image_if_not_exists(img2_path_identical, size=(100, 80), color=(100, 150, 200), text="Img1") # 创建与图片1内容相同的图片
# 图片3: 与图片1略有差异的图片 (例如,亮度稍作修改)
img3_path_slight_diff = os.path.join(IMAGE_DIR, "mse_img3_slight_diff.png") # 定义图片3(略有差异)的路径
if not os.path.exists(img3_path_slight_diff) and os.path.exists(img1_path): # 如果图片3不存在且图片1存在
try:
img1_temp = Image.open(img1_path) # 打开图片1
enhancer = ImageEnhance.Brightness(img1_temp) # 创建亮度增强器
img_slight = enhancer.enhance(1.2) # 将亮度增加20%
img_slight.save(img3_path_slight_diff) # 保存修改后的图片作为图片3
print(f"已创建略微不同的图片: {
img3_path_slight_diff}") # 打印创建成功信息
img1_temp.close() # 关闭临时打开的图片1
img_slight.close() # 关闭修改后的图片
except Exception as e:
print(f"创建 mse_img3_slight_diff.png 时出错: {
e}") # 打印错误信息
elif not os.path.exists(img1_path): # 如果图片1不存在
create_sample_image_if_not_exists(img3_path_slight_diff, size=(100, 80), color=(120, 170, 220), text="Img3") # 创建一个独立的图片3
# 图片4: 与图片1尺寸不同的图片 (用于演示预处理的重要性)
img4_path_diff_size = os.path.join(IMAGE_DIR, "mse_img4_diff_size.png") # 定义图片4(尺寸不同)的路径
create_sample_image_if_not_exists(img4_path_diff_size, size=(120, 90), color=(100, 150, 200), text="Img4") # 创建尺寸不同的图片4
# 图片5: 与图片1内容完全不同的图片
img5_path_very_diff = os.path.join(IMAGE_DIR, "mse_img5_very_diff.png") # 定义图片5(完全不同)的路径
create_sample_image_if_not_exists(img5_path_very_diff, size=(100, 80), color=(255, 100, 50), text="Img5") # 创建内容完全不同的图片5
# --- 准备工作结束 ---
def calculate_mse(image_path1, image_path2, target_size=None, to_grayscale=True): # 定义计算MSE的函数
"""
计算两幅图像之间的均方误差 (MSE)。
参数:
image_path1 (str): 第一张图像的文件路径。
image_path2 (str): 第二张图像的文件路径。
target_size (tuple, optional): 一个(width, height)元组,用于将两张图像统一调整到的尺寸。
如果为None,则要求两张图像必须已具有相同尺寸。默认为None。
to_grayscale (bool, optional): 是否在计算MSE前将图像转换为灰度图。默认为True。
返回:
float: 计算得到的MSE值。如果图像无法打开或处理中发生错误,则返回None或抛出异常。
"""
try:
# 1. 加载图像
img1_pil = Image.open(image_path1) # 使用Pillow的Image.open()加载第一张图片
img2_pil = Image.open(image_path2) # 使用Pillow的Image.open()加载第二张图片
print(f" 图像1 ('{
os.path.basename(image_path1)}'): 原始尺寸={
img1_pil.size}, 模式={
img1_pil.mode}") # 打印图像1的原始信息
print(f" 图像2 ('{
os.path.basename(image_path2)}'): 原始尺寸={
img2_pil.size}, 模式={
img2_pil.mode}") # 打印图像2的原始信息
# 2. 预处理图像
# 2.1 统一尺寸
if target_size: # 如果指定了目标尺寸
# 使用高质量的插值算法LANCZOS进行缩放
img1_pil = img1_pil.resize(target_size, Image.Resampling.LANCZOS) # 将图像1调整到目标尺寸
img2_pil = img2_pil.resize(target_size, Image.Resampling.LANCZOS) # 将图像2调整到目标尺寸
print(f" 已将两图像统一调整到尺寸: {
target_size}") # 打印尺寸调整信息
elif img1_pil.size != img2_pil.size: # 如果未指定目标尺寸,但原始尺寸不同
# 这是一个错误情况,因为MSE要求像素一一对应
raise ValueError(f"图像尺寸不匹配: {
img1_pil.size} vs {
img2_pil.size},且未指定target_size进行统一。") # 抛出值错误
# 2.2 统一模式 (转换为灰度图或确保同为RGB等)
if to_grayscale: # 如果要求转换为灰度图
if img1_pil.mode != 'L': # 如果图像1不是灰度模式
img1_pil = img1_pil.convert('L') # 将图像1转换为灰度模式('L')
if img2_pil.mode != 'L': # 如果图像2不是灰度模式
img2_pil = img2_pil.convert('L') # 将图像2转换为灰度模式('L')
print(f" 已将两图像转换为灰度模式 ('L')") # 打印模式转换信息
else: # 如果不转灰度,则需要确保它们有相同的模式,例如都是'RGB'
if img1_pil.mode != img2_pil.mode: # 如果两图像模式不同
# 尝试将它们都转换为RGB模式作为一种通用处理方式
# (更复杂的场景可能需要更细致的模式匹配逻辑)
print(f" 警告: 图像模式不匹配 ({
img1_pil.mode} vs {
img2_pil.mode}) 且 to_grayscale=False。尝试转换为RGB。") # 打印警告信息
if img1_pil.mode != 'RGB': # 如果图像1不是RGB
img1_pil = img1_pil.convert('RGB') # 转换为RGB
if img2_pil.mode != 'RGB': # 如果图像2不是RGB
img2_pil = img2_pil.convert('RGB') # 转换为RGB
if img1_pil.mode != img2_pil.mode: # 再次检查,如果转换后仍不一致
raise ValueError(f"尝试转换后图像模式仍不匹配: {
img1_pil.mode} vs {
img2_pil.mode}") # 抛出值错误
print(f" 图像按原始(或统一后的)模式进行比较: {
img1_pil.mode}") # 打印当前比较模式
# 3. 转换为NumPy数组
# 使用astype(numpy.float64)确保进行浮点数运算,避免整数溢出,并能表示负数差值
img1_np = numpy.array(img1_pil, dtype=numpy.float64) # 将Pillow图像1转换为NumPy数组,数据类型为float64
img2_np = numpy.array(img2_pil, dtype=numpy.float64) # 将Pillow图像2转换为NumPy数组,数据类型为float64
# 4. 计算像素差的平方
# NumPy数组可以直接进行元素级的算术运算
squared_diff = numpy.square(img1_np - img2_np) # 计算两个NumPy数组对应元素的差,然后对差值逐元素平方
# 5. 计算均值 (MSE)
mse_value = numpy.mean(squared_diff) # 计算平方差数组中所有元素的平均值
# 关闭Pillow图像对象
img1_pil.close() # 关闭Pillow图像对象1,释放资源
img2_pil.close() # 关闭Pillow图像对象2,释放资源
return mse_value # 返回计算得到的MSE值
except FileNotFoundError: # 捕获文件未找到的异常
print(f"错误: 其中一个或两个图像文件未找到: '{
image_path1}', '{
image_path2}'") # 打印错误信息
return None # 返回None表示计算失败
except ValueError as ve: # 捕获值错误异常 (例如尺寸不匹配)
print(f"值错误: {
ve}") # 打印值错误信息
return None # 返回None
except Exception as e: # 捕获其他所有未知异常
print(f"计算MSE时发生未知错误: {
e}") # 打印未知错误信息
# 可以在这里选择是返回None还是重新抛出异常,取决于错误处理策略
# import traceback
# traceback.print_exc() # 打印详细的堆栈跟踪信息
return None # 返回None
print("
--- 3.1 均方误差 (Mean Squared Error, MSE) ---") # 打印章节标题
# 测试用例
print("
[MSE测试用例开始]") # 打印测试开始信息
# 用例1: 图片1 vs 图片2 (完全相同)
print("
用例1: 比较两张完全相同的图片 (预期MSE ≈ 0)") # 打印用例描述
mse1 = calculate_mse(img1_path, img2_path_identical) # 调用函数计算MSE
if mse1 is not None: # 如果MSE计算成功
print(f" 计算结果: MSE = {
mse1:.4f}") # 打印MSE结果,保留4位小数
# 断言MSE值非常接近0
assert abs(mse1) < 1e-9, f"相同图片的MSE ({
mse1}) 应接近0" # 使用断言检查结果是否符合预期
# 用例2: 图片1 vs 图片3 (略有差异)
print("
用例2: 比较两张略有差异的图片 (预期MSE > 0 且较小)") # 打印用例描述
mse2 = calculate_mse(img1_path, img3_path_slight_diff) # 调用函数计算MSE
if mse2 is not None: # 如果MSE计算成功
print(f" 计算结果: MSE = {
mse2:.4f}") # 打印MSE结果
assert mse2 > 0, "略有差异图片的MSE应大于0" # 断言MSE大于0
# 用例3: 图片1 vs 图片5 (内容完全不同)
print("
用例3: 比较两张内容完全不同的图片 (预期MSE较大)") # 打印用例描述
mse3 = calculate_mse(img1_path, img5_path_very_diff) # 调用函数计算MSE
if mse3 is not None: # 如果MSE计算成功
print(f" 计算结果: MSE = {
mse3:.4f}") # 打印MSE结果
if mse2 is not None: # 如果用例2的MSE也计算成功了
assert mse3 > mse2, f"完全不同图片的MSE ({
mse3}) 应大于略有差异图片的MSE ({
mse2})" # 断言不同图片的MSE大于略有差异图片的MSE
# 用例4: 图片1 vs 图片4 (尺寸不同,不指定target_size,预期报错或返回None)
print("
用例4: 比较两张尺寸不同的图片,不进行尺寸统一 (预期处理失败)") # 打印用例描述
mse4_fail = calculate_mse(img1_path, img4_path_diff_size, target_size=None) # 调用函数,不指定target_size
if mse4_fail is None: # 如果返回None,表示按预期处理失败
print(f" 处理失败,符合预期 (因为尺寸不同且未指定target_size)。") # 打印符合预期的失败信息
else:
print(f" 处理意外成功: MSE = {
mse4_fail:.4f} (这不符合预期,请检查逻辑)") # 打印意外成功的错误信息
# 用例5: 图片1 vs 图片4 (尺寸不同,指定target_size进行统一)
print("
用例5: 比较两张尺寸不同的图片,统一到相同尺寸 (例如 (100,80))") # 打印用例描述
common_size = (100, 80) # 定义一个统一的目标尺寸
mse5_resized = calculate_mse(img1_path, img4_path_diff_size, target_size=common_size) # 调用函数,指定target_size
if mse5_resized is not None: # 如果MSE计算成功
print(f" 计算结果 (统一尺寸到{
common_size}): MSE = {
mse5_resized:.4f}") # 打印MSE结果
# 用例6: 比较彩色图像 (不转换为灰度图)
print("
用例6: 比较两张彩色图片 (img1 vs img3),不转换为灰度图") # 打印用例描述
mse6_color = calculate_mse(img1_path, img3_path_slight_diff, to_grayscale=False) # 调用函数,设置to_grayscale=False
if mse6_color is not None: # 如果MSE计算成功
print(f" 计算结果 (彩色比较): MSE = {
mse6_color:.4f}") # 打印MSE结果
if mse2 is not None: # 如果用例2(灰度比较)的MSE也存在
print(f" (对比:灰度比较的MSE = {
mse2:.4f})") # 打印灰度比较的MSE作为参考
# 通常彩色MSE的值会比灰度MSE大,因为考虑了更多通道的差异,但这不是绝对的
print("
[MSE测试用例结束]") # 打印测试结束信息
代码逐行解释 (calculate_mse 函数部分):
def calculate_mse(image_path1, image_path2, target_size=None, to_grayscale=True)::
定义名为 calculate_mse 的函数。
image_path1, image_path2: 字符串参数,表示两张图片的路径。
target_size: 可选元组参数 (width, height)。如果提供,图像会被缩放到此尺寸。默认为 None。
to_grayscale: 可选布尔参数。如果为 True (默认),图像会先转为灰度图再计算MSE。
try: ... except ...: 使用 try-except 块来捕获可能发生的错误,如文件未找到、无法处理的图像格式等。
img1_pil = Image.open(image_path1): 使用Pillow的 Image.open() 方法加载第一张图片到 img1_pil变量中。
img2_pil = Image.open(image_path2): 加载第二张图片到 img2_pil。
print(...): 打印图像的原始信息,便于调试和理解过程。
if target_size:: 检查是否提供了 target_size 参数。
img1_pil = img1_pil.resize(target_size, Image.Resampling.LANCZOS): 如果提供了 target_size,则使用 resize 方法将 img1_pil 调整到指定尺寸。Image.Resampling.LANCZOS 是一种高质量的插值算法,适用于缩小和放大。
img2_pil = img2_pil.resize(target_size, Image.Resampling.LANCZOS): 同样调整 img2_pil 的尺寸。
elif img1_pil.size != img2_pil.size:: 如果没有提供 target_size,则检查两张图片的原始尺寸是否相同。
raise ValueError(...): 如果尺寸不同且未指定 target_size,则抛出一个 ValueError 异常,因为MSE无法在不同尺寸的图像间直接计算。
if to_grayscale:: 检查是否需要将图像转换为灰度图。
if img1_pil.mode != 'L': img1_pil = img1_pil.convert('L'): 如果 img1_pil 不是灰度模式 ('L'),则使用 convert('L') 方法将其转换为灰度图。
if img2_pil.mode != 'L': img2_pil = img2_pil.convert('L'): 同样处理 img2_pil。
else: ... if img1_pil.mode != img2_pil.mode: ...: 如果不转换为灰度图 (to_grayscale is False),则检查两图模式是否一致。如果不一致,代码会尝试将它们都转为 'RGB' 模式。如果转换后仍不一致,则抛出错误。这是为了确保在比较彩色图像时,它们的通道数和类型是一致的。
img1_np = numpy.array(img1_pil, dtype=numpy.float64): 将(可能经过预处理的)Pillow图像 img1_pil 转换为NumPy数组。dtype=numpy.float64 指定数组元素的数据类型为64位浮点数,这对于后续的数学运算很重要,可以避免整数溢出并允许负数差值。
img2_np = numpy.array(img2_pil, dtype=numpy.float64): 同样转换 img2_pil。
squared_diff = numpy.square(img1_np - img2_np): 这是核心计算步骤。
img1_np - img2_np: NumPy数组可以直接相减,结果是一个新的数组,其中每个元素是 img1_np 和 img2_np 对应元素的差值。
numpy.square(...): 对上一步得到的差值数组中的每个元素进行平方操作。
mse_value = numpy.mean(squared_diff): 计算 squared_diff 数组中所有元素的平均值。这就是最终的MSE值。对于多通道图像(如果未转灰度),这将是所有通道所有像素平方差的总平均。
img1_pil.close(): 关闭Pillow图像对象,释放相关资源。这是一个好习惯。
img2_pil.close(): 关闭第二个Pillow图像对象。
return mse_value: 返回计算得到的MSE。
except FileNotFoundError: ...: 捕获当图像文件路径无效时发生的 FileNotFoundError。
except ValueError as ve: ...: 捕获之前代码中可能抛出的 ValueError。
except Exception as e: ...: 捕获所有其他类型的异常,提供通用的错误处理。
3.1.4 优缺点分析
优点:
计算简单直观:MSE的定义和计算过程都非常简单,容易理解和实现。
数学特性良好:MSE是一个凸函数,这在优化问题中是有利的(尽管在这里我们主要用作度量)。它对差异的平方使其对较大的差异更敏感。
速度较快:相对于复杂的特征提取方法,MSE的计算量较小,尤其是在使用NumPy等库进行优化后,计算速度很快。
绝对度量:MSE=0 严格表示两幅图像完全相同(在所比较的像素级别上)。
广泛应用的基础:它是许多图像处理任务(如图像压缩质量评估、图像配准、模式识别中的损失函数)的基础或组成部分。例如,PSNR就是基于MSE定义的。
缺点:
对像素位置极其敏感:
平移和旋转:即使图像内容完全相同,只要有微小的平移或旋转,对应像素就会错位,导致MSE值急剧增大,错误地判断图像不相似。
缩放和形变:同样,尺度变化或非刚性形变也会导致像素无法对齐,MSE失效。
对光照变化敏感:
如果两张图像的整体亮度不同(例如,一张偏亮,一张偏暗),即使内容和结构完全一致,它们的像素值也会有系统性的差异,导致MSE值较大。
局部光照变化(如阴影)同样会影响MSE。
对噪声敏感:图像中的随机噪声会直接影响像素值,从而增加MSE,即使人眼可能认为两张带噪图像内容相似。
不符合人类视觉感知:MSE衡量的是像素值的算术平均差异,但这并不总是与人类对图像相似性的主观感知一致。
例如,两幅图像可能在人眼看来内容差异很大,但由于某些区域像素值碰巧相似,其MSE可能反而较小。
反之,一幅图像经过轻微的、人眼难以察觉的模糊处理,其MSE可能会比较大。人类视觉系统对结构信息、边缘、纹理等更为敏感,而不仅仅是像素的绝对值。
值域不固定或不直观:MSE的值域从0到无穷大(理论上,对于归一化像素值是到1,对于0-255范围则是到 (255^2))。这个值的大小本身不具有非常直观的“相似度百分比”的含义,通常需要与其他MSE值进行相对比较,或者通过PSNR等指标转换为更易理解的范围。
颜色处理的简化:当转换为灰度图计算MSE时,会丢失颜色信息。如果颜色是区分图像的关键因素,这种简化可能会导致误判。直接在RGB通道上计算MSE虽然保留颜色信息,但同样面临上述其他问题。
3.1.5 适用场景与局限性
适用场景:
图像压缩质量评估:MSE(以及衍生的PSNR)常用于衡量有损压缩算法(如JPEG)对原始图像的失真程度。在这种场景下,原始图像和压缩重建的图像是像素对齐的。
图像去噪效果评估:比较去噪后的图像与原始无噪图像(如果存在)或带噪图像,MSE可以量化噪声的去除程度或引入的失真。
视频编码:在视频编码中,运动补偿后的残差帧与原始帧之间的差异可以用MSE来衡量。
模板匹配(特定条件下):在非常受控的环境下,如果模板和目标在图像中的姿态、光照基本一致,MSE可以作为匹配程度的一个指标(尽管通常有更鲁棒的方法)。
简单差异检测:当需要检测两幅严格对齐的图像之间是否有任何微小变化时(例如,生产线上的缺陷检测,比较标准品和待检测品图像),MSE可以作为一个快速的初步筛选工具。
作为其他算法的组成部分:某些复杂的图像对齐或配准算法可能在迭代过程中使用MSE作为优化目标之一。
教学与算法验证:由于其简单性,MSE常被用作教学示例或验证更复杂相似度算法的基准。
局限性:
不适用于一般意义上的图像检索(以图搜图):由于对平移、旋转、缩放、光照等变化非常敏感,MSE几乎不能用于在大型数据库中搜索视觉内容相似但外观有变化的图像。
不适用于识别不同视角下的同一物体。
不适用于比较经过裁剪或有遮挡的图像。
在需要模仿人类视觉感知的场景下表现不佳。对于艺术作品比较、人脸识别等,MSE提供的度量往往没有意义。
无法捕捉语义相似性:两张内容完全不同但平均颜色或亮度相似的图片,其MSE可能比两张内容主题一致但光照差异大的图片要小。MSE完全不理解图像的语义内容。
总而言之,MSE是一个简单、快速的像素级差异度量方法,但在其应用时必须清醒地认识到它的严重局限性,它更适合于比较像素级别上期望高度一致的图像对。对于更广泛的图像相似性任务,需要采用基于特征的或基于深度学习的更高级方法。
3.2 峰值信噪比 (Peak Signal-to-Noise Ratio, PSNR)
峰值信噪比(PSNR)是另一种广泛用于衡量图像(尤其是有损压缩后)质量的客观标准。它与均方误差(MSE)密切相关,实际上是基于MSE计算得出的。PSNR的值越大,通常表示图像失真越小,质量越好(即与原始图像越相似)。
3.2.1 原理与数学公式
PSNR的定义依赖于两个量:
信号的最大可能功率 (MAX):对于图像数据,这通常是像素值的最大可能值。如果像素用 (B) 位表示,则 ( ext{MAX} = 2^B – 1)。例如,对于常见的8位灰度图像,像素值范围是0-255,所以 ( ext{MAX} = 255)。
均方误差 (MSE):衡量噪声(或失真)的功率。
PSNR(单位为分贝,dB)的计算公式如下:
[ ext{PSNR} = 10 cdot log_{10} left( frac{ ext{MAX}^2}{ ext{MSE}}
ight) ]
或者等价地:
[ ext{PSNR} = 20 cdot log_{10} left( frac{ ext{MAX}}{sqrt{ ext{MSE}}}
ight) ]
公式解读:
( ext{MAX}^2):代表了信号的最大峰值功率。
MSE:代表了噪声的平均功率。
(frac{ ext{MAX}^2}{ ext{MSE}}):这个比值可以看作是信号峰值功率与噪声平均功率之比。比值越大,说明信号相对于噪声越强,图像质量越好。
(log_{10}(cdot)):取以10为底的对数,将比值转换到对数尺度。这是信号处理中常用的做法,因为人类对信号强度的感知近似对数关系,并且对数值域通常更易于处理和比较。
(10 cdot dots)(或 (20 cdot dots)):乘以10(或20,取决于公式形式)是为了将单位从“贝尔”(Bel)转换为“分贝”(dB)。1 贝尔 = 10 分贝。
特殊情况:
MSE = 0:如果两幅图像完全相同,MSE为0。此时,PSNR公式中的分母为0,会导致除零错误。在这种情况下,PSNR被认为是无穷大((infty) dB),表示没有噪声,信号完美。在实际计算中,如果MSE为0,通常会直接返回一个非常大的PSNR值或者标记为“无穷大”。
对于彩色图像:
与MSE类似,PSNR的计算也可以有几种方式:
转换为灰度图后计算PSNR:先计算灰度图像的MSE,然后基于此MSE和灰度图的MAX值(如255)计算PSNR。
分别计算各颜色通道的PSNR,然后取平均:这种方法不常见,因为PSNR本身是对数单位,直接平均对数值的物理意义不如平均MSE后再计算一个总的PSNR清晰。
基于彩色图像的总体MSE计算PSNR:先计算彩色图像的MSE(例如,上面讨论的将所有通道的平方差加总后平均,或者各通道MSE的平均值),然后使用这个总MSE和MAX值(通常还是255,假设各通道最大值相同)来计算一个总的PSNR。这是比较常用的彩色图像PSNR计算方法。
PSNR的特性:
取值范围:通常,对于有损压缩图像,PSNR值在20dB到50dB之间。
PSNR > 40dB:通常认为图像质量非常好,失真很小,人眼难以察觉。
PSNR 30-40dB:图像质量较好,失真可以接受。
PSNR 20-30dB:图像质量较差,失真比较明显。
PSNR < 20dB:图像质量很差,严重失真。
与MSE的关系:PSNR与MSE成反比。MSE越小,PSNR越大。
单位:分贝 (dB)。
PSNR提供了一个相对于信号动态范围的噪声度量,这使得它比原始MSE值在某些情况下更具可比性和直观性。例如,比较两组不同压缩算法对同一图像的处理结果时,可以直接比较它们的PSNR值。
3.2.2 Pillow 与 NumPy 实现步骤
计算PSNR的步骤紧随MSE的计算:
计算MSE:按照3.1.2节描述的步骤,使用Pillow和NumPy计算两幅图像之间的MSE。在这一步中,需要确定是计算灰度MSE还是彩色MSE,并进行相应的预处理(尺寸统一、模式转换)。
确定MAX值:根据图像数据的位深度确定像素值的最大可能值 (MAX)。
对于标准的8位图像(如Pillow加载的普通PNG, JPG转灰度后),像素范围0-255,所以 MAX = 255。
如果处理的是16位图像,则 MAX = (2^{16}-1 = 65535)。
如果图像数据在计算MSE前被归一化到 [0, 1] 范围,则 MAX = 1.0。
在我们的示例中,由于Pillow通常处理8位图像,我们将使用 MAX = 255。
处理MSE为0的情况:在应用PSNR公式前,检查计算出的MSE值。如果MSE非常接近于0(例如,小于一个极小的阈值,如 1e-10,以避免浮点数精度问题),则PSNR应视为无穷大。
应用PSNR公式:如果MSE不为0,则使用公式 ( ext{PSNR} = 10 cdot log_{10} left( frac{ ext{MAX}^2}{ ext{MSE}}
ight) ) 计算PSNR。NumPy的 numpy.log10() 函数可用于计算以10为底的对数。Python的 math.log10() 也可以。
关键考量点:
MSE的准确性:PSNR的准确性完全依赖于MSE计算的准确性。MSE计算中的所有注意事项(如数据类型、图像对齐)同样适用于PSNR。
MAX值的选择:MAX值必须与计算MSE时图像数据的实际动态范围一致。如果计算MSE时图像数据是归一化的,那么MAX也应该是归一化后的最大值(通常是1.0)。如果用的是0-255范围,MAX就是255。不匹配的MAX值会导致PSNR结果错误。
3.2.3 代码示例与逐行解释
下面是计算PSNR的函数,它会复用之前定义的 calculate_mse_for_psnr(一个稍作修改以适应PSNR内部调用的版本,或者直接调用之前的 calculate_mse)。为了代码的模块化,我们假设 calculate_mse 函数已经存在并且可以正确返回MSE值。
import math # 导入math模块,用于log10等数学函数
# (Pillow, NumPy, os 模块已在前面导入)
# (相关的目录准备和示例图片创建代码也已在前面提供)
def calculate_psnr(image_path1, image_path2, max_pixel_value=255.0, target_size=None, to_grayscale=True): # 定义计算PSNR的函数
"""
计算两幅图像之间的峰值信噪比 (PSNR)。
参数:
image_path1 (str): 第一张图像的文件路径。
image_path2 (str): 第二张图像的文件路径。
max_pixel_value (float, optional): 像素值的最大可能值。对于8位图像,默认为255.0。
target_size (tuple, optional): 传递给MSE计算的target_size参数。
to_grayscale (bool, optional): 传递给MSE计算的to_grayscale参数。
返回:
float: 计算得到的PSNR值 (dB)。如果MSE为0,返回float('inf') (无穷大)。
如果计算MSE失败,则返回None。
"""
# 首先,计算两幅图像之间的MSE
# 我们将复用之前定义的 calculate_mse 函数
# 为了确保这里的调用与 calculate_mse 的签名一致,我们传递必要的参数
mse_value = calculate_mse(image_path1, image_path2, target_size=target_size, to_grayscale=to_grayscale) # 调用之前定义的calculate_mse函数获取MSE值
if mse_value is None: # 如果MSE计算失败 (calculate_mse返回None)
print(" 由于MSE计算失败,无法计算PSNR。") # 打印错误信息
return None # 直接返回None
if mse_value == 0: # 如果MSE为0,表示图像完全相同
# 在这种情况下,PSNR理论上是无穷大
print(" MSE为0,图像完全相同。PSNR为无穷大。") # 打印信息
return float('inf') # 返回Python中的浮点数无穷大表示
try:
# 应用PSNR公式: PSNR = 10 * log10(MAX^2 / MSE)
psnr_value = 10 * math.log10((max_pixel_value ** 2) / mse_value) # 使用math.log10计算PSNR
return psnr_value # 返回计算得到的PSNR值
except Exception as e: # 捕获在PSNR计算步骤中可能发生的其他异常(例如,如果mse_value为负数,虽然不太可能)
print(f" 计算PSNR值时发生错误 (MSE={
mse_value}): {
e}") # 打印错误信息
return None # 返回None表示计算失败
print("
--- 3.2 峰值信噪比 (Peak Signal-to-Noise Ratio, PSNR) ---") # 打印章节标题
# 测试用例 (复用MSE测试中创建的图片路径)
print("
[PSNR测试用例开始]") # 打印测试开始信息
# 用例1: 图片1 vs 图片2 (完全相同) -> 预期PSNR = inf
print("
用例1: 比较两张完全相同的图片 (预期PSNR = inf)") # 打印用例描述
psnr1 = calculate_psnr(img1_path, img2_path_identical) # 调用函数计算PSNR
if psnr1 is not None: # 如果PSNR计算成功
print(f" 计算结果: PSNR = {
psnr1} dB") # 打印PSNR结果
assert psnr1 == float('inf'), f"相同图片的PSNR ({
psnr1}) 应为无穷大" # 断言结果为无穷大
# 用例2: 图片1 vs 图片3 (略有差异) -> 预期PSNR较高
print("
用例2: 比较两张略有差异的图片 (预期PSNR较高)") # 打印用例描述
psnr2 = calculate_psnr(img1_path, img3_path_slight_diff) # 调用函数计算PSNR
if psnr2 is not None: # 如果PSNR计算成功
print(f" 计算结果: PSNR = {
psnr2:.2f} dB") # 打印PSNR结果,保留2位小数
# 通常,PSNR > 30dB 就认为质量不错
if psnr2 < 20: # 如果PSNR小于20
print(f" 警告: 此处PSNR ({
psnr2:.2f}dB) 较低,可能图片差异比预期大或MAX值设置不当。") # 打印警告
# 用例3: 图片1 vs 图片5 (内容完全不同) -> 预期PSNR较低
print("
用例3: 比较两张内容完全不同的图片 (预期PSNR较低)") # 打印用例描述
psnr3 = calculate_psnr(img1_path, img5_path_very_diff) # 调用函数计算PSNR
if psnr3 is not None: # 如果PSNR计算成功
print(f" 计算结果: PSNR = {
psnr3:.2f} dB") # 打印PSNR结果
if psnr2 is not None: # 如果用例2的PSNR也计算成功
assert psnr3 < psnr2, f"完全不同图片的PSNR ({
psnr3:.2f}) 应小于略有差异图片的PSNR ({
psnr2:.2f})" # 断言不同图片的PSNR小于略有差异图片的PSNR
# 用例4: 图片1 vs 图片4 (尺寸不同,不指定target_size,预期PSNR计算失败)
print("
用例4: 比较两张尺寸不同的图片,不进行尺寸统一 (预期PSNR计算失败)") # 打印用例描述
psnr4_fail = calculate_psnr(img1_path, img4_path_diff_size, target_size=None) # 调用函数,不指定target_size
if psnr4_fail is None: # 如果返回None
print(f" PSNR计算失败,符合预期 (因为MSE计算会失败)。") # 打印符合预期的失败信息
else:
print(f" PSNR计算意外成功: PSNR = {
psnr4_fail:.2f} dB (这不符合预期,请检查逻辑)") # 打印意外成功信息
# 用例5: 图片1 vs 图片4 (尺寸不同,指定target_size进行统一)
print("
用例5: 比较两张尺寸不同的图片,统一到相同尺寸 (例如 (100,80))") # 打印用例描述
common_size_psnr = (100, 80) # 定义统一的目标尺寸
psnr5_resized = calculate_psnr(img1_path, img4_path_diff_size, target_size=common_size_psnr) # 调用函数,指定target_size
if psnr5_resized is not None: # 如果PSNR计算成功
print(f" 计算结果 (统一尺寸到{
common_size_psnr}): PSNR = {
psnr5_resized:.2f} dB") # 打印PSNR结果
# 用例6: 比较彩色图像 (不转换为灰度图)
print("
用例6: 比较两张彩色图片 (img1 vs img3),不转换为灰度图") # 打印用例描述
psnr6_color = calculate_psnr(img1_path, img3_path_slight_diff, to_grayscale=False) # 调用函数,设置to_grayscale=False
if psnr6_color is not None: # 如果PSNR计算成功
print(f" 计算结果 (彩色比较): PSNR = {
psnr6_color:.2f} dB") # 打印PSNR结果
if psnr2 is not None: # 如果用例2(灰度比较)的PSNR也存在
print(f" (对比:灰度比较的PSNR = {
psnr2:.2f} dB)") # 打印灰度比较的PSNR作为参考
# 用例7: 测试MAX值的影响
print("
用例7: 比较图片1和图片3,但使用错误的MAX值 (例如 MAX=1.0 而非255.0)") # 打印用例描述
# 假设图像像素范围是0-255,但错误地使用了max_pixel_value=1.0
psnr7_wrong_max = calculate_psnr(img1_path, img3_path_slight_diff, max_pixel_value=1.0) # 调用函数,使用错误的max_pixel_value
if psnr7_wrong_max is not None: # 如果PSNR计算成功
print(f" 计算结果 (错误MAX=1.0): PSNR = {
psnr7_wrong_max:.2f} dB") # 打印PSNR结果
if psnr2 is not None: # 如果用例2(正确MAX)的PSNR也存在
print(f" (对比:正确MAX=255.0时的PSNR = {
psnr2:.2f} dB)") # 打印正确MAX时的PSNR
print(f" 注意: 错误的MAX值 ({
1.0}) 会导致PSNR值 ({
psnr7_wrong_max:.2f} dB) 与正确值 ({
psnr2:.2f} dB) 相比发生巨大偏移。") # 解释错误MAX的影响
print("
[PSNR测试用例结束]") # 打印测试结束信息
代码逐行解释 (calculate_psnr 函数部分):
def calculate_psnr(image_path1, image_path2, max_pixel_value=255.0, target_size=None, to_grayscale=True)::
定义名为 calculate_psnr 的函数。
image_path1, image_path2: 图片路径。
max_pixel_value: 像素值的最大可能值,默认为255.0 (适用于8位图像)。这个参数非常重要,必须与计算MSE时图像数据的实际动态范围匹配。
target_size, to_grayscale: 这些参数会透传给内部调用的 calculate_mse 函数,用于图像预处理。
mse_value = calculate_mse(image_path1, image_path2, target_size=target_size, to_grayscale=to_grayscale):
调用之前我们详细实现的 calculate_mse 函数来获取两幅图像之间的均方误差。这是计算PSNR的第一步。
if mse_value is None::
检查 calculate_mse 是否成功返回了MSE值。如果 mse_value 为 None,表示MSE计算过程中发生错误。
print(...): 打印错误提示。
return None: PSNR计算也失败,返回 None。
if mse_value == 0::
检查MSE是否为0。如果为0,意味着两幅图像完全相同(在比较的像素级别上)。
print(...): 打印相应信息。
return float('inf'): 根据PSNR的定义,当MSE为0时,PSNR为无穷大。Python中用 float('inf') 表示正无穷。
try: ... except Exception as e: ...:
使用 try-except 块来包裹实际的PSNR公式计算,以防出现例如 mse_value 意外为负数(虽然不太可能由 calculate_mse 返回)或其他数学计算错误。
psnr_value = 10 * math.log10((max_pixel_value ** 2) / mse_value):
这是PSNR的核心计算公式。
max_pixel_value ** 2: 计算 ( ext{MAX}^2)。
(max_pixel_value ** 2) / mse_value: 计算 (frac{ ext{MAX}^2}{ ext{MSE}})。
math.log10(...): 计算上述比值的以10为底的对数。
10 * ...: 将结果乘以10,得到以分贝(dB)为单位的PSNR值。
return psnr_value: 返回计算得到的PSNR值。
print(f" 计算PSNR值时发生错误 ..."): 如果在PSNR公式计算中发生异常,打印错误信息。
return None: 返回 None 表示PSNR计算失败。
3.2.4 优缺点分析
由于PSNR是基于MSE派生出来的,它继承了MSE的大部分优缺点,但也有其自身的特点。
优点:
广泛接受的客观指标:PSNR是图像和视频压缩领域一个非常标准和广泛使用的客观质量评价指标。许多研究论文和工业标准都会报告PSNR值。
计算简单快速:一旦MSE计算出来,PSNR的额外计算量非常小(一个除法、一个平方、一个对数、一个乘法)。
值域相对直观:PSNR值通常在特定范围内(如20-50dB),并且值越大表示质量越好(失真越小),这比MSE的原始值(0到很大数值)在某些情况下更容易解释和比较。
对信号动态范围的归一化:通过引入MAX项,PSNR考虑了信号的峰值,使得它在一定程度上对图像的整体动态范围有所体现。
缺点:
继承MSE的所有主要缺点:
对像素位置极其敏感(平移、旋转、缩放等)。
对光照变化敏感。
对噪声敏感。
仍然不完全符合人类视觉感知 (HVS):
PSNR是像素级算术差异的度量,它不能很好地捕捉图像的结构信息、纹理信息或语义内容,而这些对人类的视觉感知非常重要。
高PSNR不一定等于高视觉质量:有时,一幅图像可能PSNR值很高,但人眼看来却存在明显的、令人不悦的失真(例如,模糊或块效应)。反之,某些类型的失真(如轻微噪声)可能导致PSNR降低,但人眼可能并不觉得图像质量差。
对不同类型的失真敏感度不一致:PSNR对所有类型的失真(如模糊、块效应、振铃效应、噪声)给予同等的权重(通过MSE),而人眼对不同类型失真的容忍度和感知度是不同的。
MAX值的依赖性:PSNR的值直接依赖于所选的 max_pixel_value。如果这个值选择不当(例如,与计算MSE时的数据范围不匹配),PSNR结果将是错误的。
MSE为0时的处理:虽然定义为无穷大是数学上一致的,但在实际比较或排序中,无穷大值可能不方便处理。
3.2.5 适用场景与局限性
适用场景:
有损图像/视频压缩算法的性能比较与优化:这是PSNR最主要和最传统的应用领域。例如,比较不同JPEG压缩级别、不同视频编码器参数设置对图像/视频质量的影响。
图像重建算法评估:如图像去噪、图像超分辨率、图像修复等任务中,比较重建图像与原始(或理想)图像的相似度。
数字水印鲁棒性测试:衡量嵌入水印后图像的失真程度。
作为优化目标(特定条件下):在一些图像处理算法的设计中,可能会以最大化PSNR(或最小化MSE)作为优化目标之一,尽管这可能不总是能带来最佳的视觉效果。
快速质量检查:在需要对大量图像进行快速、自动化的质量初步评估时,PSNR可以作为一个参考指标,尽管它不应是唯一的标准。
局限性:
不适用于主观视觉质量评估:不能完全替代人类主观评价。对于需要高度符合人类感知的应用(如艺术品复制、消费级图像编辑软件效果评估),PSNR的指导意义有限。
不适用于一般图像检索或内容相似性判断:与MSE一样,由于其对几何变换和光照的敏感性,PSNR不适合用于搜索视觉内容相似但外观可能有较大差异的图像。
可能误导优化方向:如果一个算法仅仅以优化PSNR为目标,可能会产生一些在PSNR指标上表现良好但在视觉上并不理想的结果(所谓的“PSNR hacking”)。
对于不同内容的图像,相同的PSNR值可能对应不同的主观感受:例如,一幅细节丰富的图像和一个平滑区域较多的图像,即使它们的PSNR值相同,人眼感知到的失真程度和类型也可能不同。
3.3 结构相似性指数 (Structural Similarity Index, SSIM)
SSIM由Wang等人于2004年提出,其核心思想是人类视觉系统主要已关注场景中的结构信息。因此,图像质量的评估也应该基于对原始图像和失真图像之间结构信息的比较。SSIM认为图像的失真可以看作是结构信息的变化,而不仅仅是像素值的算术差异。
3.3.1 原理与数学公式
SSIM从三个方面比较两幅图像 (x) 和 (y)(通常是在一个局部窗口内计算,然后平均得到全局SSIM值):
亮度比较 (Luminance Comparison, (l(x, y))): 衡量两幅图像平均亮度的相似性。
对比度比较 (Contrast Comparison, (c(x, y))): 衡量两幅图像对比度的相似性。对比度通常用像素值的标准差来表示。
结构比较 (Structure Comparison, (s(x, y))): 衡量两幅图像在去除亮度和对比度影响后的结构信息的相似性。这通常通过计算两幅图像归一化后的协方差(或相关系数)来度量。
局部统计量的计算:
为了计算这三个分量,SSIM首先计算图像块(局部窗口)内的一些基本统计量:
(mu_x): 图像块 (x) 的平均像素值(亮度估计)。
(mu_y): 图像块 (y) 的平均像素值。
(sigma_x^2): 图像块 (x) 的方差(对比度估计的基础)。
(sigma_y^2): 图像块 (y) 的方差。
(sigma_{xy}): 图像块 (x) 和 (y) 之间的协方差(结构相似性估计的基础)。
这些统计量通常使用一个加权窗口(如高斯窗口)进行计算,以强调窗口中心像素的贡献,并使得SSIM值对窗口内微小的平移不那么敏感。如果使用均匀窗口(所有权重相同),则:
[ mu_x = frac{1}{N} sum_{i=1}^{N} x_i ]
[ sigma_x^2 = frac{1}{N-1} sum_{i=1}^{N} (x_i – mu_x)^2 quad ( ext{或有偏估计 } frac{1}{N} sum (x_i – mu_x)^2) ]
[ sigma_{xy} = frac{1}{N-1} sum_{i=1}^{N} (x_i – mu_x)(y_i – mu_y) quad ( ext{或有偏估计 } frac{1}{N} sum (x_i – mu_x)(y_i – mu_y)) ]
其中 (N) 是窗口内的像素数量,(x_i) 和 (y_i) 是窗口内对应位置的像素值。
SSIM三个分量的计算公式:
亮度比较 (l(x, y)):
[ l(x, y) = frac{2mu_xmu_y + C_1}{mu_x^2 + mu_y^2 + C_1} ]
(C_1 = (k_1 L)^2) 是一个小的正常数,用于避免分母接近于0时不稳定的情况。
(L) 是像素值的动态范围(例如,对于8位图像,L=255)。
(k_1) 是一个很小的常数,论文中建议 (k_1 = 0.01)。
(l(x,y)) 的取值范围是 ((0, 1]) (如果 (mu_x, mu_y ge 0))。当 (mu_x = mu_y) 时,(l(x,y)) 接近1。
对比度比较 (c(x, y)):
[ c(x, y) = frac{2sigma_xsigma_y + C_2}{sigma_x^2 + sigma_y^2 + C_2} ]
(C_2 = (k_2 L)^2) 是另一个小的正常数,用途与 (C_1) 类似。
(k_2) 也是一个很小的常数,论文中建议 (k_2 = 0.03)。
(sigma_x) 和 (sigma_y) 是图像块 (x) 和 (y) 的标准差(即方差的平方根)。
(c(x,y)) 的取值范围也是 ((0, 1])。当 (sigma_x = sigma_y) 时,(c(x,y)) 接近1。
结构比较 (s(x, y)):
[ s(x, y) = frac{sigma_{xy} + C_3}{sigma_xsigma_y + C_3} ]
(C_3 = C_2 / 2) 在一些实现中被使用,或者也可以是一个独立的小常数。论文原版中没有明确的(C_3),但暗示了其形式与(C_1, C_2)相似,或直接使用(sigma_{xy} / (sigma_x sigma_y)) (即相关系数)当 (sigma_x, sigma_y) 不为零时。现代实现通常会加入 (C_3) 以增加稳定性。
(sigma_{xy}) 是 (x) 和 (y) 的协方差。
该项实际上衡量的是 (x) 和 (y) 去除各自均值并归一化(除以各自标准差)后的线性相关性。
(s(x,y)) 的取值范围近似为 ([-1, 1])(如果 (C_3) 很小),但由于SSIM公式的组合方式,通常期望它为正。
SSIM指数的最终组合:
SSIM指数是这三个分量的加权乘积:
[ ext{SSIM}(x, y) = [l(x, y)]^alpha cdot [c(x, y)]^eta cdot [s(x, y)]^gamma ]
其中 (alpha, eta, gamma) 是大于0的参数,用于调整三个分量的相对重要性。在原论文中,为了简化,通常设置 (alpha = eta = gamma = 1)。此时,SSIM公式变为:
[ ext{SSIM}(x, y) = frac{(2mu_xmu_y + C_1)(2sigma_{xy} + C_2)}{(mu_x^2 + mu_y^2 + C_1)(sigma_x^2 + sigma_y^2 + C_2)} ]
这个形式是SSIM最常见的形式。注意这里的 (2sigma_{xy} + C_2) 对应于结构和对比度信息的某种组合,因为原论文中将 (sigma_xsigma_y) 作为对比度分母的一部分,并将 (sigma_{xy}) 作为结构信息的一部分。
SSIM的特性:
取值范围:SSIM的值通常在 ([-1, 1]) 之间。
当两幅图像完全相同时,SSIM = 1。
值越接近1,表示两幅图像在结构上越相似。
负值表示结构反相关,这在自然图像中较少见,除非是特意构造的图像对。
对称性:( ext{SSIM}(x, y) = ext{SSIM}(y, x))。
有界性:SSIM的值是有界的,这比MSE的无界值域更容易解释。
全局SSIM:
上述SSIM计算是在图像的一个局部窗口(例如,8×8或11×11像素)上进行的。为了得到整幅图像的SSIM值,通常采用以下两种方法之一:
平均SSIM (Mean SSIM, MSSIM):在图像上以一定的步长滑动局部窗口,计算每个窗口的SSIM值,然后将所有窗口的SSIM值取平均,得到MSSIM。这是最常用的方法。
[ ext{MSSIM}(X, Y) = frac{1}{M_{win}} sum_{j=1}^{M_{win}} ext{SSIM}(x_j, y_j) ]
其中 (x_j) 和 (y_j) 是第 (j) 个局部窗口,(M_{win}) 是窗口总数。
多尺度SSIM (Multi-Scale SSIM, MS-SSIM):在图像的多个尺度上(通过对图像进行下采样得到不同分辨率的版本)计算SSIM,然后将不同尺度上的结果加权组合起来。MS-SSIM通常能提供比单尺度SSIM更符合人类视觉感知的评价结果,因为它考虑了不同观察距离下的视觉效果。
在本节中,我们将主要已关注单尺度平均SSIM (MSSIM) 的实现。
3.3.2 Pillow 与 NumPy/SciPy 实现步骤
直接使用Pillow和纯NumPy从头实现SSIM(特别是带高斯加权窗口的滑动窗口版本)会比较繁琐。幸运的是,scikit-image 库提供了一个高效且经过良好测试的SSIM实现。我们主要会介绍如何结合Pillow(用于图像加载和预处理)与 scikit-image (用于SSIM计算)来实现。
使用 scikit-image 计算SSIM的步骤:
安装 scikit-image:
如果尚未安装,可以通过pip安装:
pip install scikit-image
加载图像:使用Pillow的 Image.open() 加载两张待比较的图像。
预处理图像:
统一尺寸:与MSE/PSNR一样,两幅图像必须具有相同的尺寸。使用 image.resize()。
转换为灰度图:SSIM通常在灰度图像上计算,因为它主要已关注结构信息,颜色可以后续单独考虑或通过其他方式引入(例如,分别计算各颜色通道的SSIM再平均)。使用 image.convert('L')。如果要在彩色图像上计算,scikit-image的 structural_similarity 函数有一个 multichannel=True 参数,此时它会期望输入是多通道图像(如RGB),并在最后一个维度上迭代计算通道。
转换为NumPy数组:将Pillow图像对象转换为NumPy数组,数据类型通常是 float64 并且像素值归一化到 [0, 1] 范围,或者保持 uint8 类型 [0, 255] (scikit-image内部会处理)。scikit-image 的 structural_similarity 函数有一个 data_range 参数,用于指定输入数据的动态范围(例如,对于0-255的uint8图像,data_range=255)。
调用 skimage.metrics.structural_similarity:
scikit-image 库中的 metrics 模块提供了 structural_similarity 函数。
from skimage.metrics import structural_similarity as ssim_sk # 为了避免与可能的自定义ssim函数名冲突
# 假设 img1_np 和 img2_np 是预处理后的NumPy数组
# (例如,灰度图,uint8类型,范围0-255)
# ssim_index, ssim_image = ssim_sk(img1_np, img2_np,
# win_size=None, # 窗口大小,通常奇数,如7或11。None则根据图像大小选择。
# gradient=False, # 是否返回梯度图像
# data_range=img1_np.max() - img1_np.min(), # 数据的动态范围
# multichannel=False, # 如果输入是多通道图像(如RGB),设为True
# gaussian_weights=True, # 是否使用高斯加权窗口 (推荐)
# full=True, # 如果为True,则返回SSIM图像,否则只返回平均SSIM值
# # K1, K2 是SSIM公式中的常数k1, k2 (不是C1, C2)
# K1=0.01,
# K2=0.03,
# # sigma:高斯核的标准差,仅当gaussian_weights=True时使用, 默认1.5
# sigma=1.5,
# use_sample_covariance=False # 是否使用样本协方差 (N-1分母)
# )
常用参数:
im1, im2: 输入的两个NumPy数组。
win_size: 滑动窗口的边长。必须是奇数。如果为 None,则会自动设置为一个合理的值(例如7,如果图像足够大)。对于非常小的图像,可能需要手动设置一个较小的 win_size(例如3或5)。
data_range: 输入图像数据的动态范围。例如,如果图像数据是 uint8 类型(0-255),则 data_range = 255。如果数据已归一化到 [0, 1],则 data_range = 1.0。这个参数很重要,因为它影响 (C_1, C_2) 的计算。
multichannel: 如果输入图像是多通道的(例如RGB图像,形状为 (H, W, C)),并且希望在每个通道上独立计算SSIM然后平均,或者以其他方式处理多通道(具体取决于 scikit-image 的实现细节,通常是分别计算然后平均),则设置为 True。如果输入是2D灰度图,则设为 False。
gaussian_weights: 推荐设置为 True,使用高斯加权窗口,这更符合SSIM原论文的建议,并且通常能得到更好的结果。如果为 False,则使用均匀加权窗口。
full: 如果为 True,函数会返回两个值:平均SSIM指数(一个标量)和SSIM图(一个与输入图像大小相同的数组,其中每个像素值是对应局部窗口的SSIM值)。如果为 False (默认),则只返回平均SSIM指数。
K1, K2: SSIM公式中的常数 (k_1) 和 (k_2),默认值通常是0.01和0.03。
sigma: 当 gaussian_weights=True 时,高斯窗口的标准差,默认为1.5。
获取结果:
如果 full=False,直接得到平均SSIM值。
如果 full=True,则第一个返回的是平均SSIM值,第二个是SSIM图。SSIM图对于可视化图像哪些区域结构相似性高/低非常有用。
从头实现SSIM(概念性,不推荐用于生产,除非有特殊需求)
如果确实需要从头实现(例如,为了深入理解或在没有 scikit-image 的环境中使用),大致步骤如下:
图像预处理(同上)。
定义滑动窗口(例如,11×11)。
对于图像中的每一个窗口位置(或以一定步长滑动):
a. 从两幅图像中提取对应的局部窗口块。
b. 如果使用高斯加权,生成一个对应大小的高斯权重矩阵。
c. 计算加权(或非加权)的均值 (mu_x, mu_y)。
d. 计算加权(或非加权)的方差 (sigma_x^2, sigma_y^2) 和协方差 (sigma_{xy})。计算标准差 (sigma_x, sigma_y)。
e. 根据SSIM公式(使用 (L, k_1, k_2) 计算 (C_1, C_2))计算该窗口的SSIM值。
将所有窗口的SSIM值平均,得到全局MSSIM。
这个过程涉及大量的循环和窗口操作,用纯Python会很慢。用NumPy的数组操作(如 scipy.ndimage.gaussian_filter 用于计算加权均值,以及利用卷积思想计算局部方差和协方差)可以大大提速,但实现起来仍然比直接调用 scikit-image 复杂得多。
例如,计算局部均值可以使用与高斯核的卷积:
(mu_x = ext{convolve}(x, ext{gaussian_window}))
计算局部方差可以使用公式: (sigma_x^2 = ext{convolve}(x^2, ext{gaussian_window}) – mu_x^2)
计算局部协方差: (sigma_{xy} = ext{convolve}(x cdot y, ext{gaussian_window}) – mu_x mu_y)
3.3.3 代码示例与逐行解释 (使用 scikit-image)
from skimage.metrics import structural_similarity # 从scikit-image导入structural_similarity函数
# from skimage import io as skimage_io # 如果直接用skimage加载图片
# from skimage.color import rgb2gray # 如果用skimage转灰度
# (Pillow, NumPy, os, math 模块已在前面导入)
# (相关的目录准备和示例图片创建代码也已在前面提供)
# (calculate_mse 函数也已定义)
def calculate_ssim(image_path1, image_path2, target_size=None, to_grayscale=True,
win_size=None, multichannel_flag=None, data_range_val=None,
gaussian_weights_flag=True, full_flag=False,
k1_val=0.01, k2_val=0.03, sigma_val=1.5): # 定义计算SSIM的函数
"""
计算两幅图像之间的结构相似性指数 (SSIM)。
主要使用 scikit-image.metrics.structural_similarity 实现。
参数:
image_path1 (str): 第一张图像的文件路径。
image_path2 (str): 第二张图像的文件路径。
target_size (tuple, optional): (width, height)元组,用于将图像统一调整到的尺寸。
如果为None,则要求图像已具有相同尺寸。
to_grayscale (bool, optional): 是否在计算SSIM前将图像转换为灰度图。默认为True。
如果为False,则尝试按多通道计算 (multichannel_flag需配合)。
win_size (int, optional): SSIM滑动窗口的边长,必须是奇数。
如果为None, scikit-image会尝试选择一个默认值 (通常是7)。
对于小图像,可能需要手动设小此值。
multichannel_flag (bool, optional): 是否将输入图像视为多通道图像。
如果 to_grayscale=False 且图像是彩色的,应设为True。
如果 to_grayscale=True,此参数通常应为False(或skimage会自动处理)。
如果为None,则会尝试根据图像维度推断。
data_range_val (float, optional): 输入图像数据的动态范围 (max_val - min_val)。
例如,对于uint8图像 (0-255),此值为255。
如果为None, scikit-image会尝试从数据类型推断。
gaussian_weights_flag (bool, optional): 是否使用高斯加权窗口。默认为True (推荐)。
full_flag (bool, optional): 是否返回完整的SSIM图。默认为False (只返回平均SSIM值)。
如果为True,函数返回 (mean_ssim, ssim_map)。
k1_val, k2_val, sigma_val : SSIM算法的参数 K1, K2, 和高斯核标准差sigma。
返回:
float or tuple: 如果 full_flag=False, 返回平均SSIM值 (float)。
如果 full_flag=True, 返回一个元组 (mean_ssim, ssim_map)。
如果发生错误,返回None。
"""
try:
# 1. 加载图像
img1_pil = Image.open(image_path1) # 使用Pillow加载第一张图片
img2_pil = Image.open(image_path2) # 使用Pillow加载第二张图片
print(f" SSIM - 图像1 ('{
os.path.basename(image_path1)}'): 原始尺寸={
img1_pil.size}, 模式={
img1_pil.mode}") # 打印图像1信息
print(f" SSIM - 图像2 ('{
os.path.basename(image_path2)}'): 原始尺寸={
img2_pil.size}, 模式={
img2_pil.mode}") # 打印图像2信息
# 2. 预处理图像
# 2.1 统一尺寸
if target_size: # 如果指定了目标尺寸
img1_pil = img1_pil.resize(target_size, Image.Resampling.LANCZOS) # 调整图像1尺寸
img2_pil = img2_pil.resize(target_size, Image.Resampling.LANCZOS) # 调整图像2尺寸
print(f" SSIM - 已将两图像统一调整到尺寸: {
target_size}") # 打印尺寸调整信息
elif img1_pil.size != img2_pil.size: # 如果尺寸不匹配且未指定目标尺寸
raise ValueError(f"SSIM要求图像尺寸匹配: {
img1_pil.size} vs {
img2_pil.size},且未指定target_size。") # 抛出错误
# 2.2 模式处理 和 确定 multichannel_flag 与 data_range_val
actual_multichannel = False # 初始化实际的多通道标志
if to_grayscale: # 如果要求转换为灰度图
if img1_pil.mode != 'L': # 如果图像1不是灰度
img1_pil = img1_pil.convert('L') # 转换为灰度
if img2_pil.mode != 'L': # 如果图像2不是灰度
img2_pil = img2_pil.convert('L') # 转换为灰度
actual_multichannel = False # 灰度图不是多通道
print(f" SSIM - 已将两图像转换为灰度模式 ('L') for SSIM calculation.") # 打印灰度转换信息
else: # 如果不转灰度 (即计算彩色SSIM)
# 确保它们都是RGB或RGBA等兼容的多通道格式
if img1_pil.mode not in ['RGB', 'RGBA'] or img2_pil.mode not in ['RGB', 'RGBA']: # 如果任一图像不是RGB或RGBA
print(f" SSIM - 警告: to_grayscale=False,但图像模式为 {
img1_pil.mode}/{
img2_pil.mode}。尝试转换为RGB。") # 打印警告并尝试转换
if img1_pil.mode != 'RGB': img1_pil = img1_pil.convert('RGB') # 转换图像1为RGB
if img2_pil.mode != 'RGB': img2_pil = img2_pil.convert('RGB') # 转换图像2为RGB
if img1_pil.mode != img2_pil.mode: # 如果转换后模式仍不一致
raise ValueError(f"SSIM (彩色) 要求图像模式匹配: {
img1_pil.mode} vs {
img2_pil.mode}") # 抛出错误
actual_multichannel = True # 彩色图像是多通道
print(f" SSIM - 将按多通道模式 ({
img1_pil.mode}) 计算SSIM。") # 打印多通道计算信息
# 覆盖 multichannel_flag (如果用户显式提供了)
if multichannel_flag is not None: # 如果用户指定了multichannel_flag
actual_multichannel = multichannel_flag # 使用用户指定的值
print(f" SSIM - 用户指定 multichannel_flag = {
actual_multichannel}") # 打印用户指定信息
# 3. 转换为NumPy数组
img1_np = numpy.array(img1_pil) # 将Pillow图像1转换为NumPy数组
img2_np = numpy.array(img2_pil) # 将Pillow图像2转换为NumPy数组
# 确定 data_range (如果未提供)
# scikit-image 的 structural_similarity 会根据dtype自动推断,但显式提供更安全
if data_range_val is None: # 如果用户未提供data_range_val
if img1_np.dtype == numpy.uint8: # 如果数据类型是uint8
data_range_val = 255.0 # 动态范围是255
elif img1_np.dtype == numpy.uint16: # 如果数据类型是uint16
data_range_val = 65535.0 # 动态范围是65535
elif img1_np.dtype in [numpy.float32, numpy.float64]: # 如果是浮点类型
# 假设浮点数据已归一化到[0,1]或[-1,1]等,这里设为1.0作为常见情况
# 更稳健的做法是 img1_np.max() - img1_np.min() 但这可能受噪声影响
# 如果图像数据是 skimage.img_as_float转换的,则范围是[0,1]或[-1,1]
min_val = min(img1_np.min(), img2_np.min()) # 获取两个数组中的最小值
max_val = max(img1_np.max(), img2_np.max()) # 获取两个数组中的最大值
data_range_val = max_val - min_val # 计算实际的数据范围
if data_range_val == 0: data_range_val = 1.0 # 防止除零,如果图像是纯色
print(f" SSIM - 浮点数据,自动推断 data_range_val = {
data_range_val:.2f} (从 [{
min_val:.2f}, {
max_val:.2f}])") # 打印推断的data_range
else: # 其他未知数据类型
# 尝试使用最大值作为范围,但这不总是准确
data_range_val = img1_np.max() # 使用图像1的最大值作为范围
print(f" SSIM - 警告: 未知NumPy dtype ({
img1_np.dtype}),data_range_val 可能不准确,设为 {
data_range_val}。") # 打印警告
else: # 如果用户提供了data_range_val
print(f" SSIM - 用户指定 data_range_val = {
data_range_val}") # 打印用户指定信息
# 4. 调用 skimage.metrics.structural_similarity
# 根据 full_flag 决定如何调用和接收返回值
if full_flag: # 如果要求返回完整的SSIM图
mean_ssim, ssim_map = structural_similarity(
img1_np, img2_np,
win_size=win_size, # 窗口大小
data_range=data_range_val, # 数据动态范围
multichannel=actual_multichannel, # 是否为多通道
gaussian_weights=gaussian_weights_flag, # 是否使用高斯权重
full=True, # 要求返回SSIM图
K1=k1_val, K2=k2_val, sigma=sigma_val, # SSIM参数
use_sample_covariance=False # 通常设为False,使用有偏协方差(N为分母)
)
result = (mean_ssim, ssim_map) # 将均值和SSIM图作为元组返回
print(f" SSIM - 计算完成。均值SSIM={
mean_ssim:.4f}。返回SSIM图。") # 打印计算完成信息
else: # 如果只要求返回平均SSIM值
mean_ssim = structural_similarity(
img1_np, img2_np,
win_size=win_size, # 窗口大小
data_range=data_range_val, # 数据动态范围
multichannel=actual_multichannel, # 是否为多通道
gaussian_weights=gaussian_weights_flag, # 是否使用高斯权重
full=False, # 只要求返回平均值
K1=k1_val, K2=k2_val, sigma=sigma_val, # SSIM参数
use_sample_covariance=False
)
result = mean_ssim #直接返回平均SSIM值
print(f" SSIM - 计算完成。均值SSIM={
mean_ssim:.4f}。") # 打印计算完成信息
# 关闭Pillow图像对象
img1_pil.close() # 关闭Pillow图像1
img2_pil.close() # 关闭Pillow图像2
return result # 返回计算结果 (均值SSIM 或 元组(均值SSIM, SSIM图))
except FileNotFoundError: # 捕获文件未找到异常
print(f"SSIM 错误: 其中一个或两个图像文件未找到: '{
image_path1}', '{
image_path2}'") # 打印错误信息
return None # 返回None
except ValueError as ve: # 捕获值错误 (例如尺寸不匹配)
print(f"SSIM 值错误: {
ve}") # 打印错误信息
return None # 返回None
except ImportError: # 捕获scikit-image未安装的错误
print("SSIM 错误: scikit-image库未安装或无法导入。请运行 'pip install scikit-image'。") # 打印安装提示
return None # 返回None
except Exception as e: # 捕获其他所有未知异常
print(f"计算SSIM时发生未知错误: {
e}") # 打印错误信息
# import traceback
# traceback.print_exc() # 打印详细堆栈信息
return None # 返回None
print("
--- 3.3 结构相似性指数 (Structural Similarity Index, SSIM) ---") # 打印章节标题
# 测试用例 (复用之前创建的图片路径)
print("
[SSIM测试用例开始]") # 打印测试开始信息
# 为SSIM选择一个默认的窗口大小,例如7 (如果图片太小,skimage会自动调整或报错,所以选择一个通用的)
# 对于100x80的图片,win_size=7是可行的。如果图片小于win_size,会报错。
# skimage的默认win_size是7 (如果图像某维度>10) 或 图像最小维度 (如果<=10).
# 我们在这里不显式设置win_size,让skimage自行处理或使用其默认值,除非特定测试需要。
# 用例1: 图片1 vs 图片2 (完全相同) -> 预期SSIM ≈ 1.0
print("
用例1: 比较两张完全相同的图片 (预期SSIM ≈ 1.0)") # 打印用例描述
ssim1_val = calculate_ssim(img1_path, img2_path_identical) # 调用函数计算SSIM
if ssim1_val is not None: # 如果计算成功
print(f" 计算结果: 平均SSIM = {
ssim1_val:.4f}") # 打印SSIM结果
assert math.isclose(ssim1_val, 1.0, abs_tol=1e-5), f"相同图片的SSIM ({
ssim1_val}) 应非常接近1.0" # 断言结果接近1.0
# 用例2: 图片1 vs 图片3 (略有差异) -> 预期SSIM < 1.0 且较高
print("
用例2: 比较两张略有差异的图片 (预期SSIM < 1.0 且较高)") # 打印用例描述
ssim2_val = calculate_ssim(img1_path, img3_path_slight_diff) # 调用函数计算SSIM
if ssim2_val is not None: # 如果计算成功
print(f" 计算结果: 平均SSIM = {
ssim2_val:.4f}") # 打印SSIM结果
if ssim1_val is not None: # 如果用例1也成功
assert ssim2_val < ssim1_val, "略有差异图片的SSIM应小于相同图片的SSIM" # 断言SSIM小于1
assert ssim2_val > 0.5, f"略有差异图片的SSIM ({
ssim2_val}) 预期应较高 (例如 > 0.5)" # 断言SSIM较高
# 用例3: 图片1 vs 图片5 (内容完全不同) -> 预期SSIM较低
print("
用例3: 比较两张内容完全不同的图片 (预期SSIM较低)") # 打印用例描述
ssim3_val = calculate_ssim(img1_path, img5_path_very_diff) # 调用函数计算SSIM
if ssim3_val is not None: # 如果计算成功
print(f" 计算结果: 平均SSIM = {
ssim3_val:.4f}") # 打印SSIM结果
if ssim2_val is not None: # 如果用例2也成功
assert ssim3_val < ssim2_val, f"完全不同图片的SSIM ({
ssim3_val}) 应小于略有差异图片的SSIM ({
ssim2_val})" # 断言SSIM更低
# 用例4: 比较两张尺寸不同的图片,不进行尺寸统一 (预期SSIM计算失败)
print("
用例4: 比较两张尺寸不同的图片,不进行尺寸统一 (预期SSIM计算失败)") # 打印用例描述
ssim4_fail = calculate_ssim(img1_path, img4_path_diff_size, target_size=None) # 调用函数,不指定target_size
if ssim4_fail is None: # 如果返回None
print(f" SSIM计算失败,符合预期。") # 打印符合预期的失败信息
else:
print(f" SSIM计算意外成功: SSIM = {
ssim4_fail:.4f} (这不符合预期,请检查逻辑)") # 打印意外成功信息
# 用例5: 比较两张尺寸不同的图片,统一到相同尺寸
print("
用例5: 比较两张尺寸不同的图片,统一到相同尺寸 (例如 (100,80))") # 打印用例描述
common_size_ssim = (100, 80) # 定义统一的目标尺寸
ssim5_resized = calculate_ssim(img1_path, img4_path_diff_size, target_size=common_size_ssim) # 调用函数,指定target_size
if ssim5_resized is not None: # 如果计算成功
print(f" 计算结果 (统一尺寸到{
common_size_ssim}): 平均SSIM = {
ssim5_resized:.4f}") # 打印SSIM结果
# 用例6: 计算彩色图像的SSIM (img1 vs img3)
print("
用例6: 比较两张彩色图片 (img1 vs img3),使用多通道SSIM") # 打印用例描述
# img1_path和img3_path_slight_diff本身是彩色图 (PNG/JPG)
ssim6_color = calculate_ssim(img1_path, img3_path_slight_diff, to_grayscale=False, multichannel_flag=True) # 设置to_grayscale=False, multichannel_flag=True
if ssim6_color is not None: # 如果计算成功
print(f" 计算结果 (彩色多通道SSIM): 平均SSIM = {
ssim6_color:.4f}") # 打印SSIM结果
if ssim2_val is not None: # 如果用例2(灰度SSIM)也存在
print(f" (对比:灰度SSIM = {
ssim2_val:.4f})") # 打印灰度SSIM作为参考
# 彩色SSIM和灰度SSIM的值不一定哪个更大,取决于颜色差异和结构差异的相对贡献
# 用例7: 获取完整的SSIM图
print("
用例7: 比较图片1和图片3,并获取完整的SSIM图") # 打印用例描述
ssim7_full_result = calculate_ssim(img1_path, img3_path_slight_diff, full_flag=True) # 设置full_flag=True
if ssim7_full_result is not None: # 如果计算成功
mean_ssim_map, ssim_map_image = ssim7_full_result # 解包均值和SSIM图
print(f" 计算结果: 平均SSIM = {
mean_ssim_map:.4f}, SSIM图形状 = {
ssim_map_image.shape}") # 打印结果信息
# 可以将SSIM图保存为图像文件以供查看 (需要将SSIM图的值从[-1,1]或[0,1]映射到[0,255])
try:
# SSIM图的值通常在[0,1]附近(对于相似图像),但也可能为负
# 将其归一化到0-1,然后乘以255
ssim_map_normalized = (ssim_map_image - ssim_map_image.min()) / (ssim_map_image.max() - ssim_map_image.min() + 1e-9) # 归一化到[0,1]
ssim_map_uint8 = (ssim_map_normalized * 255).astype(numpy.uint8) # 转换为uint8类型
ssim_map_pil = Image.fromarray(ssim_map_uint8, mode='L') # 从NumPy数组创建Pillow图像 (灰度模式)
ssim_map_save_path = os.path.join(OUTPUT_DIR, "ssim_map_img1_vs_img3.png") # 定义SSIM图保存路径
ssim_map_pil.save(ssim_map_save_path) # 保存SSIM图
print(f" SSIM图已归一化并保存到: {
ssim_map_save_path}") # 打印保存信息
except Exception as e_save_map:
print(f" 保存SSIM图时发生错误: {
e_save_map}") # 打印保存错误
# 用例8: 测试小窗口尺寸 (例如 win_size=3,如果图片太小,默认的7可能不合适)
# 我们用一个较小的图片来测试
small_img_path1 = os.path.join(IMAGE_DIR, "mse_img_small1.png") # 定义小图片1路径
small_img_path2 = os.path.join(IMAGE_DIR, "mse_img_small2.png") # 定义小图片2路径
create_sample_image_if_not_exists(small_img_path1, size=(20,15), color="red", text="S1") # 创建小图片1
create_sample_image_if_not_exists(small_img_path2, size=(20,15), color="pink", text="S2") # 创建小图片2 (与1略不同)
print("
用例8: 比较非常小的图片,使用较小的win_size (例如 3 或 5)") # 打印用例描述
# 对于20x15的图像,win_size=7可能太大,skimage可能会报错或自动调整
# 显式设置一个较小的奇数win_size,例如 min(width, height, 7) if odd else min(width, height, 7)-1 (if > 0)
# 或者直接尝试一个小的,如3或5
effective_win_size = min(20, 15) # 获取最小维度
if effective_win_size > 7: effective_win_size = 7 # 如果最小维度大于7,则用7
if effective_win_size % 2 == 0: effective_win_size -=1 # 确保是奇数
if effective_win_size < 3: effective_win_size = 3 # 最小也得是3
print(f" (测试小图,将使用 win_size={
effective_win_size})") # 打印使用的窗口大小
ssim8_small_img = calculate_ssim(small_img_path1, small_img_path2, win_size=effective_win_size) # 调用函数,指定win_size
if ssim8_small_img is not None: # 如果计算成功
print(f" 计算结果 (小图, win_size={
effective_win_size}): 平均SSIM = {
ssim8_small_img:.4f}") # 打印SSIM结果
print("
[SSIM测试用例结束]") # 打印测试结束信息
代码逐行解释 (calculate_ssim 函数部分):
def calculate_ssim(...):: 定义 calculate_ssim 函数,参数包括图片路径、预处理选项(target_size, to_grayscale)、以及 scikit-image 的 structural_similarity 函数的多个关键参数(win_size, multichannel_flag (映射到multichannel), data_range_val (映射到data_range), gaussian_weights_flag (映射到gaussian_weights), full_flag (映射到full), k1_val, k2_val, sigma_val)。
图像加载与预处理 (尺寸统一): 与 calculate_mse 中的逻辑类似,使用Pillow加载图像并根据 target_size 统一尺寸。
模式处理与 actual_multichannel 确定:
如果 to_grayscale 为 True,图像被转换为 'L' 模式,actual_multichannel 设为 False。
如果 to_grayscale 为 False,代码尝试将图像转换为 'RGB' 模式(如果它们不是 'RGB' 或 'RGBA'),并将 actual_multichannel 设为 True。
如果用户显式提供了 multichannel_flag 参数,则它会覆盖根据 to_grayscale 推断的 actual_multichannel 值。
转换为NumPy数组: img1_np = numpy.array(img1_pil),将Pillow图像转为NumPy数组。
确定 data_range_val:
如果用户没有提供 data_range_val,代码会尝试根据NumPy数组的 dtype 推断。对 uint8 (0-255) 和 uint16 (0-65535) 有特定处理。对于浮点类型,它会计算 max_val - min_val 作为范围。显式提供此参数通常更可靠。
调用 structural_similarity:
from skimage.metrics import structural_similarity: 导入 scikit-image 的SSIM函数。
根据 full_flag 的值,决定是只获取平均SSIM值,还是同时获取平均SSIM和SSIM图。
传递所有相关参数给 structural_similarity 函数。注意 multichannel 参数使用了我们之前确定的 actual_multichannel。
结果返回与错误处理:
返回计算得到的平均SSIM值或 (mean_ssim, ssim_map) 元组。
包含对 FileNotFoundError、ValueError(例如由 scikit-image 内部因参数不当如 win_size 过大而抛出)、ImportError(如果 scikit-image 未安装)以及其他通用异常的捕获。
SSIM图保存示例 (在用例7中):
获取SSIM图 (ssim_map_image) 后,其值可能不在标准的图像显示范围内(例如,可能包含-1到1的值)。
ssim_map_normalized = (ssim_map_image - ssim_map_image.min()) / (ssim_map_image.max() - ssim_map_image.min() + 1e-9): 将SSIM图的值线性归一化到 [0, 1] 范围。1e-9 是为了防止当 max == min 时除以零。
ssim_map_uint8 = (ssim_map_normalized * 255).astype(numpy.uint8): 将归一化的值乘以255并转换为 uint8 类型,使其可以用灰度图形式保存。
Image.fromarray(ssim_map_uint8, mode='L'): 从NumPy数组创建Pillow灰度图像。
ssim_map_pil.save(...): 保存SSIM图。
3.3.4 优缺点分析
优点:
更符合人类视觉感知: 相较于MSE和PSNR,SSIM通过分别衡量亮度、对比度和结构信息,能更好地模拟人类视觉系统对图像结构失真的感知。通常,SSIM的评估结果与人类主观评价的一致性更高。
对亮度和对比度的整体变化不敏感: 由于SSIM单独评估亮度和对比度分量,它对图像整体的、线性的亮度或对比度调整具有一定的鲁棒性(只要结构信息保持不变)。例如,将图像整体调亮一些,其SSIM值可能变化不大。
已关注结构信息: SSIM的核心是比较结构。对于那些保留了主要结构但像素值有差异的图像(例如,轻微的非线性色调调整),SSIM可能给出比MSE更高的相似度。
有界且归一化的输出: SSIM值通常在 [-1, 1] (对于非常相似的图像,接近 [0, 1]),1表示完全相同。这比MSE的无界输出更易于解释和标准化。
局部化评估: SSIM通过在局部窗口上计算,可以捕捉图像不同区域的失真差异,并且通过平均得到全局评估。SSIM图本身也提供了失真空间分布的有用信息。
参数可调: SSIM公式中的 (alpha, eta, gamma) (虽然通常设为1) 以及 (k_1, k_2),高斯窗口的 (sigma),窗口大小 win_size 等参数为特定应用提供了调整空间。
缺点:
计算复杂度高于MSE/PSNR: SSIM涉及局部窗口的滑动、均值/方差/协方差的计算,以及多次乘除和幂运算,因此计算量比MSE或PSNR要大。但使用如 scikit-image 中优化过的实现,其性能在很多应用中是可以接受的。
对旋转和缩放仍然敏感: 尽管SSIM对亮度和对比度的某些变化不敏感,但它仍然依赖于像素的空间对应关系。因此,它对图像的旋转、显著的缩放、平移(如果超过窗口的鲁棒范围)以及非刚性形变等几何变换仍然是敏感的。它不是一个几何不变的特征。
对颜色信息的处理不够完善: 标准的SSIM主要在灰度图像上计算。虽然可以通过在各个颜色通道上计算SSIM然后平均来扩展到彩色图像,但这并没有充分考虑颜色通道之间的相关性或颜色感知的复杂性。有一些SSIM的彩色扩展版本(如C-SSIM, iw-SSIM),但实现更复杂。scikit-image 中的 multichannel=True 选项提供了一种处理方式。
窗口大小的选择: win_size 的选择会影响SSIM的结果。窗口太小可能对噪声敏感且无法捕捉大尺度结构;窗口太大可能平滑掉局部细节差异。通常需要根据图像内容和期望的评估尺度来选择。
对特定类型的失真可能不理想: 虽然SSIM总体上优于MSE,但对于某些特定类型的、人眼非常敏感的失真(例如,强烈的振铃效应或块效应),SSIM的评分可能不如专门针对这些失真设计的指标。
仍然是像素级的比较: 尽管它试图提取“结构”,但其基础仍然是像素值的统计特性,而不是更高级的语义特征。两张结构上相似但语义完全不同的图像(例如,一张斑马和一张条形码,如果局部纹理相似)SSIM值可能较高。
3.3.5 适用场景与局限性
适用场景:
图像和视频质量评估: 这是SSIM最主要的应用领域,特别是在需要比PSNR更接近人类主观感受的场景。例如,评估压缩算法、图像增强算法、去噪算法、超分辨率算法的效果。
图像相似性比较(当几何对齐时): 如果两幅图像在内容和结构上期望是相似的,并且已经进行了较好的空间对齐(例如,比较同一场景在不同曝光下的两张照片,或比较视频的连续帧),SSIM可以作为一个有效的相似度度量。
图像修复和重建的监控: 在迭代的图像修复或重建过程中,可以用SSIM来监控与目标图像的结构相似度的改善情况。
作为图像处理算法的损失函数或正则项: 在一些深度学习或传统优化方法中,SSIM或其变体被用作损失函数的一部分,以促使生成的图像在结构上与目标图像相似。
变化检测(特定条件下): 如果背景相对稳定,SSIM可以用于检测两幅对齐图像之间发生的显著结构变化。
局限性:
不适用于需要几何不变性的图像检索或匹配: 如果图像之间存在旋转、大的缩放、显著平移或视角变化,SSIM通常不适用。你需要特征点匹配(如SIFT, ORB)或基于深度学习的特征嵌入等方法。
不适用于语义相似性判断: SSIM无法理解图像的语义内容。两只不同品种的狗,人眼看来语义相似,但如果它们的纹理、姿态差异很大,SSIM值可能很低。
对颜色差异的感知可能不足: 即便使用多通道SSIM,其对颜色相似性的建模也不如专门的颜色相似性算法或人类感知直观。
计算成本: 对于实时性要求非常高且计算资源受限的应用,SSIM的计算成本可能仍然是一个需要考虑的因素,尽管已有优化实现。
无法完全替代主观评价: 尽管SSIM比MSE/PSNR更接近人类感知,但在要求非常严格的质量评估场景(如印刷、电影制作),人类主观评价仍然是金标准。
第四章:基于直方图的图像相似度计算方法
基于直方图的方法是一种统计学方法,它不直接比较像素值或局部结构,而是比较图像中像素强度(或颜色)的整体分布情况。直方图描述了图像中每个强度级别(或颜色区间)出现的频率。如果两幅图像的直方图相似,则可以认为它们在整体的颜色或亮度分布上是相似的。
4.1 图像直方图基础
在深入探讨直方图比较方法之前,我们首先需要理解什么是图像直方图以及如何计算和表示它。
4.1.1 什么是图像直方图?
图像直方图(Image Histogram)是一个图形化的表示,它显示了数字图像中不同像素强度值的分布情况。
横坐标 (X-axis):通常表示像素的强度值(对于灰度图像,范围通常是0-255;对于彩色图像,可以是每个颜色通道的强度值,或者量化后的颜色索引)。这些强度值也被称为“bins”或“灰度级”。
纵坐标 (Y-axis):表示具有该特定强度值的像素数量(频率或计数)。
直方图的类型:
灰度直方图 (Grayscale Histogram):
针对单通道的灰度图像。
横坐标是灰度值(例如0到255)。
纵坐标是图像中具有该灰度值的像素个数。
它能反映图像的整体亮度分布和对比度。例如,如果直方图的像素主要集中在低灰度区域,则图像偏暗;如果集中在高灰度区域,则图像偏亮;如果分布广泛,则对比度可能较高;如果分布狭窄,则对比度可能较低。
颜色直方图 (Color Histogram):
针对多通道的彩色图像(如RGB, HSV等)。
计算颜色直方图有多种方式:
单通道颜色直方图 (Per-channel Histogram):分别为每个颜色通道(例如R, G, B三个通道)独立计算一个灰度直方图。这样会得到多个直方图。
联合颜色直方图 (Joint Color Histogram / Multi-dimensional Histogram):将颜色视为一个多维向量(例如,RGB图像中的 (R, G, B) 元组)。直方图的bins对应于颜色空间中的不同颜色组合。例如,对于RGB图像,可以定义一个3D直方图,其中每个bin代表一种特定的 (R,G,B) 颜色。这种直方图能更完整地描述颜色分布,但维度较高,计算和存储开销也更大。通常需要对颜色空间进行量化(减少bins的数量)才能实际操作。例如,将每个通道的256个值量化为16或32个bins,那么3D直方图就有 (16^3) 或 (32^3) 个bins。
颜色索引直方图:如果图像是调色板图像(模式’P’),其直方图的横坐标是调色板中的颜色索引。
直方图的特点:
统计特性:直方图只关心像素值的统计分布,不包含像素的空间位置信息。这意味着两幅内容完全不同但颜色分布(或灰度分布)相似的图像,可能会有非常相似的直方图。
对几何变换的部分不变性:
平移不变性:图像整体平移不会改变其直方图。
旋转不变性:图像平面内旋转通常不会改变其直方图(假设旋转后没有像素值因插值而显著改变,或者没有像素被裁掉)。
尺度不变性(相对):如果图像被等比例缩放,其直方图的形状(归一化后)通常保持相似,但绝对像素计数会改变。归一化直方图(将每个bin的计数值除以总像素数,得到概率)对尺度变化更具鲁棒性。
对光照变化的敏感性:光照变化会显著改变像素的强度值,从而直接影响直方图的形状。例如,增加整体亮度会将直方图向右平移。
4.1.2 使用 Pillow 计算直方图
Pillow库的 Image 对象提供了一个方便的方法 histogram(mask=None, extrema=None) 来计算图像的直方图。
image.histogram(mask=None, extrema=None) 详解
功能:计算图像的直方图。
返回值:一个列表 (list),其中包含了每个像素强度值的计数值。
对于单通道图像(如 'L', 'P'),返回一个包含256个元素(或调色板大小的元素)的列表,每个元素是对应强度/索引的像素数。
对于多通道图像(如 'RGB', 'RGBA'),它会先将所有通道的像素值视为一个连续的序列,然后为每个通道计算直方图,并将这些直方图连接起来返回。例如,对于RGB图像,它会返回一个包含 (256 imes 3 = 768) 个元素的列表,前256个是R通道的直方图,接下来256个是G通道的,最后256个是B通道的。
对于 'I' 和 'F' 模式的图像,直方图的bins数量和范围可能会不同,需要查阅Pillow文档或通过 extrema 参数控制。
参数:
mask (Image object, optional):一个可选的掩码图像。如果提供,则只计算掩码图像中非零像素对应位置的原始图像像素的直方图。掩码图像必须与原图尺寸相同,并且模式为 '1' (二值) 或 'L' (灰度)。
extrema (2-tuple, optional):一个 (min, max) 元组,用于指定在计算直方图时考虑的像素值的范围。只对单波段浮点图像 ('F' 模式) 或16位整数图像 ('I;16' 等) 有意义。对于标准的8位图像,此参数通常被忽略,范围固定为0-255。
代码示例:计算并可视化直方图
from PIL import Image, ImageDraw # 导入Pillow的Image和ImageDraw模块
import matplotlib.pyplot as plt # 导入matplotlib.pyplot用于绘图,通常别名为plt
import numpy # 导入numpy库
import os # 导入os模块
# --- 确保示例图片和输出目录存在 ---
# (复用之前的目录设置和图片创建函数 ensure_dir, create_sample_image_if_not_exists)
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) if '__file__' in locals() else os.getcwd()
IMAGE_DIR_HIST = os.path.join(BASE_DIR, "images_for_histogram") # 定义直方图测试图片的目录
OUTPUT_DIR_HIST = os.path.join(BASE_DIR, "output_histogram_results") # 定义直方图结果的输出目录
ensure_dir(IMAGE_DIR_HIST) # 确保输入目录存在
ensure_dir(OUTPUT_DIR_HIST) # 确保输出目录存在
# 创建一些用于直方图测试的示例图片
hist_img1_path = os.path.join(IMAGE_DIR_HIST, "hist_img_normal.jpg") # 正常对比度图片路径
create_sample_image_if_not_exists(hist_img1_path, size=(200,150), color=(120,100,180), text="Normal") # 创建图片
hist_img2_path_dark = os.path.join(IMAGE_DIR_HIST, "hist_img_dark.jpg") # 偏暗图片路径
if not os.path.exists(hist_img2_path_dark) and os.path.exists(hist_img1_path): # 如果偏暗图片不存在且正常图片存在
try:
img_temp = Image.open(hist_img1_path) # 打开正常图片
enhancer = ImageEnhance.Brightness(img_temp) # 创建亮度增强器
img_dark = enhancer.enhance(0.5) # 亮度降低50%
img_dark.save(hist_img2_path_dark) # 保存为偏暗图片
print(f"已创建偏暗图片: {
hist_img2_path_dark}") # 打印创建信息
img_temp.close(); img_dark.close() # 关闭图片
except Exception as e: print(f"创建偏暗图片失败: {
e}") # 打印错误
elif not os.path.exists(hist_img2_path_dark): # 如果偏暗图片不存在 (且正常图片也不存在)
create_sample_image_if_not_exists(hist_img2_path_dark, size=(200,150), color=(60,50,90), text="Dark") # 创建一个独立的偏暗图片
hist_img3_path_bright = os.path.join(IMAGE_DIR_HIST, "hist_img_bright.jpg") # 偏亮图片路径
if not os.path.exists(hist_img3_path_bright) and os.path.exists(hist_img1_path): # 如果偏亮图片不存在且正常图片存在
try:
img_temp = Image.open(hist_img1_path) # 打开正常图片
enhancer = ImageEnhance.Brightness(img_temp) # 创建亮度增强器
img_bright = enhancer.enhance(1.5) # 亮度增加50%
img_bright.save(hist_img3_path_bright) # 保存为偏亮图片
print(f"已创建偏亮图片: {
hist_img3_path_bright}") # 打印创建信息
img_temp.close(); img_bright.close() # 关闭图片
except Exception as e: print(f"创建偏亮图片失败: {
e}") # 打印错误
elif not os.path.exists(hist_img3_path_bright): # 如果偏亮图片不存在
create_sample_image_if_not_exists(hist_img3_path_bright, size=(200,150), color=(180,150,220), text="Bright") # 创建一个独立的偏亮图片
hist_img4_path_low_contrast = os.path.join(IMAGE_DIR_HIST, "hist_img_low_contrast.jpg") # 低对比度图片路径
if not os.path.exists(hist_img4_path_low_contrast) and os.path.exists(hist_img1_path): # 如果低对比度图片不存在且正常图片存在
try:
img_temp = Image.open(hist_img1_path) # 打开正常图片
enhancer = ImageEnhance.Contrast(img_temp) # 创建对比度增强器
img_low_contrast = enhancer.enhance(0.5) # 对比度降低50%
img_low_contrast.save(hist_img4_path_low_contrast) # 保存为低对比度图片
print(f"已创建低对比度图片: {
hist_img4_path_low_contrast}") # 打印创建信息
img_temp.close(); img_low_contrast.close() # 关闭图片
except Exception as e: print(f"创建低对比度图片失败: {
e}") # 打印错误
elif not os.path.exists(hist_img4_path_low_contrast): # 如果低对比度图片不存在
create_sample_image_if_not_exists(hist_img4_path_low_contrast, size=(200,150), color=(100,90,140), text="LowCon") # 创建一个独立的低对比度图片
# --- 示例代码 ---
print("
--- 4.1 图像直方图基础 ---") # 打印章节标题
print(" 4.1.2 使用 Pillow 计算直方图") # 打印小节标题
def get_and_plot_histogram(image_path, save_plot_filename_prefix="hist_plot"): # 定义获取和绘制直方图的函数
"""加载图像,计算其直方图,并使用matplotlib绘制和保存直方图图像。"""
try:
img_pil = Image.open(image_path) # 使用Pillow加载图像
print(f"
处理图像: '{
os.path.basename(image_path)}', 模式: {
img_pil.mode}, 尺寸: {
img_pil.size}") # 打印图像信息
# 1. 计算直方图
# 对于Pillow的histogram()方法:
# - 单通道图 ('L', 'P'): 返回长度为256 (或调色板大小) 的列表。
# - 多通道图 ('RGB', 'RGBA'): 返回连接起来的各通道直方图列表 (如RGB是3*256=768)。
hist_data_raw = img_pil.histogram() # 调用histogram()方法获取原始直方图数据
plt.figure(figsize=(12, 6)) # 创建一个新的matplotlib图形,设置图形大小
plt.suptitle(f"直方图: {
os.path.basename(image_path)} (模式: {
img_pil.mode})", fontsize=14) # 设置图形的总标题
if img_pil.mode == 'L': # 如果是灰度图
print(f" 灰度直方图数据长度: {
len(hist_data_raw)}") # 打印直方图数据长度
# hist_data_raw 此时应该是一个长度为256的列表
plt.subplot(1, 1, 1) # 创建一个子图 (1行1列,第1个)
# x轴是0-255的灰度级
plt.bar(range(256), hist_data_raw, width=1.0, color='gray') # 绘制条形图表示直方图
plt.title("灰度直方图 (Grayscale)") # 设置子图标题
plt.xlabel("灰度级 (Pixel Intensity)") # 设置x轴标签
plt.ylabel("像素数量 (Frequency)") # 设置y轴标签
plt.xlim([0, 255]) # 设置x轴范围
elif img_pil.mode == 'RGB': # 如果是RGB彩色图
print(f" RGB直方图数据总长度: {
len(hist_data_raw)}") # 打印直方图数据总长度 (应为768)
# 将768长度的列表拆分为R, G, B三个通道的直方图数据
hist_r = hist_data_raw[0:256] # 红色通道直方图数据 (前256个)
hist_g = hist_data_raw[256:512] # 绿色通道直方图数据 (中间256个)
hist_b = hist_data_raw[512:768] # 蓝色通道直方图数据 (后256个)
# 绘制R通道直方图
plt.subplot(1, 3, 1) # 创建子图 (1行3列,第1个)
plt.bar(range(256), hist_r, width=1.0, color='red', alpha=0.7) # 绘制红色通道条形图,alpha设置透明度
plt.title("R 通道") # 子图标题
plt.xlabel("强度") # x轴标签
plt.ylabel("频率") # y轴标签
plt.xlim([0, 255]) # x轴范围
# 绘制G通道直方图
plt.subplot(1, 3, 2) # 创建子图 (1行3列,第2个)
plt.bar(range(256), hist_g, width=1.0, color='green', alpha=0.7) # 绘制绿色通道条形图
plt.title("G 通道") # 子图标题
plt.xlabel("强度") # x轴标签
# plt.ylabel("频率") # y轴标签 (可以省略以避免拥挤)
plt.xlim([0, 255]) # x轴范围
# 绘制B通道直方图
plt.subplot(1, 3, 3) # 创建子图 (1行3列,第3个)
plt.bar(range(256), hist_b, width=1.0, color='blue', alpha=0.7) # 绘制蓝色通道条形图
plt.title("B 通道") # 子图标题
plt.xlabel("强度") # x轴标签
# plt.ylabel("频率") # y轴标签
plt.xlim([0, 255]) # x轴范围
# 返回拆分后的直方图数据列表
result_hist_data = [hist_r, hist_g, hist_b] # 将各通道直方图数据存入列表
elif img_pil.mode == 'RGBA': # 如果是RGBA彩色图 (包含Alpha透明通道)
print(f" RGBA直方图数据总长度: {
len(hist_data_raw)}") # 打印直方图数据总长度 (应为1024)
hist_r = hist_data_raw[0:256] # R通道
hist_g = hist_data_raw[256:512] # G通道
hist_b = hist_data_raw[512:768] # B通道
hist_a = hist_data_raw[768:1024] # Alpha通道
# 绘制R, G, B通道 (与RGB类似,这里可以简化或选择是否绘制Alpha)
plt.subplot(2, 2, 1); plt.bar(range(256), hist_r, color='red', alpha=0.7); plt.title("R"); plt.xlim([0,255]) # R通道
plt.subplot(2, 2, 2); plt.bar(range(256), hist_g, color='green', alpha=0.7); plt.title("G"); plt.xlim([0,255]) # G通道
plt.subplot(2, 2, 3); plt.bar(range(256), hist_b, color='blue', alpha=0.7); plt.title("B"); plt.xlim([0,255]) # B通道
plt.subplot(2, 2, 4); plt.bar(range(256), hist_a, color='black', alpha=0.7); plt.title("Alpha"); plt.xlim([0,255]) # Alpha通道
result_hist_data = [hist_r, hist_g, hist_b, hist_a] # 返回包含RGBA各通道直方图的列表
elif img_pil.mode == 'P': # 如果是调色板图像
num_palette_colors = len(img_pil.getpalette()) // 3 if img_pil.getpalette() else 256 # 获取调色板颜色数 (通常是256)
print(f" 调色板图像直方图数据长度: {
len(hist_data_raw)} (预期与调色板大小相关, 通常 <=256)") # 打印信息
# hist_data_raw的长度可能小于256,如果图像使用的颜色索引没有达到255
# 我们需要创建一个长度为num_palette_colors(或256,如果hist_data_raw更短则以其为准)的数组来绘制
bins_to_plot = min(len(hist_data_raw), num_palette_colors) # 确定实际绘制的bin数量
plt.subplot(1, 1, 1) # 创建一个子图
plt.bar(range(bins_to_plot), hist_data_raw[:bins_to_plot], width=1.0, color='purple') # 绘制调色板索引的直方图
plt.title(f"调色板索引直方图 (Palette Index - 前 {
bins_to_plot} 个bins)") # 子图标题
plt.xlabel("调色板索引 (Palette Index)") # x轴标签
plt.ylabel("像素数量 (Frequency)") # y轴标签
plt.xlim([0, bins_to_plot -1 if bins_to_plot > 0 else 0]) # x轴范围
result_hist_data = [hist_data_raw] # 返回原始直方图数据列表
else: # 其他未知或不常用于直方图比较的模式
print(f" 模式 '{
img_pil.mode}' 的直方图绘制暂未针对性实现,将尝试显示原始数据。") # 打印提示
# 尝试绘制,但这可能不直观
# plt.subplot(1,1,1); plt.plot(hist_data_raw); plt.title(f"原始直方图数据 (模式: {img_pil.mode})")
result_hist_data = [hist_data_raw] # 返回原始直方图数据列表
plt.tight_layout(rect=[0, 0, 1, 0.96]) # 调整子图布局,避免标题和标签重叠,rect为总标题留空间
# 保存绘制的直方图图像
plot_filename = f"{
save_plot_filename_prefix}_{
os.path.splitext(os.path.basename(image_path))[0]}.png" # 构建保存文件名
plot_save_path = os.path.join(OUTPUT_DIR_HIST, plot_filename) # 构建完整保存路径
plt.savefig(plot_save_path) # 保存图形到文件
print(f" 直方图已绘制并保存到: {
plot_save_path}") # 打印保存信息
# plt.show() # 如果需要在脚本运行时显示图形,取消此行注释 (可能会暂停脚本)
plt.close() # 关闭当前图形,释放内存
img_pil.close() # 关闭Pillow图像对象
return result_hist_data # 返回处理后的直方图数据 (可能是列表的列表)
except FileNotFoundError: # 捕获文件未找到错误
print(f" 错误: 图像文件 '{
image_path}' 未找到。") # 打印错误信息
return None # 返回None
except ImportError: # 捕获matplotlib未安装的错误
print(f" 错误: matplotlib库未安装或无法导入。无法绘制直方图。请运行 'pip install matplotlib'。") # 打印安装提示
# 即使无法绘图,我们仍然可以尝试返回原始直方图数据 (如果已计算)
if 'img_pil' in locals() and img_pil: # 如果Pillow图像已加载
hist_data_fallback = img_pil.histogram() # 尝试获取直方图数据
img_pil.close() # 关闭图像
if img_pil.mode == 'RGB': # 如果是RGB
return [hist_data_fallback[0:256], hist_data_fallback[256:512], hist_data_fallback[512:768]] # 返回拆分后的数据
# (可以为其他模式添加类似的回退逻辑)
return [hist_data_fallback] # 否则返回原始列表的列表
return None # 如果图像都未加载,则返回None
except Exception as e: # 捕获其他所有错误
print(f" 计算或绘制直方图时发生错误 for '{
image_path}': {
e}") # 打印错误信息
# import traceback
# traceback.print_exc()
if 'img_pil' in locals() and img_pil: img_pil.close() # 尝试关闭图像
if 'plt' in locals() and plt.get_fignums(): plt.close('all') # 尝试关闭所有matplotlib图形
return None # 返回None
# --- 测试直方图计算和绘制 ---
print("
[直方图计算与绘制测试]") # 打印测试标题
# 测试正常对比度图像 (通常是彩色JPG,会被转为RGB处理)
hist_data1 = get_and_plot_histogram(hist_img1_path, "hist_plot_normal") # 调用函数处理正常图片
if hist_data1: print(f" '{
os.path.basename(hist_img1_path)}' 的直方图数据 (首通道前5个bin): {
hist_data1[0][:5] if hist_data1 and hist_data1[0] else 'N/A'}") # 打印部分直方图数据
# 测试偏暗图像
hist_data2 = get_and_plot_histogram(hist_img2_path_dark, "hist_plot_dark") # 调用函数处理偏暗图片
if hist_data2: print(f" '{
os.path.basename(hist_img2_path_dark)}' 的直方图数据 (首通道前5个bin): {
hist_data2[0][:5] if hist_data2 and hist_data2[0] else 'N/A'}") # 打印部分数据
# 测试偏亮图像
hist_data3 = get_and_plot_histogram(hist_img3_path_bright, "hist_plot_bright") # 调用函数处理偏亮图片
if hist_data3: print(f" '{
os.path.basename(hist_img3_path_bright)}' 的直方图数据 (首通道前5个bin): {
hist_data3[0][:5] if hist_data3 and hist_data3[0] else 'N/A'}") # 打印部分数据
# 测试低对比度图像
hist_data4 = get_and_plot_histogram(hist_img4_path_low_contrast, "hist_plot_low_contrast") # 调用函数处理低对比度图片
if hist_data4: print(f" '{
os.path.basename(hist_img4_path_low_contrast)}' 的直方图数据 (首通道前5个bin): {
hist_data4[0][:5] if hist_data4 and hist_data4[0] else 'N/A'}") # 打印部分数据
# 测试灰度图像 (需要先创建一个灰度图)
gray_img_path = os.path.join(IMAGE_DIR_HIST, "hist_img_grayscale.png") # 定义灰度图路径
if not os.path.exists(gray_img_path) and os.path.exists(hist_img1_path): # 如果灰度图不存在且正常图存在
try:
Image.open(hist_img1_path).convert('L').save(gray_img_path) # 打开正常图,转灰度,保存
print(f"已创建灰度测试图片: {
gray_img_path}") # 打印创建信息
except Exception as e: print(f"创建灰度图失败: {
e}") # 打印错误
elif not os.path.exists(gray_img_path): # 如果灰度图不存在
create_sample_image_if_not_exists(gray_img_path, size=(100,100), color=128, text="Gray") # 创建一个独立的灰度图 (注意color是整数)
# Image.new('L', size, color_int)
hist_data_gray = get_and_plot_histogram(gray_img_path, "hist_plot_gray") # 调用函数处理灰度图
if hist_data_gray: print(f" '{
os.path.basename(gray_img_path)}' 的灰度直方图数据 (前5个bin): {
hist_data_gray[0][:5] if hist_data_gray and hist_data_gray[0] else 'N/A'}") # 打印部分数据
print("
[直方图计算与绘制测试结束]") # 打印测试结束信息
代码逐行解释 (get_and_plot_histogram 函数部分):
def get_and_plot_histogram(...): 定义函数,接收图像路径和保存文件名前缀。
img_pil = Image.open(image_path): 加载图像。
hist_data_raw = img_pil.histogram(): 调用Pillow的 histogram() 方法获取原始直方图数据。这是一个扁平化的列表。
plt.figure(...), plt.suptitle(...): 使用 matplotlib 创建绘图区域并设置总标题。
模式判断 (if img_pil.mode == 'L': ... elif img_pil.mode == 'RGB': ...):
灰度图 ('L'): hist_data_raw 直接就是长度为256的灰度直方图。使用 plt.bar() 绘制条形图。
RGB图 ('RGB'): hist_data_raw 是长度为768的列表。代码将其拆分为 hist_r, hist_g, hist_b 三个长度为256的列表,分别对应R, G, B通道的直方图。然后为每个通道绘制一个子图。result_hist_data 保存拆分后的列表。
RGBA图 ('RGBA'): 类似RGB,但有四个通道 (R, G, B, Alpha),总长度1024。代码拆分并绘制所有四个通道。
调色板图 ('P'): 获取调色板颜色数,并绘制调色板索引的直方图。注意,hist_data_raw 的长度可能小于256,取决于实际用到的调色板索引数量。
其他模式: 打印提示,并尝试返回原始数据。
plt.subplot(...): 用于在同一图形中创建多个子图。
plt.bar(range(256), channel_hist, ...): 为每个通道绘制条形图,x轴是0-255的强度值,y轴是 channel_hist 中的像素计数值。
plt.title(...), plt.xlabel(...), plt.ylabel(...), plt.xlim(...): 设置子图的标题、轴标签和轴范围。
plt.tight_layout(...): 自动调整子图参数,使其很好地填充图形区域,并为总标题留出空间。
plot_save_path = ...; plt.savefig(plot_save_path): 构建保存路径并保存绘制的直方图为PNG文件。
plt.close(): 关闭图形,释放内存,这在循环处理多张图片时很重要。
img_pil.close(): 关闭Pillow图像对象。
return result_hist_data: 返回一个列表,其中每个元素是对应通道的直方图数据列表(对于灰度图或P模式图,外部列表只有一个元素)。
错误处理: 包含对 FileNotFoundError,ImportError (如果matplotlib未安装) 以及其他通用异常的捕获。如果无法绘图但能计算直方图,会尝试返回原始直方图数据。
通过运行上述代码,您可以看到不同特性(正常、偏暗、偏亮、低对比度、灰度)的图像所对应的直方图形状。例如:
偏暗图像的直方图会向左偏移(集中在低强度值区域)。
偏亮图像的直方图会向右偏移(集中在高强度值区域)。
低对比度图像的直方图会比较狭窄,集中在一个较小的强度范围内。
4.1.3 直方图归一化
在比较不同图像的直方图时,如果图像的尺寸不同,那么原始直方图中的像素计数值也会有很大差异,这不利于直接比较。为了消除尺寸的影响,通常会对直方图进行归一化 (Normalization)。
归一化直方图是将每个bin的计数值除以图像的总像素数,使得每个bin的值表示具有该强度(或颜色)的像素所占的比例或概率。归一化后,所有bin的值的总和为1。
计算公式:
令 (H(k)) 为原始直方图中第 (k) 个bin的计数值,图像总像素数为 (N_{total})。
则归一化直方图的第 (k) 个bin的值 (H_{norm}(k)) 为:
[ H_{norm}(k) = frac{H(k)}{N_{total}} ]
对于彩色图像:
如果分别计算了各通道的直方图(例如,R, G, B三个直方图 (H_R, H_G, H_B)),则可以分别对每个通道的直方图进行归一化。此时,(N_{total}) 是图像的总像素数(宽 x 高)。
[ H_{R,norm}(k) = frac{H_R(k)}{N_{total}} ]
[ H_{G,norm}(k) = frac{H_G(k)}{N_{total}} ]
[ H_{B,norm}(k) = frac{H_B(k)}{N_{total}} ]
注意:这里每个归一化通道直方图的和可能不为1,而是 (N_{channel_pixels} / N_{total}),其中 (N_{channel_pixels}) 是该通道的总计数值。更常见的做法是,如果将RGB图像视为一个整体,那么 (N_{total}) 应该是 width * height。然后,Pillow的 histogram() 返回的连接起来的768个值,如果按通道拆分,每个通道的256个值加起来应该等于 width * height。那么,将每个通道的每个bin除以 width * height,则每个归一化通道直方图的和将为1。
或者,另一种归一化方式是,先获得Pillow的原始直方图数据(例如RGB是768个值),然后将这个包含所有通道计数的列表中的每个值都除以图像的总像素数 (width * height)。这样得到的归一化直方图(768个值),其所有元素之和将等于通道数(例如RGB是3)。
在实际比较中,更常用的做法是:
灰度图:计算灰度直方图(256个bins),然后将每个bin的计数值除以总像素数。
彩色图:
方法一(分别归一化): 分别计算R, G, B三个通道的直方图(每个256 bins)。然后,对R直方图,将其每个bin除以总像素数;对G直方图,将其每个bin除以总像素数;对B直方图,将其每个bin除以总像素数。这样得到三个独立的归一化直方图,每个直方图的和为1。
方法二(联合归一化,较少用于Pillow直接输出): 如果是量化后的多维颜色直方图(例如,将RGB量化为 (8 imes 8 imes 8 = 512) 个颜色bins),则将每个颜色bin的计数值除以总像素数。
Pillow实现归一化直方图:
Pillow的 histogram() 方法返回的是原始计数值。我们需要手动进行归一化。
def normalize_histogram(hist_data_list, num_pixels): # 定义归一化直方图的函数
"""
对输入的直方图数据列表进行归一化。
假设 hist_data_list 是一个列表,其中每个元素是一个单通道的原始直方图数据 (列表)。
num_pixels 是图像的总像素数。
"""
normalized_hists = [] # 初始化一个空列表,用于存放归一化后的直方图
if not hist_data_list or num_pixels == 0: # 如果输入列表为空或总像素数为0
return normalized_hists # 返回空列表
for single_channel_hist_raw in hist_data_list: # 遍历输入直方图数据列表中的每个单通道直方图
if not isinstance(single_channel_hist_raw, list) and not isinstance(single_channel_hist_raw, numpy.ndarray): # 检查数据类型是否为列表或NumPy数组
print(f" 归一化警告: 输入的通道直方图不是列表或NumPy数组: {
type(single_channel_hist_raw)}") # 打印警告
# 可以选择跳过或尝试转换
normalized_hists.append(single_channel_hist_raw) # 直接添加原始数据
continue # 继续下一个循环
# 将原始计数值转换为浮点数,然后除以总像素数
# 使用numpy数组操作更高效
hist_np = numpy.array(single_channel_hist_raw, dtype=numpy.float64) # 将单通道直方图数据转换为NumPy float64数组
norm_hist_channel = hist_np / num_pixels # 对数组中的每个元素除以总像素数,得到归一化直方图
normalized_hists.append(norm_hist_channel.tolist()) # 将归一化后的NumPy数组转回列表,并添加到结果列表中
# 验证归一化 (可选)
# print(f" 单通道归一化后直方图 (sum={numpy.sum(norm_hist_channel):.4f}, len={len(norm_hist_channel)})")
return normalized_hists # 返回包含各通道归一化直方图的列表
# --- 测试直方图归一化 ---
print("
[直方图归一化测试]") # 打印测试标题
if os.path.exists(hist_img1_path): # 如果正常图片存在
try:
img_for_norm = Image.open(hist_img1_path) # 打开图片
num_total_pixels = img_for_norm.width * img_for_norm.height # 计算总像素数
print(f" 测试图像: '{
os.path.basename(hist_img1_path)}', 总像素数: {
num_total_pixels}") # 打印图像信息
raw_hist_data_list = [] # 初始化原始直方图数据列表
if img_for_norm.mode == 'L': # 如果是灰度图
raw_hist_data_list.append(img_for_norm.histogram()) # 添加灰度直方图
elif img_for_norm.mode == 'RGB': # 如果是RGB图
full_hist = img_for_norm.histogram() # 获取768长度的直方图
raw_hist_data_list.append(full_hist[0:256]) # R通道
raw_hist_data_list.append(full_hist[256:512]) # G通道
raw_hist_data_list.append(full_hist[512:768]) # B通道
# (可以为其他模式添加类似逻辑)
else:
print(f" 模式 {
img_for_norm.mode} 的归一化未专门处理,将使用原始histogram()输出。") # 打印提示
raw_hist_data_list.append(img_for_norm.histogram()) # 直接使用原始输出
if raw_hist_data_list: # 如果成功获取了原始直方图数据
normalized_histograms = normalize_histogram(raw_hist_data_list, num_total_pixels) # 调用函数进行归一化
if normalized_histograms: # 如果归一化成功
print(f" 归一化后的直方图 (通道数: {
len(normalized_histograms)}):") # 打印通道数
for i, norm_h in enumerate(normalized_histograms): # 遍历每个归一化后的通道直方图
channel_name = ['R', 'G', 'B', 'A', 'Gray', 'Palette'][i] if i < 6 else f"通道{
i}" # 定义通道名称
print(f" {
channel_name} 通道归一化直方图 (前5个bin): {
[f'{
x:.4f}' for x in norm_h[:5]]}") # 打印前5个bin的值,格式化为4位小数
print(f" {
channel_name} 通道归一化直方图之和: {
sum(norm_h):.4f} (应接近1.0)") # 打印直方图之和,验证是否接近1.0
else:
print(" 未能获取用于归一化的原始直方图数据。") # 打印错误
img_for_norm.close() # 关闭图片
except Exception as e:
print(f" 直方图归一化测试时发生错误: {
e}") # 打印错误信息
else:
print(f" 跳过直方图归一化测试,因为 '{
hist_img1_path}' 不存在。") # 打印跳过信息
print("
[直方图归一化测试结束]") # 打印测试结束信息
代码逐行解释 (normalize_histogram 函数部分):
def normalize_histogram(hist_data_list, num_pixels): 定义函数,接收一个列表 hist_data_list(其中每个元素是一个单通道的原始直方图计数列表)和图像的总像素数 num_pixels。
normalized_hists = []: 初始化一个空列表来存储归一化后的各通道直方图。
for single_channel_hist_raw in hist_data_list: 遍历传入的每个单通道原始直方图。
hist_np = numpy.array(single_channel_hist_raw, dtype=numpy.float64): 将该通道的原始直方图(一个计数值列表)转换为NumPy的 float64 数组,以便进行浮点数除法。
norm_hist_channel = hist_np / num_pixels: NumPy数组可以直接进行标量除法,这里将直方图的每个bin的计数值都除以总像素数 num_pixels。结果 norm_hist_channel 就是该通道的归一化直方图。
normalized_hists.append(norm_hist_channel.tolist()): 将归一化后的NumPy数组转换回Python列表,并添加到 normalized_hists 列表中。
return normalized_hists: 返回包含所有通道归一化直方图的列表。
在测试代码中,我们首先加载一张图片,计算总像素数,然后根据其模式(L或RGB)提取原始的单通道(或多通道拆分后的)直方图数据列表,最后调用 normalize_histogram 进行归一化,并打印部分结果以及每个归一化通道直方图的和(应该接近1.0)。
有了计算和归一化直方图的基础,我们接下来就可以讨论如何比较这些直方图来衡量图像的相似性了。
4.2 直方图比较方法
一旦我们获得了两幅图像的(通常是归一化的)直方图,就需要选择一种度量方法来比较这两个直方图的相似性。有多种方法可以实现这一点,每种方法都有其特点。
假设我们有两幅图像 (I_1) 和 (I_2),它们对应的(归一化)直方图分别为 (H_1) 和 (H_2)。每个直方图都有 (B) 个bins(例如,对于8位灰度图,B=256)。(H_1(k)) 和 (H_2(k)) 分别表示两个直方图在第 (k) 个bin的值。
4.2.1 直方图相交 (Histogram Intersection)
直方图相交是一种简单直观的比较方法。它计算两个直方图在每个对应bin上重叠部分的“面积”之和。
数学公式:
[ D_{intersect}(H_1, H_2) = sum_{k=0}^{B-1} min(H_1(k), H_2(k)) ]
解读:
对于每个bin (k),取 (H_1(k)) 和 (H_2(k)) 中的较小值,这代表了在该强度级别上两幅图像共有的像素(或像素比例,如果直方图已归一化)。
将所有bins上的这些最小值累加起来。
特性:
取值范围:
如果使用原始计数值直方图,(D_{intersect}) 的值介于0(完全不重叠)和图像的总像素数(如果两个直方图完全相同且图像尺寸相同)之间。
如果使用归一化直方图(每个直方图的和为1),则 (D_{intersect}) 的值介于0(完全不重叠)和1(两个归一化直方图完全相同)之间。此时,该值可以直接作为相似度得分,值越大越相似。
对称性:(D_{intersect}(H_1, H_2) = D_{intersect}(H_2, H_1))。
对噪声和微小变化相对鲁棒:相比于MSE等逐像素比较,直方图相交对图像中少量像素的剧烈变化不那么敏感,因为它已关注的是整体分布的重叠。
计算简单。
Pillow与NumPy实现:
def compare_histograms_intersection(hist1_norm_list, hist2_norm_list): # 定义直方图相交比较函数
"""
使用直方图相交方法比较两组归一化直方图。
hist1_norm_list 和 hist2_norm_list 是列表,其中每个元素是一个单通道的归一化直方图 (列表或NumPy数组)。
假设两个输入列表的通道数相同,且对应通道的直方图bin数量也相同。
"""
if len(hist1_norm_list) != len(hist2_norm_list): # 检查通道数是否一致
print(" 直方图相交错误: 输入的直方图列表通道数不匹配。") # 打印错误信息
return None # 返回None
num_channels = len(hist1_norm_list) # 获取通道数
if num_channels == 0: # 如果通道数为0
print(" 直方图相交错误: 输入的直方图列表为空。") # 打印错误信息
return 0.0 # 返回0.0相似度
total_intersection_score = 0.0 # 初始化总相交得分
for i in range(num_channels): # 遍历每个通道
h1_channel = numpy.array(hist1_norm_list[i], dtype=numpy.float64) # 将通道1的直方图转为NumPy float64数组
h2_channel = numpy.array(hist2_norm_list[i], dtype=numpy.float64) # 将通道2的直方图转为NumPy float64数组
if h1_channel.shape != h2_channel.shape: # 检查当前通道的直方图形状(bin数量)是否一致
print(f" 直方图相交错误: 通道 {
i} 的直方图bin数量不匹配。") # 打印错误信息
return None # 返回None
# 计算当前通道的相交值
intersection_channel = numpy.sum(numpy.minimum(h1_channel, h2_channel)) # 使用numpy.minimum计算逐元素的最小值,然后用numpy.sum求和
total_intersection_score += intersection_channel # 将当前通道的相交得分累加到总分
# print(f" 通道 {i} 的相交得分: {intersection_channel:.4f}") # (可选) 打印每个通道的得分
# 如果有多个通道,通常会将各通道的得分平均,以得到一个介于0和1之间的总相似度
# (假设每个通道的归一化直方图和为1,则每个通道的相交得分也在0-1之间)
average_intersection_score = total_intersection_score / num_channels # 计算平均相交得分
return average_intersection_score # 返回平均相交得分
# --- 测试直方图相交 ---
print("
--- 4.2.1 直方图相交 (Histogram Intersection) ---") # 打印小节标题
# 我们需要先获取几张图片的归一化直方图数据
# (假设 hist_img1_path, hist_img2_path_dark, hist_img3_path_bright, hist_img4_path_low_contrast, gray_img_path 已定义)
# 辅助函数:从路径获取并处理(归一化)直方图
def get_normalized_hist_from_path(image_path): # 定义从路径获取归一化直方图的辅助函数
"""加载图片,计算其直方图,并返回归一化后的直方图列表。"""
try:
img = Image.open(image_path) # 打开图片
num_pixels = img.width * img.height # 计算总像素数
raw_hists = [] # 初始化原始直方图列表
if img.mode == 'L': # 如果是灰度图
raw_hists.append(img.histogram()) # 添加灰度直方图
elif img.mode == 'RGB': # 如果是RGB图
h_rgb_flat = img.histogram() # 获取768长度的直方图
raw_hists.append(h_rgb_flat[0:256]) # R
raw_hists.append(h_rgb_flat[256:512]) # G
raw_hists.append(h_rgb_flat[512:768]) # B
elif img.mode == 'RGBA': # 如果是RGBA图
h_rgba_flat = img.histogram() # 获取1024长度的直方图
raw_hists.append(h_rgba_flat[0:256]) # R
raw_hists.append(h_rgba_flat[256:512]) # G
raw_hists.append(h_rgba_flat[512:768]) # B
raw_hists.append(h_rgba_flat[768:1024]) # A (通常在比较时不使用Alpha,但这里完整获取)
# 在实际比较中,可能只取RGB部分
else: # 其他模式
print(f" 警告: 模式 {
img.mode} 的直方图提取未优化,可能不适合比较。") # 打印警告
raw_hists.append(img.histogram()) # 添加原始直方图
img.close() # 关闭图片
if not raw_hists: return None # 如果没有获取到原始数据,返回None
normalized_hists_result = normalize_histogram(raw_hists, num_pixels) # 调用归一化函数
return normalized_hists_result # 返回归一化后的直方图列表
except Exception as e:
print(f" 从路径 '{
image_path}' 获取归一化直方图时出错: {
e}") # 打印错误信息
return None # 返回None
print("
[直方图相交测试用例]") # 打印测试标题
# 获取测试图片的归一化直方图
norm_hist1 = get_normalized_hist_from_path(hist_img1_path) # 正常图片的归一化直方图
norm_hist1_again = get_normalized_hist_from_path(hist_img1_path) # 再次获取正常图片的直方图 (用于自比较)
norm_hist_dark = get_normalized_hist_from_path(hist_img2_path_dark) # 偏暗图片的归一化直方图
norm_hist_bright = get_normalized_hist_from_path(hist_img3_path_bright) # 偏亮图片的归一化直方图
norm_hist_low_contrast = get_normalized_hist_from_path(hist_img4_path_low_contrast) # 低对比度图片的归一化直方图
norm_hist_gray = get_normalized_hist_from_path(gray_img_path) # 灰度图片的归一化直方图
# 用例1: 正常图 vs 自身 (预期相交得分为1.0)
if norm_hist1 and norm_hist1_again: # 如果两个直方图都成功获取
score1_self = compare_histograms_intersection(norm_hist1, norm_hist1_again) # 比较直方图
if score1_self is not None: # 如果比较成功
print(f" 正常图 vs 自身 (RGB): 直方图相交得分 = {
score1_self:.4f} (预期 ≈ 1.0)") # 打印得分
assert math.isclose(score1_self, 1.0, abs_tol=1e-5), "自身比较的相交得分应接近1.0" # 断言结果
# 用例2: 正常图 vs 偏暗图
if norm_hist1 and norm_hist_dark: # 如果两个直方图都成功获取
score_normal_dark = compare_histograms_intersection(norm_hist1, norm_hist_dark) # 比较直方图
if score_normal_dark is not None: # 如果比较成功
print(f" 正常图 vs 偏暗图 (RGB): 直方图相交得分 = {
score_normal_dark:.4f}") # 打印得分
# 用例3: 正常图 vs 偏亮图
if norm_hist1 and norm_hist_bright: # 如果两个直方图都成功获取
score_normal_bright = compare_histograms_intersection(norm_hist1, norm_hist_bright) # 比较直方图
if score_normal_bright is not None: # 如果比较成功
print(f" 正常图 vs 偏亮图 (RGB): 直方图相交得分 = {
score_normal_bright:.4f}") # 打印得分
# 用例4: 正常图 vs 低对比度图
if norm_hist1 and norm_hist_low_contrast: # 如果两个直方图都成功获取
score_normal_low_contrast = compare_histograms_intersection(norm_hist1, norm_hist_low_contrast) # 比较直方图
if score_normal_low_contrast is not None: # 如果比较成功
print(f" 正常图 vs 低对比度图 (RGB): 直方图相交得分 = {
score_normal_low_contrast:.4f}") # 打印得分
# 用例5: 灰度图 vs 自身 (如果灰度图的norm_hist_gray获取成功)
if norm_hist_gray: # 如果灰度图直方图获取成功
norm_hist_gray_again = get_normalized_hist_from_path(gray_img_path) # 再次获取灰度图直方图
if norm_hist_gray_again: # 如果再次获取也成功
score_gray_self = compare_histograms_intersection(norm_hist_gray, norm_hist_gray_again) # 比较直方图
if score_gray_self is not None: # 如果比较成功
print(f" 灰度图 vs 自身 (L): 直方图相交得分 = {
score_gray_self:.4f} (预期 ≈ 1.0)") # 打印得分
assert math.isclose(score_gray_self, 1.0, abs_tol=1e-5), "灰度图自身比较得分应接近1.0" # 断言结果
# 用例6: 比较不同内容但可能有相似颜色分布的图片 (需要额外创建图片)
# (这个场景更能体现直方图方法的局限性——丢失空间信息)
# 假设创建一个颜色与hist_img1_path相似但内容完全不同的图片
rand_color_img_path = os.path.join(IMAGE_DIR_HIST, "hist_img_rand_color_content.png") # 定义随机内容图片路径
if not os.path.exists(rand_color_img_path) and norm_hist1: # 如果图片不存在且正常图直方图存在
try:
# 创建一个随机噪声图,但其颜色取样自hist_img1_path的主要颜色 (简化版)
# 或者更简单,创建一个与hist_img1_path颜色基调相似但结构不同的图
img_rand = Image.new('RGB', (200,150), color=(110, 90, 170)) # 使用与hist_img1_path基色接近的颜色创建纯色块
draw_rand = ImageDraw.Draw(img_rand) # 获取Draw对象
# 在上面画一些随机的、颜色也接近的形状,打乱结构
for _ in range(20): # 绘制20个随机矩形
x1 = numpy.random.randint(0, 180) # 随机x1坐标
y1 = numpy.random.randint(0, 130) # 随机y1坐标
x2 = x1 + numpy.random.randint(10, 20) # 随机x2坐标
y2 = y1 + numpy.random.randint(10, 20) # 随机y2坐标
fill_r = numpy.random.randint(80, 150) # 随机R值
fill_g = numpy.random.randint(70, 130) # 随机G值
fill_b = numpy.random.randint(150, 200) # 随机B值
draw_rand.rectangle([x1,y1,x2,y2], fill=(fill_r, fill_g, fill_b)) # 绘制矩形
img_rand.save(rand_color_img_path) # 保存图片
print(f"已创建随机内容但颜色基调相似的图片: {
rand_color_img_path}") # 打印创建信息
img_rand.close() # 关闭图片
except Exception as e_rand: print(f"创建随机内容图片失败: {
e_rand}") # 打印错误
norm_hist_rand_color = get_normalized_hist_from_path(rand_color_img_path) # 获取随机内容图片的直方图
if norm_hist1 and norm_hist_rand_color: # 如果两个直方图都获取成功
score_normal_rand_color = compare_histograms_intersection(norm_hist1, norm_hist_rand_color) # 比较直方图
if score_normal_rand_color is not None: # 如果比较成功
print(f" 正常图 vs 随机内容但颜色基调相似图 (RGB): 直方图相交得分 = {
score_normal_rand_color:.4f}") # 打印得分
# 这个得分可能会比预期高,显示了直方图方法的局限
print("
[直方图相交测试用例结束]") # 打印测试结束信息
代码逐行解释 (compare_histograms_intersection 函数部分):
def compare_histograms_intersection(hist1_norm_list, hist2_norm_list): 定义函数,接收两个列表,每个列表包含对应图像的(已归一化的)单通道直方图。
if len(hist1_norm_list) != len(hist2_norm_list): 检查两组直方图的通道数是否相同。如果不同,则无法比较,返回 None。
total_intersection_score = 0.0: 初始化总的相交得分。
for i in range(num_channels): 遍历每个颜色通道(或灰度通道)。
h1_channel = numpy.array(hist1_norm_list[i], dtype=numpy.float64): 将当前通道的直方图数据(来自第一个图像)转换为NumPy float64 数组。
h2_channel = numpy.array(hist2_norm_list[i], dtype=numpy.float64): 类似地转换第二个图像的对应通道直方图。
if h1_channel.shape != h2_channel.shape: 检查当前比较的两个单通道直方图的bin数量是否相同。如果不同,则无法比较,返回 None。
intersection_channel = numpy.sum(numpy.minimum(h1_channel, h2_channel)): 这是计算单个通道相交的核心。
numpy.minimum(h1_channel, h2_channel): 对两个NumPy数组进行逐元素比较,返回一个新的数组,其中每个元素是对应位置的较小值。
numpy.sum(...): 将上述结果数组中的所有元素(即每个bin上的最小重叠)相加,得到该通道的总相交值。
total_intersection_score += intersection_channel: 将当前通道的相交得分累加到总得分中。
average_intersection_score = total_intersection_score / num_channels: 如果是多通道图像(例如RGB),将所有通道的相交得分总和除以通道数,得到一个平均的相交得分。这使得最终得分仍然大致在0到1的范围内(假设每个归一化通道直方图的和为1)。
return average_intersection_score: 返回计算得到的(平均)直方图相交得分。
get_normalized_hist_from_path 辅助函数解释:
这个函数封装了从图像文件路径加载图像、根据模式提取原始直方图数据(并为RGB/RGBA做拆分)、然后调用 normalize_histogram 进行归一化的整个流程。这使得在测试用例中获取归一化直方图更为便捷。对RGBA图像的处理,它提取了所有四个通道,但在实际比较中,可能只对RGB通道进行比较,或者将Alpha通道区别对待。
直方图相交是一个简单且计算成本较低的方法。当图像的整体颜色或亮度分布是区分它们的重要因素,并且对微小的空间变化不敏感时,它能提供一个有用的相似性度量。然而,它的主要缺点是完全忽略了像素的空间布局信息。
接下来我们将讨论其他的直方图比较方法。
4.2.2 相关性 (Correlation)
直方图相关性将两个直方图视为两个向量(或序列),并计算它们之间的统计相关系数(通常是皮尔逊相关系数)。相关系数衡量了两个变量(在这里是两个直方图在对应bins上的值)线性相关的程度。
数学公式 (皮尔逊相关系数):
对于两个直方图 (H_1) 和 (H_2),每个都有 (B) 个bins,其相关性 (D_{corr}(H_1, H_2)) 可以计算为:
[ D_{corr}(H_1, H_2) = frac{sum_{k=0}^{B-1} (H_1(k) – ar{H}_1)(H_2(k) – ar{H}2)}{sqrt{sum{k=0}^{B-1} (H_1(k) – ar{H}1)^2} sqrt{sum{k=0}^{B-1} (H_2(k) – ar{H}_2)^2}} ]
其中:
(H_1(k)) 和 (H_2(k)) 是直方图在第 (k) 个bin的值。
(ar{H}1 = frac{1}{B} sum{k=0}^{B-1} H_1(k)) 是直方图 (H_1) 所有bin值的平均值。
(ar{H}2 = frac{1}{B} sum{k=0}^{B-1} H_2(k)) 是直方图 (H_2) 所有bin值的平均值。
解读:
分子是 (H_1) 和 (H_2) 在对应bin上偏离各自均值的乘积之和(协方差的分子部分,如果除以B-1)。
分母是 (H_1) 和 (H_2) 各自标准差的乘积(如果标准差公式是除以B的话,这里没有除以B)。
相关系数的值域是 ([-1, 1])。
+1: 表示完全正线性相关。如果一个直方图的某个bin值高于其均值,另一个直方图对应bin的值也倾向于高于其均值,反之亦然。形状非常相似。
-1: 表示完全负线性相关。如果一个直方图的某个bin值高于其均值,另一个直方图对应bin的值倾向于低于其均值。形状相反。
0: 表示没有线性相关性。
在直方图比较中,我们通常期望得到正相关值,越接近+1表示两个直方图的形状越相似。
特性:
对直方图的整体形状敏感:它不仅仅看值的重叠,还看两个直方图在各个bin上增减趋势的一致性。
对直方图的绝对尺度不敏感:如果一个直方图是另一个直方图的线性变换(例如 (H_2(k) = a cdot H_1(k) + b),其中 (a>0)),它们的相关系数仍然是1。这意味着如果使用未归一化的直方图,只要形状相似,即使总像素数不同,相关性也可以很高。但通常还是建议使用归一化直方图进行比较。
对称性:(D_{corr}(H_1, H_2) = D_{corr}(H_2, H_1))。
Pillow与NumPy/SciPy实现:
NumPy 提供了计算相关系数的函数 numpy.corrcoef()。
# (numpy 模块已导入)
def compare_histograms_correlation(hist1_norm_list, hist2_norm_list): # 定义直方图相关性比较函数
"""
使用皮尔逊相关系数比较两组归一化直方图。
hist1_norm_list 和 hist2_norm_list 是列表,其中每个元素是一个单通道的归一化直方图。
"""
if len(hist1_norm_list) != len(hist2_norm_list): # 检查通道数是否一致
print(" 直方图相关性错误: 输入的直方图列表通道数不匹配。") # 打印错误信息
return None # 返回None
num_channels = len(hist1_norm_list) # 获取通道数
if num_channels == 0: # 如果通道数为0
print(" 直方图相关性错误: 输入的直方图列表为空。") # 打印错误信息
return 0.0 # 返回0.0
total_correlation_score = 0.0 # 初始化总相关性得分
for i in range(num_channels): # 遍历每个通道
h1_channel = numpy.array(hist1_norm_list[i], dtype=numpy.float64) # 将通道1的直方图转为NumPy float64数组
h2_channel = numpy.array(hist2_norm_list[i], dtype=numpy.float64) # 将通道2的直方图转为NumPy float64数组
if h1_channel.shape != h2_channel.shape: # 检查当前通道的直方图形状是否一致
print(f" 直方图相关性错误: 通道 {
i} 的直方图bin数量不匹配。") # 打印错误信息
return None # 返回None
if len(h1_channel) < 2: # 如果bin数量小于2,无法计算相关性 (标准差会是0或NaN)
print(f" 直方图相关性警告: 通道 {
i} 的直方图bin数量 ({
len(h1_channel)}) 过少,无法计算有效相关性。跳过此通道。") # 打印警告
# 可以选择给一个默认值,或者不计入平均
# 如果只有一个通道且bin数少,则整体返回None或0
if num_channels == 1: return 0.0 # 如果只有一个通道且无法计算,则返回0
continue # 跳过当前通道
# 使用 numpy.corrcoef() 计算相关系数矩阵
# corrcoef 会返回一个2x2的矩阵:
# [[corr(h1,h1), corr(h1,h2)],
# [corr(h2,h1), corr(h2,h2)]]
# 我们需要的是非对角线元素,例如 [0, 1] 或 [1, 0]
try:
correlation_matrix = numpy.corrcoef(h1_channel, h2_channel) # 计算相关系数矩阵
# 检查返回的矩阵是否有效 (例如,如果一个直方图所有值都相同,标准差为0,corrcoef可能返回NaN)
if numpy.isnan(correlation_matrix[0, 1]): # 如果相关系数是NaN (Not a Number)
# 当一个或两个直方图的标准差为0时 (例如,纯色图像的直方图只有一个非零bin)
# 此时,如果两个直方图都在同一个bin上有值,可以认为它们是相关的(例如得分为1)
# 如果一个有值,一个没值(或都在不同bin),则不相关(例如得分为0)
# 这是一个需要特殊处理的边界情况。
# 简单处理:如果NaN,认为不相似或给0分。
print(f" 直方图相关性警告: 通道 {
i} 计算得到NaN,可能因标准差为零。设此通道相关性为0。") # 打印警告
correlation_channel = 0.0 # 将NaN情况下的相关性设为0
else:
correlation_channel = correlation_matrix[0, 1] # 获取h1_channel和h2_channel之间的相关系数
except Exception as e_corr: # 捕获计算相关性时可能发生的其他错误
print(f" 直方图相关性计算错误: 通道 {
i} 发生错误: {
e_corr}。设此通道相关性为0。") # 打印错误信息
correlation_channel = 0.0 # 将错误情况下的相关性设为0
total_correlation_score += correlation_channel # 将当前通道的相关性得分累加到总分
# print(f" 通道 {i} 的相关性得分: {correlation_channel:.4f}") # (可选) 打印每个通道的得分
# 平均各通道的相关性得分
average_correlation_score = total_correlation_score / num_channels # 计算平均相关性得分
return average_correlation_score # 返回平均相关性得分
# --- 测试直方图相关性 ---
print("
--- 4.2.2 相关性 (Correlation) ---") # 打印小节标题
print("
[直方图相关性测试用例]") # 打印测试标题
# (复用之前获取的norm_hist1, norm_hist1_again, norm_hist_dark 等归一化直方图数据)
# 用例1: 正常图 vs 自身 (预期相关性 ≈ 1.0)
if norm_hist1 and norm_hist1_again: # 如果两个直方图都成功获取
corr1_self = compare_histograms_correlation(norm_hist1, norm_hist1_again) # 比较直方图
if corr1_self is not None: # 如果比较成功
print(f" 正常图 vs 自身 (RGB): 直方图相关性 = {
corr1_self:.4f} (预期 ≈ 1.0)") # 打印得分
assert math.isclose(corr1_self, 1.0, abs_tol=1e-5), "自身比较的相关性应接近1.0" # 断言结果
# 用例2: 正常图 vs 偏暗图
if norm_hist1 and norm_hist_dark: # 如果两个直方图都成功获取
corr_normal_dark = compare_histograms_correlation(norm_hist1, norm_hist_dark) # 比较直方图
if corr_normal_dark is not None: # 如果比较成功
print(f" 正常图 vs 偏暗图 (RGB): 直方图相关性 = {
corr_normal_dark:.4f}") # 打印得分
# 偏暗图的直方图形状可能与正常图有一定偏移,相关性会降低
# 用例3: 正常图 vs 偏亮图
if norm_hist1 and norm_hist_bright: # 如果两个直方图都成功获取
corr_normal_bright = compare_histograms_correlation(norm_hist1, norm_hist_bright) # 比较直方图
if corr_normal_bright is not None: # 如果比较成功
print(f" 正常图 vs 偏亮图 (RGB): 直方图相关性 = {
corr_normal_bright:.4f}") # 打印得分
# 用例4: 正常图 vs 低对比度图
# 低对比度图的直方图会更集中,形状变化较大,相关性可能显著降低
if norm_hist1 and norm_hist_low_contrast: # 如果两个直方图都成功获取
corr_normal_low_contrast = compare_histograms_correlation(norm_hist1, norm_hist_low_contrast) # 比较直方图
if corr_normal_low_contrast is not None: # 如果比较成功
print(f" 正常图 vs 低对比度图 (RGB): 直方图相关性 = {
corr_normal_low_contrast:.4f}") # 打印得分
# 用例5: 灰度图 vs 自身
if norm_hist_gray: # 如果灰度图直方图获取成功
norm_hist_gray_again_corr = get_normalized_hist_from_path(gray_img_path) # 再次获取灰度图直方图
if norm_hist_gray_again_corr: # 如果再次获取也成功
corr_gray_self = compare_histograms_correlation(norm_hist_gray, norm_hist_gray_again_corr) # 比较直方图
if corr_gray_self is not None: # 如果比较成功
print(f" 灰度图 vs 自身 (L): 直方图相关性 = {
corr_gray_self:.4f} (预期 ≈ 1.0)") # 打印得分
assert math.isclose(corr_gray_self, 1.0, abs_tol=1e-5), "灰度图自身比较相关性应接近1.0" # 断言结果
# 用例6: 正常图 vs 随机内容但颜色基调相似图
if norm_hist1 and norm_hist_rand_color: # 如果两个直方图都获取成功
corr_normal_rand_color = compare_histograms_correlation(norm_hist1, norm_hist_rand_color) # 比较直方图
if corr_normal_rand_color is not None: # 如果比较成功
print(f" 正常图 vs 随机内容但颜色基调相似图 (RGB): 直方图相关性 = {
corr_normal_rand_color:.4f}") # 打印得分
# 这个得分可能也较高,如果颜色分布确实相似
print("
[直方图相关性测试用例结束]") # 打印测试结束信息
代码逐行解释 (compare_histograms_correlation 函数部分):
与 compare_histograms_intersection 类似的参数检查和通道遍历结构。
if len(h1_channel) < 2: 检查直方图的bin数量是否至少为2。如果少于2个bins(例如,只有一个bin),则标准差无法有意义地计算(或为0),导致相关系数未定义或为NaN。这种情况下,代码会打印警告并跳过该通道(或对单通道情况返回0)。
correlation_matrix = numpy.corrcoef(h1_channel, h2_channel): 使用 numpy.corrcoef() 计算两个一维数组(单通道直方图)之间的皮尔逊相关系数。它返回一个2×2的相关矩阵。
if numpy.isnan(correlation_matrix[0, 1]): 检查计算出的相关系数是否为NaN。当输入数组的标准差为零时(例如,直方图只有一个非零值,代表图像是纯色的),corrcoef 可能会返回NaN。代码中对此进行了简单处理,将NaN情况下的相关性视为0。更复杂的处理可能需要根据具体情况判断(例如,如果两个直方图都在同一个bin上是100%,其他都为0,那么它们应该被认为是完全相关的)。
correlation_channel = correlation_matrix[0, 1]: 从相关矩阵中提取我们感兴趣的 (H_1) 和 (H_2) 之间的相关系数。
其余部分(累加得分、平均得分、返回结果)与相交方法类似。
相关性方法能够捕捉直方图形状的相似性,即使它们的绝对值有所不同(例如,由于光照强度的整体缩放)。然而,它对直方图的平移(例如,整体变亮或变暗导致直方图向右或向左移动)比较敏感,因为这会改变偏离均值的模式。
4.2.3 卡方距离 (Chi-Squared Distance)
卡方距离(或卡方检验统计量)是另一种常用于比较直方图(或其他频数分布)的度量。它衡量的是观测频率(一个直方图)与期望频率(另一个直方图)之间的差异。
数学公式:
对于两个直方图 (H_1) 和 (H_2),每个都有 (B) 个bins,卡方距离 (D_{chi^2}(H_1, H_2)) 的一种常见形式是:
[ D_{chi^2}(H_1, H_2) = sum_{k=0}^{B-1} frac{(H_1(k) – H_2(k))^2}{H_1(k) + H_2(k)} ]
或者有时也用:
[ D_{chi^2}(H_1, H_2) = sum_{k=0}^{B-1} frac{(H_1(k) – H_2(k))^2}{H_1(k)} quad ( ext{如果 } H_1 ext{ 是期望分布}) ]
或者对称形式(常用于直方图比较):
[ D_{chi^2}(H_1, H_2) = frac{1}{2} sum_{k=0}^{B-1} frac{(H_1(k) – H_2(k))^2}{H_1(k) + H_2(k)} ]
(注意:不同的文献和库可能对卡方距离的定义有细微差别,例如分母的选择,或者是否有1/2的系数。在比较不同实现的卡方距离结果时需要注意这一点。)
解读:
((H_1(k) – H_2(k))^2): 计算两个直方图在第 (k) 个bin上差值的平方。
分母 (H_1(k) + H_2(k)) (或 (H_1(k))) 起到归一化的作用,使得差异相对于bin的计数值本身的大小来衡量。如果一个bin的计数值很小,那么即使是很小的绝对差异,其相对差异也可能很大。
将所有bins上的这些归一化平方差累加起来。
处理分母为0的情况:当 (H_1(k) + H_2(k) = 0)(即两个直方图在该bin上都为0)时,该项对总和的贡献为0。如果只有 (H_1(k)=0) (在使用第二种公式时),则可能导致除零。实际实现中需要处理这些情况,例如,如果分母为0,则该项跳过或设为0(如果分子也为0)或一个大值(如果分子非0)。通常,如果 (H_1(k) + H_2(k) = 0),那么 (H_1(k)) 和 (H_2(k)) 必然都为0,此时分子也为0,该项为0。
特性:
非负性:(D_{chi^2}(H_1, H_2) ge 0)。
零值:当且仅当 (H_1(k) = H_2(k)) 对所有 (k) 成立时,(D_{chi^2}(H_1, H_2) = 0)。表示两个直方图完全相同。
距离度量:值越大,表示两个直方图差异越大,相似度越低。它不是一个严格的距离度量(metric),例如它不一定满足三角不等式。
对低计数值的bin的差异更敏感:由于分母的存在,如果一个bin的期望计数值(例如 (H_1(k)) 或它们的和)较小,那么即使是较小的绝对差异 ((H_1(k) – H_2(k))) 也会导致该项贡献一个较大的值。
对称性(对于使用 (H_1(k)+H_2(k)) 作为分母的形式)。
Pillow与NumPy/SciPy实现:
SciPy 库的 scipy.stats.chisquare 函数用于执行卡方拟合优度检验,它计算的是检验统计量,与我们这里定义的距离形式不完全相同(它通常比较观测频数与期望频数)。我们可以手动实现上述卡方距离公式。
# (numpy 模块已导入)
def compare_histograms_chi_squared(hist1_norm_list, hist2_norm_list, eps=1e-10): # 定义卡方距离比较函数, eps用于避免除零
"""
使用卡方距离比较两组归一化直方图。
hist1_norm_list 和 hist2_norm_list 是列表,其中每个元素是一个单通道的归一化直方图。
返回的是距离值,值越小表示越相似。
eps 是一个小常数,用于防止分母为零。
"""
if len(hist1_norm_list) != len(hist2_norm_list): # 检查通道数是否一致
print(" 卡方距离错误: 输入的直方图列表通道数不匹配。") # 打印错误信息
return None # 返回None
num_channels = len(hist1_norm_list) # 获取通道数
if num_channels == 0: # 如果通道数为0
print(" 卡方距离错误: 输入的直方图列表为空。") # 打印错误信息
return float('inf') # 可以返回无穷大表示差异最大,或0如果认为空列表是相似的
total_chi_squared_distance = 0.0 # 初始化总卡方距离
for i in range(num_channels): # 遍历每个通道
h1_channel = numpy.array(hist1_norm_list[i], dtype=numpy.float64) # 将通道1的直方图转为NumPy float64数组
h2_channel = numpy.array(hist2_norm_list[i], dtype=numpy.float64) # 将通道2的直方图转为NumPy float64数组
if h1_channel.shape != h2_channel.shape: # 检查当前通道的直方图形状是否一致
print(f" 卡方距离错误: 通道 {
i} 的直方图bin数量不匹配。") # 打印错误信息
return None # 返回None
# 计算卡方距离的分子: (h1 - h2)^2
numerator = numpy.square(h1_channel - h2_channel) # 计算差值的平方
# 计算卡方距离的分母: h1 + h2
# 添加一个小常数eps到分母,以避免当 h1[k] + h2[k] = 0 时除以零的错误。
# 如果 h1[k] + h2[k] = 0,那么 h1[k] 和 h2[k] 都必须是0 (因为它们是非负的),此时分子也为0。
# (h1[k]-h2[k])^2 / (h1[k]+h2[k]+eps) 当h1,h2都为0时,结果是 0 / eps,接近0。
denominator = h1_channel + h2_channel + eps # 计算和,并加上eps
# 另一种处理分母为0的方式:只在分母非零时计算
# chi_squared_terms = numpy.zeros_like(h1_channel) # 初始化为0
# valid_mask = (h1_channel + h2_channel) > 0 # 创建一个掩码,标记分母大于0的位置
# chi_squared_terms[valid_mask] = numerator[valid_mask] / (h1_channel[valid_mask] + h2_channel[valid_mask])
chi_squared_terms = numerator / denominator # 计算每个bin的卡方项
distance_channel = 0.5 * numpy.sum(chi_squared_terms) # 对所有bin的卡方项求和,并乘以0.5 (对称形式)
# (如果不乘以0.5,也是一种有效的卡方距离变体)
total_chi_squared_distance += distance_channel # 将当前通道的卡方距离累加到总距离
# print(f" 通道 {i} 的卡方距离: {distance_channel:.4f}") # (可选) 打印每个通道的距离
# 平均各通道的卡方距离 (或者直接加总,取决于应用需求)
average_chi_squared_distance = total_chi_squared_distance / num_channels # 计算平均卡方距离
# 卡方距离是“距离”,值越小越相似。
# 如果需要一个“相似度”得分 (0到1,1表示最相似),可以对其进行转换,
# 例如: similarity = 1 / (1 + average_chi_squared_distance)
# 或者使用更复杂的归一化方法。但通常直接使用距离值。
return average_chi_squared_distance # 返回平均卡方距离
# --- 测试卡方距离 ---
print("
--- 4.2.3 卡方距离 (Chi-Squared Distance) ---") # 打印小节标题
print("
[卡方距离测试用例]") # 打印测试标题
# (复用之前获取的norm_hist1, norm_hist1_again, norm_hist_dark 等归一化直方图数据)
# 用例1: 正常图 vs 自身 (预期卡方距离 ≈ 0.0)
if norm_hist1 and norm_hist1_again: # 如果两个直方图都成功获取
dist1_self_chi = compare_histograms_chi_squared(norm_hist1, norm_hist1_again) # 比较直方图
if dist1_self_chi is not None: # 如果比较成功
print(f" 正常图 vs 自身 (RGB): 卡方距离 = {
dist1_self_chi:.4f} (预期 ≈ 0.0)") # 打印距离
assert math.isclose(dist1_self_chi, 0.0, abs_tol=1e-5), "自身比较的卡方距离应接近0.0" # 断言结果
# 用例2: 正常图 vs 偏暗图
if norm_hist1 and norm_hist_dark: # 如果两个直方图都成功获取
dist_normal_dark_chi = compare_histograms_chi_squared(norm_hist1, norm_hist_dark) # 比较直方图
if dist_normal_dark_chi is not None: # 如果比较成功
print(f" 正常图 vs 偏暗图 (RGB): 卡方距离 = {
dist_normal_dark_chi:.4f}") # 打印距离
# 用例3: 正常图 vs 偏亮图
if norm_hist1 and norm_hist_bright: # 如果两个直方图都成功获取
dist_normal_bright_chi = compare_histograms_chi_squared(norm_hist1, norm_hist_bright) # 比较直方图
if dist_normal_bright_chi is not None: # 如果比较成功
print(f" 正常图 vs 偏亮图 (RGB): 卡方距离 = {
dist_normal_bright_chi:.4f}") # 打印距离
# 用例4: 正常图 vs 低对比度图
if norm_hist1 and norm_hist_low_contrast: # 如果两个直方图都成功获取
dist_normal_low_contrast_chi = compare_histograms_chi_squared(norm_hist1, norm_hist_low_contrast) # 比较直方图
if dist_normal_low_contrast_chi is not None: # 如果比较成功
print(f" 正常图 vs 低对比度图 (RGB): 卡方距离 = {
dist_normal_low_contrast_chi:.4f}") # 打印距离
if dist_normal_dark_chi is not None: # 如果用例2也成功
# 通常低对比度图与正常图的直方图差异会比偏暗/偏亮图更大
print(f" (对比: 正常vs偏暗 卡方距离 = {
dist_normal_dark_chi:.4f})") # 打印对比信息
# 用例5: 灰度图 vs 自身
if norm_hist_gray: # 如果灰度图直方图获取成功
norm_hist_gray_again_chi = get_normalized_hist_from_path(gray_img_path) # 再次获取灰度图直方图
if norm_hist_gray_again_chi: # 如果再次获取也成功
dist_gray_self_chi = compare_histograms_chi_squared(norm_hist_gray, norm_hist_gray_again_chi) # 比较直方图
if dist_gray_self_chi is not None: # 如果比较成功
print(f" 灰度图 vs 自身 (L): 卡方距离 = {
dist_gray_self_chi:.4f} (预期 ≈ 0.0)") # 打印距离
assert math.isclose(dist_gray_self_chi, 0.0, abs_tol=1e-5), "灰度图自身比较卡方距离应接近0.0" # 断言结果
# 用例6: 正常图 vs 随机内容但颜色基调相似图
if norm_hist1 and norm_hist_rand_color: # 如果两个直方图都获取成功
dist_normal_rand_color_chi = compare_histograms_chi_squared(norm_hist1, norm_hist_rand_color) # 比较直方图
if dist_normal_rand_color_chi is not None: # 如果比较成功
print(f" 正常图 vs 随机内容但颜色基调相似图 (RGB): 卡方距离 = {
dist_normal_rand_color_chi:.4f}") # 打印距离
# 如果颜色分布确实相似,这个距离可能较小
print("
[卡方距离测试用例结束]") # 打印测试结束信息
代码逐行解释 (compare_histograms_chi_squared 函数部分):
与前两种比较方法类似的参数检查和通道遍历结构。
eps=1e-10: 定义一个小常数 eps,用于加到分母中以防止除以零的错误。这是一个常见的技巧。
numerator = numpy.square(h1_channel - h2_channel): 计算每个bin上差值的平方,即 ((H_1(k) – H_2(k))^2)。
denominator = h1_channel + h2_channel + eps: 计算每个bin上两个直方图值之和,并加上 eps,即 (H_1(k) + H_2(k) + epsilon)。
chi_squared_terms = numerator / denominator: 计算每个bin的卡方项 (frac{(H_1(k) – H_2(k))^2}{H_1(k) + H_2(k) + epsilon})。
distance_channel = 0.5 * numpy.sum(chi_squared_terms): 将所有bin的卡方项加起来,并乘以0.5(对应于对称形式的卡方距离)。这个 0.5 的系数有时省略,但加上它可以使某些理论推导更方便,或者与其他库的实现保持一致。
其余部分(累加距离、平均距离、返回结果)与前述方法类似,但需要记住这里返回的是距离(值越小越相似)。
卡方距离对两个直方图之间的差异赋予了权重,特别是当某个bin的期望频率(即两个直方图在该bin的和)较小时,即使是很小的绝对差异也会被放大。这使得它对稀有事件(或低频像素值)的差异比较敏感。
4.2.4 巴氏距离 (Bhattacharyya Distance)
巴氏距离(或巴氏系数)是另一种衡量两个概率分布(或归一化直方图)之间相似性的方法。它与Hellinger距离密切相关。
巴氏系数 (Bhattacharyya Coefficient):
首先定义巴氏系数 (BC(H_1, H_2)),对于两个归一化直方图 (H_1) 和 (H_2)(每个bin的值表示概率,且 (sum H_1(k) = 1, sum H_2(k) = 1)):
[ BC(H_1, H_2) = sum_{k=0}^{B-1} sqrt{H_1(k) cdot H_2(k)} ]
解读:
对于每个bin (k),计算两个直方图在该bin上值的乘积的平方根。
将所有bins上的这些值累加起来。
巴氏系数的值域是 [0, 1]。
当 (H_1) 和 (H_2) 完全相同时,(BC(H_1, H_2) = sum H_1(k) = 1)。
当 (H_1) 和 (H_2) 在任何非零bin上都没有重叠时(即如果 (H_1(k)>0),则 (H_2(k)=0),反之亦然),(BC(H_1, H_2) = 0)。
巴氏系数本身可以作为一种相似度度量,值越大越相似。
巴氏距离 (Bhattacharyya Distance):
巴氏距离 (D_B(H_1, H_2)) 是根据巴氏系数定义的:
[ D_B(H_1, H_2) = -ln(BC(H_1, H_2)) ]
解读:
取巴氏系数的自然对数的相反数。
特性:
非负性:(D_B(H_1, H_2) ge 0)。
零值:当且仅当 (BC(H_1, H_2) = 1)(即 (H_1 = H_2))时,(D_B(H_1, H_2) = 0)。
距离度量:值越大,表示两个直方图差异越大(相似度越低)。
当 (BC(H_1, H_2) = 0) 时(完全不重叠),(D_B(H_1, H_2) = -ln(0) = infty)。实际计算中需要处理这种情况。
Hellinger 距离与巴氏距离相关:
Hellinger距离 (D_H(H_1, H_2)) 可以通过巴氏系数计算:
[ D_H^2(H_1, H_2) = 1 – BC(H_1, H_2) ]
或者
[ D_H(H_1, H_2) = sqrt{1 – BC(H_1, H_2)} ]
Hellinger距离的值域是 [0, 1](如果使用 (sqrt{2(1-BC)}) 形式,则值域是 ([0, sqrt{2}])),它是一个严格的距离度量(满足三角不等式)。
在图像处理中,巴氏系数和巴氏距离都常被用来比较直方图。巴氏系数更直接地表示相似度(0到1),而巴氏距离则是一个非负的差异度量。
Pillow与NumPy实现:
我们可以手动实现巴氏系数和巴氏距离的计算。
# (numpy, math 模块已导入)
def compare_histograms_bhattacharyya(hist1_norm_list, hist2_norm_list): # 定义巴氏距离比较函数
"""
使用巴氏距离比较两组归一化直方图。
hist1_norm_list 和 hist2_norm_list 是列表,其中每个元素是一个单通道的归一化直方图。
返回的是巴氏距离值 (越小越相似) 和巴氏系数 (越大越相似)。
"""
if len(hist1_norm_list) != len(hist2_norm_list): # 检查通道数是否一致
print(" 巴氏距离错误: 输入的直方图列表通道数不匹配。") # 打印错误信息
return None, None # 返回None, None
num_channels = len(hist1_norm_list) # 获取通道数
if num_channels == 0: # 如果通道数为0
print(" 巴氏距离错误: 输入的直方图列表为空。") # 打印错误信息
return float('inf'), 0.0 # 距离无穷大,系数0
total_bhattacharyya_coeff_sum = 0.0 # 初始化巴氏系数总和 (用于平均)
problematic_channel = False # 标记是否有通道计算出现问题
for i in range(num_channels): # 遍历每个通道
h1_channel = numpy.array(hist1_norm_list[i], dtype=numpy.float64) # 通道1直方图转为NumPy float64数组
h2_channel = numpy.array(hist2_norm_list[i], dtype=numpy.float64) # 通道2直方图转为NumPy float64数组
if h1_channel.shape != h2_channel.shape: # 检查当前通道直方图形状是否一致
print(f" 巴氏距离错误: 通道 {
i} 的直方图bin数量不匹配。") # 打印错误信息
return None, None # 返回None, None
# 确保直方图是归一化的 (和为1) 且非负 - 虽然调用者应保证,但可以加校验
# sum_h1 = numpy.sum(h1_channel)
# sum_h2 = numpy.sum(h2_channel)
# if not (math.isclose(sum_h1, 1.0, abs_tol=1e-5) and math.isclose(sum_h2, 1.0, abs_tol=1e-5)):
# print(f" 巴氏距离警告: 通道 {i} 的直方图未正确归一化 (sum_h1={sum_h1:.3f}, sum_h2={sum_h2:.3f})。结果可能不准确。")
# if numpy.any(h1_channel < 0) or numpy.any(h2_channel < 0):
# print(f" 巴氏距离警告: 通道 {i} 的直方图包含负值。结果可能不准确。")
# 计算巴氏系数: sum(sqrt(h1[k] * h2[k]))
# sqrt(h1*h2) 要求 h1[k]*h2[k] >= 0。由于h1, h2是归一化直方图,它们本身>=0。
bc_channel = numpy.sum(numpy.sqrt(h1_channel * h2_channel)) # 计算当前通道的巴氏系数
total_bhattacharyya_coeff_sum += bc_channel # 累加巴氏系数
# print(f" 通道 {i} 的巴氏系数: {bc_channel:.4f}") # (可选) 打印每个通道的系数
# 平均巴氏系数
average_bhattacharyya_coeff = total_bhattacharyya_coeff_sum / num_channels # 计算平均巴氏系数
# 计算巴氏距离: -log(average_bhattacharyya_coeff)
# 需要处理 average_bhattacharyya_coeff 为0的情况 (避免 log(0))
if average_bhattacharyya_coeff <= 1e-10: # 如果平均巴氏系数非常接近0 (或为0)
# (1e-10 是为了处理浮点数精度问题,理论上BC >= 0)
bhattacharyya_distance = float('inf') # 巴氏距离为无穷大
print(f" 巴氏距离警告: 平均巴氏系数 ({
average_bhattacharyya_coeff:.4e}) 接近零,巴氏距离设为无穷大。") # 打印警告
else:
bhattacharyya_distance = -math.log(average_bhattacharyya_coeff) # 计算巴氏距离 (使用自然对数 math.log)
return bhattacharyya_distance, average_bhattacharyya_coeff # 返回巴氏距离和平均巴氏系数
# --- 测试巴氏距离 ---
print("
--- 4.2.4 巴氏距离 (Bhattacharyya Distance) ---") # 打印小节标题
print("
[巴氏距离测试用例]") # 打印测试标题
# (复用之前获取的norm_hist1, norm_hist1_again, norm_hist_dark 等归一化直方图数据)
# 用例1: 正常图 vs 自身 (预期巴氏距离 ≈ 0.0, 巴氏系数 ≈ 1.0)
if norm_hist1 and norm_hist1_again: # 如果两个直方图都成功获取
dist1_self_bhat, coeff1_self_bhat = compare_histograms_bhattacharyya(norm_hist1, norm_hist1_again) # 比较直方图
if dist1_self_bhat is not None and coeff1_self_bhat is not None: # 如果比较成功
print(f" 正常图 vs 自身 (RGB): 巴氏距离 = {
dist1_self_bhat:.4f} (预期 ≈ 0.0), 巴氏系数 = {
coeff1_self_bhat:.4f} (预期 ≈ 1.0)") # 打印结果
assert math.isclose(dist1_self_bhat, 0.0, abs_tol=1e-5), "自身比较的巴氏距离应接近0.0" # 断言距离
assert math.isclose(coeff1_self_bhat, 1.0, abs_tol=1e-5), "自身比较的巴氏系数应接近1.0" # 断言系数
# 用例2: 正常图 vs 偏暗图
if norm_hist1 and norm_hist_dark: # 如果两个直方图都成功获取
dist_normal_dark_bhat, coeff_normal_dark_bhat = compare_histograms_bhattacharyya(norm_hist1, norm_hist_dark) # 比较直方图
if dist_normal_dark_bhat is not None: # 如果距离计算成功
print(f" 正常图 vs 偏暗图 (RGB): 巴氏距离 = {
dist_normal_dark_bhat:.4f}, 巴氏系数 = {
coeff_normal_dark_bhat:.4f}") # 打印结果
# 用例3: 正常图 vs 偏亮图
if norm_hist1 and norm_hist_bright: # 如果两个直方图都成功获取
dist_normal_bright_bhat, coeff_normal_bright_bhat = compare_histograms_bhattacharyya(norm_hist1, norm_hist_bright) # 比较直方图
if dist_normal_bright_bhat is not None: # 如果距离计算成功
print(f" 正常图 vs 偏亮图 (RGB): 巴氏距离 = {
dist_normal_bright_bhat:.4f}, 巴氏系数 = {
coeff_normal_bright_bhat:.4f}") # 打印结果
# 用例4: 正常图 vs 低对比度图
if norm_hist1 and norm_hist_low_contrast: # 如果两个直方图都成功获取
dist_normal_low_contrast_bhat, coeff_normal_low_contrast_bhat = compare_histograms_bhattacharyya(norm_hist1, norm_hist_low_contrast) # 比较直方图
if dist_normal_low_contrast_bhat is not None: # 如果距离计算成功
print(f" 正常图 vs 低对比度图 (RGB): 巴氏距离 = {
dist_normal_low_contrast_bhat:.4f}, 巴氏系数 = {
coeff_normal_low_contrast_bhat:.4f}") # 打印结果
# 用例5: 灰度图 vs 自身
if norm_hist_gray: # 如果灰度图直方图获取成功
norm_hist_gray_again_bhat = get_normalized_hist_from_path(gray_img_path) # 再次获取灰度图直方图
if norm_hist_gray_again_bhat: # 如果再次获取也成功
dist_gray_self_bhat, coeff_gray_self_bhat = compare_histograms_bhattacharyya(norm_hist_gray, norm_hist_gray_again_bhat) # 比较直方图
if dist_gray_self_bhat is not None: # 如果距离计算成功
print(f" 灰度图 vs 自身 (L): 巴氏距离 = {
dist_gray_self_bhat:.4f} (预期 ≈ 0.0), 巴氏系数 = {
coeff_gray_self_bhat:.4f} (预期 ≈ 1.0)") # 打印结果
assert math.isclose(dist_gray_self_bhat, 0.0, abs_tol=1e-5), "灰度图自身比较巴氏距离应接近0.0" # 断言距离
assert math.isclose(coeff_gray_self_bhat, 1.0, abs_tol=1e-5), "灰度图自身比较巴氏系数应接近1.0" # 断言系数
# 用例6: 正常图 vs 随机内容但颜色基调相似图
if norm_hist1 and norm_hist_rand_color: # 如果两个直方图都成功获取
dist_normal_rand_color_bhat, coeff_normal_rand_color_bhat = compare_histograms_bhattacharyya(norm_hist1, norm_hist_rand_color) # 比较直方图
if dist_normal_rand_color_bhat is not None: # 如果距离计算成功
print(f" 正常图 vs 随机内容但颜色基调相似图 (RGB): 巴氏距离 = {
dist_normal_rand_color_bhat:.4f}, 巴氏系数 = {
coeff_normal_rand_color_bhat:.4f}") # 打印结果
print("
[巴氏距离测试用例结束]") # 打印测试结束信息
代码逐行解释 (compare_histograms_bhattacharyya 函数部分):
与前几种比较方法类似的参数检查和通道遍历结构。
total_bhattacharyya_coeff_sum = 0.0: 初始化用于累加各通道巴氏系数的变量。
bc_channel = numpy.sum(numpy.sqrt(h1_channel * h2_channel)): 计算当前通道的巴氏系数。
h1_channel * h2_channel: 逐元素乘以两个直方图。
numpy.sqrt(...): 对乘积结果逐元素取平方根。
numpy.sum(...): 将所有bin上的结果相加。
total_bhattacharyya_coeff_sum += bc_channel: 累加。
average_bhattacharyya_coeff = total_bhattacharyya_coeff_sum / num_channels: 计算多通道情况下的平均巴氏系数。
计算巴氏距离:
if average_bhattacharyya_coeff <= 1e-10: 检查平均巴氏系数是否非常接近0。如果为0或极小值,则 (ln(BC)) 会趋向负无穷,导致巴氏距离为正无穷。这里用 1e-10 作为阈值处理浮点数精度。
bhattacharyya_distance = float('inf'): 如果系数接近0,距离设为无穷大。
else: bhattacharyya_distance = -math.log(average_bhattacharyya_coeff): 否则,计算 (-ln(BC)) 作为巴氏距离。注意 math.log 是自然对数。
return bhattacharyya_distance, average_bhattacharyya_coeff: 函数返回两个值:计算得到的(平均)巴氏距离和(平均)巴氏系数。
巴氏距离和巴氏系数对直方图的整体形状和重叠都很敏感,并且由于平方根的运算,它对计数值较小的bin的差异给予了相对较大的权重(与卡方距离类似,但方式不同)。巴氏距离在统计学和模式识别中被广泛应用。
4.2.5 其他距离/相似性度量
除了上述几种常用的直方图比较方法外,还有一些其他的距离或相似性度量也可以用于比较直方图:
欧氏距离 (Euclidean Distance):
[ D_{Euclidean}(H_1, H_2) = sqrt{sum_{k=0}^{B-1} (H_1(k) – H_2(k))^2} ]
将直方图视为B维空间中的点,计算它们之间的直线距离。值越小越相似。对绝对差异敏感。
曼哈顿距离 (Manhattan Distance / L1 Norm / City Block Distance):
[ D_{Manhattan}(H_1, H_2) = sum_{k=0}^{B-1} |H_1(k) – H_2(k)| ]
计算每个bin上差值的绝对值之和。值越小越相似。
余弦相似度 (Cosine Similarity):
[ S_{Cosine}(H_1, H_2) = frac{sum_{k=0}^{B-1} H_1(k) H_2(k)}{sqrt{sum_{k=0}^{B-1} H_1(k)^2} sqrt{sum_{k=0}^{B-1} H_2(k)^2}} = frac{H_1 cdot H_2}{||H_1|| cdot ||H_2||} ]
将直方图视为向量,计算它们之间夹角的余弦值。值域为 [-1, 1](对于非负直方图,通常为 [0, 1])。值越接近1表示方向越一致(形状越相似),而不关心向量的长度(即直方图的总体幅度)。
地球移动距离 (Earth Mover’s Distance, EMD) / Wasserstein Distance:
EMD是一种更复杂的度量,它将一个直方图看作一堆“土”,另一个直方图看作一堆“洞”,然后计算将“土”移动到“洞”所需的最小“工作量”。工作量定义为移动的量乘以移动的距离(这里的“距离”是bin之间的距离,例如 (|k_1 – k_2|))。
EMD的优点在于它可以捕捉“跨bin”的相似性。例如,如果一个直方图在bin 10有一个高峰,另一个在bin 11有一个高峰,其他方法可能认为它们差异很大,但EMD会认为它们比较相似,因为只需要将“土”从bin 10移动到bin 11,距离很近。
EMD的计算比上述方法复杂得多,通常涉及到线性规划问题。SciPy 提供了 scipy.stats.wasserstein_distance (用于一维分布)。对于多维直方图或更通用的EMD,可能需要专门的库(如 PyEMD)。
选择哪种比较方法?
没有一种方法是绝对最好的,选择取决于具体的应用需求和图像特性:
直方图相交:简单,对归一化直方图,结果直观(0-1相似度)。
相关性:衡量形状的线性相关性,对整体尺度不敏感。
卡方距离:对低频bin的差异敏感,是非对称的(除非使用对称形式)。
巴氏距离/系数:在统计上性质良好,对概率分布的比较常用。
欧氏/曼哈顿距离:简单,但对直方图值的绝对差异敏感。
余弦相似度:已关注形状(方向)而不关心幅度,适合比较归一化向量。
EMD:能处理跨bin相似性,更符合某些感知,但计算复杂。
在实际应用中,可能需要尝试几种不同的方法,并根据实际效果选择最合适的一种,或者将多种方法的得分组合起来。
4.2.6 直方图比较的优缺点总结
优点:
计算相对简单 (EMD除外):大多数直方图比较方法(如相交、相关性、卡方、巴氏)的计算都比较直接,特别是当直方图本身已经计算出来之后。
对全局几何变换不敏感:由于直方图不包含空间信息,因此它对图像的平移、旋转以及一定程度的尺度变化(特别是使用归一化直方图时)不敏感。这使得它在某些场景下比MSE或SSIM更鲁棒。
能够捕捉全局颜色/亮度分布特征:直方图能够有效地描述图像整体的色调、明暗、对比度等特性。
可以处理不同尺寸的图像(通过归一化直方图)。
缺点:
丢失所有空间信息:这是直方图方法最主要的缺点。两幅内容完全不同、仅仅是颜色分布相似的图像,可能会被判断为高度相似。例如,一张主要是蓝天白云的风景照和一张主要是蓝色海洋白色浪花的照片,它们的直方图可能很相似。
对光照变化敏感:虽然对全局的平移旋转不敏感,但直方图对光照条件的改变非常敏感,因为光照会直接改变像素的强度值,从而改变直方图的形状。
颜色表示的局限性:
对于RGB颜色直方图,如果分别计算三个通道的直方图并独立比较,会丢失颜色之间的相关性。
使用高维联合颜色直方图(如3D RGB直方图)可以更好地表示颜色,但维度高,计算量大,且容易受到“维度灾难”的影响,需要仔细进行颜色量化。
选择合适的颜色空间(如HSV, Lab)并在该空间计算直方图可能比在RGB空间更有优势,因为这些颜色空间更符合人类对颜色的感知。例如,HSV中的H(色相)通道对光照变化相对鲁棒。
量化(Binning)的影响:直方图的bin的数量和划分方式会影响比较结果。太少的bins会丢失细节,太多的bins可能导致直方图稀疏,难以比较。
无法区分纹理和局部结构:直方图完全忽略了图像的纹理和局部细节。
适用场景:
图像检索(作为初步筛选或与其他特征结合):当主要已关注图像的整体颜色风格或色调时,直方图可以作为一种快速的检索手段。例如,搜索与查询图像颜色主题相似的图片。但通常需要结合其他特征(如纹理、形状)来提高准确性。
场景分类(特定类型):某些场景类型(如户外晴天、室内、黄昏)可能有其特有的颜色或亮度分布,直方图可以辅助进行分类。
图像分割:直方图分析(如寻找波谷作为阈值)是图像分割中常用的技术。
颜色迁移/颜色校正:通过匹配源图像和目标图像的直方图,可以实现颜色的迁移或校正。
内容变化检测(当空间信息不重要时):如果只关心整体颜色分布是否发生变化,而不关心变化发生在哪里。
何时不适用:
当图像的空间结构、物体形状、纹理等是区分图像的关键因素时。
当图像之间存在显著的光照变化,且未进行光照补偿时。
当需要非常精确的相似性匹配时(例如,人脸识别,物体识别)。
直方图方法提供了一种从统计分布角度理解和比较图像的有效途径。虽然它们有明显的局限性(主要是丢失空间信息),但在合适的场景下,或与其他方法结合使用时,仍然是非常有价值的工具。
4.3 不同颜色空间中的直方图
我们之前主要讨论的是灰度直方图或RGB颜色空间中的直方图。然而,RGB颜色空间虽然是显示设备常用的模型,但它并不总是最适合进行图像分析和相似度比较的,主要因为它将亮度和色度信息耦合在一起,并且不太符合人类的视觉感知。选择合适的颜色空间,并在该空间中计算和比较直方图,有时能带来更好的效果,特别是对于抵抗光照变化和已关注特定颜色特性。
4.3.1 常见的颜色空间及其特性回顾
在详细讨论它们在直方图分析中的应用前,我们先简要回顾几个常见的颜色空间:
RGB (Red, Green, Blue):
原理:加法混色模型,通过混合不同强度的红、绿、蓝三原色光来产生各种颜色。这是数字图像最常用的存储和显示格式。
特性:
通道之间高度相关。例如,增加图像亮度通常会导致R, G, B三个分量同时增加。
对光照变化敏感。亮度的改变直接影响R, G, B的值。
与人类视觉感知的非线性关系。人眼对R, G, B的敏感度不同。
Pillow中的模式:'RGB' (3×8位像素), 'RGBA' (带Alpha通道)。
HSV / HSL (Hue, Saturation, Value / Lightness):
原理:更符合人类对颜色描述的直观方式。
H (Hue, 色相):表示纯粹的颜色类别(如红、黄、绿、蓝、紫等)。通常用角度表示(0-360度)。
S (Saturation, 饱和度):表示颜色的纯度或鲜艳程度。值越高颜色越纯,越低则颜色越趋向于灰色。通常范围是0-1或0-100%。
V (Value, 明度) 或 L (Lightness, 亮度):表示颜色的明亮程度。V和L的定义略有不同,V模型(也称HSB, B for Brightness)更接近画家调色的方式,而HSL模型在某些情况下能更好地区分纯色和暗色/亮色。通常范围是0-1或0-100%。
特性:
色相(H)对光照变化相对鲁棒:在一定范围内,场景的整体光照强度变化主要影响S和V/L分量,而H分量保持相对稳定。这使得H通道的直方图在需要抵抗光照变化的相似度比较中非常有用。
亮度和色度信息分离:V/L分量直接对应亮度,而H和S分量共同描述色度信息。
圆柱形/双锥形模型:HSV和HSL通常被可视化为圆柱体或双六棱锥。H是角度,S是半径,V/L是高度。
Pillow中的模式:Pillow的 Image 对象可以直接通过 image.convert('HSV') 转换为HSV模式。转换后,图像仍然是3通道的8位图像,其中H, S, V分量被映射到0-255的范围。
H: 0-255 (对应 0-360度,通常 Pillow 的映射是 (H_{pillow} = H_{degrees} imes 255 / 360))
S: 0-255
V: 0-255
YCbCr / YUV:
原理:主要用于视频压缩和传输(如JPEG, MPEG标准)。它们将图像分为一个亮度分量 (Y) 和两个色度分量 (Cb/U 和 Cr/V)。
Y (Luma, 亮度):表示图像的亮度信息,类似于灰度图像。
Cb/U (Chrominance blue, 蓝色度差):表示蓝色分量与亮度值的差异。
Cr/V (Chrominance red, 红色度差):表示红色分量与亮度值的差异。
特性:
亮度和色度分离:与HSV/HSL类似,实现了亮度和色度信息的分离。
色度二次采样友好:由于人眼对亮度信息比对色度信息更敏感,Cb和Cr通道通常可以进行下采样(降低分辨率)以减少数据量,而对视觉质量影响较小。
Y通道直方图:类似于灰度直方图,可以反映亮度分布。
Cb, Cr通道直方图:反映了颜色在特定色度方向上的分布。
Pillow中的模式:'YCbCr'。转换后也是3×8位图像。
Lab (CIELAB):
原理:一种感知均匀的颜色空间,由国际照明委员会(CIE)设计。这意味着在Lab空间中,两个颜色之间的欧氏距离能更好地对应于人眼感知到的颜色差异。
L* (Lightness, 亮度):表示亮度,从0(黑)到100(白)。
a*:表示从绿色 (-a*) 到红色 (+a*) 的范围。
b*:表示从蓝色 (-b*) 到黄色 (+b*) 的范围。
特性:
感知均匀性:这是Lab空间最重要的特性。在图像比较中,如果两个颜色在Lab空间中的距离小,那么它们在视觉上也更可能相似。
设备无关性 (理论上):Lab设计为独立于具体的显示或输入设备。
亮度和色度分离:L*是亮度,a*和b*是色度通道。
Pillow中的模式:Pillow本身不直接支持转换为严格意义上的CIELAB模式。要进行准确的Lab转换,通常需要借助 scikit-image.color.rgb2lab 或 OpenCV (cv2.cvtColor(img, cv2.COLOR_RGB2Lab)) 等库。这些库会返回浮点型的L*, a*, b*值,其范围可能需要根据具体实现来确定和归一化。
4.3.2 在不同颜色空间计算直方图的策略与优势
选择在哪个颜色空间计算直方图,以及如何组合或使用这些直方图,取决于具体的应用目标。
1. HSV 颜色空间
策略:
将RGB图像转换为HSV模式。
主要已关注H (Hue) 通道:由于H通道对光照变化相对不敏感,因此单独计算和比较H通道的直方图是抵抗光照影响、已关注主色调相似性的常用策略。
结合S (Saturation) 和 V (Value) 通道:S通道直方图可以反映色彩的鲜艳程度,V通道直方图反映整体明暗程度。可以将三个通道的直方图分别计算并比较,然后将它们的相似度得分组合起来(例如加权平均)。
量化:
H通道是环形的(0度等于360度),在计算直方图时需要注意。通常H的范围是0-255 (Pillow) 或0-179 (OpenCV的8位H)。可以将其量化为较少的bins(例如,12、16、32或更多,取决于对颜色区分的精度要求)。
S和V通道通常也是0-255范围,可以量化为例如8或16个bins。
2D或3D直方图:可以计算例如H-S二维联合直方图,它能更精细地描述颜色(色相和饱和度的组合)的分布。例如,量化H为16bins, S为8bins,得到一个 (16 imes 8 = 128) bins的2D直方图。3D (H-S-V) 直方图虽然信息更全,但bins数量巨大,计算和比较成本高,通常较少直接使用,除非进行非常粗略的量化。
优势:
对光照变化鲁棒性(主要通过H通道):这是HSV空间最大的优势之一。如果两张图片内容相似但拍摄光照条件不同,它们的H直方图可能仍然很接近。
直观性:H, S, V的含义比R, G, B更符合人类对颜色的感知。
颜色分割:HSV空间常用于基于颜色的图像分割,因为特定颜色通常在H通道中占据一个连续的范围。
Pillow实现HSV直方图:
# (Pillow, matplotlib.pyplot, numpy, os 已导入)
# (get_and_plot_histogram, normalize_histogram, compare_histograms_... 等函数已定义)
def get_hsv_histograms_pil(image_path, h_bins=32, s_bins=16, v_bins=16, normalize=True): # 定义获取HSV直方图的函数
"""
加载图像,转换为HSV,并分别计算H, S, V通道的量化直方图。
Pillow的HSV转换将H,S,V都映射到0-255。
我们将根据指定的bin数量对这些0-255的值进行量化。
"""
try:
img_pil_orig = Image.open(image_path) # 使用Pillow加载原始图像
# 1. 转换为HSV模式
img_hsv_pil = img_pil_orig.convert('HSV') # 将图像转换为HSV模式
# img_hsv_pil 仍然是一个3通道的Image对象,每个通道代表H, S, V,值域0-255
h_channel, s_channel, v_channel = img_hsv_pil.split() # 分离H, S, V三个通道
# 每个通道都是一个 'L' 模式 (灰度) 的Image对象,值域0-255
hists_hsv = [] # 初始化列表用于存放H, S, V的直方图
num_pixels = img_pil_orig.width * img_pil_orig.height # 计算总像素数
# 2. 计算并量化H通道直方图
# Pillow的histogram()对'L'模式图像返回256个bins的直方图
hist_h_raw_256 = numpy.array(h_channel.histogram(), dtype=numpy.float64) # 获取H通道的256-bin原始直方图
# 量化到 h_bins 个bins
hist_h_quantized = numpy.zeros(h_bins, dtype=numpy.float64) # 初始化量化后的H直方图 (全0)
bin_width_h = 256.0 / h_bins # 计算每个量化bin对应的原始值范围宽度
for i in range(256): # 遍历原始256个bins
quantized_bin_index_h = min(int(i / bin_width_h), h_bins - 1) # 计算当前原始bin应映射到的量化bin索引
hist_h_quantized[quantized_bin_index_h] += hist_h_raw_256[i] # 将原始bin的计数值累加到对应的量化bin
if normalize and num_pixels > 0: # 如果需要归一化且总像素数大于0
hist_h_quantized /= num_pixels # 进行归一化
hists_hsv.append(hist_h_quantized.tolist()) # 添加到结果列表
# 3. 计算并量化S通道直方图 (同理)
hist_s_raw_256 = numpy.array(s_channel.histogram(), dtype=numpy.float64) # 获取S通道的256-bin原始直方图
hist_s_quantized = numpy.zeros(s_bins, dtype=numpy.float64) # 初始化量化后的S直方图
bin_width_s = 256.0 / s_bins # 计算S通道量化bin宽度
for i in range(256): # 遍历原始bins
quantized_bin_index_s = min(int(i / bin_width_s), s_bins - 1) # 计算量化bin索引
hist_s_quantized[quantized_bin_index_s] += hist_s_raw_256[i] # 累加计数值
if normalize and num_pixels > 0: # 如果需要归一化
hist_s_quantized /= num_pixels # 归一化
hists_hsv.append(hist_s_quantized.tolist()) # 添加到结果列表
# 4. 计算并量化V通道直方图 (同理)
hist_v_raw_256 = numpy.array(v_channel.histogram(), dtype=numpy.float64) # 获取V通道的256-bin原始直方图
hist_v_quantized = numpy.zeros(v_bins, dtype=numpy.float64) # 初始化量化后的V直方图
bin_width_v = 256.0 / v_bins # 计算V通道量化bin宽度
for i in range(256): # 遍历原始bins
quantized_bin_index_v = min(int(i / bin_width_v), v_bins - 1) # 计算量化bin索引
hist_v_quantized[quantized_bin_index_v] += hist_v_raw_256[i] # 累加计数值
if normalize and num_pixels > 0: # 如果需要归一化
hist_v_quantized /= num_pixels # 归一化
hists_hsv.append(hist_v_quantized.tolist()) # 添加到结果列表
img_pil_orig.close(); img_hsv_pil.close() # 关闭图像对象
h_channel.close(); s_channel.close(); v_channel.close() # 关闭分离的通道图像对象
return hists_hsv # 返回包含H, S, V量化(和可选归一化)直方图的列表
except Exception as e:
print(f" 计算HSV直方图时发生错误 for '{
image_path}': {
e}") # 打印错误信息
if 'img_pil_orig' in locals() and img_pil_orig: img_pil_orig.close() # 尝试关闭
if 'img_hsv_pil' in locals() and img_hsv_pil: img_hsv_pil.close() # 尝试关闭
return None # 返回None
# --- 测试HSV直方图计算和比较 ---
print("
--- 4.3.2 HSV颜色空间中的直方图 ---") # 打印小节标题
# 使用之前的 "正常" 和 "偏亮" 图片进行测试
# 预期:H通道直方图相似度应较高,V通道相似度较低
hsv_bins_config = {
'h_bins': 30, 's_bins': 32, 'v_bins': 32} # 定义HSV直方图的bin数量配置
print(f"
比较 '{
os.path.basename(hist_img1_path)}' (正常) 和 '{
os.path.basename(hist_img3_path_bright)}' (偏亮) 的HSV直方图:") # 打印比较信息
hsv_hist1_norm = get_hsv_histograms_pil(hist_img1_path, **hsv_bins_config, normalize=True) # 获取正常图片的HSV归一化直方图
hsv_hist3_bright_norm = get_hsv_histograms_pil(hist_img3_path_bright, **hsv_bins_config, normalize=True) # 获取偏亮图片的HSV归一化直方图
if hsv_hist1_norm and hsv_hist3_bright_norm: # 如果两个HSV直方图都成功获取
# 分别比较H, S, V通道的直方图
# H通道 (索引0)
h_hist1 = [hsv_hist1_norm[0]] # 将H通道直方图放入列表,以符合比较函数的输入格式
h_hist3 = [hsv_hist3_bright_norm[0]] # 同上
sim_h_intersect = compare_histograms_intersection(h_hist1, h_hist3) # 使用相交法比较H通道
sim_h_corr = compare_histograms_correlation(h_hist1, h_hist3) # 使用相关性法比较H通道
dist_h_bhat, coeff_h_bhat = compare_histograms_bhattacharyya(h_hist1, h_hist3) # 使用巴氏距离法比较H通道
print(f" H通道相似度: 相交={
sim_h_intersect:.4f}, 相关性={
sim_h_corr:.4f}, 巴氏系数={
coeff_h_bhat:.4f}") # 打印H通道相似度
# S通道 (索引1)
s_hist1 = [hsv_hist1_norm[1]] # S通道直方图
s_hist3 = [hsv_hist3_bright_norm[1]]
sim_s_intersect = compare_histograms_intersection(s_hist1, s_hist3) # 比较S通道
sim_s_corr = compare_histograms_correlation(s_hist1, s_hist3)
dist_s_bhat, coeff_s_bhat = compare_histograms_bhattacharyya(s_hist1, s_hist3)
print(f" S通道相似度: 相交={
sim_s_intersect:.4f}, 相关性={
sim_s_corr:.4f}, 巴氏系数={
coeff_s_bhat:.4f}") # 打印S通道相似度
# V通道 (索引2)
v_hist1 = [hsv_hist1_norm[2]] # V通道直方图
v_hist3 = [hsv_hist3_bright_norm[2]]
sim_v_intersect = compare_histograms_intersection(v_hist1, v_hist3) # 比较V通道
sim_v_corr = compare_histograms_correlation(v_hist1, v_hist3)
dist_v_bhat, coeff_v_bhat = compare_histograms_bhattacharyya(v_hist1, v_hist3)
print(f" V通道相似度: 相交={
sim_v_intersect:.4f}, 相关性={
sim_v_corr:.4f}, 巴氏系数={
coeff_v_bhat:.4f}") # 打印V通道相似度
# 综合比较 (例如,对三个通道的相似度得分进行平均)
# 这里以巴氏系数为例
avg_coeff_hsv = (coeff_h_bhat + coeff_s_bhat + coeff_v_bhat) / 3.0 # 计算三个通道巴氏系数的平均值
print(f" HSV三通道平均巴氏系数: {
avg_coeff_hsv:.4f}") # 打印平均巴氏系数
# 也可以直接将三个通道的直方图列表传递给比较函数
# (需要确保比较函数能正确处理多通道列表的平均)
# 例如,我们之前的比较函数已经是这样设计的
overall_sim_intersect = compare_histograms_intersection(hsv_hist1_norm, hsv_hist3_bright_norm) # 整体比较 (相交法)
overall_sim_corr = compare_histograms_correlation(hsv_hist1_norm, hsv_hist3_bright_norm) # 整体比较 (相关性法)
overall_dist_bhat, overall_coeff_bhat = compare_histograms_bhattacharyya(hsv_hist1_norm, hsv_hist3_bright_norm) # 整体比较 (巴氏距离法)
print(f" HSV整体比较: 相交={
overall_sim_intersect:.4f}, 相关性={
overall_sim_corr:.4f}, 巴氏系数={
overall_coeff_bhat:.4f}") # 打印整体比较结果
else:
print(" 未能获取足够的HSV直方图数据进行比较。") # 打印错误信息
代码逐行解释 (get_hsv_histograms_pil 函数部分):
img_hsv_pil = img_pil_orig.convert('HSV'): 将原始RGB图像转换为Pillow的HSV模式。
h_channel, s_channel, v_channel = img_hsv_pil.split(): 分离出H, S, V三个单通道图像。每个都是'L'模式,值域0-255。
手动量化直方图:
hist_h_raw_256 = numpy.array(h_channel.histogram(), ...): 获取该通道的完整256-bin直方图。
hist_h_quantized = numpy.zeros(h_bins, ...): 创建一个用于存储量化后直方图的NumPy数组,元素个数为用户指定的 h_bins。
bin_width_h = 256.0 / h_bins: 计算每个量化bin覆盖的原始256级灰度值的范围。
for i in range(256): ... quantized_bin_index_h = min(int(i / bin_width_h), h_bins - 1): 遍历原始256个bins,计算每个原始bin应该映射到哪个量化后的bin索引。min(..., h_bins - 1)确保索引不越界。
hist_h_quantized[quantized_bin_index_h] += hist_h_raw_256[i]: 将原始bin的像素计数累加到对应的量化bin中。
if normalize and num_pixels > 0: hist_h_quantized /= num_pixels: 如果指定了归一化,则将量化后的直方图除以总像素数。
对S和V通道进行类似的手动量化和归一化处理。
返回一个包含H, S, V三个量化(和可选归一化)直方图的列表。
在测试部分,我们加载了两张光照有差异的图片,分别获取它们的HSV直方图,然后分别比较H, S, V三个通道的相似度。通常预期H通道的相似度会比V通道(亮度)的相似度更高。最后还演示了如何将三个通道的相似度得分进行组合(如平均巴氏系数),或直接将包含H,S,V三个直方图的列表传递给之前的比较函数(这些比较函数设计为可以平均多通道得分)。
2. Lab 颜色空间
策略:
将RGB图像转换为Lab颜色空间。这通常需要 scikit-image.color.rgb2lab 或 OpenCV。
L* 通道:L*通道的直方图代表亮度分布,其比较类似于灰度直方图。由于Lab的感知均匀性,L*通道的差异可能更符合人眼对亮度差异的感知。
a* 和 b* 通道:这两个色度通道的直方图描述了颜色在绿-红和蓝-黄轴上的分布。
可以分别比较L*, a*, b*三个通道的直方图,然后组合相似度得分。
量化:L* 的范围通常是 [0, 100]。a* 和 b* 的范围大致在 [-100, 100] 或 [-128, 127](取决于具体实现和数据类型)。在计算直方图前,需要将这些浮点值(可能为负)映射到合适的整数bins。例如,可以将L* 量化为32bins,a* 和 b* 分别平移并缩放映射到0-255范围后再量化为例如32bins。
优势:
感知均匀性:这是Lab空间最大的优点。在Lab空间中计算的直方图差异(如果使用合适的距离度量)理论上能更好地反映人眼感知到的颜色和亮度差异。
亮度与色度分离。
使用 scikit-image 实现Lab转换和直方图计算:
from skimage.color import rgb2lab # 从scikit-image导入rgb2lab转换函数
from skimage import img_as_float, img_as_ubyte # 导入类型转换函数
# (Pillow, matplotlib.pyplot, numpy, os 已导入)
def get_lab_histograms_skimage(image_path, l_bins=32, a_bins=32, b_bins=32, normalize=True): # 定义获取Lab直方图的函数
"""
加载图像,使用skimage转换为Lab,并分别计算L*, a*, b*通道的量化直方图。
L* 范围 [0, 100]. a*, b* 范围约 [-100, 100] 或 [-128, 127] (取决于实现和数据类型).
我们需要将 a* 和 b* 映射到非负整数范围以便于计算直方图。
"""
try:
img_pil_orig = Image.open(image_path) # 使用Pillow加载原始图像
# 1. 转换为skimage兼容的格式 (通常是float64, 范围[0,1] 或 uint8 [0,255])
# rgb2lab 期望输入是RGB图像
if img_pil_orig.mode != 'RGB': # 如果不是RGB模式
img_pil_rgb = img_pil_orig.convert('RGB') # 转换为RGB模式
else:
img_pil_rgb = img_pil_orig # 已经是RGB,直接使用
img_rgb_np_float = img_as_float(numpy.array(img_pil_rgb)) # 将Pillow RGB图像转为NumPy数组,并归一化到[0,1]浮点类型
# scikit-image的rgb2lab通常期望这种格式
# 2. 转换为Lab颜色空间
img_lab_np = rgb2lab(img_rgb_np_float) # 使用skimage的rgb2lab函数进行转换
# img_lab_np 是一个 (height, width, 3) 的NumPy数组,值为浮点数
# L* 在 img_lab_np[:,:,0],范围 [0, 100]
# a* 在 img_lab_np[:,:,1],范围约 [-86, 98] (典型值)
# b* 在 img_lab_np[:,:,2],范围约 [-107, 94] (典型值)
# (注意: a*和b*的实际范围可能略有不同,取决于输入RGB的色域)
hists_lab = [] # 初始化列表用于存放L*, a*, b*的直方图
num_pixels = img_pil_orig.width * img_pil_orig.height # 计算总像素数
# 3. 处理L*通道
l_channel_data = img_lab_np[:, :, 0].flatten() # 提取L*通道数据并展平为一维数组
# 计算L*的直方图 (范围[0, 100] 量化到 l_bins)
# numpy.histogram(data, bins, range)
hist_l_quantized, _ = numpy.histogram(l_channel_data, bins=l_bins, range=(0, 100)) # 使用numpy.histogram计算L*通道直方图
if normalize and num_pixels > 0: # 如果需要归一化
hist_l_quantized = hist_l_quantized.astype(numpy.float64) / num_pixels # 归一化
hists_lab.append(hist_l_quantized.tolist()) # 添加到结果列表
# 4. 处理a*通道
a_channel_data = img_lab_np[:, :, 1].flatten() # 提取a*通道数据并展平
# 假设a*的典型范围是 [-128, 127] 以便映射到0-255的类似范围 (或直接使用其真实范围)
# 这里我们使用一个近似的范围,例如 [-100, 100] 或根据数据动态确定
a_min_approx, a_max_approx = -100, 100 # 定义a*的近似范围 (可以根据数据实际min/max调整)
hist_a_quantized, _ = numpy.histogram(a_channel_data, bins=a_bins, range=(a_min_approx, a_max_approx)) # 计算a*通道直方图
if normalize and num_pixels > 0: # 如果需要归一化
hist_a_quantized = hist_a_quantized.astype(numpy.float64) / num_pixels # 归一化
hists_lab.append(hist_a_quantized.tolist()) # 添加到结果列表
# 5. 处理b*通道
b_channel_data = img_lab_np[:, :, 2].flatten() # 提取b*通道数据并展平
b_min_approx, b_max_approx = -100, 100 # 定义b*的近似范围
hist_b_quantized, _ = numpy.histogram(b_channel_data, bins=b_bins, range=(b_min_approx, b_max_approx)) # 计算b*通道直方图
if normalize and num_pixels > 0: # 如果需要归一化
hist_b_quantized = hist_b_quantized.astype(numpy.float64) / num_pixels # 归一化
hists_lab.append(hist_b_quantized.tolist()) # 添加到结果列表
img_pil_orig.close() # 关闭原始Pillow图像
if img_pil_rgb != img_pil_orig : img_pil_rgb.close() # 如果创建了临时的RGB图像,也关闭它
return hists_lab # 返回包含L*, a*, b*量化(和可选归一化)直方图的列表
except ImportError: # 捕获skimage未安装的错误
print(" Lab直方图错误: scikit-image库未安装或无法导入rgb2lab。请运行 'pip install scikit-image'。") # 打印安装提示
return None # 返回None
except Exception as e:
print(f" 计算Lab直方图时发生错误 for '{
image_path}': {
e}") # 打印错误信息
if 'img_pil_orig' in locals() and img_pil_orig: img_pil_orig.close() # 尝试关闭
return None # 返回None
# --- 测试Lab直方图计算 ---
print("
--- 4.3.2 Lab颜色空间中的直方图 (使用 scikit-image) ---") # 打印小节标题
lab_bins_config = {
'l_bins': 32, 'a_bins': 32, 'b_bins': 32} # 定义Lab直方图的bin数量配置
print(f"
处理 '{
os.path.basename(hist_img1_path)}' (正常) 的Lab直方图:") # 打印处理信息
lab_hist1_norm = get_lab_histograms_skimage(hist_img1_path, **lab_bins_config, normalize=True) # 获取正常图片的Lab归一化直方图
if lab_hist1_norm: # 如果Lab直方图成功获取
print(f" L*通道直方图 (前5 bins): {
[f'{
x:.4f}' for x in lab_hist1_norm[0][:5]]}") # 打印L*通道前5个bin的值
print(f" a*通道直方图 (前5 bins): {
[f'{
x:.4f}' for x in lab_hist1_norm[1][:5]]}") # 打印a*通道前5个bin的值
print(f" b*通道直方图 (前5 bins): {
[f'{
x:.4f}' for x in lab_hist1_norm[2][:5]]}") # 打印b*通道前5个bin的值
else:
print(f" 未能获取 '{
os.path.basename(hist_img1_path)}' 的Lab直方图数据。") # 打印错误信息
代码逐行解释 (get_lab_histograms_skimage 函数部分):
from skimage.color import rgb2lab 和 from skimage import img_as_float: 导入必要的 scikit-image 函数。
图像加载和预转换到RGB (如果需要),然后使用 img_as_float(numpy.array(img_pil_rgb)) 将Pillow图像转换为 scikit-image rgb2lab 函数期望的NumPy浮点数组(通常是[0,1]范围)。
img_lab_np = rgb2lab(img_rgb_np_float): 进行RGB到Lab的转换。结果 img_lab_np 是一个 HxWx3 的NumPy数组,包含L*, a*, b*三个通道的浮点值。
l_channel_data = img_lab_np[:, :, 0].flatten(): 提取L*通道数据并将其展平为一维数组,以便传递给 numpy.histogram。
hist_l_quantized, _ = numpy.histogram(l_channel_data, bins=l_bins, range=(0, 100)): 使用 numpy.histogram 计算L*通道的直方图。bins=l_bins 指定了量化的bin数量,range=(0, 100) 指定了L*值的预期范围。numpy.histogram 返回两个数组:直方图计数值和bin的边界,我们只需要计数值。
对a*和b*通道进行类似处理。关键在于为 range 参数选择合适的范围。由于a*和b*的值可以为负,我们为它们选择了一个近似的对称范围(例如 (-100, 100))。在更精确的应用中,可能需要根据数据的实际最小值和最大值来动态确定范围,或者使用更复杂的映射方法将负值移到非负区间。
可选的归一化步骤与之前类似。
返回一个包含L*, a*, b*三个量化(和可选归一化)直方图的列表。
3. YCbCr 颜色空间
策略:
将RGB图像转换为YCbCr。Pillow的 image.convert('YCbCr') 可以实现。
Y通道(亮度):Y通道直方图的比较类似于灰度直方图。
Cb和Cr通道(色度):这两个通道的直方图反映了颜色在蓝-黄和红-绿方向上的分布。
可以分别比较三个通道的直方图并组合结果。
Pillow转换后的YCbCr各通道也是0-255范围,可以直接使用256个bins或进行量化。
优势:
亮度和色度分离。
Y通道对整体亮度变化敏感,而Cb, Cr对颜色变化敏感。
计算相对简单,Pillow直接支持。
总结比较不同颜色空间策略:
| 特性/目标 | RGB直方图 | HSV直方图 (主用H) | Lab直方图 | YCbCr直方图 (主用Y) |
|---|---|---|---|---|
| 光照鲁棒性 | 差 | 好 (H通道) | 中等 (L*仍受影响) | 中等 (Y受影响) |
| 感知均匀性 | 差 | 中等 | 好 | 差 |
| 颜色信息保留 | 好 | 好 (H,S组合) | 好 (a*,b*组合) | 好 (Cb,Cr组合) |
| 计算复杂度 (Pillow) | 低 | 低 | 较高 (需skimage) | 低 |
| 主要已关注点 | 整体颜色混合 | 主色调、鲜艳度 | 感知颜色差异 | 亮度、色度差 |
| 常见应用 | 简单颜色统计 | 颜色识别、光照不变形检索 | 精确颜色比较 | 视频、压缩相关分析 |
选择建议:
如果需要抵抗光照变化,优先考虑使用HSV空间的H通道直方图,或者H-S二维直方图。
如果需要进行感知上更准确的颜色差异比较,Lab空间是理论上更好的选择,但转换和处理(尤其是负值和范围)略复杂。
如果只是想简单地分离亮度和颜色信息,YCbCr是一个直接的选择,其Y通道直方图可作为灰度替代。
RGB直方图虽然简单,但在光照和感知方面有明显劣势,通常不作为鲁棒相似度比较的首选,除非场景非常受控。
在实际比较时,可以将选定颜色空间中各通道的直方图相似度得分(例如,使用前面讨论的相交、相关性、巴氏距离等方法计算)进行组合,例如:
简单平均:将各通道的相似度得分(或距离的倒数/归一化)平均。
加权平均:根据不同通道的重要性给予不同的权重。例如,在HSV中,可能给予H通道更高的权重。
只用关键通道:例如,在HSV中只使用H通道的相似度。
4.4 直方图均衡化 (Histogram Equalization)
直方图均衡化是一种常用的图像增强技术,旨在通过重新分布图像的像素强度值来改善图像的对比度。其基本思想是使得输出图像的直方图尽可能平坦(均匀分布),从而利用全部的灰度级范围。
4.4.1 原理与目的
原理:直方图均衡化通过一个变换函数 (T®) 将输入图像的像素强度 ® 映射到输出图像的像素强度 (s),即 (s = T®)。这个变换函数的选择基于输入图像的累积分布函数 (Cumulative Distribution Function, CDF)。
对于离散的灰度级 (r_k) (k=0, 1, …, L-1,L是灰度级总数,如256),其概率(归一化直方图值)为 (p(r_k) = n_k / N),其中 (n_k) 是强度为 (r_k) 的像素数,N是总像素数。
变换函数 (T(r_j)) 计算为:
[ s_j = T(r_j) = (L-1) sum_{k=0}^{j} p(r_k) = (L-1) imes ext{CDF}(r_j) ]
这意味着新的像素值 (s_j) 是通过将原始像素值 (r_j) 的累积概率乘以最大灰度级 ((L-1)) 并取整得到的。
目的:
增强对比度:对于那些像素值集中在某个狭窄范围内的图像(低对比度图像),均衡化可以扩展其动态范围,使得暗区域更暗,亮区域更亮(相对而言),从而增强图像的整体对比度,使细节更清晰。
标准化亮度分布:尝试使图像的亮度分布更均匀,这在某些后续处理(如特征提取)中可能是有益的。
4.4.2 Pillow 实现直方图均衡化
Pillow的 ImageOps 模块提供了一个方便的函数 ImageOps.equalize(image, mask=None) 来执行直方图均衡化。
ImageOps.equalize(image, mask=None) 详解
功能:对输入图像进行直方图均衡化。
参数:
image: 输入的Pillow Image 对象。该函数通常对灰度图像 ('L' 模式) 操作效果最明显和直接。如果输入是彩色图像(如RGB),ImageOps.equalize 会对每个通道独立进行均衡化。这可能会导致颜色失真(色彩漂移),因为每个通道的CDF不同,映射关系也不同,从而改变了通道间的相对比例。
mask (Image object, optional):一个可选的掩码图像。如果提供,均衡化只基于掩码区域内的像素计算CDF,并且只对掩码区域内的像素应用变换。
返回值:一个新的Pillow Image 对象,表示均衡化后的图像。
代码示例:
from PIL import Image, ImageOps # 导入Image和ImageOps模块
# (matplotlib.pyplot, os, get_and_plot_histogram 已导入或定义)
print("
--- 4.4 直方图均衡化 (Histogram Equalization) ---") # 打印章节标题
# 使用之前创建的低对比度图片进行测试
img_low_contrast_path = os.path.join(IMAGE_DIR_HIST, "hist_img_low_contrast.jpg") # 低对比度图片路径
img_normal_path_for_eq_ref = os.path.join(IMAGE_DIR_HIST, "hist_img_normal.jpg") # 正常对比度图片路径(作为参考)
if os.path.exists(img_low_contrast_path): # 如果低对比度图片存在
try:
img_lc = Image.open(img_low_contrast_path) # 打开低对比度图片
print(f"
原始低对比度图像: '{
os.path.basename(img_low_contrast_path)}', 模式: {
img_lc.mode}") # 打印原始图片信息
# 1. 对灰度图进行均衡化
img_lc_gray = img_lc.convert('L') # 首先转换为灰度图
print(f" 将其转换为灰度图进行均衡化。") # 打印转换信息
img_eq_gray = ImageOps.equalize(img_lc_gray) # 对灰度图进行直方图均衡化
# 保存均衡化后的灰度图
eq_gray_save_path = os.path.join(OUTPUT_DIR_HIST, "equalized_low_contrast_gray.png") # 定义保存路径
img_eq_gray.save(eq_gray_save_path) # 保存图片
print(f" 均衡化后的灰度图已保存到: {
eq_gray_save_path}") # 打印保存信息
# 绘制原始灰度图和均衡化后灰度图的直方图进行对比
print(" 绘制原始灰度图及其均衡化后的直方图...") # 打印绘图提示
get_and_plot_histogram(img_lc_gray, save_plot_filename_prefix="hist_plot_lc_gray_orig") # 绘制原始灰度图直方图
# (需要将Image对象传入,或先保存再传入路径)
# 临时保存一下再画
temp_lc_gray_path = os.path.join(OUTPUT_DIR_HIST, "_temp_lc_gray.png") # 定义临时路径
img_lc_gray.save(temp_lc_gray_path) # 保存临时文件
get_and_plot_histogram(temp_lc_gray_path, save_plot_filename_prefix="hist_plot_lc_gray_orig") # 绘制
os.remove(temp_lc_gray_path) # 删除临时文件
get_and_plot_histogram(eq_gray_save_path, save_plot_filename_prefix="hist_plot_lc_gray_eq") # 绘制均衡化后灰度图直方图
# (已保存,直接用路径)
# 2. 对彩色图直接进行均衡化 (可能会改变颜色)
if img_lc.mode == 'RGB' or img_lc.mode == 'RGBA': # 如果原始低对比度图是RGB或RGBA
print(f"
对原始彩色图 ({
img_lc.mode}) 直接进行均衡化 (可能导致颜色失真)...") # 打印提示
img_eq_color = ImageOps.equalize(img_lc) # 对原始彩色图(可能是RGB)进行均衡化
eq_color_save_path = os.path.join(OUTPUT_DIR_HIST, "equalized_low_contrast_color.png") # 定义保存路径
img_eq_color.save(eq_color_save_path) # 保存图片
print(f" 均衡化后的彩色图已保存到: {
eq_color_save_path}") # 打印保存信息
# 绘制其直方图
get_and_plot_histogram(eq_color_save_path, save_plot_filename_prefix="hist_plot_lc_color_eq") # 绘制直方图
img_eq_color.close() # 关闭均衡化后的彩色图
img_lc.close(); img_lc_gray.close(); img_eq_gray.close() # 关闭所有打开的图像
except Exception as e:
print(f" 直方图均衡化测试时发生错误: {
e}") # 打印错误信息
else:
print(f" 跳过直方图均衡化测试,因为 '{
img_low_contrast_path}' 不存在。") # 打印跳过信息
print("
[直方图均衡化测试结束]") # 打印测试结束信息
代码逐行解释:
from PIL import Image, ImageOps: 导入 ImageOps 模块。
首先,我们将低对比度图像转换为灰度图 (img_lc_gray = img_lc.convert('L')),因为均衡化在灰度图上效果最典型。
img_eq_gray = ImageOps.equalize(img_lc_gray): 调用 ImageOps.equalize() 对灰度图进行均衡化。
然后,我们保存并绘制了原始低对比度灰度图的直方图和均衡化后灰度图的直方图。预期均衡化后的直方图会更宽,分布更均匀。
接着,代码演示了直接对原始彩色图(假设是RGB)调用 ImageOps.equalize()。如前所述,这会对R,G,B三个通道独立均衡化,可能会导致颜色看起来不自然。均衡化后的彩色图及其直方图也被保存和绘制。
4.4.3 均衡化对直方图相似度计算的影响
直方图均衡化作为一种预处理步骤,对后续基于直方图的相似度计算会产生显著影响:
可能提高相似度(对于对比度差异大的图像):
如果两幅图像内容相似,但仅仅因为拍摄时的整体对比度不同(例如,一张是低对比度,另一张是正常或高对比度),它们原始的直方图可能会差异很大。对这两幅图像都进行直方图均衡化后,它们的直方图都会趋向于更均匀的分布,从而使得均衡化后的直方图之间的相似度(例如用相交、相关性等方法计算)可能比原始直方图的相似度更高。这在一定程度上起到了“标准化”直方图形状的作用。
可能降低相似度(如果原始对比度是重要特征):
如果图像的原始对比度本身就是区分它们的一个重要特征,那么均衡化会破坏这个特征。例如,如果要区分一张本身就是高对比度的图像和一张本身就是低对比度的图像,均衡化后它们都可能变得对比度较高,反而使得它们在直方图上更相似。
对颜色直方图的影响:
如前所述,对RGB图像的每个通道独立进行均衡化可能会导致颜色失真。如果后续是比较颜色直方图,这种失真会直接影响结果。一个替代方法是:先将RGB图像转换到像HSV或YCbCr这样的亮度-色度分离的空间,然后只对亮度通道(V或Y)进行均衡化,保持色度通道(H, S或Cb, Cr)不变,最后再转换回RGB(如果需要显示)或直接在均衡化后的亮度通道和原始色度通道上计算直方图进行比较。这样可以在增强对比度的同时更好地保留颜色信息。
# 示例:只对HSV的V通道进行均衡化
def equalize_v_channel_hsv(image_path, output_path_prefix="eq_v_hsv"): # 定义只均衡化V通道的函数
try:
img_orig = Image.open(image_path) # 打开原始图像
if img_orig.mode != 'RGB' and img_orig.mode != 'RGBA' : # 检查模式是否为RGB或RGBA
img_rgb = img_orig.convert('RGB') # 如果不是,则转换为RGB
print(f" 图像 '{
os.path.basename(image_path)}' 从 {
img_orig.mode} 转为 RGB") # 打印转换信息
else:
img_rgb = img_orig # 已经是RGB或RGBA(split会处理A)
img_hsv = img_rgb.convert('HSV') # 转换为HSV
h, s, v = img_hsv.split() # 分离H, S, V通道
v_equalized = ImageOps.equalize(v) # 只对V通道进行均衡化
img_hsv_eq_v = Image.merge('HSV', (h, s, v_equalized)) # 将均衡化后的V通道与原始H,S通道合并
img_rgb_eq_v = img_hsv_eq_v.convert('RGB') # 转换回RGB以便保存和显示
save_filename = f"{
output_path_prefix}_{
os.path.splitext(os.path.basename(image_path))[0]}.png" # 构建保存文件名
save_path = os.path.join(OUTPUT_DIR_HIST, save_filename) # 构建完整保存路径
img_rgb_eq_v.save(save_path) # 保存图像
print(f" 仅V通道均衡化后的图像已保存到: {
save_path}") # 打印保存信息
# 清理
img_orig.close();
if img_rgb != img_orig : img_rgb.close() # 如果创建了临时RGB,关闭它
img_hsv.close(); h.close(); s.close(); v.close(); v_equalized.close() # 关闭所有中间图像
img_hsv_eq_v.close(); img_rgb_eq_v.close()
return save_path # 返回保存后的图像路径,方便后续处理
except Exception as e:
print(f" 均衡化V通道时发生错误 for '{
image_path}': {
e}") # 打印错误信息
return None # 返回None
# 测试对V通道均衡化
print("
测试仅对HSV空间的V通道进行均衡化:") # 打印测试提示
if os.path.exists(img_low_contrast_path): # 如果低对比度图片存在
eq_v_output_path = equalize_v_channel_hsv(img_low_contrast_path) # 调用函数进行V通道均衡化
if eq_v_output_path: # 如果成功
# 可以获取这个新图像的HSV直方图,并与原始低对比度图的HSV直方图进行比较
# 或与正常对比度图(也进行V通道均衡化后)的HSV直方图比较
print(f" 现在可以比较 '{
os.path.basename(img_low_contrast_path)}' (V通道均衡化后) 与其他图像的HSV直方图了。") # 打印提示
# 例如,比较原始低对比度图 与 其V通道均衡化后的图 的HSV直方图
hsv_hist_lc_orig_norm = get_hsv_histograms_pil(img_low_contrast_path, **hsv_bins_config, normalize=True) # 获取原始低对比度图的HSV直方图
hsv_hist_lc_eq_v_norm = get_hsv_histograms_pil(eq_v_output_path, **hsv_bins_config, normalize=True) # 获取V通道均衡化后的HSV直方图
if hsv_hist_lc_orig_norm and hsv_hist_lc_eq_v_norm: # 如果都成功获取
# 比较V通道 (索引2)
v_lc_orig = [hsv_hist_lc_orig_norm[2]] # 原始V通道直方图
v_lc_eq = [hsv_hist_lc_eq_v_norm[2]] # 均衡化后V通道直方图
sim_v_intersect_eq = compare_histograms_intersection(v_lc_orig, v_lc_eq) # 比较V通道 (相交法)
print(f" 原始低对比度图V通道 vs 其均衡化后V通道 (相交法): {
sim_v_intersect_eq:.4f}") # 打印相似度
# 这个值通常不会很高,因为均衡化改变了分布
是否使用均衡化的决策:
如果预期的图像对可能存在显著的全局对比度差异,但内容相似,那么在比较直方图之前对两幅图都进行均衡化(最好是只均衡化亮度通道)可能是有益的。
如果原始的亮度/对比度分布是区分图像的关键特征,则不应使用均衡化。
均衡化是一种全局操作,它不考虑局部对比度。对于需要保留局部对比度细节的场景,可能需要使用自适应直方图均衡化(Adaptive Histogram Equalization, AHE),如CLAHE (Contrast Limited AHE)。Pillow本身不直接提供CLAHE,但可以通过OpenCV等库实现,然后再转回Pillow图像进行后续处理。
总结:
选择合适的颜色空间并在其中计算直方图,以及是否使用直方图均衡化等预处理步骤,都对最终的直方图相似度计算结果有重要影响。
HSV的H通道对于光照不变的颜色主调比较非常有用。
Lab空间因其感知均匀性,在需要精确颜色差异比较时有优势。
直方图均衡化可以标准化图像的对比度,可能有助于比较因拍摄条件不同而对比度差异大的图像,但需要注意其对颜色和原始对比度特征的潜在影响。只对亮度通道进行均衡化通常是更稳妥的做法。
在这一大类方法中,感知哈希算法 (Perceptual Hashing Algorithms) 因其独特性和高效性而占据重要地位。感知哈希算法的核心思想是将图像内容(而非像素本身或简单的统计分布)映射为一个紧凑的、固定长度的“指纹”或“哈希值”。这个哈希值对图像的感知内容敏感,但对某些不改变核心内容的变换(如轻微的缩放、旋转、亮度调整、有损压缩等)具有一定的鲁棒性。
与密码学哈希(如MD5, SHA-256)不同,密码学哈希要求输入哪怕发生1比特的变化,输出的哈希值也应该完全不同。而感知哈希则追求“相似的图像产生相似的哈希值”(通常用汉明距离来衡量哈希值之间的相似度),“不相似的图像产生显著不同的哈希值”。这种特性使得感知哈希非常适用于大规模图像库的近似重复检测、图像检索、数字水印等领域。
第五章:基于感知哈希的图像相似度计算
5.1 感知哈希算法概述
5.1.1 什么是感知哈希?
感知哈希算法(Perceptual Hash Algorithm,简称pHash)是一种为多媒体数据(尤其是图像和音频)生成“指纹”(fingerprint)的技术。这个指纹是一个紧凑的二进制串,它能够代表多媒体文件的感知内容。与传统的密码学哈希函数(如MD5或SHA1)不同,密码学哈希函数对输入数据的微小变化非常敏感(雪崩效应),即使输入只改变一个比特,输出的哈希值也会截然不同。这种特性使得密码学哈希非常适合用于验证数据完整性,但不适用于查找相似内容。
相比之下,感知哈希算法的设计目标是:
内容相关性:哈希值应该捕获图像的主要视觉特征。
鲁棒性:对于不改变图像核心内容的某些变换(如缩放、旋转、亮度/对比度调整、轻微模糊、JPEG压缩等),生成的哈希值应该保持不变或变化很小。
区分性:对于视觉内容显著不同的图像,生成的哈希值应该有显著差异。
紧凑性:哈希值通常较短(例如64位、256位),便于存储和快速比较。
5.1.2 感知哈希与密码学哈希的关键区别
| 特性 | 感知哈希 (Perceptual Hash) | 密码学哈希 (Cryptographic Hash) |
|---|---|---|
| 目标 | 内容相似性判断 | 数据完整性校验、唯一标识 |
| 输入敏感性 | 对微小、不改变核心内容的变换不敏感(或变化小) | 对输入的任何微小变化都极其敏感(雪崩效应) |
| 输出相似性 | 相似输入产生相似(汉明距离近)的输出 | 相似输入产生完全不同的输出 |
| 碰撞性 | 允许(甚至期望)感知上相似的不同输入产生相同或相近的哈希 | 极力避免碰撞(两个不同输入产生相同哈希) |
| 主要应用 | 图像/音频检索、重复检测、数字水印、内容追踪 | 数据校验、数字签名、密码存储、区块链 |
5.1.3 感知哈希的基本流程
尽管存在多种不同的感知哈希算法,但它们通常遵循一个相似的基本流程:
图像预处理 (Preprocessing):
尺寸归一化 (Resizing):将输入图像缩放到一个固定的、较小的尺寸(例如 8×8, 32×32)。这有助于减少计算量,并对图像的原始尺寸和比例具有一定的鲁棒性。
颜色空间转换 (Color Space Conversion):通常将彩色图像转换为灰度图像。这是因为颜色信息对于很多感知哈希算法来说不是主要因素,且转换为灰度可以减少数据维度。
特征提取 (Feature Extraction):
这是感知哈希算法的核心步骤,不同的算法在这一步采用不同的策略。目的是从预处理后的图像中提取出能够代表其核心视觉内容的特征。这些特征可能基于像素的平均值、像素间的差异、频域系数(如DCT系数)等。
哈希生成 (Hash Generation):
将提取到的特征转换为一个二进制串(哈希值)。这通常通过比较特征值与某个阈值(例如平均值、中位数)来完成。如果特征值大于阈值,则对应比特为1,否则为0(反之亦可)。
5.1.4 哈希值比较:汉明距离 (Hamming Distance)
一旦为两幅图像生成了感知哈希值(两个等长的二进制串),就可以通过计算它们之间的汉明距离来衡量它们的相似度。汉明距离是指两个等长字符串之间对应位置上不同字符的个数。
例如,哈希值 H1 = 10110100 和 H2 = 10011101
它们在第3、5、8位不同,所以汉明距离为3。
汉明距离越小,表示两个哈希值越相似,进而意味着对应的两幅图像在感知上也越相似。通常会设定一个阈值,如果两图像哈希值的汉明距离小于该阈值,则认为它们是相似的或重复的。阈值的选择取决于具体的应用场景和对误报、漏报的容忍度。
5.2 平均哈希算法 (Average Hash, aHash)
平均哈希算法(aHash)是最简单、最直观的感知哈希算法之一。它主要依赖于图像的低频信息,即图像的整体明暗和结构。
5.2.1 aHash 算法步骤
缩放 (Resize):将图像缩小到一个固定的小尺寸,例如 8×8 像素。这个尺寸虽然小,但足以保留图像的低频结构信息。缩小图像的目的是去除高频细节,并提高对缩放和比例变化的鲁棒性。
灰度化 (Grayscale):将缩放后的彩色图像转换为灰度图像。这样每个像素就只有一个值,简化了计算。
计算平均灰度值 (Calculate Average Gray Value):计算这个 8×8 灰度图像中所有 64 个像素的灰度值的平均值。
生成哈希 (Generate Hash):遍历 8×8 灰度图像的每个像素。如果当前像素的灰度值大于或等于平均灰度值,则哈希串的对应位记为 ‘1’;如果小于平均灰度值,则记为 ‘0’。这样最终会得到一个 8×8 = 64 位的二进制哈希串。
5.2.2 aHash Python 实现 (Pillow)
我们将使用 Pillow 库来实现 aHash。
from PIL import Image
def average_hash(image_path, hash_size=8):
"""
计算图像的平均哈希值 (aHash)。
参数:
image_path (str): 图像文件的路径。
hash_size (int): 哈希的大小,生成的哈希值将是 hash_size * hash_size 位。
常用的值为 8,生成 64 位哈希。
返回:
int: 图像的 aHash 值 (十进制表示)。
如果图像无法打开,则返回 None。
"""
try:
img = Image.open(image_path) # 打开图像文件
except IOError:
print(f"无法打开图像: {
image_path}") # 打印错误信息
return None # 返回 None 表示处理失败
# 步骤 1 & 2: 缩放并转换为灰度图像
# 使用 LANCZOS 插值算法进行高质量缩放
# 'L' 模式表示灰度图像 (8-bit pixels, black and white)
resized_img = img.resize((hash_size, hash_size), Image.LANCZOS).convert('L') # 将图像缩放至 hash_size x hash_size 大小,并转换为灰度模式
# 步骤 3: 计算平均灰度值
pixels = list(resized_img.getdata()) # 获取图像所有像素的灰度值列表
avg_pixel_value = sum(pixels) / len(pixels) # 计算所有像素灰度值的平均值
# 步骤 4: 生成哈希
# 比较每个像素的灰度值与平均值,生成二进制哈希串
hash_bits = [] # 初始化一个空列表来存储哈希的每一位
for pixel_value in pixels: # 遍历每个像素的灰度值
if pixel_value >= avg_pixel_value: # 如果当前像素值大于或等于平均值
hash_bits.append(1) # 哈希位为 1
else: # 否则
hash_bits.append(0) # 哈希位为 0
# 将二进制列表转换为十六进制字符串或十进制整数
# 这里我们转换为十进制整数,方便后续直接进行位运算计算汉明距离
hash_value = 0 # 初始化哈希值为 0
for bit in hash_bits: # 遍历哈希的每一位 (0 或 1)
hash_value = (hash_value << 1) | bit # 将当前哈希值左移一位,然后与当前位进行或运算,构建整数表示的哈希
return hash_value # 返回计算得到的 aHash 值(十进制整数)
def hamming_distance(hash1, hash2):
"""
计算两个整数哈希值之间的汉明距离。
汉明距离是指两个等长字符串对应位置的不同字符的个数。
对于整数,可以通过异或运算后计算结果中 '1' 的个数来实现。
参数:
hash1 (int): 第一个哈希值 (十进制整数)。
hash2 (int): 第二个哈希值 (十进制整数)。
返回:
int: 两个哈希值之间的汉明距离。
"""
xor_result = hash1 ^ hash2 # 对两个哈希值进行异或 (XOR) 运算,结果中位为1的地方表示原始哈希对应位不同
distance = 0 # 初始化汉明距离为 0
while xor_result > 0: # 当异或结果大于0时,说明还有位为1
distance += xor_result & 1 # 检查最低位是否为1,如果是则距离加1
xor_result >>= 1 # 将异或结果右移一位,继续检查下一位
return distance # 返回计算得到的汉明距离
# --- 示例使用 ---
if __name__ == "__main__":
# 假设我们有两张图像: image1.jpg, image2.jpg, image3.jpg
# image1.jpg 和 image2.jpg 是相似的
# image3.jpg 与前两者差异较大
# 为了运行示例,请创建或替换为你的图像路径
# 这里我们用伪代码示意,实际运行时需要真实的图像文件
# 创建一些虚拟图像文件用于测试 (实际应用中请替换为真实图像路径)
try:
img1_data = Image.new('RGB', (256, 256), color = 'red') # 创建一个256x256的红色图像
img1_data.save("image1_temp.png") # 保存为 image1_temp.png
img1_path = "image1_temp.png" # 设置图像1的路径
img2_data = Image.new('RGB', (250, 250), color = (250, 10, 10)) # 创建一个略有不同的红色图像 (尺寸和颜色略微变化)
img2_data.save("image2_temp.png") # 保存为 image2_temp.png
img2_path = "image2_temp.png" # 设置图像2的路径
img3_data = Image.new('RGB', (256, 256), color = 'blue') # 创建一个256x256的蓝色图像
img3_data.save("image3_temp.png") # 保存为 image3_temp.png
img3_path = "image3_temp.png" # 设置图像3的路径
hash1 = average_hash(img1_path, hash_size=8) # 计算图像1的 aHash
hash2 = average_hash(img2_path, hash_size=8) # 计算图像2的 aHash
hash3 = average_hash(img3_path, hash_size=8) # 计算图像3的 aHash
if hash1 is not None and hash2 is not None: # 确保哈希值成功计算
dist12 = hamming_distance(hash1, hash2) # 计算图像1和图像2哈希值的汉明距离
print(f"图像1的aHash: {
hash1:016x} (十六进制)") # 打印图像1的aHash值 (格式化为16位十六进制)
print(f"图像2的aHash: {
hash2:016x} (十六进制)") # 打印图像2的aHash值 (格式化为16位十六进制)
print(f"图像1和图像2之间的汉明距离 (aHash): {
dist12}") # 打印图像1和图像2之间的汉明距离
if hash1 is not None and hash3 is not None: # 确保哈希值成功计算
dist13 = hamming_distance(hash1, hash3) # 计算图像1和图像3哈希值的汉明距离
print(f"图像3的aHash: {
hash3:016x} (十六进制)") # 打印图像3的aHash值 (格式化为16位十六进制)
print(f"图像1和图像3之间的汉明距离 (aHash): {
dist13}") # 打印图像1和图像3之间的汉明距离
# 设定一个相似度阈值
# 通常对于64位哈希,汉明距离在0-5之间可以认为是高度相似
# 6-10之间认为是比较相似
# 大于10则认为差异较大,具体阈值需要根据应用场景调整
similarity_threshold = 10 # 设置相似度阈值
if hash1 is not None and hash2 is not None and dist12 <= similarity_threshold: # 如果图像1和图像2的汉明距离小于等于阈值
print("图像1和图像2被认为是相似的 (基于aHash)") # 打印它们相似
else:
print("图像1和图像2被认为是不相似的 (基于aHash)") # 否则打印它们不相似
if hash1 is not None and hash3 is not None and dist13 <= similarity_threshold: # 如果图像1和图像3的汉明距离小于等于阈值
print("图像1和图像3被认为是相似的 (基于aHash)") # 打印它们相似
else:
print("图像1和图像3被认为是不相似的 (基于aHash)") # 否则打印它们不相似
except ImportError:
print("请先安装Pillow库: pip install Pillow") # 如果Pillow库未安装,提示用户安装
except Exception as e:
print(f"发生错误: {
e}") # 捕获并打印其他可能的异常
finally:
# 清理临时文件 (可选)
import os # 导入os模块
if os.path.exists("image1_temp.png"): os.remove("image1_temp.png") # 如果存在临时文件image1_temp.png,则删除它
if os.path.exists("image2_temp.png"): os.remove("image2_temp.png") # 如果存在临时文件image2_temp.png,则删除它
if os.path.exists("image3_temp.png"): os.remove("image3_temp.png") # 如果存在临时文件image3_temp.png,则删除它
5.2.3 aHash 的代码解释
average_hash(image_path, hash_size=8) 函数:
Image.open(image_path): 使用Pillow的Image模块打开指定路径的图像。
img.resize((hash_size, hash_size), Image.LANCZOS): 将图像大小调整为hash_sizexhash_size。Image.LANCZOS (在较新 Pillow 版本中是 Image.Resampling.LANCZOS) 是一种高质量的下采样(缩小)滤波器,有助于保留图像的主要特征。
.convert('L'): 将调整大小后的图像转换为灰度模式 (‘L’ 代表 luminance)。每个像素只有一个灰度值,范围通常是0-255。
pixels = list(resized_img.getdata()): getdata() 方法返回一个包含图像所有像素值的序列。对于灰度图,每个元素就是一个像素的灰度值。我们将其转换为列表。
avg_pixel_value = sum(pixels) / len(pixels): 计算所有像素灰度值的算术平均数。
hash_bits.append(1) 或 hash_bits.append(0): 遍历每个像素,如果其灰度值大于或等于平均值,则该位为1,否则为0。
hash_value = (hash_value << 1) | bit: 这是一种将二进制位列表转换为整数的有效方法。hash_value << 1 将hash_value左移一位(相当于乘以2),然后 | bit 将当前位(0或1)设置到最低位。重复此操作,可以将整个二进制序列构建成一个整数。
hamming_distance(hash1, hash2) 函数:
xor_result = hash1 ^ hash2: ^ 是异或运算符。两个整数进行异或运算后,结果中位为 ‘1’ 的地方表示这两个整数在二进制表示下对应位不同,位为 ‘0’ 的地方表示对应位相同。这正是汉明距离的基础。
while xor_result > 0:: 循环直到异或结果的所有位都变为0。
distance += xor_result & 1: & 1 操作是取xor_result的最低位。如果最低位是1,说明此处有一个差异,distance加1。
xor_result >>= 1: >>= 是右移赋值运算符。将xor_result右移一位,抛弃最低位,以便在下一次循环中检查新的最低位。
这个过程实际上是在计算xor_result二进制表示中 ‘1’ 的个数,这直接等于hash1和hash2之间的汉明距离。
5.2.4 aHash 的优缺点
优点:
实现简单 (Simple to Implement): 算法逻辑非常直接,代码易于编写和理解。
计算速度快 (Fast Computation): 涉及的操作(缩放、灰度化、平均值计算、比较)都非常基础,计算开销小。
对图像整体亮度、对比度变化具有一定的鲁棒性: 由于是和平均值比较,整体的亮度提升或降低,只要不改变像素间的相对明暗关系,哈希值可能保持不变或变化很小。
对轻微的缩放和比例变化不敏感: 初始的缩放步骤使其对原始图像尺寸不敏感。
缺点:
对内容改变敏感 (Sensitive to Content Changes): 即使是很小的、但结构性的内容修改(例如添加一个小物体,或擦除一部分),也可能导致哈希值发生较大变化。
对旋转、翻转等几何变换敏感 (Sensitive to Rotations, Flips): aHash没有内在机制来处理旋转或翻转。旋转90度的图像会产生完全不同的哈希值。
对伽马校正或非线性色调调整敏感: 这些操作会改变像素间的相对亮度关系,从而影响平均值和比较结果,导致哈希值变化。
精度相对较低 (Relatively Low Accuracy): 依赖于非常粗略的图像信息(平均灰度),可能会将一些视觉上差异较大的图像误判为相似(如果它们的平均灰度分布相似),或者将一些视觉上相似但平均灰度计算导致跨越阈值的图像误判为不相似。
容易受到高频噪声的影响(如果预处理不够): 虽然缩放会抑制一些高频,但如果图像在缩放前就有强烈的棋盘格噪声等,可能会影响平均值。
5.2.5 aHash 的应用场景
尽管aHash相对简单,但在某些场景下仍然有用:
快速初筛 (Quick Initial Screening): 在大规模图像库中,可以用aHash进行第一轮的快速筛选,找出潜在的相似项,然后再用更精确但计算量更大的算法进行复核。
查找完全相同或极度相似的副本 (Finding Exact or Very Close Duplicates): 对于几乎没有修改的图像副本(例如只是改变了文件名或元数据),aHash通常能很好地识别。
简单的图像分类或聚类任务的特征输入: 在一些不要求高精度的场景下,aHash可以作为图像的一种简单特征。
5.2.6 aHash 的哈希尺寸 (hash_size) 选择
hash_size 参数决定了最终哈希值的位数(hash_size * hash_size)。
较小的 hash_size(例如 8×8=64位):
优点:计算更快,哈希更紧凑,对图像细节更不敏感(更已关注整体结构)。
缺点:区分能力更弱,更容易发生哈希碰撞(不同的图像产生相同的哈希)。
较大的 hash_size(例如 16×16=256位):
优点:区分能力更强,能捕捉更多图像细节。
缺点:计算量稍大,哈希更长,对微小变化的敏感度可能略微增加(相对于极小的hash_size而言)。
常用的值是 8 或 16。选择取决于具体的应用需求,需要在鲁棒性、区分度和计算效率之间进行权衡。对于大多数一般用途,hash_size=8(64位哈希)是一个不错的起点。
5.2.7 aHash 的汉明距离阈值选择
汉明距离的阈值选取非常关键,它直接影响到相似性判断的准确率(召回率和精确率)。
阈值过低:
精确率高(被判断为相似的确实很相似)。
召回率低(很多实际相似的图像可能因为汉明距离略高于阈值而被漏掉)。
会导致很多相似图像被误判为不相似。
阈值过高:
召回率高(更多实际相似的图像会被找出来)。
精确率低(一些不那么相似甚至不相似的图像也可能被误判为相似)。
会导致一些不相似的图像被误判为相似。
对于64位的aHash,经验性的阈值范围如下:
0: 几乎完全相同的图像。
1-5: 非常相似,很可能是同一张图的不同版本(例如轻微压缩、亮度调整)。
6-10: 可能相似,内容主题可能一致,但可能存在一些可见差异。
>10 (或 >15): 通常认为是不同的图像。
这个阈值并没有固定的标准,强烈建议根据具体应用的数据集和需求进行实验和调整。可以选取一部分样本数据,手动标注相似对和非相似对,然后尝试不同的阈值,观察其在测试集上的表现(例如计算Precision-Recall曲线,或者F1-score),从而选择一个最佳的平衡点。
例如,在构建一个去重系统时,如果目标是尽可能找出所有重复项,宁可错杀一些(后续人工复核),可以适当提高阈值;如果目标是高精度地确认重复,避免误判,则应降低阈值。

















暂无评论内容