前言介绍
这篇文章的起因是数字图像处理这门课程的作业需要做一个基于OpenCV的图像美化系统,感觉OpenCV的各种环境配置有点麻烦,之前看到朋友用过插件实现在Unity中使用OpenCV的接口,我就寻思为什么不用Unity做这个作业呢,于是找到了OpenCVForUnity这个插件开整。上手后发现网上关于该插件的文章较少。虽说该插件是Java版OpenCV的复制,理论上使用起来几乎没有区别,但是经实际使用还是出现了一些问题,于是我打算将我的使用经历及踩坑情况一一记录下来。
本文使用的Unity版本为2020.3.40f1c1版,OpenCVForUnity插件版本为2.5.8
项目地址(不包含插件)
成品地址

实验内容
图片的读取与保存
作业要求是自选图片地址打开,而Unity本身并没有对资源管理器中操作的相关支持。查询资料后发现需要引入c#中Comdlg.dll中的方法实现打开任务管理器并获取到用户选择文件路径。故通过此方法附加上OpenCV中自带的imread方法读取图像,通过imwrite方法保存图像。在此插件中两方法都位于Imgcodecs类中。代码与使用效果如下:
(不知道为什么选c#没有代码高亮 这边就选java了 ouo)
引入dll
/// <summary>
/// 调用系统的窗口,数据接收类
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public class OpenFileName
{
public int structSize = 0;
public IntPtr dlgOwner = IntPtr.Zero;
public IntPtr instance = IntPtr.Zero;
public String filter = null;
public String customFilter = null;
public int maxCustFilter = 0;
public int filterIndex = 0;
public String file = null;
public int maxFile = 0;
public String fileTitle = null;
public int maxFileTitle = 0;
public String initialDir = null;
public String title = null;
public int flags = 0;
public short fileOffset = 0;
public short fileExtension = 0;
public String defExt = null;
public IntPtr custData = IntPtr.Zero;
public IntPtr hook = IntPtr.Zero;
public String templateName = null;
public IntPtr reservedPtr = IntPtr.Zero;
public int reservedInt = 0;
public int flagsEx = 0;
}
public class WindowDll
{
[DllImport("Comdlg32.dll", SetLastError = true, ThrowOnUnmappableChar = true, CharSet = CharSet.Auto)]
public static extern bool GetOpenFileName([In, Out] OpenFileName ofn);
[DllImport("Comdlg32.dll", SetLastError = true, ThrowOnUnmappableChar = true, CharSet = CharSet.Auto)]
public static extern bool GetSaveFileName([In, Out] OpenFileName ofd);
}
读取图片
除了上述问题,在这部分还出了一个小插曲。由于正版插件很贵,我也只用于学习用途,我原本是在网上随便找了一个版本的插件,用imread读取图片时完全没有出现问题。后来由于该版本在噪声添加方面有不可解决的问题,我又找到了现在的2.5.8版本。替换后发现读取完的图片直接用matToTexture2D转为Texture显示后突然出现倒置现象。找了很久的问题也没有解决,最后采用了读取后手动转正的方法。在保存时也出现了类似的问题,可能和插件中实现方式有关。
public Mat LoadPic()
{
OpenFileName ofn = new OpenFileName();
ofn.structSize = Marshal.SizeOf(ofn);
ofn.filter = "图片文件(*.jpg*.png)\0*.jpg;*.png"; //显示的可选文件
ofn.file = new string(new char[256]);
ofn.maxFile = ofn.file.Length;
ofn.fileTitle = new string(new char[64]);
ofn.maxFileTitle = ofn.fileTitle.Length;
string path = Application.streamingAssetsPath; //默认路径
path = path.Replace('/', '\\');
ofn.initialDir = path;
ofn.title = "Open Project";
ofn.defExt = "JPG";
//注意 以下项目不一定要全选 但是0x00000008项不要缺少
ofn.flags = 0x00080000 | 0x00001000 | 0x00000800 | 0x00000200 | 0x00000008;//OFN_EXPLORER|OFN_FILEMUSTEXIST|OFN_PATHMUSTEXIST| OFN_ALLOWMULTISELECT|OFN_NOCHANGEDIR
//点击Windows窗口时开始加载选中的图片
if (WindowDll.GetOpenFileName(ofn))
{
Debug.Log("Selected file with full path: " + ofn.file);
Utils.setDebugMode(true);
Mat read = Imgcodecs.imread(Utils.getFilePath(ofn.file), Imgcodecs.IMREAD_UNCHANGED); //得到图片,读取图片通道类型遵循原图片类型
//手动反转两次
Core.flip(read, read, 0);
Core.flip(read, read, 1);
//将原本读取的RGB颜色类型转换成Mat存储适用的BGR颜色类型
Imgproc.cvtColor(read, read, Imgproc.COLOR_RGBA2BGRA);
return read;
}
return null;
}
保存图片
public void SavePic()
{
OpenFileName ofn = new OpenFileName();
ofn.structSize = Marshal.SizeOf(ofn);
ofn.filter = "图片文件(*.jpg*.png)\0*.jpg;*.png";
ofn.file = new string(new char[256]);
ofn.maxFile = ofn.file.Length;
ofn.fileTitle = new string(new char[64]);
ofn.maxFileTitle = ofn.fileTitle.Length;
string path = Application.streamingAssetsPath;
path = path.Replace('/', '\\');
ofn.initialDir = path;
ofn.title = "Open Project";
ofn.defExt = "JPG";
ofn.flags = 0x00080000 | 0x00001000 | 0x00000800 | 0x00000200 | 0x00000008;
if (WindowDll.GetSaveFileName(ofn))
{
CancelOption();
Mat toSave = savedMat.clone();
Core.flip(toSave, toSave, 0); //手动反转
Imgproc.cvtColor(toSave, toSave, Imgproc.COLOR_BGRA2RGBA);
Imgcodecs.imwrite(ofn.file, toSave);
}
}
效果展示
打开

保存

调整明亮度与对比度
亮度和对比度可以通过公式$\alpha * f(i,j) + \beta$来调整。其中$\alpha$可以代表对比度,$\beta$代表亮度。在OpenCV中既可以使用遍历像素的方法实现,也可以使用自带的ConvertTo方法快速调整。
public void AdjustBrightness()
{
Mat curMat = PictureLoader.Instance.savedMat.clone();
Mat newMat = new Mat(curMat.size(), curMat.type());
float value = brightness.value;
curMat.convertTo(newMat, curMat.type(), 1, value);
PictureLoader.Instance.curMat = newMat;
curMat.Dispose();
}
public void AdjustAlpha()
{
Mat curMat = PictureLoader.Instance.savedMat.clone();
Mat newMat = new Mat(curMat.size(), curMat.type());
float value = alpha.value;
curMat.convertTo(newMat, curMat.type(), value, 0);
PictureLoader.Instance.curMat = newMat;
curMat.Dispose();
}


饱和度调整
算法步骤:
计算RGB三通道的最大最小值,并进一步得到delta和value。
$$
delta = (Max - Min)/255
$$$$
value = (Max+Min)/255
$$若最大最小一致,即delta=0,则表明为灰点,不需继续操作,直接处理下个像素。
通过value算出HSL中L的值
$$
L=(Max-Min)/(2*255)
$$S值为
$$
\begin{cases}
S=delta/value,L<0.5\S=delta/(2-value),L\ge0.5
\end{cases}
$$当percent大于等于0时,即提高色彩饱和度,那么alpha值为:
$$
\begin{cases}
alpha = S, percent+S\ge1\
alpha=1-percent,else
\end{cases}
$$
此时,调整后的图像RGB三通道值为:
$$
RGB=RGB+(RGB-L*255)*alpha
$$若percent小于0时,即降低色彩饱和度,则alpha=percent,此时调整后的图像RGB三通道值为:
$$
RGB=L255+(RGB-L255)*(1-alpha)
$$
当时不知道为什么这段代码一运行程序就卡死了。后来发现是这个遍历的过程特别慢,可能算法比较复杂,在每个像素上运行计算量就很大。于是我就给它加了个协程顺便记了个时,发现它是真慢。每次运行大概要8s到12s左右。推测跟插件的效率也有关?
IEnumerator Sat()
{
Mat curMat = PictureLoader.Instance.savedMat.clone();
Mat newMat = new Mat(curMat.size(), curMat.type());
float stime = Time.time;
float saturation = sat.value;
Debug.Log("s");
Debug.Log(curMat);
float increment = (saturation - 80) * 1.0f / 200f;
for (int col = 0; col < curMat.cols(); col++)
{
for (int row = 0; row < curMat.rows(); row++)
{
// R,G,B 分别对应数组中下标的 2,1,0
float r = (float)curMat.get(row, col)[2];
float g = (float)curMat.get(row, col)[1];
float b = (float)curMat.get(row, col)[0];
float maxn = Mathf.Max(r, Mathf.Max(g, b));
float minn = Mathf.Max(r, Mathf.Max(g, b));
float delta, value;
delta = (maxn - minn) / 255;
value = (maxn + minn) / 255;
float new_r, new_g, new_b;
float light, sat, alpha;
light = value / 2;
if (light < 0.5)
sat = delta / value;
else
sat = delta / (2 - value);
if (increment >= 0)
{
if ((increment + sat) >= 1)
alpha = sat;
else
{
alpha = 1 - increment;
}
alpha = 1 / alpha - 1;
new_r = r + (r - light * 255) * alpha;
new_g = g + (g - light * 255) * alpha;
new_b = b + (b - light * 255) * alpha;
}
else
{
alpha = increment;
new_r = light * 255 + (r - light * 255) * (1 + alpha);
new_g = light * 255 + (g - light * 255) * (1 + alpha);
new_b = light * 255 + (b - light * 255) * (1 + alpha);
}
newMat.put(row, col, new double[] { new_b, new_g, new_r, curMat.get(row, col)[3] });
}
yield return null;
}
PictureLoader.Instance.curMat = newMat;
Debug.Log("f" + (Time.time - stime));
}

添加边框
先加载边框图片,然后resize到原图大小,生成边框的灰度图作为mask,然后用copyTo方法附加到原图上。
public void MakeBorder()
{
Mat border = PictureLoader.Instance.LoadPic(); //加载边框图片
Mat curMat = PictureLoader.Instance.savedMat.clone();
Imgproc.resize(border, border, curMat.size()); //调整边框大小适应图片
Mat mask = border.clone();
Imgproc.cvtColor(mask, mask, Imgproc.COLOR_BGRA2GRAY); //生成灰度图作为mask
Imgproc.threshold(mask, mask, 10, 255, Imgproc.THRESH_BINARY);
border.copyTo(curMat, mask); //复制边框到原图
PictureLoader.Instance.curMat = curMat;
}


浮雕效果
经查阅浮雕效果的原理是每个像素的RGB值都设置为该位置的初始值减去其右下方第二的像素的差,最后统一加上128用于控制灰度,显示出类似浮雕的灰色。这样处理的思路是,将图像上的每个点与它的对角线的像素点形成差值,这样淡化相似的颜色,突出不同的颜色、边缘,从而使图像产生纵深感,产生类似于浮雕的效果。(解释来自)
public void FuDiao()
{
Mat src = PictureLoader.Instance.savedMat.clone();
if (src.type() == CvType.CV_8UC1) return;
Mat dst = src.clone();
int rowNumber = dst.rows();
int colNumber = dst.cols();
for (int i = 1; i < rowNumber - 1; ++i)
{
for (int j = 1; j < colNumber - 1; ++j)
{
dst.put(i, j, new double[] { (src.get(i + 1, j + 1)[0] - src.get(i - 1, j - 1)[0] + 128), (src.get(i + 1, j + 1)[1] - src.get(i - 1, j - 1)[1] + 128) , (src.get(i + 1, j + 1)[2] - src.get(i - 1, j - 1)[2] + 128), 255 });
}
}
PictureLoader.Instance.curMat = dst;
}

简单倒影效果
采用随机将像素偏移一定位置的方法创造出类似倒影的效果,再将原图与倒影图上下拼接完成效果。代码如下。
public void Reflection()
{
Mat img = PictureLoader.Instance.savedMat.clone();
Mat dstImg = new Mat(2 * img.rows(), img.cols(), img.type());
img.copyTo(new Mat(dstImg, new Rect(0, img.rows(), img.cols(), img.rows())));
int rowNumber = img.rows();
int colNumber = img.cols();
for (int i = 1; i < rowNumber - 1; ++i)
{
for (int j = 1; j < colNumber - 1; ++j)
{
int deltax = UnityEngine.Random.Range(0, 50);
int deltay = UnityEngine.Random.Range(0, 50);
while (j + deltax >= colNumber)
{
deltax = UnityEngine.Random.Range(0, 50);
}
while (i + deltay >= rowNumber)
{
deltay = UnityEngine.Random.Range(0, 50);
}
img.put(i, j, new double[] { img.get(i + deltay, j + deltax)[0], img.get(i + deltay, j + deltax)[1], img.get(i + deltay, j + deltax)[2], 255 });
}
}
Core.flip(img, img, 0);
img.copyTo(new Mat(dstImg, new Rect(0, 0, img.cols(), img.rows())));
PictureLoader.Instance.curMat = dstImg;
}

实验总结
感觉大部分时间都花在查资料和api以及代码的调试上了,实际上并没有很多产出。还有很多内容由于一些无法解决的问题没有实现。希望之后能继续调试并完善这个项目。