实现OCR语言识别Demo(二)- 图片及识别内容的展现和交互

实现OCR语言识别Demo(二)- 图片及识别内容的展现和交互

上一篇文章中(想要回顾的可以看这里),我们实现了BottomSheet,那么这篇文章中,我们要来实现图片展现及识别内容展现了

实现图片展现

我们有2种方式去选择我们的图片,一种是通过选择本地的相册的图片,另一种是通过拍照获得,我们来实现这两种方式

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
	...
    super.onCreate(savedInstanceState)
    bindListeners()
    ...
}

private fun bindListeners() {
    open_take_pic.setOnClickListener { takeImage() }
    open_select_image.setOnClickListener { selectImageFromGallery() }
}

private val takeImageResult =
        registerForActivityResult(ActivityResultContracts.TakePicture()) { isSuccess ->
            if (isSuccess) {
                latestTmpUri?.let { _ ->
                    CoroutineScope(Dispatchers.IO).launch {
                        val compressedImageFile = Compressor.compress(
                            applicationContext,
                            takeImageTempFile!!
                        )
                        image_preview.clearOcrBoxes()
                        adapter.submitList(mutableListOf())
                        previewPhoto(compressedImageFile)
                        postImage(compressedImageFile)
                    }
                }
            }
        }

    private val selectImageFromGalleryResult =
        registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
            uri?.let {
                val inputStream = contentResolver.openInputStream(uri)
                val tempFile = createTempFile()
                val fos = tempFile.outputStream()
                val buffer = ByteArray(8 * 1024)
                var byteCount: Int
                fos.use {
                    while (inputStream?.read(buffer)
                            .also { byteCount = it!! } != -1
                    ) {
                        it.write(buffer, 0, byteCount)
                    }
                }
                inputStream?.close()

                CoroutineScope(Dispatchers.IO).launch {
                    val compressedImageFile =
                        Compressor.compress(applicationContext, tempFile)
                    image_preview.clearOcrBoxes()
                    adapter.submitList(mutableListOf())
                    previewPhoto(compressedImageFile)
                    postImage(compressedImageFile)
                }
            }
        }

private fun takeImage() {
    takeImageTempFile = createTempFile()
    if (takeImageTempFile == null) {
        return
    }
    lifecycleScope.launchWhenStarted {
        getUriFromFile(takeImageTempFile!!).let { uri ->
                                                 latestTmpUri = uri
                                                 takeImageResult.launch(uri)
                                                }
    }
}

private fun selectImageFromGallery() =
selectImageFromGalleryResult.launch("image/*")

private fun createTempFile() =
	File.createTempFile("tmp_image_file", ".png", filesDir).apply {
    	createNewFile()
    	deleteOnExit()
}

private fun getUriFromFile(file: File): Uri {
    return FileProvider.getUriForFile(
        applicationContext,
        "${BuildConfig.APPLICATION_ID}.provider",
        file
    )
}

private fun previewPhoto(file: File) {
    runOnUiThread {
        Glide.with(applicationContext)
        .asBitmap()
        .override(displayWidth, displayHeight)
        .load(file)
        .into(image_preview)
    }
}

private fun postImage(file: File) {
    val picData = file.source().buffer().readByteString()
    val bodyData = "image_data=${
        URLEncoder.encode(
            picData.base64(),
            Charsets.UTF_8.name()
        )
	}"

    mockWebServer.enqueue(
        MockResponse().setBody(mockResponseJsonStr)
    )
	val mockUrl = mockWebServer.url("/ocr")

    val url = "http://${mockUrl.host}:${mockWebServer.port}"
    //        val url = "http://10.0.200.20:8080/ocr"

    val requestBody =
    bodyData.toRequestBody("application/x-www-form-urlencoded".toMediaType())
    val request = Request.Builder()
        .url(url)
        .post(requestBody)
        .addHeader("Content-Type", "application/x-www-form-urlencoded")
        .build()
	client.newCall(request).enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) {
            Log.d("postImage", "onFailure: ${e.message}")
        }

        override fun onResponse(call: Call, response: Response) {
            if (response.isSuccessful) {
                val bodyString = response.body?.string()
                Log.d("postImage", "onResponse: $bodyString")
                val ocrResult =
                Gson().fromJson(bodyString, OcrResult::class.java)
                if (ocrResult.errId != 0) {
                    Log.d(
                        "postImage",
                        "onResponse error: errorId=${ocrResult.errId}, errorMsg = ${ocrResult.errMsg}"
                    )
                } else {
                    val ocrItems = convertOcrItems(ocrResult.result)
                    drawOcrBoxes(file, ocrItems)
                    adapter.submitList(ocrItems)
                }
            } else {
                Log.d(
                    "postImage",
                    "onResponse failure: " + response.body?.string()
                )
            }
        }
	})
}

