一. 图像编辑器 Monica
Monica 是一款跨平台的桌面图像编辑软件(早期主要是个人为了验证一些算法而产生的)。
其技术栈如下:
- Kotlin 编写 UI(使用 Compose Desktop 作为 UI 框架)
- 部分算法使用 Kotlin 实现
- 基于 mvvm 架构,使用 koin 作为依赖注入框架
- 使用 JDK 17 进行编译
- 其余的算法使用 OpenCV C++ 来实现,Kotlin 通过 jni 来调用。
- 深度学习的模型主要使用 ONNXRuntime 进行部署和推理
- 少部分模型使用 OpenCV DNN 进行部署和推理。
Monica 目前还处于开发阶段,当前版本的可以参见 github 地址:https://github.com/fengzhizi715/Monica
二. 实验性的功能——为简单的 CV 算法提供快速调参的能力
由于工作原因,我时常需要写一些 CV 的算法,也时常会为了某个算法而不断地调参。有时也厌烦枯燥的调参,所以在 Monica 中做了这个模块。前期主要是方便自己能够对一些简单的算法快速调参,后续希望它也可以帮助到别人。
下面展示该模块的入口
以及该模块的首页
目前我只规划了二值化、边缘检测、轮廓分析、图像卷积、形态学操作、模版匹配等功能,并实现了其中几个。
2.1 二值化
Monica 提供了全局阈值分割、自适应阈值分割、Canny 边缘检测以及通过 OpenCV 的 inRange() 函数进行彩色图像分割来实现二值化。这些都是比较常见的二值化的方法。
下面加载的图片是我工作中经常遇到的,并需要做图像处理的,所以以下图为例
通过全局阈值分割实现二值化,就可以看到手机的轮廓。
下图是为了展示 Canny 边缘检测
通过 Canny 边缘检测实现二值化。
下图是为了展示彩色图像分割
图像通过色彩空间转换,在 OpenCV 中将图像从 BGR 转换成 HSV,然后再用 inRange() 进行颜色分割实现二值化。 对于该二值化的图像,后续还要再进行一些形态学的操作,才有助于进一步的轮廓分析。
2.2 边缘检测
图像的边缘是图像中亮度变化比较大的点。Monica 提供了常见的边缘检测算子。
下面以 Laplace 算子为例,实现边缘检测。
2.3 轮廓分析
图像的轮廓是指图像中具有相同颜色或灰度值的连续点的曲线。轮廓和边缘是有联系的,边缘是轮廓的基础,轮廓是边缘的连续集合。轮廓分析呢,简而言之就是找到图像中物体的轮廓。
下图以回形针为例,查找图中回形针的轮廓。
首先对图像进行二值化。
然后对二值图像进行轮廓查找,并将轮廓的外接矩形、最小外接矩形、质心显示到原图中。
有时为了找个某些轮廓,需要对所有轮廓进行过滤。目前支持通过周长、面积、圆度、长宽比这些设置来过滤轮廓。
三. 功能的实现
该模块功能的实现,主要是封装 OpenCV 各个函数的调用,其实是蛮简单的。
不过有一些需要注意比如:
- jni 层调用 OpenCV 函数实现二值化后,生成的二值图像如何在应用层展示?
- 应用层需要处理的二值图像(BufferedImage.TYPE_BYTE_BINARY),通过 byte array 如何由 jni 转换成 OpenCV 的 Mat 对象?
下面以调用 canny 函数和轮廓分析为例,简单进行说明。
3.1 应用层的设计和调用
对于应用层,需要编写好调用 jni 层的代码:
object ImageProcess {
private val loadPath by lazy{
System.getProperty("compose.application.resources.dir") + File.separator
}
val resourcesDir by lazy {
File(loadPath)
}
init {
// 需要先加载图像处理库,否则无法通过 jni 调用算法
loadMonicaImageProcess()
}
/**
* 对于不同的平台加载的库是不同的,mac 是 dylib 库,windows 是 dll 库,linux 是 so 库
*/
private fun loadMonicaImageProcess() {
if (isMac) {
if (arch == "aarch64") { // 即使是 mac 系统,针对不同的芯片 也需要加载不同的 dylib 库
System.load("${loadPath}libMonicaImageProcess_aarch64.dylib")
} else {
System.load("${loadPath}libMonicaImageProcess.dylib")
}
} else if (isWindows) {
System.load("${loadPath}opencv_world481.dll")
System.load("${loadPath}MonicaImageProcess.dll")
}
}
......
/**
* 实现 canny 算子
*/
external fun canny(src: ByteArray, threshold1:Double, threshold2: Double, apertureSize:Int):IntArray
......
/**
* 轮廓分析
*/
external fun contourAnalysis(src: ByteArray, binary: ByteArray, contourFilterSettings: ContourFilterSettings, contourDisplaySettings: ContourDisplaySettings):IntArray
}
在某个 viewModel 中,调用 ImageProcess 的 canny() 函数,并将其展示到 UI 上。注意,这里图像的类型是 BufferedImage.TYPE_BYTE_BINARY。因为 canny() 函数生成的是二值图像。
fun canny(state: ApplicationState, threshold1:Double, threshold2: Double, apertureSize:Int) {
state.scope.launchWithLoading {
OpenCVManager.invokeCV(state, type = BufferedImage.TYPE_BYTE_BINARY, action = { byteArray ->
ImageProcess.canny(byteArray,threshold1,threshold2,apertureSize)
}, failure = { e ->
logger.error("canny is failed", e)
})
}
}
object OpenCVManager {
/**
* 封装调用 OpenCV 的方法
* 便于"当前的图像"进行调用 OpenCV 的方法,以及对返回的 IntArray 进行处理返回成 BufferedImage
*
* @param state 当前应用的 state
* @param type 生成图像的类型
* @param action 通过 jni 调用 OpenCV 的方法
* @param failure 失败的回调
*/
fun invokeCV(state: ApplicationState,
type:Int = BufferedImage.TYPE_INT_ARGB,
action: CVAction,
failure: CVFailure) {
if (state.currentImage!=null) {
val (width,height,byteArray) = state.currentImage!!.getImageInfo()
try {
val outPixels = action.invoke(byteArray)
state.addQueue(state.currentImage!!)
state.currentImage = BufferedImages.toBufferedImage(outPixels,width,height,type)
} catch (e:Exception) {
failure.invoke(e)
}
}
}
......
}
类似地,轮廓分析的调用也是在某个 viewModel 中
fun contourAnalysis(state: ApplicationState, contourFilterSettings: ContourFilterSettings, contourDisplaySettings: ContourDisplaySettings) {
val type = if (contourDisplaySettings.showOriginalImage) { BufferedImage.TYPE_INT_ARGB } else BufferedImage.TYPE_BYTE_BINARY
state.scope.launchWithLoading {
OpenCVManager.invokeCV(state, type = type, action = { byteArray ->
val srcByteArray = state.rawImage!!.image2ByteArray()
ImageProcess.contourAnalysis(srcByteArray, byteArray, contourFilterSettings, contourDisplaySettings)
}, failure = { e ->
logger.error("contourAnalysis is failed", e)
})
}
}
需要注意的是,这里传递了两个对象 ContourFilterSettings、ContourDisplaySettings 到 jni 层。
data class ContourFilterSettings (
var minPerimeter:Double = 0.0,
var maxPerimeter:Double = 0.0,
var minArea:Double = 0.0,
var maxArea:Double = 0.0,
var minRoundness:Double = 0.0,
var maxRoundness:Double = 0.0,
var minAspectRatio:Double = 0.0,
var maxAspectRatio:Double = 0.0
)
data class ContourDisplaySettings(
var showOriginalImage: Boolean = false,
var showBoundingRect: Boolean = false,
var showMinAreaRect: Boolean = false,
var showCenter: Boolean = false
)
3.2 jni 层的编写
对于 jni 层,需要先在头文件里定义好应用层对应的函数
JNIEXPORT jintArray JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_canny
(JNIEnv* env, jobject,jbyteArray array,jdouble threshold1,jdouble threshold2,jint apertureSize);
JNIEXPORT jintArray JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_contourAnalysis
(JNIEnv* env, jobject,jbyteArray srcArray, jbyteArray binaryArray, jobject jobj1, jobject jobj2);
然后,编写对应的实现。
JNIEXPORT jintArray JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_canny
(JNIEnv* env, jobject,jbyteArray array,jdouble threshold1,jdouble threshold2,jint apertureSize) {
Mat image = byteArrayToMat(env,array);
Mat dst;
canny(image, dst, threshold1, threshold2, apertureSize);
return binaryMatToIntArray(env,dst);
}
JNIEXPORT jintArray JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_contourAnalysis
(JNIEnv* env, jobject,jbyteArray srcArray, jbyteArray binaryArray, jobject jobj1, jobject jobj2) {
ContourFilterSettings contourFilterSettings;
ContourDisplaySettings contourDisplaySettings;
Mat src = byteArrayToMat(env, srcArray);
Mat binary = byteArrayTo8UC1Mat(env,binaryArray);
// 获取 jclass 实例
jclass jcls1 = env->FindClass("cn/netdiscovery/monica/ui/controlpanel/ai/experiment/model/ContourFilterSettings");
jfieldID minPerimeterId = env->GetFieldID(jcls1, "minPerimeter", "D");
jfieldID maxPerimeterId = env->GetFieldID(jcls1, "maxPerimeter", "D");
jfieldID minAreaId = env->GetFieldID(jcls1, "minArea", "D");
jfieldID maxAreaId = env->GetFieldID(jcls1, "maxArea", "D");
jfieldID minRoundnessId = env->GetFieldID(jcls1, "minRoundness", "D");
jfieldID maxRoundnessId = env->GetFieldID(jcls1, "maxRoundness", "D");
jfieldID minAspectRatioId = env->GetFieldID(jcls1, "minAspectRatio", "D");
jfieldID maxAspectRatioId = env->GetFieldID(jcls1, "maxAspectRatio", "D");
contourFilterSettings.minPerimeter = env->GetDoubleField(jobj1, minPerimeterId);
contourFilterSettings.maxPerimeter = env->GetDoubleField(jobj1, maxPerimeterId);
contourFilterSettings.minArea = env->GetDoubleField(jobj1, minAreaId);
contourFilterSettings.maxArea = env->GetDoubleField(jobj1, maxAreaId);
contourFilterSettings.minRoundness = env->GetDoubleField(jobj1, minRoundnessId);
contourFilterSettings.maxRoundness = env->GetDoubleField(jobj1, maxRoundnessId);
contourFilterSettings.minAspectRatio = env->GetDoubleField(jobj1, minAspectRatioId);
contourFilterSettings.maxAspectRatio = env->GetDoubleField(jobj1, maxAspectRatioId);
// 获取 jclass 实例
jclass jcls2 = env->FindClass("cn/netdiscovery/monica/ui/controlpanel/ai/experiment/model/ContourDisplaySettings");
jfieldID showOriginalImageId = env->GetFieldID(jcls2, "showOriginalImage", "Z");
jfieldID showBoundingRectId = env->GetFieldID(jcls2, "showBoundingRect", "Z");
jfieldID showMinAreaRectId = env->GetFieldID(jcls2, "showMinAreaRect", "Z");
jfieldID showCenterId = env->GetFieldID(jcls2, "showCenter", "Z");
contourDisplaySettings.showOriginalImage = env->GetBooleanField(jobj2, showOriginalImageId);
contourDisplaySettings.showBoundingRect = env->GetBooleanField(jobj2, showBoundingRectId);
contourDisplaySettings.showMinAreaRect = env->GetBooleanField(jobj2, showMinAreaRectId);
contourDisplaySettings.showCenter = env->GetBooleanField(jobj2, showCenterId);
contourAnalysis(src, binary, contourFilterSettings, contourDisplaySettings);
if (contourDisplaySettings.showOriginalImage) {
return matToIntArray(env,src);
} else {
return binaryMatToIntArray(env, binary);
}
jni 层还需要调用 OpenCV 对应的函数,这块因为篇幅原因暂时省略,感兴趣的话可以直接看项目的源码。
还有一个值得注意的是,从应用层传递的 ContourFilterSettings、ContourDisplaySettings 对象,需要通过 jobject 转换到 jclass 然后再获取对应的各个属性。在 jni 层也需要定义好对应的 ContourFilterSettings、ContourDisplaySettings 对象。
typedef struct {
double minPerimeter;
double maxPerimeter;
double minArea;
double maxArea;
double minRoundness;
double maxRoundness;
double minAspectRatio;
double maxAspectRatio;
} ContourFilterSettings;
typedef struct {
bool showOriginalImage;
bool showBoundingRect;
bool showMinAreaRect;
bool showCenter;
} ContourDisplaySettings;
四. 总结
Monica 对 CV 算法快速调参的模块只实现了二值化、边缘检测、轮廓分析这些功能,还有很多功能没有完善,预计到过年前能够完善。完善后再规划该模块之后的功能。
Monica 后续的重点除了这块,还有将其现有使用的模型部署到云端。这样一方面可以减少软件安装包的体积,另一方面后续也可以部署更多有意思的模型。
Monica github 地址:https://github.com/fengzhizi715/Monica