文章

图片相似度检测原理与Python实现

图片相似度检测:原理与 Python 实现

图片重新保存、压缩、改变元数据或缩放后,文件字节可能变化,画面却仍接近。MD5、SHA-256 等文件哈希只能筛查字节层面的相同内容,哈希之间的距离不表示视觉差异;不改变字节的网络传输本身不会改变文件哈希。

本文采用两阶段检测:先用感知哈希 pHash 筛选候选,再用透明度、颜色误差、局部误差和结构相似性 SSIM 复核。适用目标是同一画面的近重复检测,不是识别相同人物、物体或主题。

1. 判定范围与流程

结果含义
duplicate宽高比、播放结构及全部帧通过近重复规则;不要求文件字节相同
similar首帧通过 pHash 筛选,但至少一项复核未通过,仅表示候选
different首帧 pHash 距离超过阈值,结束比较

以上结果不是概率,也不保证符合所有人的视觉判断。尤其是 similar 可能包含仅轮廓接近的异色图片,different 也可能漏掉严重压缩后的同图。输入无法解码时抛出异常,不返回这三类结果。

检测顺序如下:

  1. 按顺序解码各帧,校正 EXIF 方向;存在 ICC 配置时转换至 sRGB,否则采用解码后的 RGB 数值。
  2. 保留 Alpha 通道,以预乘透明度方式缩放至 256×256,生成白底画面的 64 位 pHash。
  3. 比较首帧哈希;通过后检查原始宽高比、帧数、帧时长和循环信息。
  4. 逐帧检查 pHash 和透明度差异,再分别在白底、黑底上复核颜色与结构。每项均通过才返回 duplicate。

动图使用解码器还原的完整画布,不直接比较压缩帧片段。不做帧重采样或时间轴对齐:视觉播放接近但帧结构不同的动画不能据此判为近重复。

2. 算法与公式

2.1 透明度与尺寸归一化

对颜色通道 C∈[0,255]C\in[0,255]、透明度 a=α/255∈[0,1]a=\alpha/255\in[0,1] 和背景 BB,合成画面为:

CB=aC+(1−a)BC_B=aC+(1-a)B

缩放前使用预乘颜色 Cp=aCC_p=aC,共同插值 CpC_p 与 aa,再还原非零透明度处的颜色。这样可减少完全透明像素中的隐藏 RGB 对边缘的污染。代码通过 Pillow 的 RGBa 模式完成,实际计算包含 8 位量化与舍入。Pillow 图像模式说明。

统一尺寸会改变非方形图片的采样比例,因此另用 w1h2=w2h1w_1h_2=w_2h_1 检查原始宽高比。该检查允许等比例缩放,不提供裁剪、平移或旋转对齐。方向校正和色彩转换分别使用 ImageOps 与 ImageCms。

2.2 pHash 与汉明距离

ImageHash 4.3.2 的默认 pHash 将画面转换为 32×32 灰度图,计算二维 DCT,取左上角 8×8 低频系数,以这 64 个系数的中位数为阈值生成位串。该实现保留直流系数参与计算,比较条件为严格大于中位数。ImageHash。

两个哈希 h1,h2h_1,h_2 的汉明距离为:

dH(h1,h2)=popcount⁡(h1⊕h2),0≤dH≤64d_H(h_1,h_2)=\operatorname{popcount}(h_1\oplus h_2),\qquad 0\le d_H\le64

距离越小表示哈希越接近,但灰度哈希丢失了颜色与细节。示例以 dH≤10d_H\le10 筛选候选,不能单独据此判为近重复。

2.3 全图与局部误差

对白底、黑底各自合成的两幅图,先施加半径 0.6 的高斯模糊,减轻轻度编码振铃的影响。令模糊后的 RGB 图为 X,YX,Y,高宽为 H,WH,W:

Di,j,c=∣Xi,j,c−Yi,j,c∣,Emean=13HW∑i,j,cDi,j,cD_{i,j,c}=|X_{i,j,c}-Y_{i,j,c}|,\qquad E_{\mathrm{mean}}=\frac{1}{3HW}\sum_{i,j,c}D_{i,j,c}

把画面分成不重叠的 8×8 块,逐块、逐通道取均值,再取最大值:

Elocal=max⁡b,c164∑(i,j)∈bDi,j,cE_{\mathrm{local}}=\max_{b,c}\frac{1}{64}\sum_{(i,j)\in b}D_{i,j,c}

两项均使用 0–255 的像素数值单位。逐通道局部上限用于减少小块文字或颜色变化被全图平均稀释的情况,但不能保证检出任意微小修改。