private fun convertOcrItems(result: List<OcrResult.Result>): List<OcrItem> {
    val ocrItems = mutableListOf<OcrItem>()
    result.forEach {
        val colorR = (0..255).random()
        val colorG = (0..255).random()
        val colorB = (0..255).random()
        val ocrItem = OcrItem(
            boxes = it.boxes,
            text = it.text,
            score = it.score,
            color = Color.argb(
                255,
                colorR,
                colorG,
                colorB
            ).toColor()
        )
        ocrItems.add(ocrItem)
    }
    return ocrItems
}
  • 我们实现了选择图片和拍照的事件,并在其回调中获取到图片文件进行操作
  • 我们使用了第三方库Compressor对图片进行了压缩,这样用来减少服务器压力
  • 展现图片以及展现识别内容前我们会先将以前的识别内容进行清空
  • 将我们的识别内容列表进行清空操作
  • 预先进行图片的预览,这一步由于我们去服务端请求需要耗费时间,所以如果是等待服务端响应后再一起展现图片的话,给用户的体验不是很好,所以这边先会将图片展现,然后再去请求服务端
  • 展现图片和请求服务端都是使用压缩后的图片
  • 展现图片使用了Glide进行渲染,并且对图片尺寸进行了设置,设置成了与我们在计算图片控件缩放时候一样的值
  • 请求服务端数据使用okhttp进行接口调用,而我们现在使用了MockWebServer进行了假数据的模拟

最后,我们要实现我们的OcrImageView,我们定义了一个自定义控件OcrImageView,其继承自AppCompatImageView,其主要功能是根据我们的OCR识别内容进行在图片上识别内容的绘制以及一些点击的交互行为,首先我们先来进行自定义控件OcrImageView的实现

这边需要注意的是,我们是依靠了设置我们的ImageView自定义控件的 a n d r o i d : s c a l e T y p e = " f i t C e n t e r " android:scaleType="fitCenter" android:scaleType="fitCenter"属性根据控件高度的改变来达到了我们使其内部自动的进行了图片的缩放来达到的这个效果,并且是按比例进行缩放的,只有这样按比例进行操作,我们在后续才能进行精确的比例关系的映射来定位我们的识别内容

OCR识别内容的绘制

OcrImageView.kt

