ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#图像相似度比较:感知哈希、直方图与SSIM的工程实现

C#图像相似度比较:感知哈希、直方图与SSIM的工程实现

简介:这是一套面向C#开发者与图像处理初学者的相似度比较示例工程,基于灰度直方图方法实现两幅图像的相似度评估,并内置Windows窗体界面。包内共32个文件,核心包括9个.cs源码文件、3个.exe可执行程序,以及配置、资源和Visual Studio解决方案文件,整体约80KB,可直接打开工程编译或运行exe查看效果。程序从灰度转换、直方图统计到交叉核对公式计算相似度,完整演示了图像特征提取与匹配流程;其中直方图数组的构建与归一化、相似度阈值设定等关键逻辑清晰可读,便于移植到其他C#项目中。除直方图交叉核对外,还可参考欧氏距离、SSIM等度量方式,为后续优化提供扩展方向;窗体设计文件与逻辑代码分离,便于初学者在Visual Studio中逐行对照学习。已有3019人学习,适合需要快速搭建图像比对原型或入门C#图像处理的开发者参考。

1. 这个C#图像相似度比较Demo能解决什么:给“看着像但字节不同”的图片找判定依据

一个电商后台每天要收几百张商品图,同事被“图库找重复”的活折腾得不行:同一张照片,压缩一下、换个分辨率、改个文件名,人眼扫过去就是同一张,文件层面却因为字节不一样被判成两个文件。这个 C# 图像相似度比较 Demo,解决的就是这类“看着相似、字节不同”的比对问题。它是个 Windows 控制台小工程,输入两张图片路径,几秒钟给出 aHash/dHash 哈希距离、直方图余弦相似度和 SSIM 三个分数。你可以拿它验证重复图片检测、版权比对的算法思路,也可以把核心类直接抄进后端服务。对想学感知哈希的 C# 初学者来说,这是一个能完整跑通的参考实现;对要快速做图片去重验证的开发者,它同样够用。

2. 整体设计:哈希、直方图、SSIM三种算法各管一个维度

很多人在网上找相似度算法,拿到的都是一段孤零零的函数。这个 Demo 的价值在于,它不是单算法的小代码片,而是给你搭好了一层可替换的对照架构。核心思路:哈希负责粗筛,直方图负责颜色分布,SSIM 负责像素结构。

2.1 技术选型:为什么用 System.Drawing 而不是机器视觉框架

选型上我刻意没有上重型机器视觉框架。原因之一是 Demo 阶段用不到特征点匹配,原因之二是 System.Drawing.Common 在 Windows 下只需要一个包引用,Bitmap、Graphics 都是现成的,缩放到 8×8、取像素都只需要几行代码,对新手最友好。如果后面要部署到 Linux,只需要替换 Imaging 层,哈希和直方图逻辑完全不依赖 Bitmap,改动成本可控。

算法比较粒度典型适用场景判定相似参考
aHash整图缩略特征快速去重、同一素材不同压缩率汉明距离 ≤ 10
dHash相邻像素亮度梯度亮度变化明显的同一对象汉明距离 ≤ 10
直方图RGB 颜色分布色块图、风格图、渐变图余弦相似度 ≥ 0.9
SSIM局部像素结构图像质量评估、水印前后对比SSIM ≥ 0.95

举个例子来说:两张同一场景的照片,一个存成 JPG 一个存成 PNG,aHash 分数通常在 0.9 左右;但如果是同一场景、拍摄亮度完全不同,dHash 会比 aHash 稳;如果两张图是纯色广告图,直方图才是唯一有意义的那一项。所以 Demo 默认一次性把三个分数全算出来,由业务方决定组合规则。上面四个阈值只是起步值,真实业务里图片是商品图、截图还是摄影作品,阈值差异会非常大,后面第 5.3 我会讲怎么用对照用例把阈值校准成你自己的。

2.2 目录结构:核心类库与入口分离,二次开发不迷路

ImageSimilarityDemo/ ├── ImageSimilarity.Core/ │ ├── Comparers/ │ │ ├── HashComparer.cs │ │ ├── HistogramComparer.cs │ │ └── SsimComparer.cs │ ├── Hashing/ │ │ └── PerceptualHash.cs │ └── Imaging/ │ ├── ImagePreprocessor.cs │ └── Loader/ │ └── IImageLoader.cs ├── ImageSimilarity.Console/ │ ├── Program.cs │ └── ImageSimilarity.Console.csproj └── ImageSimilarity.Tests/ └── SimilarityTests.cs

