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

一. 图像编辑器 Monica

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

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

下面展示该模块的入口6318a4e5da5a4a9b28be947801046b7d.jpeg

以及该模块的首页22ab2bd4bc7a099b7b98876df7e386cc.jpeg

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

2.1 二值化

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

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

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

下图是为了展示 Canny 边缘检测3d534a6e2e1a03eacde63c3355a49f5d.jpeg

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

下图是为了展示彩色图像分割98600a1e33b6b70853bd95bb1201d2d9.jpeg

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

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

2.2 边缘检测

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

4e4f76fba5124187c69cf849055b9ce3.jpeg
边缘检测算子.png

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

2.3 轮廓分析

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

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

cb74c7025f3b3bbd0aa70152b768e4a6.jpeg
加载回形针图片.png

首先对图像进行二值化。43e39ea9ac7a539f6286c7b5b5855fa4.jpeg

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

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

三. 功能的实现

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

Java与Android技术栈】公众号

关注 Java/Kotlin 服务端、桌面端 、Android 、机器学习、端侧智能

更多精彩内容请关注:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值