
简介这是一套面向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整图缩略特征快速去重、同一素材不同压缩率汉明距离 ≤ 10dHash相邻像素亮度梯度亮度变化明显的同一对象汉明距离 ≤ 10直方图RGB 颜色分布色块图、风格图、渐变图余弦相似度 ≥ 0.9SSIM局部像素结构图像质量评估、水印前后对比SSIM ≥ 0.95举个例子来说两张同一场景的照片一个存成 JPG 一个存成 PNGaHash 分数通常在 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.00.85 分对应大概翻转 9 位以内直方图和 SSIM 本身就是 0~1 的分数不需要再换算。Verdict 是给业务层快速决策用的实际场景里你完全可以根据分数分布再划档。整条启动链路是Program 把两个路径交给 ImagePreprocessor 读图和缩放再把处理后的图分别交给三个 Comparer。由于三个 Comparer 都是无状态的纯计算类完全可以在 ASP.NET Core 里注册成单例这也是我把它们拆到 Core 类库的原因。3. 核心实现拆解从读图到相似度分数的完整链路现在把每个核心方法当面拆开。我按 Demo 里的调用顺序讲先预处理再哈希再直方图最后 SSIM。3.1 预处理缩到 8×8 再计算白底解决透明 PNGpublic 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×8dHash 固定传 9×8直方图我一般传 128×128SSIM 传 256 宽。g.Clear(Color.White)这一行是反直觉但重要的不刷白底透明 PNG 的透明区域可能被当成黑色参与计算后面相似度会莫名其妙地低。插值模式固定成 HighQualityBicubic这样同一张图不管怎么缩放缩出来的像素差异都最小。GetPixel 在 8×8 小图上没什么性能压力但如果你拿去做直方图且没缩图就直接遍历原图一张 4000×3000 的照片就是 1200 万次慢速调用会慢到怀疑人生。所以直方图计算前先缩到 128×128 再用。3.2 aHash8×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.0C# 里 int/int 得到的是 int0.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直方图吃缩略后的 BitmapSSIM 吃灰度数组接口少、职责清楚后续加新算法只需要再加一个 Comparer。4. 避坑指南相似度结果不准先排查这五个工程坑下面这五个问题不是算法原理层面的是在 Windows 桌面上把 Demo 跑起来最容易碰到的工程坑我按现象、原因、解决排好你可以直接对照检查。4.1 文件删不掉先看看是不是 Bitmap 占住了文件句柄现象程序跑完删源图片时提示“文件正在使用”。原因new Bitmap(path)这种构造方式会在文件上保留一个句柄只要 Bitmap 实例没释放句柄就一直在。解决优先用字节流构造 Bitmapbyte[] raw File.ReadAllBytes(path); using var ms new MemoryStream(raw); using var bmp new Bitmap(ms);这两个 using 在同一个作用域里bmp 释放时 ms 也一并释放。注意不要单独把 ms 在外面提前释放再传 BitmapSystem.Drawing 下面这个流一旦关闭Bitmap 后续操作会抛 ArgumentException。我自己最开始为了“省资源”把流先释放了结果跑一张崩一张。4.2 Linux 上一跑就异常System.Drawing 跨平台有边界现象把 Demo 发布到 Linux 容器一运行到 Bitmap 相关代码就抛 PlatformNotSupportedException。原因System.Drawing.Common 在 .NET 7 里被限制为 Windows only跨平台环境裸用这个包必炸。解决把读图和灰度化抽成一个接口哈希核心只依赖灰度数组不依赖 Bitmappublic 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 没有旋转不变性特征是坐标相关的。左半张脸和右半张脸的哈希是完全不同的两串 01SSIM 对旋转更不适用。解决如果业务里确实存在旋转处理 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 / 64int 除以 int 在 C# 里是整数除法6/64 结果是 0。解决只要把一个操作数写成小数即可1.0 - d / 64.0。这个坑虽小但很典型第 3.4 的代码已经写对二次开发时很容易在复制过程中不自觉改回去。5. 二次开发批量去重、相似图检索与阈值校准跑通之后先别急着把阈值写死先想清楚你拿它做什么。这里给三条我实际用过的改造路线。5.1 批量去重目录遍历加哈希分组var seen new Dictionarystring, 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。希望帮到你。本文还有配套的精品资源点击获取