图片相似度检测原理与Python实现
图片相似度检测:原理与 Python 实现
图片重新保存、压缩、改变元数据或缩放后,文件字节可能变化,画面却仍接近。MD5、SHA-256 等文件哈希只能筛查字节层面的相同内容,哈希之间的距离不表示视觉差异;不改变字节的网络传输本身不会改变文件哈希。
本文采用两阶段检测:先用感知哈希 pHash 筛选候选,再用透明度、颜色误差、局部误差和结构相似性 SSIM 复核。适用目标是同一画面的近重复检测,不是识别相同人物、物体或主题。
1. 判定范围与流程
| 结果 | 含义 |
|---|---|
duplicate | 宽高比、播放结构及全部帧通过近重复规则;不要求文件字节相同 |
similar | 首帧通过 pHash 筛选,但至少一项复核未通过,仅表示候选 |
different | 首帧 pHash 距离超过阈值,结束比较 |
以上结果不是概率,也不保证符合所有人的视觉判断。尤其是 similar 可能包含仅轮廓接近的异色图片,different 也可能漏掉严重压缩后的同图。输入无法解码时抛出异常,不返回这三类结果。
检测顺序如下:
- 按顺序解码各帧,校正 EXIF 方向;存在 ICC 配置时转换至 sRGB,否则采用解码后的 RGB 数值。
- 保留 Alpha 通道,以预乘透明度方式缩放至 256×256,生成白底画面的 64 位 pHash。
- 比较首帧哈希;通过后检查原始宽高比、帧数、帧时长和循环信息。
- 逐帧检查 pHash 和透明度差异,再分别在白底、黑底上复核颜色与结构。每项均通过才返回
duplicate。
动图使用解码器还原的完整画布,不直接比较压缩帧片段。不做帧重采样或时间轴对齐:视觉播放接近但帧结构不同的动画不能据此判为近重复。
2. 算法与公式
2.1 透明度与尺寸归一化
对颜色通道 、透明度 和背景 ,合成画面为:
缩放前使用预乘颜色 ,共同插值 与 ,再还原非零透明度处的颜色。这样可减少完全透明像素中的隐藏 RGB 对边缘的污染。代码通过 Pillow 的 RGBa 模式完成,实际计算包含 8 位量化与舍入。Pillow 图像模式说明。
统一尺寸会改变非方形图片的采样比例,因此另用 检查原始宽高比。该检查允许等比例缩放,不提供裁剪、平移或旋转对齐。方向校正和色彩转换分别使用 ImageOps 与 ImageCms。
2.2 pHash 与汉明距离
ImageHash 4.3.2 的默认 pHash 将画面转换为 32×32 灰度图,计算二维 DCT,取左上角 8×8 低频系数,以这 64 个系数的中位数为阈值生成位串。该实现保留直流系数参与计算,比较条件为严格大于中位数。ImageHash。
两个哈希 的汉明距离为:
距离越小表示哈希越接近,但灰度哈希丢失了颜色与细节。示例以 筛选候选,不能单独据此判为近重复。
2.3 全图与局部误差
对白底、黑底各自合成的两幅图,先施加半径 0.6 的高斯模糊,减轻轻度编码振铃的影响。令模糊后的 RGB 图为 ,高宽为 :
把画面分成不重叠的 8×8 块,逐块、逐通道取均值,再取最大值:
两项均使用 0–255 的像素数值单位。逐通道局部上限用于减少小块文字或颜色变化被全图平均稀释的情况,但不能保证检出任意微小修改。
透明度使用未模糊的单通道差值 ,以同样方式计算全图均值与分块最大均值。黑白两种背景都通过复核,才允许判为近重复;完全透明区域的隐藏颜色不直接参与画面比较。
2.4 SSIM
对局部窗口 ,SSIM 的常用形式为:
其中 为局部均值, 为局部方差, 为协方差;、,8 位画面取 。得分通常越接近 1,结构越接近,也可能为负,不应换算成“重复概率”。SSIM 原作者资料。
代码采用 scikit-image 0.25.2:高斯权重、sigma=1.5、data_range=255,分别计算 RGB 通道后取平均。显式设置 use_sample_covariance=True,局部二阶矩使用 修正,默认高斯窗口对应 ;剔除边缘窗口后平均。原论文对照配置使用 False,因此这里不宣称逐值复现原论文。SSIM API。
3. 独立 Python 实现
以下代码以 Python 3.12 运行,直接接收图片字节,不下载图片或读写数据库。安装依赖:
python -m pip install "Pillow==12.3.0" "numpy==2.5.4" "ImageHash==4.3.2" "scikit-image==0.25.2" "scipy==1.18.1"将下面的代码保存为 image_similarity.py。ImageFingerprint 是 fingerprint() 生成的内存数据,比较期间不应手动修改其中的数组;不提供外部指纹反序列化接口。
from dataclasses import dataclassfrom io import BytesIOfrom typing import Literal
import imagehashimport numpy as npfrom PIL import Image, ImageCms, ImageFilter, ImageOpsfrom skimage.metrics import structural_similarity
Similarity = Literal["duplicate", "similar", "different"]SAMPLE_SIZE = 256BLOCK_SIZE = 8PHASH_DISTANCE = 10MAX_MEAN_ERROR = 2.8MAX_LOCAL_ERROR = 12.0MAX_ALPHA_MEAN_ERROR = 1.5MAX_ALPHA_LOCAL_ERROR = 8.0MIN_SSIM = 0.985
@dataclass(frozen=True)class ImageFingerprint: width: int height: int durations: tuple[float, ...] loop: int | None frames: tuple[np.ndarray, ...] hashes: tuple[int, ...]
def fingerprint(data: bytes) -> ImageFingerprint: """解码完整帧并生成画面指纹;解码或色彩转换失败时直接抛出异常。""" with Image.open(BytesIO(data)) as image: frame_count = getattr(image, "n_frames", 1) # APNG 的可选默认图不是动画帧,不参与播放内容比较。 start = 1 if image.info.get("default_image", False) else 0 animated = frame_count - start > 1 loop = image.info.get("loop") if animated else None icc = image.info.get("icc_profile") source_profile = ImageCms.ImageCmsProfile(BytesIO(icc)) if icc else None target_profile = ImageCms.createProfile("sRGB") if icc else None frames, hashes, durations = [], [], [] size = None
for index in range(start, frame_count): image.seek(index) oriented = ImageOps.exif_transpose(image) rgba = oriented.convert("RGBA") if source_profile is not None: source = ( oriented if oriented.mode in {"RGB", "CMYK", "LAB", "L"} else oriented.convert("RGB") ) converted = ImageCms.profileToProfile( source, source_profile, target_profile, outputMode="RGB" ) converted.putalpha(rgba.getchannel("A")) rgba = converted if size is None: size = rgba.size elif rgba.size != size: raise ValueError("各帧画布尺寸必须一致")
# 预乘透明度后插值,减少隐藏 RGB 对透明边缘的影响。 sample = ( rgba.convert("RGBa") .resize((SAMPLE_SIZE, SAMPLE_SIZE), Image.Resampling.LANCZOS) .convert("RGBA") ) frames.append(np.array(sample, dtype=np.uint8)) white = Image.alpha_composite( Image.new("RGBA", sample.size, "white"), sample ).convert("RGB") hashes.append(int(str(imagehash.phash(white)), 16)) # 毫秒保留小数;缺失时长采用 0,不推测播放器的最小时长。 duration = float(image.info.get("duration", 0)) if animated else 0.0 if not np.isfinite(duration) or duration < 0: raise ValueError("帧时长必须为有限的非负数") durations.append(duration)
return ImageFingerprint( width=size[0], height=size[1], durations=tuple(durations), loop=loop, frames=tuple(frames), hashes=tuple(hashes), )
def compare(first: ImageFingerprint, second: ImageFingerprint) -> Similarity: if (first.hashes[0] ^ second.hashes[0]).bit_count() > PHASH_DISTANCE: return "different" if first.width * second.height != second.width * first.height: return "similar" if (len(first.frames) != len(second.frames) or first.durations != second.durations or first.loop != second.loop): return "similar"
blocks = SAMPLE_SIZE // BLOCK_SIZE for index, (left_frame, right_frame) in enumerate(zip(first.frames, second.frames)): if (first.hashes[index] ^ second.hashes[index]).bit_count() > PHASH_DISTANCE: return "similar" if np.array_equal(left_frame, right_frame): continue alpha_error = np.abs( left_frame[:, :, 3].astype(np.float32) - right_frame[:, :, 3] ) alpha_local = alpha_error.reshape( blocks, BLOCK_SIZE, blocks, BLOCK_SIZE ).mean(axis=(1, 3)) if (alpha_error.mean() > MAX_ALPHA_MEAN_ERROR or alpha_local.max() > MAX_ALPHA_LOCAL_ERROR): return "similar"
# 两种背景均通过,避免仅在一种背景上掩盖透明度或颜色差异。 for background in ("white", "black"): left_image = Image.alpha_composite( Image.new("RGBA", (SAMPLE_SIZE, SAMPLE_SIZE), background), Image.fromarray(left_frame), ).convert("RGB") right_image = Image.alpha_composite( Image.new("RGBA", (SAMPLE_SIZE, SAMPLE_SIZE), background), Image.fromarray(right_frame), ).convert("RGB") left = np.asarray(left_image.filter(ImageFilter.GaussianBlur(0.6)), dtype=np.float32) right = np.asarray(right_image.filter(ImageFilter.GaussianBlur(0.6)), dtype=np.float32) error = np.abs(left - right) # 保留通道维度,限制任意一个分块中的单通道平均误差。 local_error = error.reshape( blocks, BLOCK_SIZE, blocks, BLOCK_SIZE, 3 ).mean(axis=(1, 3)) if (error.mean() > MAX_MEAN_ERROR or local_error.max() > MAX_LOCAL_ERROR): return "similar" score = structural_similarity( left, right, channel_axis=2, data_range=255, gaussian_weights=True, sigma=1.5, use_sample_covariance=True, K1=0.01, K2=0.03, ) if score < MIN_SSIM: return "similar" return "duplicate"最小调用示例:
from pathlib import Pathfrom image_similarity import compare, fingerprint
first = fingerprint(Path("first.png").read_bytes())second = fingerprint(Path("second.webp").read_bytes())print(compare(first, second))动画帧时长按解码后的毫秒值精确比较;缺失时长取 0。循环字段缺失保留 None,不与数值 0 合并;也不转换不同格式间循环次数的语义。APNG 的独立默认图被跳过。格式间的帧合并、时长量化和循环约定不同,可能使相近动画停留在 similar。
代码保留 Pillow 默认的解压炸弹警告与异常,不开启截断图片容错。全部缩略帧常驻内存,仅 RGBA 数组就约需 字节,即每帧 256 KiB,另有原图解码和比较临时内存。Pillow 的像素保护不等于动画总内存限制;此示例用于有限规模输入。
4. 参数、评估与局限
| 参数 | 示例值 | 作用 |
|---|---|---|
| 比较尺寸 | 256×256 | 固定空间采样精度 |
| pHash 长度 | 64 位 | 灰度低频候选特征 |
| 汉明距离上限 | 10 | 首帧筛选及后续帧复核 |
| 高斯模糊半径 | 0.6 | 缓和轻度编码误差 |
| RGB 全图平均误差上限 | 2.8 | 限制整体颜色差异 |
| RGB 分块误差上限 | 12 | 8×8 块中逐通道均值的最大值 |
| Alpha 全图平均误差上限 | 1.5 | 限制整体透明度变化 |
| Alpha 分块误差上限 | 8 | 限制局部透明度变化 |
| SSIM 下限 | 0.985 | 限制结构变化 |
各项阈值包含边界值,黑白背景分别检查。表中的参数构成一套示例判定规则,不是通用标准;比较尺寸、模糊程度或库版本改变后,应重新验证阈值。
评估时按原图划分校准集与验收集,避免同一原图的不同变体同时进入两组。正例覆盖元数据修改、不同压缩质量、重复压缩、等比例缩放和格式转换;反例覆盖文字、颜色、透明度、水印、边框、裁剪以及动画后续帧或时序变化。分别统计误判为 duplicate 的数量、近重复检出率和 similar 数量,不能只报告总体准确率。标注为重复且检出记为 TP,异图误判为重复记为 FP,重复图未检出记为 FN,则:
分母为 0 时应说明该指标不可计算。similar 不计为近重复检出,另行报告;指标参考 imagededup 评估方法。
在上述依赖环境下,使用同一合成图生成方法的 6 张不同原图,按 3 张校准、3 张验收划分;每组 27 个同图及近重复样例全部返回 duplicate,18 个修改样例均未返回 duplicate。另验证了透明隐藏颜色、EXIF、sRGB ICC、GIF/WebP/APNG 后续帧与时序变化、APNG 默认图及小数帧时长。它们仅是合成样例验证,不是独立真实图库评测。
缩略图会丢失小字和细线,模糊会进一步降低微小差异;首帧哈希筛选也可能提前漏掉真重复。旋转、裁剪、严重压缩、跨格式颜色处理以及帧结构变化都没有稳定检出保证。合成样例通过只能说明这些样例满足规则,不能据此宣称真实图片零误判。
批量分组可按固定输入顺序选择参照图,让每个成员直接与参照图比较。A 与 B 接近、B 与 C 接近,不推出 A 与 C 接近。多个参照图可匹配时,优先选择 duplicate,否则选择首个 similar;无候选时建立新组。该方法受输入顺序影响,最坏仍需 次候选比较,pHash 筛选主要减少昂贵的画面复核,不自动消除两两比较开销。
附录:参考资料
- ImageHash:感知哈希实现与距离比较;本文采用 4.3.2 的
phash。 - SSIM 原作者资料:Wang、Bovik、Sheikh、Simoncelli,2004,Image Quality Assessment: From Error Visibility to Structural Similarity;提供论文和参考实现。
- scikit-image 0.25 SSIM API:数据范围、高斯权重、通道与协方差参数。
- Pillow 图像模式、ImageOps、ImageCms:预乘透明度、EXIF 方向与 ICC 色彩转换。
- Pillow 图像格式:动画帧、播放参数与 APNG 默认图约定。
- imagededup 评估方法:重复图片对的标注与分类评估。