OpenCV实现 各种图形绘制、图像调色

科技   2024-12-08 22:30   江苏  

一. 图像编辑器 Monica

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

screenshot.png

其技术栈如下:

  • Kotlin 编写 UI(Kotlin Compose Desktop 作为 UI 框架)
  • 基于 mvvm 模式,依赖注入使用 koin,编译使用 JDK 17。
  • 部分算法使用 Kotlin 实现。
  • 其余的算法使用 OpenCV C++ 来实现,Kotlin 通过 jni 来调用。
  • Monica 所使用的模型,主要使用 ONNXRuntime 进行部署和推理。
  • 其余少部分模型使用 OpenCV DNN 进行部署和推理。
  • 本地的算法库使用 C++ 17 编译。


Monica 目前还处于开发阶段,当前版本的可以参见 github 地址:https://github.com/fengzhizi715/Monica

在这个月里,我完成了 Monica 比较重要的两个功能:图形绘制、图像调色。

二. 图形绘制

Monica 支持在图像上的任意位置绘制线段、圆、三角形、矩形、任意多边形,在任意位置添加文字,以及对这些绘制的图形更改属性。

2.1 形状绘制

下面是展示形状绘制的入口。

以及绘制形状的页面。

Monica 提供了图像上的任意位置绘制各种图形的功能,以及修改图形的属性比如图像的颜色、透明度、是否填充、边框类型。

保存图像.png

在图像中绘制形状,主要是调用 Compose 的 Canvas API。在实现绘制功能之前,需要先定义好能够绘制图形的类型。

sealed class Shape {
   data class Line(val from: Offset, val to: Offset, val shapeProperties: ShapeProperties): Shape()

   data class Circle(val center: Offset, val radius:Float, val shapeProperties: ShapeProperties): Shape()

   data class Triangle(val first: Offset, val second: Offset?=null, val third: Offset?=null, val shapeProperties: ShapeProperties): Shape()

   data class Rectangle(val tl: Offset, val bl: Offset, val br: Offset, val tr: Offset, val rectFirst: Offset,val shapeProperties: ShapeProperties): Shape()

   data class Polygon(val points: List<Offset>, val shapeProperties: ShapeProperties): Shape()

   data class Text(val point: Offset, val message:String, val shapeProperties: ShapeProperties): Shape()
}

对于不同图形的绘制,需要确定好相关点的坐标。这块的逻辑比较多,可以查看项目的源码。下面主要讲讲如何绘制图形。

class ShapeDrawingViewModel {