工程分成 Core 类库、控制台入口和测试项目。Core 里 Hashing 只负责把 Image 变成哈希字符串或灰度数组;Comparers 负责两两比较;Imaging 负责读图和统一预处理。控制台 Program 很薄,只是解析路径、调用 Core、把结果打印出来。这样你二次开发的时候,把 Core 类库拖进自己的解决方案就能用。

工程到手第一件事是跑起来,在根目录执行dotnet restore && dotnet build,然后进入 Console 项目运行:dotnet run --project ImageSimilarity.Console -- --image1 a.jpg --image2 b.jpg。这里我没有引入任何第三方命令行解析库,直接用 args 手工解析,就是为了让你读入口代码时少一层心智负担。

2.3 比较流程:输出一个 SimilarityReport 而不是裸分数

把三种算法的结果包在一个结果对象里,上层可以直接拿 Verdict 做后续判断,不用到处散落魔法数字:

public sealed class SimilarityReport { public double HashScore { get; set; } public double HistogramScore { get; set; } public double SsimScore { get; set; } public string Verdict { get { if (HashScore >= 0.85) return "两图内容大概率相似"; if (HistogramScore >= 0.90) return "颜色分布相似,内容需二次确认"; return "不相似"; } } }

HashScore 是怎么换算的:64 位哈希的汉明距离 d,相似度取1 - d / 64.0,0.85 分对应大概翻转 9 位以内;直方图和 SSIM 本身就是 0~1 的分数,不需要再换算。Verdict 是给业务层快速决策用的,实际场景里你完全可以根据分数分布再划档。整条启动链路是:Program 把两个路径交给 ImagePreprocessor 读图和缩放,再把处理后的图分别交给三个 Comparer。由于三个 Comparer 都是无状态的纯计算类,完全可以在 ASP.NET Core 里注册成单例,这也是我把它们拆到 Core 类库的原因。

3. 核心实现拆解:从读图到相似度分数的完整链路

现在把每个核心方法当面拆开。我按 Demo 里的调用顺序讲:先预处理,再哈希,再直方图,最后 SSIM。

3.1 预处理:缩到 8×8 再计算,白底解决透明 PNG

public static Bitmap Normalize(Image source, int width, int height) { var target = new Bitmap(width, height); using (var g = Graphics.FromImage(target)) { g.Clear(Color.White); g.InterpolationMode = InterpolationMode.HighQualityBicubic; g.DrawImage(source, 0, 0, width, height); } return target; }

先说参数:width 和 height 是目标尺寸。aHash 固定传 8×8,dHash 固定传 9×8,直方图我一般传 128×128,SSIM 传 256 宽。g.Clear(Color.White)这一行是反直觉但重要的:不刷白底,透明 PNG 的透明区域可能被当成黑色参与计算,后面相似度会莫名其妙地低。插值模式固定成 HighQualityBicubic,这样同一张图不管怎么缩放,缩出来的像素差异都最小。

GetPixel 在 8×8 小图上没什么性能压力,但如果你拿去做直方图且没缩图就直接遍历原图,一张 4000×3000 的照片就是 1200 万次慢速调用,会慢到怀疑人生。所以直方图计算前,先缩到 128×128 再用。

3.2 aHash:8×8 里取平均亮度,逐位比较