class OcrImageView(context: Context, attributeSet: AttributeSet) :
    AppCompatImageView(context, attributeSet) {
        private var originWidth: Int = 0
    	private var originHeight: Int = 0
        
    	private val mPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
        private val ocrItems: MutableList<OcrItem> = mutableListOf()
        
        fun drawOcrBoxes(
            ocrItems: List<OcrItem>,
            originWidth: Int,
            originHeight: Int
        ) {
            this.ocrItems.clear()
            this.ocrItems.addAll(ocrItems)
            this.originWidth = originWidth
            this.originHeight = originHeight
            this.postInvalidate()
        }
        
        fun clearOcrBoxes() {
        	this.ocrItems.clear()
        	postInvalidate()
    	}
        
        override fun onDraw(canvas: Canvas?) {
        	super.onDraw(canvas)
            if (canvas != null) {
                onDrawOcrBoxes(canvas)
            }
    	}
        
        private fun onDrawOcrBoxes(canvas: Canvas) {
            if (drawable == null) {
                return
            }

            if (ocrItems.isEmpty()) {
                ocrRegions.clear()
                return
            }

            val count = canvas.save()

            val values = FloatArray(9)
            imageMatrix.getValues(values)

            val offsetX = values[2]
            val offsetY = values[5]
            val scaleX = values[0]
            val scaleY = values[4]

            //图片在控件里的大小 * 在控件里的缩放比例 / 原始图片大小
            val widthRatio: Float =
                1.0f * drawable.intrinsicWidth * scaleX / originWidth
            val heightRatio: Float =
                1.0f * drawable.intrinsicHeight * scaleY / originHeight

            ocrRegions.clear()
            ocrItems.forEach {
                mPaint.reset()
                mPaint.color = it.color.toArgb()
                mPaint.style = Paint.Style.STROKE
                mPaint.strokeWidth = 4f

                val path = Path()

                val point0 = it.boxes[0]
                val point1 = it.boxes[1]
                val point2 = it.boxes[2]
                val point3 = it.boxes[3]

                path.moveTo(
                    point0[0] * widthRatio + offsetX,
                    point0[1] * heightRatio + offsetY
                )
                path.lineTo(
                    point1[0] * widthRatio + offsetX,
                    point1[1] * heightRatio + offsetY
                )
                path.lineTo(
                    point2[0] * widthRatio + offsetX,
                    point2[1] * heightRatio + offsetY
                )
                path.lineTo(
                    point3[0] * widthRatio + offsetX,
                    point3[1] * heightRatio + offsetY
                )
                path.lineTo(
                    point0[0] * widthRatio + offsetX,
                    point0[1] * heightRatio + offsetY
                )

                canvas.drawPath(path, mPaint)
            }

            canvas.restoreToCount(count)
        }
}
  • 我们提供方法drawOcrBoxes供外层调用用来更新我们的识别内容,其中有三个参数,分别是OCR识别内容list,原始图片宽度,原始图片的高度,这边的原始图片的宽度和高度是指送去进行OCR识别的那张图片的宽高,即压缩过的图片的宽度和高度,这样我们才能根据缩放比例关系来将数据中的坐标点转换到我们的控件中的缩放后的图片上
  • 我们在onDraw的super.onDraw方法后面添加我们的绘制逻辑,onDrawOcrBoxes里面我们会先获取图片的矩阵信息getImageMatrix方法获取,对于图片矩阵信息的知识这边不展开说了,我们只要知道这个矩阵信息里面包含了图片的偏移,缩放等信息,我们主要用到的就是其偏移和缩放值
  • 通过矩阵信息获取其偏移量offsetX和offsetY,以及获取其缩放值scaleX和scaleY
  • 计算出现在的图片对于原始图片的宽度和高度的比例,公式为 宽高比 = 图片在控件里的大小 ∗ 在控件里的缩放比例 / 原始图片大小 宽高比 = 图片在控件里的大小 * 在控件里的缩放比例 / 原始图片大小 宽高比=图片在控件里的大小在控件里的缩放比例/原始图片大小
  • 对列表进行循环,画每一个识别内容box,在画的时候,我们采用drawPath的方式进行绘制,绘制一个闭合的四边形区域,对每个box设置不同的颜色边框,颜色值我们会在得到数据后就进行数据的创建,所以这边只是需要取出来即可,采用Argb的形式进行设置
  • 画每一个box的时候每一个path的坐标点都乘以宽高比,以转换到我们现有的图片上来,并且增加偏移量,如
path.moveTo(
    point0[0] * widthRatio + offsetX,
    point0[1] * heightRatio + offsetY
)
  • 有draw就有clear,所以我们添加clearOcrBoxer方法,用于清除图片上的boxes

OCR识别内容的点击效果实现

最后,我们实现一下图片上点击OCR识别内容box的点击效果,要实现这个我们需要对点击事件进行监听,监听到点击事件后,进行颜色的改变,并回调,而我们需要怎么确定我们点击的是哪一个box呢?我们先来看下代码实现

OcrImageView.kt

private val ocrRegions: MutableList<Region> = mutableListOf()

private var touchX = 0
private var touchY = 0

private var clickOcrItem: OcrItem? = null

