
简介这是一份面向C#开发者的图像相似度比较示例工程基于灰度直方图实现图片相似度评估适合学习图像处理基础算法或快速集成到项目中。资源为Visual Studio解决方案共32个文件包含9个C#源码文件、3个可执行程序、配置文件、资源文件及编译缓存等压缩包仅80KB结构紧凑便于直接打开查看算法实现并运行调试。已有3019人学习下载。示例完整覆盖从彩色图像转灰度、统计256级直方图到交叉核对相似度计算的全过程代码中清晰体现核心公式并附有可运行的exe及项目配置读者可对照源码理解直方图比较思路也可替换测试图片验证效果为后续扩展SSIM等更复杂相似度方法提供基础。1. C#图像相似度比较Demo把两张图片的差异量化成一行数字做UI自动化测试的朋友十有八九遇到过这种场景测试跑了一夜第二天打开报告看到“截图比对不一致”可把两张原图翻出来对照半天除了几个边缘像素的抗锯齿差异肉眼根本看不出区别。这个C#图像相似度比较Demo就是为这类场景准备的——它把任意两张图片压缩成一组哈希指纹再通过汉明距离输出一个0到100%的相似度分数让“像不像”从一个主观判断变成可复现的数值。项目很轻。核心由一个Windows Forms界面和一个ImageCompare静态类组成覆盖平均哈希、感知哈希、直方图差异三种算法所有源码加起来不到五百行。它不依赖任何第三方付费识别平台适合.NET开发人员拿去做截图比对和图片查重也适合刚接触图像处理的人用一段能直接跑通的C#代码理解哈希指纹的来龙去脉。下面按选型、结构、算法、运行坑点和二次改造依次拆开讲。2. 技术选型与技术栈System.Drawing为何是Demo的默认解2.1 目标框架与依赖一个能F5就跑起来的项目边界相似度比较Demo首先要回答“用哪个图像库读像素”。这个项目选的是一套最省事的组合Windows Forms负责演示界面System.Drawing.Common负责位图解码和像素操作。在.NET 8下创建一个WinForms项目需要引入的包只有System.Drawing.Common和System.Windows.Forms不需要额外安装第三方组件。这里有个细节System.Drawing.Common在较新的版本里被标记为Windows-only所以目标框架最好写成net8.0-windows。如果直接写net8.0部分环境会在运行时报与System.Drawing相关的类型加载失败加上-windows后缀后编译器和运行时都会走正确的Windows分支。这个改动在Visual Studio的项目文件里一分钟就能完成却是开场最容易踩的第一个坑。2.2 像素读取的两种姿势GetPixel慢在哪、LockBits快多少最容易想到的写法是双重循环逐像素调用GetPixel把RGB转成灰度private static long SumGraySlow(Bitmap bmp) { long sum 0; for (int y 0; y bmp.Height; y) { for (int x 0; x bmp.Width; x) { Color c bmp.GetPixel(x, y); sum (int)(c.R * 0.299 c.G * 0.587 c.B * 0.114); } } return sum; }这是最直觉的版本但GetPixel内部要做格式换算和边界安全检测每次调用都是有成本的。1920x1080的截图要循环约两百万次耗时往往达到几百毫秒单张图还能忍批量处理就会明显拖慢速度。换成LockBits后位图变成一段连续内存用Marshal.Copy一次性把整块数据拷到托管字节数组循环退化成纯粹的数组访问不再走GDI的逐像素调用链。对同一张图速度基本能提升一个数量级private static byte[] ReadGrayBytesFromRgb24(Bitmap bmp) { int w bmp.Width; int h bmp.Height; Rectangle rect new Rectangle(0, 0, w, h); BitmapData data bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); try { int stride data.Stride; byte[] buffer new byte[stride * h]; Marshal.Copy(data.Scan0, buffer, 0, buffer.Length); byte[] gray new byte[w * h]; for (int row 0; row h; row) { int rowBase row * stride; // 真正的行偏移是Stride不是Width*3 for (int col 0; col w; col) { int offset rowBase col * 3; byte b buffer[offset]; byte g buffer[offset 1]; byte r buffer[offset 2]; gray[row * w col] (byte)(r * 0.299 g * 0.587 b * 0.114); } } return gray; } finally { bmp.UnlockBits(data); } }参数说明LockBits的第三个参数PixelFormat.Format24bppRgb用于强制以每像素3字节的格式读取无论原图是32位还是其他格式GDI都会按这个格式做一次底层转换返回值BitmapData的Stride代表一行像素实际占用的字节数它通常比Width3略大因为GDI默认按4字节对齐内存行如果直接按Width3跳行就会读到错位的像素。finally块保证UnlockBits一定执行否则位图一直被锁住后续保存或再次读取都会抛异常。2.3 预缩放与灰度化为什么哈希算法都先做这两步一张原始截图可能是1920x1080另一张却是1440x900如果直接比较原始像素尺寸不同就无从下手。所以平均哈希和感知哈希的第一步都是把图像缩小到固定尺寸——aHash用8x8pHash用32x32。缩小一方面统一了输入另一方面顺带抹掉了高频噪点让算法更关注稳定的结构信息。灰度化则把三通道图像归约成单通道亮度值。上面用的0.299、0.587、0.114系数来自ITU-R BT.601标准分别对应红光、绿光、蓝光对人眼亮度的贡献。很多入门版本直接取(RGB)/3算出来也能跑通但对黄蓝色调的区分会差一些。要稳定复现结果我一般还是按标准系数来。3. 目录结构与调用链路从选择文件到相似度出分的全过程3.1 解决方案与文件划分Demo的解决方案刻意保持简洁全项目主要就是三个文件文件职责关键成员Program.cs程序入口Main方法启动MainFormMainForm.cs演示界面两个图片选择按钮、一个比较按钮、结果文本框ImageCompare.cs算法核心AverageHash、PerceptualHash、HistogramScore、HammingDistance、SimilarityScore把算法和界面彻底拆开是为了让后续改造少一点阻力。界面只做三件事弹文件选择框、把文件路径交给ImageCompare、把返回值格式化后显示在文本框。ImageCompare不引用任何窗体类型它接收Bitmap或文件路径返回字符串哈希或double分数。以后哪怕要把算法搬到控制台项目或者Web API里代码直接复制即可。3.2 一次比较请求的完整执行链路从用户角度来说整个流程是一个普通的点击事件驱动private void btnCompare_Click(object sender, EventArgs e) { string first picImage1.Tag as string; string second picImage2.Tag as string; if (string.IsNullOrEmpty(first) || string.IsNullOrEmpty(second)) { MessageBox.Show(请先选择两张图片); return; } try { double aScore ImageCompare.AverageHashScore(first, second); double pScore ImageCompare.PerceptualHashScore(first, second); double hScore ImageCompare.HistogramScore(first, second); txtResult.Text $平均哈希相似度{aScore:P1}\r\n $感知哈希相似度{pScore:P1}\r\n $直方图相似度{hScore:P1}; } catch (Exception ex) { MessageBox.Show($比较失败{ex.Message}); } }这里的picImage1和picImage2是PictureBox控件Tag属性暂存文件路径避免为了保存两个路径再引入额外的隐藏字段。AverageHashScore内部依次做四件事加载Bitmap、缩放到8x8、转灰度、算汉明距离每一步都是独立方法。出错时异常信息能直接看出是加载阶段还是计算阶段的问题不需要在界面层做复杂的断点排查。3.3 UI层与算法层的职责边界图片分辨率不高时同步调用完全没有问题。但如果把高分辨率截图丢进来缩略和DCT计算仍然会阻塞UI线程窗口会出现几秒钟的“未响应”状态。一个稳妥的做法是把比较动作丢给后台线程用async/await等待结果界面只负责展示最终数值。具体写法放在后面的避坑章节里展开因为这是新手最容易忽略但改起来又不难的问题。4. 核心算法实现aHash、pHash与直方图差异的代码级拆解4.1 平均哈希8x8缩略图和64位指纹的生成平均哈希的思想很直白把图像缩小到8x8得到64个灰度值算出这64个值的平均值每个灰度如果大于等于平均值就把对应位记成1否则记成0最终拼成一个64位指纹字符串。public static string AverageHash(Bitmap source) { using (Bitmap small new Bitmap(source, new Size(8, 8))) { byte[] gray ReadGrayBytesFromRgb24(small); double avg 0; foreach (byte g in gray) { avg g; } avg / gray.Length; char[] bits new char[64]; for (int i 0; i gray.Length; i) { bits[i] gray[i] avg ? 1 : 0; } return new string(bits); } }逻辑说明new Bitmap(source, new Size(8, 8))会通过高分辨率重采样把任意尺寸的图压成8x8这一步同时完成尺寸统一和噪声平滑avg是64个灰度值的整体均值不是三通道的RGB均值也不是固定阈值。比较时用“大于等于”而不是“大于”是为了保证灰度恰好等于平均值的边界像素有一个确定的方向不依赖数组遍历顺序。适用场景是缩略图、图标、产品图级别的快速查重。如果图片只是被简单平移几个像素aHash的区分力就不太够这种情况要交给感知哈希。4.2 感知哈希DCT低频系数怎么变成指纹感知哈希的核心是二维离散余弦变换。先把图片缩到32x32并转成灰度做一次32x32的DCT变换得到同样大小的系数矩阵。DCT把图像的能量集中到左上角低频分量代表图像的主体结构越往右下越接近高频细节。标准pHash只取左上角8x8的低频系数用这64个系数的平均值作为阈值把系数转成0/1指纹。private static double[,] DCT2D(byte[] pixels, int side) { double[,] result new double[side, side]; for (int u 0; u side; u) { for (int v 0; v side; v) { double cu u 0 ? Math.Sqrt(1.0 / side) : Math.Sqrt(2.0 / side); double cv v 0 ? Math.Sqrt(1.0 / side) : Math.Sqrt(2.0 / side); double sum 0; for (int y 0; y side; y) { for (int x 0; x side; x) { double cosX Math.Cos((2 * x 1) * v * Math.PI / (2 * side)); double cosY Math.Cos((2 * y 1) * u * Math.PI / (2 * side)); sum pixels[y * side x] * cosX * cosY; } } result[u, v] cu * cv * sum; } } return result; }这是DCT-II的正变换三个嵌套循环的复杂度是O(n^4)但side固定为32时计算量完全可接受。这里没有做快速变换类优化是因为Demo更看重可读性真要跑大规模批处理再换成行列分离的快速版本就好。拿到DCT系数矩阵后生成指纹的过程如下public static string PerceptualHash(Bitmap source) { using (Bitmap small new Bitmap(source, new Size(32, 32))) { byte[] gray ReadGrayBytesFromRgb24(small); double[,] dct DCT2D(gray, 32); double total 0; int count 0; for (int u 0; u 8; u) { for (int v 0; v 8; v) { total dct[u, v]; count; } } double mean total / count; char[] bits new char[64]; int idx 0; for (int u 0; u 8; u) { for (int v 0; v 8; v) { bits[idx] dct[u, v] mean ? 1 : 0; } } return new string(bits); } }逻辑说明取左上角8x8块时u对应垂直方向频率v对应水平方向频率mean是这64个系数做整体平均随图片内容变化是对亮度偏移的自动校准。所有系数与mean比较后形成指纹所以pHash对全局亮度变化有天然抗性。它对轻微缩放、微小的裁剪、JPEG重压缩都比较稳定这正是它比aHash更“感知”的根本原因。4.3 汉明距离与直方图交叉相似度分数怎么定义拿到两串64位指纹后比较的基本单位是汉明距离——逐位比对统计不同位的个数public static int HammingDistance(string hash1, string hash2) { if (hash1.Length ! hash2.Length) { throw new ArgumentException(两个哈希长度不一致无法计算距离); } int distance 0; for (int i 0; i hash1.Length; i) { if (hash1[i] ! hash2[i]) { distance; } } return distance; } public static double HashSimilarity(string hash1, string hash2) { int distance HammingDistance(hash1, hash2); return 1.0 - (double)distance / hash1.Length; }参数说明hash1.Length就是指纹位数64位时相似度1.0-汉明距离/64。汉明距离为0代表完全一致距离大于25基本可以断定内容不同。实际项目里aHash的距离阈值通常设在10以内pHash在10到15之间。阈值不是精确科学最后必须以自己的测试样本为准。直方图交叉是另一种常见的并行验证手段尤其适合补足哈希对色调分布不敏感的问题public static double HistogramScore(Bitmap bmp1, Bitmap bmp2) { int[] hist1 BuildGrayHistogram(bmp1); int[] hist2 BuildGrayHistogram(bmp2); double overlap 0; for (int i 0; i 256; i) { overlap Math.Min(hist1[i], hist2[i]); } int maxPixelCount Math.Max(bmp1.Width * bmp1.Height, bmp2.Width * bmp2.Height); return overlap / maxPixelCount; }BuildGrayHistogram逐像素统计0到255灰度级的出现次数。Math.Min取两个直方图在同一灰度级的较小值overlap代表两张图在灰度分布上的重合量再除以较大的像素总数完成归一化。这个指标对色调布局敏感但不太在乎具体内容适合做哈希的互补信号例如验证纯色背景图、渐变图的差异。提示哈希和直方图测的不是同一件事。哈希关注“结构是否一样”直方图关注“色调分布是否接近”。做真实查重时我一般同时跑pHash和直方图两条线都通过才认定相似避免单一指标误判。5. 运行与排查避坑指南从Build到出分值的五个翻车现场5.1 翻车一运行就报System.Drawing.Common类型找不到现象代码编译通过点击运行时抛FileNotFoundException说找不到System.Drawing.Common.dll。原因System.Drawing.Common在.NET 6之后被拆成了需要显式引用的包而项目目标框架写成了不带-windows后缀的net8.0运行时没把Windows专属库打进输出目录。解决在csproj里把目标框架改成net8.0-windows或者通过NuGet显式添加System.Drawing.Common包。两种方案取其一重新生成后问题消失。5.2 翻车部分图片读出来的灰度明显错乱现象同一张图第一次读灰度正常换一个尺寸相近的图片再读输出的哈希完全不对肉眼可见颜色错位。原因读取时用了PixelFormat.Format24bppRgb而源图本身是32bppArgb格式。LockBits返回的Stride并不等于Width3如果代码按Width3计算行偏移后面的行会逐渐向右错位。解决统一用row * stride作为行基址把col * 3当作行内偏移不要用Width*3去跳行。这里没有捷径Stride是GDI留下的对齐原则必须尊重它。5.3 翻车透明PNG和加了白底的PNG明明内容相同相似度却很低现象一张透明底图标和同一张图加了白底的版本肉眼完全一致但pHash距离达到二三十。原因透明通道的像素在解码时RGB值取决于存储格式PNG的透明区域可能是纯黑底也可能残留未定义的颜色值。压缩算法把区域转成灰度时黑底和白底就产生了系统性差异。解决在预处理阶段先把图像统一绘到白色背景上。新建24bpp位图用Graphics.Clear(Color.White)填充后再DrawImage把所有alpha影响抹平。这个方法我每次做图片工具都会写进公共预处理里不需要每次重新考虑。5.4 翻车比较高分辨率截图时窗口假死现象点击比较按钮后窗口变成“未响应”过几秒才恢复。原因比较操作占用了UI线程。平均哈希和感知哈希内部包含多次缩放和高维循环图片一大就会产生明显阻塞。解决改成异步结构把算法放到Task.Run里执行界面在等待期间继续处理消息。注意参数的读取要留在UI线程内完成Task.Run里只接收已经准备好的文件路径避免跨线程访问控件属性引发不确定行为private async void btnCompare_Click(object sender, EventArgs e) { btnCompare.Enabled false; string first picImage1.Tag as string; string second picImage2.Tag as string; try { double score await Task.Run(() ImageCompare.PerceptualHashScore(first, second)); txtResult.Text $感知哈希相似度{score:P1}; } finally { btnCompare.Enabled true; } }异步改造的价值在批量场景里会放大单张图只省几十毫秒处理上千张时界面不卡这件事本身就很值钱。5.5 翻车纯色图、渐变图和任何图都比较出高相似度现象一张纯蓝背景图和一张包含丰富内容的照片比较pHash给出的相似度竟然超过60%。原因纯色图做DCT后能量几乎全部集中在左上角第一个系数其他低频系数接近零均值被第一个系数牵着走最终指纹退化成“全0”或“全1”的模式丢失了区分度。解决把直方图差异加进来做前置筛选。两张直方图形状差异极大时即使哈希距离很小也标记成“待人工确认”。针对纯色图还可以增加一个灰度方差检查方差过小时直接判定为无结构图不参与哈希比较这样能避免把大量无意义样本推进比较链路。6. 二次开发改造把单张比较升级成批量图片去重工具6.1 第一批改造增加文件夹扫描和指纹缓存单张比较跑通后最自然的改造是支持文件夹批量扫描。做法是遍历目标目录下的所有图片对每张图只计算一次pHash指纹把文件和指纹缓存到字典里再两两比对汉明距离。指纹缓存是关键——1000张图两两比较的成本是50万次距离计算但每张图只需要解码一次整体吞吐量完全可控。Dictionarystring, string hashCache new Dictionarystring, string(); foreach (string file in Directory.EnumerateFiles(folder, *.*)) { string ext Path.GetExtension(file).ToLowerInvariant(); if (ext is .jpg or .jpeg or .png or .bmp or .webp) { using (Bitmap bmp new Bitmap(file)) { hashCache[file] ImageCompare.PerceptualHash(bmp); } } }Directory.EnumerateFiles返回惰性枚举的文件列表不会一次把全部路径加载到内存ToLowerInvariant避免出现“.JPG”和“.jpg”两种写法导致同一张图被过滤。扩展名合法时才加载位图不支持的格式跳过避免解码过程抛异常打断批量任务。6.2 第二批改造阈值策略与分组输出批量去重的核心参数是汉明距离阈值。我的经验是先用Demo比较几组“看起来应该重复”和“看起来不重复”的图取它们距离值的中点作为阈值不要凭空拍一个固定值。不同的压缩率、不同的截图来源阈值会有明显偏移。分组输出的报告用CSV格式保存每一行记录“重复组ID、文件路径、相似度、决策标记”。这样能在文本编辑器里快速浏览也可以直接喂给后续清理脚本。清理动作只标记不删除——做识别工具时我始终让程序先生成结果人工确认后再执行删除。程序误判删掉一张重要素材的代价远高于多花几分钟人工复核。6.3 验证方法自己造一套受控样张改造完成后一定要做受控验证。方法是找一组原始图片分别做缩放、轻微旋转、JPEG重压缩、纯色背景叠加、加噪声这几种变换再把变换后的图片和原图一一比较统计相似度分布。如果原图和缩放版的相似度低于原图与另一张完全无关图的相似度说明预处理或参数哪里没调对。这时候应该回头检查灰度读取和DCT系数而不是去调阈值。从那以后我每次写图像处理工具都会强制走一遍这套自检流程先选10张样图造好变换副本跑通“应该相似的都超过阈值、应该不同的都低于阈值”再把工具交给自动化任务使用心态踏实很多。希望帮到你。本文还有配套的精品资源点击获取