简介:这是一份面向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用8x8,pHash用32x32。缩小一方面统一了输入,另一方面顺带抹掉了高频噪点,让算法更关注稳定的结构信息。
灰度化则把三通道图像归约成单通道亮度值。上面用的0.299、0.587、0.114系数来自ITU-R BT.601标准,分别对应红光、绿光、蓝光对人眼亮度的贡献。很多入门版本直接取(R+G+B)/3,算出来也能跑通,但对黄蓝色调的区分会差一些。要稳定复现结果,我一般还是按标准系数来。
3. 目录结构与调用链路:从选择文件到相似度出分的全过程
3.1 解决方案与文件划分
Demo的解决方案刻意保持简洁,全项目主要就是三个文件:
| 文件 | 职责 | 关键成员 |
|---|---|---|
| Program.cs | 程序入口 | Main方法,启动MainForm |
| MainForm.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万次距离计算,但每张图只需要解码一次,整体吞吐量完全可控。
Dictionary<string, string> hashCache = new Dictionary<string, 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张样图,造好变换副本,跑通“应该相似的都超过阈值、应该不同的都低于阈值”,再把工具交给自动化任务使用,心态踏实很多。希望帮到你。
本文还有配套的精品资源,点击获取