private var ocrImageListener: OcrImageListener? = null

private fun clickOcrItem(clickOcrItem: OcrItem) {
    if(this.clickOcrItem != clickOcrItem) {
        this.clickOcrItem = clickOcrItem
    }else{
        this.clickOcrItem = null
    }
    postInvalidate()
}

private fun findOcrItemPositionWithRegion(x: Int, y: Int): Int? {
    for ((index, region) in ocrRegions.withIndex()) {
        if (region.contains(x, y)) {
            return index
        }
    }
    return null
}

override fun performClick(): Boolean {
    return super.performClick()
}

override fun onTouchEvent(event: MotionEvent?): Boolean {

    when (event?.action) {
        MotionEvent.ACTION_DOWN -> {
            touchX = event.x.toInt()
            touchY = event.y.toInt()
            return true
        }
        MotionEvent.ACTION_MOVE -> {
        }
        MotionEvent.ACTION_UP -> {
            if (touchX == event.x.toInt() && touchY == event.y.toInt()) {

                if (ocrRegions.isEmpty()) {
                    return super.onTouchEvent(event)
                }

                val x = event.x.toInt()
                val y = event.y.toInt()

                val clickOcrItemIndex = findOcrItemPositionWithRegion(x, y)
                if (clickOcrItemIndex != null) {
                    val item = ocrItems[clickOcrItemIndex]
                    clickOcrItem(item)
                    ocrImageListener?.onClick(clickOcrItemIndex, item)
                    super.performClick()
                }
            }
        }
    }
    return super.onTouchEvent(event)
}

fun setOcrImageListener(ocrImageListener: OcrImageListener){
    this.ocrImageListener = ocrImageListener
}

fun selectOcrBox(ocrItem: OcrItem){
    this.clickOcrItem(ocrItem)
}

private fun buildRegion(path: Path): Region {
    val pathBoundsRect = RectF()
    path.computeBounds(pathBoundsRect, false)
    return Region().apply {
        setPath(
            path, Region(
                pathBoundsRect.left.toInt(),
                pathBoundsRect.top.toInt(),
                pathBoundsRect.right.toInt(),
                pathBoundsRect.bottom.toInt()
            )
        )
    }
}

private fun onDrawOcrBoxes(canvas: Canvas) {
	...
    if (ocrItems.isEmpty()) {
        ocrRegions.clear()
        return
    }
    
    ocrRegions.clear()
    ocrItems.forEach{
        ...
        
        val region = buildRegion(path)
        ocrRegions.add(region)

        //画clickocritem
        if(this.clickOcrItem != null && this.clickOcrItem == it){
            mPaint.style = Paint.Style.FILL
            mPaint.color = ContextCompat.getColor(this.context, R.color.select_background)
            canvas.drawPath(path, mPaint)
        }
        ...
    }
    ...
}
  • 我们在画好box后,我们会根据box的四个点坐标,创建出一个Region区域,循环结束后,维护一个与ocrItem一一对应的一个List
  • 我们重写了onTouchEvent方法,监听了鼠标的点击事件,如果是单击,我们使用点击的坐标x,y去我们的区域列表里面找,看是否有区域正好包含了这个x,y坐标点,如果包含,则说明点击了这个区域,我们即获取到这个区域对应的ocrItem,并为这个item设置成当前的clickItem,这样我们触发再次的onDraw重新绘制,我们就是画出此clickOcrItem
  • 画完item后给与外层回调,外层可以用此回调来更新我们的RecycleView的列表显示
  • 外层也可以通过selectOcrBox来使得触发选中某个OcrBox的操作,用于点击RecycleView列表中的某个item后反向选中对应的box

至此,整个Demo的技术点我们都得到了实现,有遇到类似需求的时候可以参考参考,其中的代码可能并非最好的,所以在使用的时候还是以自己的理解为主,这样你理解了吗?

另,完整的项目大家可以到github去查看:https://github.com/xiaozeiqwe8/OCRDemo

最后还望各位兄弟姐妹们点个赞,关个注,更多的我理解的内容我还会陆续和大家分享的,谢谢大家!