public static string ComputeAHash(Image source) { using var small = Normalize(source, 8, 8); var gray = new int[64]; long sum = 0; for (int y = 0; y < 8; y++) { for (int x = 0; x < 8; x++) { var c = small.GetPixel(x, y); // 标准亮度公式,绿通道权重最高 gray[x + y * 8] = (int)(c.R * 0.299 + c.G * 0.587 + c.B * 0.114); sum += gray[x + y * 8]; } } int avg = (int)(sum / 64); var sb = new StringBuilder(64); for (int i = 0; i < 64; i++) sb.Append(gray[i] >= avg ? '1' : '0'); return sb.ToString(); }

灰度公式是Y = 0.299R + 0.587G + 0.114B,这是图像处理里的标准亮度公式,绿色通道权重大,符合人眼敏感度。avg 是 64 个像素亮度的算术平均,所以 aHash 的全称是 Average Hash,也就是平均哈希:每个像素只拿它和平均值的相对大小当特征。这决定了它对整体亮度的轻微变化不敏感,对压缩和 JPEG 伪影也有很强的容忍度。

参数上,aHash 不需要额外参数,8×8 是原论文就定的尺寸。再大一点也能用,比如 16×16,哈希变成 256 位,但误判率并不会成比例下降。我实测 8×8 对一个 Demo 来说足够了,而且哈希字符串短,调试的时候肉眼也容易比对。

3.3 dHash:比较相邻列的亮度梯度,扛亮度变化

public static string ComputeDHash(Image source) { using var small = Normalize(source, 9, 8); var sb = new StringBuilder(64); for (int y = 0; y < 8; y++) { for (int x = 0; x < 8; x++) { var left = small.GetPixel(x, y); var right = small.GetPixel(x + 1, y); int leftGray = (int)(left.R * 0.299 + left.G * 0.587 + left.B * 0.114); int rightGray = (int)(right.R * 0.299 + right.G * 0.587 + right.B * 0.114); sb.Append(leftGray > rightGray ? '1' : '0'); } } return sb.ToString(); }

dHash 比较的是相邻像素的亮度差方向,所以叫差异哈希。它只保留“左边比右边亮”还是“右边比左边亮”的符号信息,丢掉绝对亮度值。因此同一张图整体变亮、变暗、对比度调低,dHash 结果几乎不变。9×8 不是写错,64 位哈希需要有 64 个二进制位,横向 8 组比较、纵向 8 行,正好 64 组。

3.4 汉明距离:把两串哈希变成分数

public static int HammingDistance(string a, string b) { if (a.Length != b.Length) throw new ArgumentException("两串哈希长度不一致"); int distance = 0; for (int i = 0; i < a.Length; i++) { if (a[i] != b[i]) distance++; } return distance; } public static double HashToScore(string a, string b) { int d = HammingDistance(a, b); return 1.0 - d / 64.0; }

汉明距离就是两个等长字符串在多少个位上不同。aHash 和 dHash 都产出 64 位字符串,最多 64 处不同,所以距离正好落在 0~64 区间。HashToScore把它映射到 0~1,距离 6 位时得分约 0.91,距离 10 位时是 0.84。一般业务里 0.85 可以作为起步阈值。

提示:整数除法把d / 64写成d / 64.0,C# 里 int/int 得到的是 int,0.84 会被截成 0,这是很多人在改写时容易一脚踩进去的坑。

3.5 直方图:RGB 三通道各 256 个 bin,用余弦距离衡量颜色分布

public static float[] BuildHistogram(Bitmap image) { var hist = new float[256 * 3]; int total = image.Width * image.Height; for (int y = 0; y < image.Height; y++) { for (int x = 0; x < image.Width; x++) { var c = image.GetPixel(x, y); hist[c.R]++; hist[256 + c.G]++; hist[512 + c.B]++; } } for (int i = 0; i < hist.Length; i++) hist[i] /= total; return hist; } public static double CosineSimilarity(float[] a, float[] b) { double dot = 0, normA = 0, normB = 0; for (int i = 0; i < a.Length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } if (normA == 0 || normB == 0) return 0; return dot / (Math.Sqrt(normA) * Math.Sqrt(normB)); }

数组长度 768,前 256 个存 R 通道,中间 256 个存 G,最后 256 个存 B。hist[c.R]++本质是在做离散直方图统计。除以 total 是为了把频数变成频率,避免两张图分辨率不同导致计数规模不一样。CosineSimilarity 把两个 768 维向量归一化后求夹角余弦,值越接近 1,颜色分布越接近。

注意一个容易误解的地方:直方图完全丢弃了像素位置信息。一张蓝天、一张蓝地,直方图都可能得高分。所以它永远只是辅助判断,不能单独当去重标准。另外如果业务想忽略颜色,只关注明暗结构,可以把 768 维换成 256 维灰度直方图,调用逻辑完全不用改。

3.6 SSIM:单个小窗口怎么算出来的

private static double SsimForWindow(double[] x, double[] y) { double mx = x.Average(); double my = y.Average(); double vx = x.Select(v => (v - mx) * (v - mx)).Average(); double vy = y.Select(v => (v - my) * (v - my)).Average(); double cov = x.Zip(y, (a, b) => (a, b)) .Select(p => (p.a - mx) * (p.b - my)) .Average(); const double c1 = 6.5025; // (0.01 * 255)^2 const double c2 = 58.5225; // (0.03 * 255)^2 return ((2 * mx * my + c1) * (2 * cov + c2)) / ((mx * mx + my * my + c1) * (vx + vy + c2)); }

这段只算了一个滑动窗口的 SSIM。完整实现要把两张灰度图切成 8×8 的窗口,每个窗口调用一次,最后对所有窗口的分数取平均。c1、c2 是防止分母接近 0 的稳定常量,公式里 mx/my 比较亮度,vx/vy 比较对比度,cov 比较结构变化。所以它比哈希精细得多,但对位移、旋转、裁切非常敏感。性能方面,这份代码只是演示语义,每个窗口多次枚举必然慢。做批量任务时我会先把图缩到 256 宽再跑,要更快就得用积分图优化,那是另一个话题了。

3.7 组装起来:Program 里怎么把三个分数填进报告

using var srcA = ImagePreprocessor.LoadImage(pathA); using var srcB = ImagePreprocessor.LoadImage(pathB); string hashA = PerceptualHash.ComputeAHash(srcA); string hashB = PerceptualHash.ComputeAHash(srcB); report.HashScore = PerceptualHash.HashToScore(hashA, hashB); var histA = HistogramComparer.BuildHistogram(ImagePreprocessor.Normalize(srcA, 128, 128)); var histB = HistogramComparer.BuildHistogram(ImagePreprocessor.Normalize(srcB, 128, 128)); report.HistogramScore = HistogramComparer.CosineSimilarity(histA, histB);

到这里一个完整链路就闭环了:同一张原始图分别经过 Normalize 得到不同用途的缩小图,再分别进入三个比较器,最终填进 SimilarityReport。哈希方法吃 Bitmap,直方图吃缩略后的 Bitmap,SSIM 吃灰度数组,接口少、职责清楚,后续加新算法只需要再加一个 Comparer。

4. 避坑指南:相似度结果不准,先排查这五个工程坑

下面这五个问题不是算法原理层面的,是在 Windows 桌面上把 Demo 跑起来最容易碰到的工程坑,我按现象、原因、解决排好,你可以直接对照检查。

4.1 文件删不掉?先看看是不是 Bitmap 占住了文件句柄

现象:程序跑完,删源图片时提示“文件正在使用”。

原因:new Bitmap(path)这种构造方式会在文件上保留一个句柄,只要 Bitmap 实例没释放,句柄就一直在。

解决:优先用字节流构造 Bitmap:

byte[] raw = File.ReadAllBytes(path); using var ms = new MemoryStream(raw); using var bmp = new Bitmap(ms);

这两个 using 在同一个作用域里,bmp 释放时 ms 也一并释放。注意不要单独把 ms 在外面提前释放再传 Bitmap,System.Drawing 下面这个流一旦关闭,Bitmap 后续操作会抛 ArgumentException。我自己最开始为了“省资源”把流先释放了,结果跑一张崩一张。

4.2 Linux 上一跑就异常,System.Drawing 跨平台有边界

现象:把 Demo 发布到 Linux 容器,一运行到 Bitmap 相关代码就抛 PlatformNotSupportedException。

原因:System.Drawing.Common 在 .NET 7+ 里被限制为 Windows only,跨平台环境裸用这个包必炸。

解决:把读图和灰度化抽成一个接口,哈希核心只依赖灰度数组,不依赖 Bitmap:

public interface IImageLoader { byte[] LoadGrayscale(string path); }

这个接口输入路径,输出一维灰度数组。Windows 实现用 System.Drawing 解码后 LockBits 复制灰度;Linux 实现换成 SkiaSharp 读取像素再转灰度。接口两端的调用代码一行不用改,只是换了个图像来源。跨平台部署是这类图像工具最常见的翻车点,项目一开始就留这层接口,后面省很多事。

4.3 透明 PNG 一致性地翻车,原来是透明区域被当成黑像素

现象:透明背景的 logo 和白色背景的同一个 logo 比较,相似度只有一半。

原因:透明像素在某些解码路径里 RGB 分量是 0,也就是黑色。黑色参与了均值计算和直方图统计,哈希特征被整体带偏。

解决:回到 3.1 的预处理,检查有没有g.Clear(Color.White)。如果没有,补上;如果源图本身是带 Alpha 的 PNG,这一步会在白色背景上完成 Alpha 混合,透明区域贡献的灰度变成 255,而不是 0。最玄学的是这条,整改前你很难意识到问题出在 Alpha 通道上。

4.4 内容一样,旋转 90° 就直接“不相似”

现象:同一张照片旋转 90° 后,哈希分数从 0.9 掉到 0.4。

原因:aHash 和 dHash 没有旋转不变性,特征是坐标相关的。左半张脸和右半张脸的哈希是完全不同的两串 01,SSIM 对旋转更不适用。

解决:如果业务里确实存在旋转,处理 Exif 里的方向字段是必须要做的一步:

private static Bitmap NormalizeOrientation(Bitmap source) { // 读取 source.PropertyItems 中 id 为 0x0112 的项, // value 为 1 保持原样,6 旋转 90°,3 旋转 180°,8 旋转 270°, // 用 RotateFlip 转正后,把 Orientation 重置为 1 }

这是处理手机、相机直出图必备的一步。如果旋转是任意的,比如扫描件旋转了 15°,哈希类算法救不了,得上特征点匹配,那就超出这个 Demo 的范畴了。

4.5 哈希分数永远是 0 或 1,大概率踩了整数除法

现象:汉明距离明明算出来是 6,但 HashToScore 永远返回 0。

原因:1 - d / 64,int 除以 int 在 C# 里是整数除法,6/64 结果是 0。

解决:只要把一个操作数写成小数即可:1.0 - d / 64.0。这个坑虽小但很典型,第 3.4 的代码已经写对,二次开发时很容易在复制过程中不自觉改回去。

5. 二次开发:批量去重、相似图检索与阈值校准

跑通之后,先别急着把阈值写死,先想清楚你拿它做什么。这里给三条我实际用过的改造路线。

5.1 批量去重:目录遍历加哈希分组

var seen = new Dictionary<string, string>(); foreach (var path in Directory.EnumerateFiles(folder, "*.jpg")) { using var img = ImagePreprocessor.LoadImage(path); string hash = PerceptualHash.ComputeAHash(img); if (seen.ContainsKey(hash)) Console.WriteLine($"{path} 与 {seen[hash]} 疑似重复"); else seen[hash] = path; }

这里用的是严格相等,适合压缩率差异不大的场景。如果你处理的图里有大量“压得很狠”的副本,哈希会有几位翻转,严格相等会漏掉,需要改成近邻查找。

5.2 相似图检索:64 位哈希切四段,分桶缩小候选集

对海量图做全量两两比较是 O(n²),十万张图就跑不动了。一个很实用的工程化办法:把 64 位哈希切成 4 段,每段 16 位,每段作为字典 key,图片 ID 进桶。查询时拿目标哈希的 4 段分别拉候选集合,再对候选集做完整汉明距离排序。大部分无关图片根本不会进入距离计算,速度能压到接近 O(n)。这个思路本质是局部敏感哈希的简化版,不需要额外依赖。

5.3 验证方法:用对照测试集把阈值校准到业务值

拿原图做五份测试副本:80% 质量 JPG、分辨率改为一半、亮度加 15、饱和度降 20、加一行文字水印。跑一遍三种算法,把分数打印成表。你会发现 aHash 对压缩和缩放都很稳,亮度变化会打折扣;直方图对亮度变化无感,但会把“加文字水印”判成高分,因为颜色分布几乎没变;SSIM 对文字水印非常敏感,分数会明显掉。看完这张表,你就能定出自己的阈值,而不是抄网上那个 0.85。

从那以后我每次接图像类需求,都会先花半小时搭一组对照测试图,把算法阈值摸一遍再写业务逻辑,而不是让用户去面对一个拍脑袋定的 0.85。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进