简介
最近接到了一个需求,需要对比图片并自动生成对比报表,核心功能就是获取图片相似度,生成表格。
这里仅介绍如何实现的图片相似度获取;
思路
相似度计算的算法选择的是SSIM算法,具体算法原理参考的是SSIM 的原理和代码实现,算法中涉及了卷积运算,还有图片的矩阵运算,决定选用OpenCV库来实现。因为后台使用的是C#写的,OpenCV使用的是C++,所以决定用C++封装图像相似度处理的函数,通过dll导出接口到C#中使用;(C#中有已经封装的OpenCV库,OpencvSharp和Emgu都是很好的,但是这次功能简单,没有必要使用)
实现
VS2019下的OpenCV环境搭建
OpenCV源代码编译
- 从https://opencv.org/releases/下载源代码,如果不介意官方打包的dll和lib体积太大的话,也可以下载exe版本,双击即可,可以省去后面的编译步骤
- 从https://cmake.org/download/下载cmake,用于源码编译
- 下载完cmake后解压,运行 解压目录/bin/cmake-gui.exe ,通过Browse Source找到第一步下载解压的OpenCV源码目录,然后选择一个结果输出路径比如我这里分别是:E:/Program Zip/opencv-4.4.0/opencv-4.4.0和 E:/Program Zip/opencv-4.4.0/opencv-4.4.0/build
- 点击左下角的Configure按钮,选择编译选项如下,然后点finish;这里我只配置了64位的,其他平台可以自行选择
- - 待配置完成后,点击generate,等待生成完成,点击project按钮打开解决方案
- 选择Debug或Releas编译选项,在
INSTALL
项目上右键,Build - 漫长的Build之后,你会在输出目录下(比如我的是
E:\Program Zip\opencv-4.4.0\opencv-4.4.0\build\install
)看到, 如果中间报了python相关的错误,可以忽略 - 到这里,opencv的编译就成功完成了
项目配置
- 把上一步编译好的opencv库中的
include,x64
目录拷贝到合适的地方,最好是release合debug版本的分目录存放,下面是我的目录结构,因为暂时不考虑多个vs版本的,所以x64下面的vc目录去掉了 - 新建一个空的C++项目
- 在项目上右键,跳转到:
属性页->Configuration Properties->General->Configuration Type
,修改为Dynamic Library (.dll)
- 跳转到:
属性页 ->C/C++->General->Additional Include Directories
,填入存放opencv库的include目录的路径 - 跳转到:
属性页->Linker->General->Additional Library Directories
,填入存放opencv库的 路径中对应版本的lib路径,我这里使用了相应的编译选项宏以及编译平台宏,可以视情况选择 - 跳转到:
属性页->Linker->Input->Additioinal Dependencies
;release编译选项下填入opencv_core440.lib opencv_imgcodecs440.lib opencv_imgproc440.lib opencv_gapi440.lib opencv_calib3d440.lib
,debug选项下记得名字后面要加d,因为我的代码中只用到了这些lib,所以只填了这些lib,如果不介意大小的话,可以把opencv所有的lib都加上,这样可以避免出现 找不到定义的报错 - 到此,项目配置就完成了,dll我们就直接拷贝到项目的输出路径去即可,可以按需拷贝,运行若报错说某个dll找不到就拷贝过去即可
SSIM的算法实现
算法的原理可以参考SSIM 的原理和代码实现,讲的非常清晰,同时也建议阅读下python下的实现
下面是计算图片差异的核心函数,返回的Mat中 包含了两幅图片各个像素点的相似度,如果需要整张图的,求一下均值即可;完整代码放在末尾
static const double C1 = 6.5025, C2 = 58.5225;
Mat Compare_SSIM(Mat image1, Mat image2)
{
Mat validImage1, validImage2;
image1.convertTo(validImage1, CV_32F); //数据类型转换为 float,防止后续计算出现错误
image2.convertTo(validImage2, CV_32F);
Mat image1_1 = validImage1.mul(validImage1); //图像乘积
Mat image2_2 = validImage2.mul(validImage2);
Mat image1_2 = validImage1.mul(validImage2);
Mat gausBlur1, gausBlur2, gausBlur12;
GaussianBlur(validImage1, gausBlur1, Size(11, 11), 1.5); //高斯卷积核计算图像均值
GaussianBlur(validImage2, gausBlur2, Size(11, 11), 1.5);
GaussianBlur(image1_2, gausBlur12, Size(11, 11), 1.5);
Mat imageAvgProduct = gausBlur1.mul(gausBlur2); //均值乘积
Mat u1Squre = gausBlur1.mul(gausBlur1); //各自均值的平方
Mat u2Squre = gausBlur2.mul(gausBlur2);
Mat imageConvariance, imageVariance1, imageVariance2;
Mat squreAvg1, squreAvg2;
GaussianBlur(image1_1, squreAvg1, Size(11, 11), 1.5); //图像平方的均值
GaussianBlur(image2_2, squreAvg2, Size(11, 11), 1.5);
imageConvariance = gausBlur12 - gausBlur1.mul(gausBlur2);// 计算协方差
imageVariance1 = squreAvg1 - gausBlur1.mul(gausBlur1); //计算方差
imageVariance2 = squreAvg2 - gausBlur2.mul(gausBlur2);
auto member = ((2 * gausBlur1 .mul(gausBlur2) + C1).mul(2 * imageConvariance + C2));
auto denominator = ((u1Squre + u2Squre + C1).mul(imageVariance1 + imageVariance2 + C2));
Mat ssim;
divide(member, denominator, ssim);
return ssim;
}
dll导出接口
dll导出接口给C#使用,主要有以下几点需要关注
- 接口导出的参数传递和命名规则 ,这里用的是
__stdcall
, 还有其他几种传递规则,可以参考官方文档参数传递和命名约定,这里我是直接使用的默认约定,其他几种规则没有尝试,有兴趣可以研究一下; -
extern "C"
,这个在导出接口前一定要加上,不然C#会找不到对应的函数; 因为C++编译默认会给C++函数添加名字修饰,就是按照一定的规则加上一些修饰字符,而C#中是通过函数名称调用dll中的函数的,加上extern "C"
的目的就让编译器不要加修饰 - 参数传递;如何从C++传递参数到C#,基本数据类型可以直接传递,字符串,结构体什么的传递就很麻烦了,我是用的是在C++中new内存,传递指针到C#中,然后C#中调用C++导出的release函数释放之前new的内存,具体代码可以参考下文
C++头文件部分
#pragma once
#include "stdafx.h"
#define Export_Dll extern "C" _declspec(dllexport)
#define Dll_API __stdcall
#pragma pack(1) //必须写,防止出现字节补齐,导致C#中读到错误的字节
struct Mat_Struct
{
int width;
int height;
int channels;
uchar data[0]; //必须这么写,使用指针的话,C#中读取数据太麻烦,得读两次指针
};
//所有导出接口均使用malloc进行内存分配,不许使用new
Export_Dll uchar* Dll_API SSIM_Image(const char* imgPath1, const char* imgPath2);
Export_Dll double Dll_API SSIM_Percent(const char* imgPath1, const char* imgPath2);
Export_Dll void Dll_API Release(void* ptr);
C++源文件部分
#include "DllExport.h"
#include "ImageCompare.h"
uchar* Dll_API SSIM_Image(const char* imgPath1, const char* imgPath2)
{
auto img1 = imread(_imagePath1);
auto img2 = imread(_imagePath2);
auto result = Compare_SSIM(img1,img2); //这个就是上面的ssim的核心函数了
int width = result.size().width;
int height = result.size().height;
int channels = result.channels();
int length = width * height * channels;
Mat_Struct* res = static_cast<Mat_Struct*>(malloc(sizeof(Mat_Struct) + length)); //传递给dll外部,不要在这里释放
res->width = width;
res->height = height;
res->channels = channels;
memcpy(res->data, result.data, length);
return (uchar*)res;
}
double Dll_API SSIM_Percent(const char* imgPath1, const char* imgPath2)
{
double sum = 0.0f;
auto img1 = imread(_imagePath1);
auto img2 = imread(_imagePath2);
auto result = Compare_SSIM(img1,img2);
auto meanResult = mean(result);
int channels = result.channels();
for (auto depthIndex = 0; depthIndex < channels; ++depthIndex)
{
sum += meanResult[depthIndex];
}
sum /= channels;
return sum;
}
void Dll_API Release(void* ptr)
{
if (ptr)
{
free(ptr);
ptr = nullptr;
}
}
C#调用C++的dll
[StructLayout(LayoutKind.Sequential, Pack = 1), Serializable] //防止字节补齐,如果不需要C#往C++传递,不写也可以
struct Mat_Struct
{
public int width;
public int height;
public int channels;
public byte[] data;
public Mat_Struct(byte[] byteArray) //这里是原先想通过字节数组传递,发现行不通,放弃了,留这里当二进制读取的例子吧
{
MemoryStream ms = new MemoryStream(byteArray);
BinaryReader br = new BinaryReader(ms);
try
{
width = br.ReadInt32();
height = br.ReadInt32();
channels = br.ReadInt32();
data = br.ReadBytes(width * height * channels);
}
catch (EndOfStreamException eofEx)
{
width = 0;
height = 0;
channels = 0;
data = new byte[0];
LogHelper.Error(eofEx);
}
}
//核心解析函数,从非托管C++的指针指向的内存中读取结构体中的数据
//参考: https://docs.microsoft.com/zh-cn/dotnet/api/system.runtime.interopservices.marshal?view=netcore-3.1
//https://docs.microsoft.com/zh-cn/dotnet/api/system.intptr.add?view=netcore-3.1
public Mat_Struct(IntPtr ptr)
{
IntPtr iter = ptr;
width = Marshal.ReadInt32(iter);
iter = IntPtr.Add(iter, sizeof(int)); //需要注意,这一步不能少,读取操作是没有自动偏移的
height = Marshal.ReadInt32(iter);
iter = IntPtr.Add(iter, sizeof(int));
channels = Marshal.ReadInt32(iter);
iter = IntPtr.Add(iter, sizeof(int));
var length = width * height * channels;
data = new byte[length];
Marshal.Copy(iter, data, 0, length);
}
};
public class OpencvAdapter
{
//dll名称和函数名称声明,注意不要写错名字了
[DllImport("OpencvInterface.dll", EntryPoint = "SSIM_Image", CharSet = CharSet.Ansi)]
public static extern IntPtr SSIM_Image([MarshalAs(UnmanagedType.LPStr)] string path1, [MarshalAs(UnmanagedType.LPStr)] string path2);
[DllImport("OpencvInterface.dll", EntryPoint = "SSIM_Percent", CharSet = CharSet.Ansi)]
public static extern double SSIM_Percent([MarshalAs(UnmanagedType.LPStr)] string path1, [MarshalAs(UnmanagedType.LPStr)] string path2);
[DllImport("OpencvInterface.dll", EntryPoint = "Release", CharSet = CharSet.Ansi)]
public static extern void Release(IntPtr ptr);
public static Example()
{
//注意,不要使用 Marshal.FreeHGlobal(IntPtr)来释放内存,因为内存是从非托管C++中申请的,要用导出的Release函数释放
IntPtr res = new IntPtr(0);
try
{
res = SSIM_Image(img1Path, img2Path);
var matData = new Mat_Struct(res);
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
finally
{
Release(res); //一定要用Release释放
}
}
}
C#调用C++的dll是一个比较常见的场景,在实现的过程中,难点就是参数传递,返回值传递
,因为涉及到了托管内存和非托管内存的交互,好在C#中是有指针的,实现起来没有想象中的复杂(实在不行,用unsafe和裸指针强行读内存也可以解决)
C++核心代码github: https://github.com/luochanganz/ImageDiff
参考文档