透明度使用未模糊的单通道差值 Dα=∣α1−α2∣D^\alpha=|\alpha_1-\alpha_2|,以同样方式计算全图均值与分块最大均值。黑白两种背景都通过复核,才允许判为近重复;完全透明区域的隐藏颜色不直接参与画面比较。

2.4 SSIM

对局部窗口 x,yx,y,SSIM 的常用形式为:

SSIM⁡(x,y)=(2μxμy+C1)(2σxy+C2)(μx2+μy2+C1)(σx2+σy2+C2)\operatorname{SSIM}(x,y)= \frac{(2\mu_x\mu_y+C_1)(2\sigma_{xy}+C_2)} {(\mu_x^2+\mu_y^2+C_1)(\sigma_x^2+\sigma_y^2+C_2)}

其中 μ\mu 为局部均值,σ2\sigma^2 为局部方差,σxy\sigma_{xy} 为协方差;C1=(0.01L)2C_1=(0.01L)^2、C2=(0.03L)2C_2=(0.03L)^2,8 位画面取 L=255L=255。得分通常越接近 1,结构越接近,也可能为负,不应换算成“重复概率”。SSIM 原作者资料。

代码采用 scikit-image 0.25.2:高斯权重、sigma=1.5、data_range=255,分别计算 RGB 通道后取平均。显式设置 use_sample_covariance=True,局部二阶矩使用 N/(N−1)N/(N-1) 修正,默认高斯窗口对应 N=112N=11^2;剔除边缘窗口后平均。原论文对照配置使用 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 dataclass
from io import BytesIO
from typing import Literal
import imagehash
import numpy as np
from PIL import Image, ImageCms, ImageFilter, ImageOps
from skimage.metrics import structural_similarity
Similarity = Literal["duplicate", "similar", "different"]
SAMPLE_SIZE = 256
BLOCK_SIZE = 8
PHASH_DISTANCE = 10
MAX_MEAN_ERROR = 2.8
MAX_LOCAL_ERROR = 12.0
MAX_ALPHA_MEAN_ERROR = 1.5
MAX_ALPHA_LOCAL_ERROR = 8.0
MIN_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 Path
from 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×256×4×F256\times256\times4\times F 字节,即每帧 256 KiB,另有原图解码和比较临时内存。Pillow 的像素保护不等于动画总内存限制;此示例用于有限规模输入。

4. 参数、评估与局限

参数示例值作用
比较尺寸256×256固定空间采样精度
pHash 长度64 位灰度低频候选特征
汉明距离上限10首帧筛选及后续帧复核
高斯模糊半径0.6缓和轻度编码误差
RGB 全图平均误差上限2.8限制整体颜色差异
RGB 分块误差上限128×8 块中逐通道均值的最大值
Alpha 全图平均误差上限1.5限制整体透明度变化
Alpha 分块误差上限8限制局部透明度变化
SSIM 下限0.985限制结构变化

各项阈值包含边界值,黑白背景分别检查。表中的参数构成一套示例判定规则,不是通用标准;比较尺寸、模糊程度或库版本改变后,应重新验证阈值。

评估时按原图划分校准集与验收集,避免同一原图的不同变体同时进入两组。正例覆盖元数据修改、不同压缩质量、重复压缩、等比例缩放和格式转换;反例覆盖文字、颜色、透明度、水印、边框、裁剪以及动画后续帧或时序变化。分别统计误判为 duplicate 的数量、近重复检出率和 similar 数量,不能只报告总体准确率。标注为重复且检出记为 TP,异图误判为重复记为 FP,重复图未检出记为 FN,则:

Precision=TPTP+FP,Recall=TPTP+FN\mathrm{Precision}=\frac{TP}{TP+FP},\qquad \mathrm{Recall}=\frac{TP}{TP+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;无候选时建立新组。该方法受输入顺序影响,最坏仍需 O(n2)O(n^2) 次候选比较,pHash 筛选主要减少昂贵的画面复核,不自动消除两两比较开销。

附录:参考资料

  1. ImageHash:感知哈希实现与距离比较;本文采用 4.3.2 的 phash。
  2. SSIM 原作者资料:Wang、Bovik、Sheikh、Simoncelli,2004,Image Quality Assessment: From Error Visibility to Structural Similarity;提供论文和参考实现。
  3. scikit-image 0.25 SSIM API:数据范围、高斯权重、通道与协方差参数。
  4. Pillow 图像模式、ImageOps、ImageCms:预乘透明度、EXIF 方向与 ICC 色彩转换。
  5. Pillow 图像格式:动画帧、播放参数与 APNG 默认图约定。
  6. imagededup 评估方法:重复图片对的标注与分类评估。