   fun drawShape(canvasDrawer:CanvasDrawer,
                 lines: Map<Offset, Line>,
                 circles: Map<Offset, Circle>,
                 triangles: Map<Offset, Triangle>,
                 rectangles: Map<Offset, Rectangle>,
                 polygons: Map<Offset, Polygon>,
                 texts: Map<Offset, Text>,
                 saveFlag: Boolean = false)
{

       lines.forEach {

           val line = it.value

           if (line.from != Offset.Unspecified && !saveFlag) {
               canvasDrawer.point(line.from, line.shapeProperties.color)
           }

           if (line.from != Offset.Unspecified && line.to != Offset.Unspecified) {
               canvasDrawer.line(line.from,line.to, Style(null, line.shapeProperties.color, line.shapeProperties.border, null, fill = line.shapeProperties.fill, scale = 1f, alpha = line.shapeProperties.alpha, bounded = true))
           }
       }

       circles.forEach {

           val circle = it.value

           if (circle.center != Offset.Unspecified && !saveFlag) {
               canvasDrawer.point(circle.center, circle.shapeProperties.color)
           }

           canvasDrawer.circle(circle.center, circle.radius, Style(null, circle.shapeProperties.color, circle.shapeProperties.border, null, fill = circle.shapeProperties.fill, scale = 1f, alpha = circle.shapeProperties.alpha, bounded = true))
       }

       triangles.forEach {
           val triangle = it.value

           if (triangle.first != Offset.Unspecified && !saveFlag) {
               canvasDrawer.point(triangle.first, triangle.shapeProperties.color)
           }

           if (triangle.second != Offset.Unspecified && !saveFlag) {
               canvasDrawer.point(triangle.second!!, triangle.shapeProperties.color)
               canvasDrawer.line(triangle.first,triangle.second, Style(null, triangle.shapeProperties.color, triangle.shapeProperties.border, null, fill = triangle.shapeProperties.fill, scale = 1f, alpha = triangle.shapeProperties.alpha, bounded = true))
           }

           if (triangle.first != Offset.Unspecified && triangle.second != Offset.Unspecified && triangle.third != Offset.Unspecified) {
               val list = mutableListOf<Offset>().apply {
                   add(triangle.first)
                   add(triangle.second!!)
                   add(triangle.third!!)
               }

               canvasDrawer.polygon(list, Style(null, triangle.shapeProperties.color, triangle.shapeProperties.border, null, fill = triangle.shapeProperties.fill, scale = 1f, alpha = triangle.shapeProperties.alpha,  bounded = true))
           }
       }

       rectangles.forEach {
           val rect = it.value

           if (rect.rectFirst!=Offset.Unspecified && !saveFlag) {
               canvasDrawer.point(rect.rectFirst, rect.shapeProperties.color)
           }

           if (rect.tl!=Offset.Unspecified && rect.bl!=Offset.Unspecified && rect.br!=Offset.Unspecified && rect.tr!=Offset.Unspecified) {
               val list = mutableListOf<Offset>().apply {
                   add(rect.tl)
                   add(rect.bl)
                   add(rect.br)
                   add(rect.tr)
               }

               canvasDrawer.polygon(list, Style(null, rect.shapeProperties.color, rect.shapeProperties.border, null, fill = rect.shapeProperties.fill, scale = 1f, alpha = rect.shapeProperties.alpha, bounded = true))
           }
       }

       polygons.forEach {
           val polygon = it.value

           if (polygon.points[0]!=null && polygon.points[0] != Offset.Unspecified && !saveFlag) {
               canvasDrawer.point(polygon.points[0] , polygon.shapeProperties.color)
           }

           if (polygon.points.size>1 && polygon.points[1] != Offset.Unspecified && !saveFlag) {
               canvasDrawer.point(polygon.points[1] , polygon.shapeProperties.color)
               canvasDrawer.line(polygon.points[0], polygon.points[1], Style(null, polygon.shapeProperties.color, Border.Line, null, fill = polygon.shapeProperties.fill, scale = 1f, alpha = polygon.shapeProperties.alpha, bounded = true))
           }

           canvasDrawer.polygon(polygon.points, Style(null, polygon.shapeProperties.color, polygon.shapeProperties.border, null, fill = polygon.shapeProperties.fill, scale = 1f, alpha = polygon.shapeProperties.alpha, bounded = true))
       }

       texts.forEach {
           val text = it.value

           if (text.point!= Offset.Unspecified) {
               val list = mutableListOf<String>().apply {
                   add(text.message)
               }
               canvasDrawer.text(text.point, list, text.shapeProperties.color, text.shapeProperties.fontSize)
           }
       }
   }
   ......

}

2.2 添加文字

Monica 支持在图像的任意位置添加文字,修改文字的属性比如字体大小、颜色。

修改属性.png
修改颜色.png
保存图像.png

在图像中添加文字,需要一个可以拖动的 TextField ,这样才能在图像的任意位置添加文字。因此,DraggableTextField 控件如下:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DraggableTextField(
   modifier: Modifier = Modifier,
   text: String,
   bitmapWidth: Int,
   bitmapHeight: Int,
   density: Density,
   onTextChanged: (String) -> Unit,
   onDragged: (Offset) -> Unit
)
{
   var offset by remember { mutableStateOf(Offset.Zero) }
   val halfWidth = bitmapWidth/2
   val halfHeight = bitmapHeight/2
   val halfTextFieldWidth = 125/density.density
   val halfTextFieldHeight = 65/density.density

   Box(
       modifier = modifier
           .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
           .pointerInput(Unit) {
               detectDragGestures { change ->
                   offset += change
                   if (abs(offset.x) > halfWidth - halfTextFieldWidth || abs(offset.y) > halfHeight - halfTextFieldHeight) {
                       offset -= change
                       return@detectDragGestures
                   }
               }
           }
           .shadow(8.dp)
           .background(Color.White)
           .padding(16.dp)
           .fillMaxWidth()
           .wrapContentHeight(Alignment.Top)
           .clip(RoundedCornerShape(8.dp))
   ) {
       Column {
           TextField (
               value = text,
               onValueChange = onTextChanged,
               modifier = Modifier.width(220.dp)
           )

           confirmButton(true, modifier = Modifier.align(Alignment.End).padding(top = 5.dp)) {
               onDragged.invoke(offset)
           }
       }
   }
}

需要注意的是 DraggableTextField 中的 offset 通过 onDragged 回调给当前图像,但是 offset 要变成图像中的当前的坐标,还需要做一些坐标转换才行。

三. 图像调色

Monica 支持调节图像的对比度、色调、饱和度、亮度、色温等,从而帮助大家调整图像的色彩。

