图像编辑器 Monica 之简单 CV 算法的快速调参

一. 图像编辑器 Monica

Monica 是一款跨平台的桌面图像编辑软件(早期主要是个人为了验证一些算法而产生的)。

screenshot.png

其技术栈如下:

  • 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 中做了这个模块。前期主要是方便自己能够对一些简单的算法快速调参,后续希望它也可以帮助到别人。

下面展示该模块的入口


展示该模块的入口.png

以及该模块的首页


该模块的首页.png

目前我只规划了二值化、边缘检测、轮廓分析、图像卷积、形态学操作、模版匹配等功能,并实现了其中几个。

2.1 二值化

Monica 提供了全局阈值分割、自适应阈值分割、Canny 边缘检测以及通过 OpenCV 的 inRange() 函数进行彩色图像分割来实现二值化。这些都是比较常见的二值化的方法。

下面加载的图片是我工作中经常遇到的,并需要做图像处理的,所以以下图为例


二值化.png

通过全局阈值分割实现二值化,就可以看到手机的轮廓。


全局阈值分割实现二值化.png

下图是为了展示 Canny 边缘检测


展示Canny边缘检测.png

通过 Canny 边缘检测实现二值化。


通过Canny实现二值化.png

下图是为了展示彩色图像分割


展示彩色图像分割.png

图像通过色彩空间转换,在 OpenCV 中将图像从 BGR 转换成 HSV,然后再用 inRange() 进行颜色分割实现二值化。 对于该二值化的图像,后续还要再进行一些形态学的操作,才有助于进一步的轮廓分析。

通过彩色图像分割实现二值化.png

2.2 边缘检测

图像的边缘是图像中亮度变化比较大的点。Monica 提供了常见的边缘检测算子。

边缘检测算子.png

下面以 Laplace 算子为例,实现边缘检测。


通过Laplace算子实现边缘检测.png

2.3 轮廓分析

图像的轮廓是指图像中具有相同颜色或灰度值的连续点的曲线。轮廓边缘是有联系的,边缘是轮廓的基础,轮廓是边缘的连续集合。轮廓分析呢,简而言之就是找到图像中物体的轮廓。

下图以回形针为例,查找图中回形针的轮廓。

加载回形针图片.png

首先对图像进行二值化。


对图像进行二值化.png

然后对二值图像进行轮廓查找,并将轮廓的外接矩形、最小外接矩形、质心显示到原图中。


对二值图像进行轮廓查找.png

有时为了找个某些轮廓,需要对所有轮廓进行过滤。目前支持通过周长、面积、圆度、长宽比这些设置来过滤轮廓。


对轮廓进行过滤.png

三. 功能的实现

该模块功能的实现,主要是封装 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 224,815评论 6 522
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 96,251评论 3 402
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 171,999评论 0 366
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 60,996评论 1 300
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 69,993评论 6 400
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 53,477评论 1 314
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 41,848评论 3 428
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 40,823评论 0 279
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 47,361评论 1 324
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 39,401评论 3 346
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 41,518评论 1 354
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 37,119评论 5 351
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 42,850评论 3 338
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 33,292评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 34,424评论 1 275
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 50,072评论 3 381
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 46,588评论 2 365

推荐阅读更多精彩内容