图像调色的入口.png
图像调色的界面.png
支持拖动调节各个参数.png
支持拖动调节各个参数.png
保存图像.png

3.1 应用层的设计和调用

该模块功能的实现,最终也是调用了封装 OpenCV 的函数。对于应用层,需要编写好调用 jni 层的代码:

object ImageProcess {

   ......

   /**
    * 初始化图像调色模块
    */

   external fun initColorCorrection(src: ByteArray): Long

   /**
    * 图像调色
    */

   external fun colorCorrection(src: ByteArray, colorCorrectionSettings: ColorCorrectionSettings, cppObjectPtr:Long):IntArray

   /**
    * 删除 ColorCorrection
    */

   external fun deleteColorCorrection(cppObjectPtr:Long): Long

   ......
}

其中,initColorCorrection() 返回的是 Long 类型,其实是一个指针的地址,表示的是一个 C++ 对象。之所以这么做是为了在处理当前图片时,能够复用该  C++ 对象。

viewModel 的 colorCorrection 会调用 ImageProcess 的 colorCorrection() 函数,然后将结果展示到 UI 上。离着这个界面的时候,会清除所有的状态,以及回收所用到的 C++ 对象。

class ColorCorrectionViewModel {

   private val logger: Logger = logger<ColorCorrectionViewModel>()

   var contrast by mutableStateOf(255f )
   var hue by mutableStateOf(180f )
   var saturation by mutableStateOf(255f )
   var lightness by mutableStateOf(255f )
   var temperature by mutableStateOf(255f )
   var highlight by mutableStateOf(255f )
   var shadow by mutableStateOf(255f )
   var sharpen by mutableStateOf(0f )
   var corner by mutableStateOf(0f )

   private var cppObjectPtr:Long = 0

   private var init:AtomicBoolean = AtomicBoolean(false)

   fun colorCorrection(state: ApplicationState, image: BufferedImage, colorCorrectionSettings: ColorCorrectionSettings,
                       success: CVSuccess)
{

       logger.info("colorCorrectionSettings = ${GsonUtils.toJson(colorCorrectionSettings)}")

       state.scope.launchWithLoading {
           if (!init.get()) {
               init.set(true)

               val byteArray = image.image2ByteArray()
               cppObjectPtr = ImageProcess.initColorCorrection(byteArray)
           }

           OpenCVManager.invokeCV(image,
               action  = { byteArray -> ImageProcess.colorCorrection(byteArray, colorCorrectionSettings, cppObjectPtr) },
               success = { success.invoke(it) },
               failure = { e ->
                   logger.error("colorCorrection is failed", e)
               })
       }
   }

   ......

   fun clearAllStatus() {
       init.set(false)

       contrast = 255f
       hue = 180f
       saturation = 255f
       lightness = 255f
       temperature = 255f
       highlight = 255f
       shadow = 255f
       sharpen = 0f
       corner = 0f

       colorCorrectionSettings = ColorCorrectionSettings()

       ImageProcess.deleteColorCorrection(cppObjectPtr)
       cppObjectPtr = 0
   }
}

3.2 jni 层的编写

对于 jni 层,需要先在头文件里定义好应用层对应的函数

JNIEXPORT jlong JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_initColorCorrection
(JNIEnv* env, jobject,jbyteArray array)
;

JNIEXPORT jintArray JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_colorCorrection
       (JNIEnv* env, jobject,jbyteArray array, jobject jobj,  jlong cppObjectPtr)
;

JNIEXPORT void JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_deleteColorCorrection
       (JNIEnv* env, jobject, jlong cppObjectPtr)
;

然后,编写对应的实现。

JNIEXPORT jlong JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_initColorCorrection
(JNIEnv* env, jobject, jbyteArray array)
{
   Mat image = byteArrayToMat(env, array);

   // 创建 C++ 对象并存储指针
   ColorCorrection* cppObject = new ColorCorrection(image);
   return reinterpret_cast<jlong>(cppObject);
}

JNIEXPORT jintArray JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_colorCorrection
       (JNIEnv* env, jobject,jbyteArray array, jobject jobj,  jlong cppObjectPtr)
{

   ColorCorrection* colorCorrection = reinterpret_cast<ColorCorrection*>(cppObjectPtr);
   ColorCorrectionSettings colorCorrectionSettings;

   Mat image = byteArrayToMat(env, array);

   // 获取 jclass 实例
   jclass jcls = env->FindClass("cn/netdiscovery/monica/ui/controlpanel/colorcorrection/model/ColorCorrectionSettings");
   jfieldID contrastId = env->GetFieldID(jcls, "contrast", "I");
   jfieldID hueId = env->GetFieldID(jcls, "hue", "I");
   jfieldID saturationId = env->GetFieldID(jcls, "saturation", "I");
   jfieldID lightnessId = env->GetFieldID(jcls, "lightness", "I");
   jfieldID temperatureId = env->GetFieldID(jcls, "temperature", "I");
   jfieldID highlightId = env->GetFieldID(jcls, "highlight", "I");
   jfieldID shadowId = env->GetFieldID(jcls, "shadow", "I");
   jfieldID sharpenId = env->GetFieldID(jcls, "sharpen", "I");
   jfieldID cornerId = env->GetFieldID(jcls, "corner", "I");
   jfieldID statusId = env->GetFieldID(jcls, "status", "I");

   colorCorrectionSettings.contrast = env->GetIntField(jobj, contrastId);
   colorCorrectionSettings.hue = env->GetIntField(jobj, hueId);
   colorCorrectionSettings.saturation = env->GetIntField(jobj, saturationId);
   colorCorrectionSettings.lightness = env->GetIntField(jobj, lightnessId);
   colorCorrectionSettings.temperature = env->GetIntField(jobj, temperatureId);
   colorCorrectionSettings.highlight = env->GetIntField(jobj, highlightId);
   colorCorrectionSettings.shadow = env->GetIntField(jobj, shadowId);
   colorCorrectionSettings.sharpen = env->GetIntField(jobj, sharpenId);
   colorCorrectionSettings.corner = env->GetIntField(jobj, cornerId);
   colorCorrectionSettings.status = env->GetIntField(jobj, statusId);

   Mat dst;

   try {
       colorCorrection->doColorCorrection(colorCorrectionSettings, dst);
   } catch(...) {
   }

   jthrowable mException = NULL;
   mException = env->ExceptionOccurred();

   if (mException != NULL) {
     env->ExceptionClear();
     jclass exceptionClazz = env->FindClass("java/lang/Exception");
     env->ThrowNew(exceptionClazz, "jni exception");
     env->DeleteLocalRef(exceptionClazz);

     return env->NewIntArray(0);
   }

   env->DeleteLocalRef(jcls);  // 手动释放局部引用

   return matToIntArray(env, dst);
}

JNIEXPORT void JNICALL Java_cn_netdiscovery_monica_opencv_ImageProcess_deleteColorCorrection
       (JNIEnv* env, jobject, jlong cppObjectPtr)
{
   // 删除 C++对象,防止内存泄漏
   ColorCorrection* colorCorrection = reinterpret_cast<ColorCorrection*>(cppObjectPtr);
   delete colorCorrection;
}

jni 层还需要调用 C++ 对应的 ColorCorrection 类,这块因为篇幅原因暂时省略,感兴趣的话可以直接看项目的源码。

同意还有一个值得注意的是,从应用层传递的 ColorCorrectionSettings 对象,需要通过 jobject 转换到 jclass 然后再获取对应的各个属性。在 jni 层也需要定义好对应的 ColorCorrectionSettings 对象。

typedef struct {
   int contrast;
   int hue;
   int saturation;
   int lightness;
   int temperature;
   int highlight;
   int shadow;
   int sharpen;
   int corner;
   int status;
} ColorCorrectionSettings;

四. 总结

Monica 支持了图形绘制、图像调色之后,它才算是一款比较基础的图像编辑器。后面还有很长的路要走,Monica 现有的每一个功能都需要打磨一下。

到农历过年前,我对 Monica 的规划是争取完善 CV 算法快速调参的模块和将部分模型部署到云端。如果能完成这些的话,明年可以做更多有意思的功能。

最后,Monica github 地址:https://github.com/fengzhizi715/Monica


OpenCV4系统化学习


深度学习系统化学习

推荐阅读

OpenCV4.8+YOLOv8对象检测C++推理演示

ZXING+OpenCV打造开源条码检测应用

攻略 | 学习深度学习只需要三个月的好方法

三行代码实现 TensorRT8.6 C++ 深度学习模型部署

实战 | YOLOv8+OpenCV 实现DM码定位检测与解析

对象检测边界框损失 – 从IOU到ProbIOU

初学者必看 | 学习深度学习的五个误区


OpenCV学堂
三本书《Java数字图像处理-编程技巧与应用实践》、《OpenCV Android开发实战》、《OpenCV4应用开发-入门、进阶与工程化实践》作者。OpenCV实验大师平台 软件作者,OpenCV开发专家、OpenCV研习社创始人。
 最新文章