实现OCR语言识别Demo(一)- BottomSheet实现
实现OCR语言识别Demo(一)- ButtomSheet实现
先来看看最终效果
正如你们看到的,这个Demo的功能是我们可以从手机里或者是拍照的方式获取到某张图片,然后经过OCR识别文字后,将识别出来的文字在图片上全部都框选出来,并且在底部以扩展界面的方式可以查看识别内容的列表,点击列表里的某一项识别项就会在图片上选中这一项识别项,反过来点击图片上的框选的识别项也会在列表中进行这一对应项的选中。
想要实现上面的这种效果,我们需要解决这几个技术点
- 实现内嵌RecycleView的BottomSheet
- 图片大小跟随BottomSheet展开折叠的缩放处理
- 图片上展示识别内容的渲染方式
- 图片上识别内容的点击行为实现
让我们一步一步来实现并且一步步的解决这些问题
实现BottomSheet
BottomSheet布局实现
首先我们为应用创建一个带BottomSheet的布局,实现main页面布局
activity_main.xml中
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/coordinator_Layout"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:background="@color/white">
<io.github.karl.ocrdemo.OcrImageView
android:id="@+id/image_preview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center|top"
android:scaleType="fitCenter"
tools:src="@tools:sample/backgrounds/scenic" />
<LinearLayout
android:id="@+id/custom_bottom_sheet"
android:layout_width="match_parent"
android:layout_height="210dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"
app:layout_anchorGravity="bottom|end"
app:behavior_peekHeight="50dp"
app:behavior_hideable="false"
android:background="@drawable/bottom_sheet_layout_shape"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/image_view"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="2dp"
android:scaleType="center"
android:src="@mipmap/round_bar_icon"
app:layout_constraintTop_toTopOf="parent"
android:clickable="true"
android:focusable="true"/>
<ImageView
android:id="@+id/open_take_pic"
android:layout_width="30dp"
android:layout_height="30dp"
android:alpha="0.25"
android:src="@mipmap/icon_takepic"
android:scaleType="fitCenter"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="@color/black" />
<ImageView
android:id="@+id/open_select_image"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="10dp"
android:alpha="0.25"
android:src="@mipmap/picture"
android:scaleType="fitCenter"
app:layout_constraintRight_toLeftOf="@id/open_take_pic"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="@color/black" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycle_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/image_view"
android:nestedScrollingEnabled="true"
/>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
这边的页面中,为了使用BottomSheet(对ButtonSheet不熟悉的可以看BottomSheet的使用),
- 我们使用CoordinatorLayout布局
- 我们会有个自定义的ImageView控件OcrImageView,在之后我们会去实现这个控件,然后为其设置 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"属性,使其缩放是等比例的,这一步很关键,只有这样设置后,才能使我们的图片可以自动的进行缩放来调整后续我们的识别内容位置
- 我们会有个LinearLayout来作为BottomSheet的整个布局载体,为其加入 a p p : l a y o u t _ b e h a v i o r = " c o m . g o o g l e . a n d r o i d . m a t e r i a l . b o t t o m s h e e t . B o t t o m S h e e t B e h a v i o r " app:layout\_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"属性,并使其在最底部位置,设置 a p p : b e h a v i o r _ h i d e a b l e app:behavior\_hideable app:behavior_hideable屬性使其不能被隐藏,设置 b e h a v i o r _ p e e k H e i g h t behavior\_peekHeight behavior_peekHeight属性使其折叠时候的高度为50dp,做这些属性设置是为了让我们的BottomSheet被当做一个抽屉的把手一样,我们可以靠拉动把手来展示出来我们的列表内容
- 使用一个ConstraintLayout来加入一个bar的图片,并加入选择照片按钮和选择图片按钮
- 使用了一个圆角布局使其更加的美观些 a n d r o i d : b a c k g r o u n d = " @ d r a w a b l e / b o t t o m s h e e t l a y o u t s h a p e " android:background="@drawable/bottom_sheet_layout_shape" android:background="@drawable/bottomsheetlayoutshape"
- 同样将我们的RecyclerView放置在BottomSheet区域的最下面,完成布局
这边我们要注意,我们的BottomSheet即我们的LinearLayout可以给其一个高度,不然图片都被其展开的时候都遮住了也就没什么意义了
假数据准备
需要说明的是,这边我使用的数据是通过服务端接口返回的,这个接口是内部使用的,而我这边为了方便起见,拿来借用了一下,下面会贴出来其数据格式,数据格式字段都很清晰一看就知道了,这边的数据只是图个方便,当然OCR识别这块的API也有很多厂商都提供的,所以数据格式的话,需要自行解析,我这边就写死一个数据内容来模拟我从我们内部使用的接口获取的数据来进行说明,数据如下:
private val mockResponseJsonStr = """
{
"errId": 0,
"errMsg": "",
"result": [{
"boxes": [[71, 1798],[264, 1806],[262, 1854],[69, 1846]],
"text": "Authority",
"score": 0.9999019
}, {
"boxes": [[75, 1742],[716, 1748],[714, 1794],[73, 1788]],
"text": "Government Root Certification",
"score": 0.9929104
}, {
"boxes": [[77, 1552],[714, 1552],[714, 1586],[77, 1586]],
"text": "Go Daddy Root Certificate Authority-G2",
"score": 0.99476784
}, {
"boxes": [[79, 1484],[470, 1490],[468, 1536],[77, 1530]],
"text": "saacammonne",
"score": 0.55913043
}, {
"boxes": [[71, 1284],[387, 1288],[385, 1328],[69, 1324]],
"text": "GlobalSign Root CA",
"score": 0.9982361
}, {
"boxes": [[73, 1222],[432, 1230],[430, 1282],[71, 1274]],
"text": "GlobalSign nv-sa",
"score": 0.9990079
}, {
"boxes": [[73, 1026],[250, 1034],[248, 1074],[71, 1066]],
"text": "GlobalSign",
"score": 0.99623775
}, {
"boxes": [[73, 966],[301, 974],[299, 1022],[71, 1014]],
"text": "GlobalSign",
"score": 0.98376197
}, {
"boxes": [[73, 768],[250, 776],[248, 816],[71, 808]],
"text": "Gamasaspe",
"score": 0.59074664
}, {
"boxes": [[73, 704],[305, 714],[303, 768],[71, 758]],
"text": "GlobalSign",
"score": 0.9983882
}, {
"boxes": [[71, 510],[252, 518],[250, 558],[69, 550]],
"text": "GlobalSign",
"score": 0.99850523
}, {
"boxes": [[73, 444],[305, 454],[303, 508],[71, 498]],
"text": "swdsasignGd",
"score": 0.6600375
}, {
"boxes": [[665, 232],[752, 232],[752, 286],[665, 286]],
"text": "电电电",
"score": 0.68177694
}, {
"boxes": [[329, 230],[418, 230],[418, 284],[329, 284]],
"text": "杂东景",
"score": 0.28567907
}, {
"boxes": [[73, 122],[450, 122],[450, 176],[73, 176]],
"text": "个一信任的证书",
"score": 0.9976823
}, {
"boxes": [[889, 32],[1038, 28],[1040, 76],[891, 80]],
"text": "物电区A",
"score": 0.0875141
}, {
"boxes": [[41, 34],[297, 28],[299, 74],[43, 80]],
"text": "17:030Ri08",
"score": 0.73744255
}]
}
""".trimIndent()
这个数据对应的图片是这张,接下来我都会使用这张图片以及这个数据进行说明演示
()
原图大小1080*1920,boxes中存放了对应于上传解析的原图的OCR识别的坐标点信息,是一个闭合的四边形,4个数组分别是四边形左上,右上,右下,左下四个角的坐标点,每个坐标点数组中的2个值代表了其X轴和Y轴坐标,text为识别出来的文本内容,score为识别可行度评分
BottomSheet代码实现
数据有了,下来我们就来实现代码
在mainActivity中,我们初始化我们的BottomSheet控件,进行一些设置
private lateinit var behavior: BottomSheetBehavior<View>
//获取状态栏的高度
private fun getStatusBarHeight(activity: Activity): Int {
val resourceId = activity.resources.getIdentifier(
"status_bar_height",
"dimen",
"android"
)
return if (resourceId > 0) {
activity.resources.getDimensionPixelSize(resourceId)
} else 0
}
private val displayWidth: Int by lazy {
resources.displayMetrics.widthPixels
}
private val displayHeight: Int by lazy {
resources.displayMetrics.heightPixels - behavior.peekHeight - getStatusBarHeight(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
...
behavior = BottomSheetBehavior.from(findViewById(R.id.custom_bottom_sheet))
behavior.addBottomSheetCallback(object :
BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
val bottomSheetHeightOffset =
(bottomSheet.height - behavior.peekHeight) * slideOffset
val layoutParams = image_preview.layoutParams
val height = displayHeight
layoutParams.height =
(height - bottomSheetHeightOffset).toInt()
image_preview.layoutParams = layoutParams
}
})
...
}
我们为BottomSheet设置了一个callback监听,监听其折叠和展开的事件,我们在onSlide中得到其slideOffset的值(关于这个值的详细描述可以看BottomSheet的使用),并根据这个值计算出我们的图片的高度要如何调整,首先 B o t t o m S h e e t 高度偏移量 = ( b o t t o m S h e e t 的总高度 − b o t t o m S h e e t 的 p e e k H e i g h t 折叠高度) ∗ o f f s e t 比例值 BottomSheet高度偏移量 = (bottomSheet的总高度 - bottomSheet的peekHeight折叠高度) * offset比例值 BottomSheet高度偏移量=(bottomSheet的总高度−bottomSheet的peekHeight折叠高度)∗offset比例值,算出高度偏移量后,我们改变ImageView的高度值,使其等于 I m a g e V i e w 控件高度 − B o t t o m S h e e t 高度偏移量 ImageView控件高度 - BottomSheet高度偏移量 ImageView控件高度−BottomSheet高度偏移量,控件的高度我们给个固定值,即 屏幕高度 − B o t t o m S h e e t 折叠时候的高度 − 状态栏的高度 屏幕高度 - BottomSheet折叠时候的高度 - 状态栏的高度 屏幕高度−BottomSheet折叠时候的高度−状态栏的高度,这样,当我们展开或折叠BottomSheet的时候,ImageView会被重新计算其大小,并会进行与调节BottomSheet一样高度的值来调节其高度值
完成上一步后,接下来我们的BottomSheet会展现一个识别内容的列表,我们已经在xml中定义了RecycleView控件,那么我们为这个控件进行一些初始化,使其能展现我们上面提供的数据出来
MainActivity.kt
private val adapter =
OcrListAdapter(OcrItemListener { _, item ->
image_preview.selectOcrBox(ocrItem = item)
})
override fun onCreate(savedInstanceState: Bundle?) {
...
recycle_view.adapter = adapter
recycle_view.layoutManager = LinearLayoutManager(this)
recycle_view.addItemDecoration(
DividerItemDecoration(
this,
LinearLayoutManager.VERTICAL
)
)
...
}
上面代码简单的进行了初始化的工作,我们会自定义一个Adapter,并且我们会有一个点击列表中某一Item的回调,回调中我们会使其与我们的imageView进行交互,使得我们点击列表中的某一项的时候,也会同时在图片中显示选中效果,我们先将回调接口方法全都定义好
OcrItemListener.kt
class OcrItemListener(private val clickListener: (position: Int, ocrItem : OcrItem) -> Unit) {
fun onClick(position: Int, ocrItem: OcrItem) = clickListener(position, ocrItem)
}
然后我们来实现我们的Adapter并简单实现一下RecycleView布局
ocr_row_item.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:background="@drawable/ocr_list_bg_no_selector"
>
<TextView
android:id="@+id/ocr_result_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<TextView
android:id="@+id/ocr_result_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
app:layout_constraintStart_toEndOf="@id/ocr_result_text"
app:layout_constraintTop_toTopOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
OcrListAdapter.kt
class OcrListAdapter(
private val ocrItemListener: OcrItemListener
) : ListAdapter<OcrItem, OcrListAdapter.ViewHolder>(OcrDiffCallBack()) {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val textView: TextView = view.findViewById(R.id.ocr_result_text)
val scoreView: TextView = view.findViewById(R.id.ocr_result_score)
}
var selectPosition = -1
var isClick: Boolean = false
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.ocr_row_item, parent, false)
return ViewHolder(view).apply {
view.setOnClickListener {
refreshClickItem(this.adapterPosition)
onClick(this.adapterPosition)
}
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val ocrItem = getItem(position)
holder.textView.text = ocrItem.text
holder.textView.setTextColor(ocrItem.color.toArgb())
holder.scoreView.text = ocrItem.score.toString()
holder.scoreView.setTextColor(ocrItem.color.toArgb())
if (selectPosition == position && isClick) {
holder.itemView.setBackgroundResource(R.color.select_background)
} else {
holder.itemView.setBackgroundResource(R.color.white)
}
}
private fun onClick(position: Int) =
ocrItemListener.onClick(position, getItem(position))
fun linkageClick(position: Int) {
refreshClickItem(position)
}
private fun refreshClickItem(position: Int) {
isClick = if (!isClick) {
true
} else {
selectPosition != position
}
notifyItemChanged(selectPosition)
selectPosition = position
notifyItemChanged(selectPosition)
}
class OcrDiffCallBack : DiffUtil.ItemCallback<OcrItem>() {
override fun areItemsTheSame(
oldItem: OcrItem,
newItem: OcrItem
): Boolean {
return oldItem.text == newItem.text
}
override fun areContentsTheSame(
oldItem: OcrItem,
newItem: OcrItem
): Boolean {
return oldItem == newItem
}
}
}
我们来解释一下比较特殊的代码部分
- 我们为我们的数据格式定义了一个OcrItem类,用来封装我们的数据格式,并且我们定义了一个color属性,用来使得我们的识别内容可以显示不同颜色的边框
OcrItem.kt
data class OcrItem(
val boxes: List<List<Int>> = listOf(),
val text: String,
val color: Color,
val score: Float
)
- 定义了optionPosition来让RecycleView知道当前想要进行操作的是哪一个Item
- 定义isClick来使其支持取消选中状态
- 在onCreateViewHolder中为Item项目设置onClick事件,并通过ocrItemListener回调给外层
- refreshClickItem方法中,我们会先将原先的列表选中状态刷新掉然后再进行新的选择项的状态的更新
- 预留linkageClick方法,使得外层能够控制我们的列表进行选中,用于在图片上点击某个识别内容的时候,也进行Recycle列表Item的选中效果
至此,BottomSheet已经实现了,接下来我们会实现图片的展示以及识别内容的展示和交互功能,在这之前,还是建议先把这一部分的文章理解下,因为下一篇文章是在这一篇基础上来实现的,另,先附上完整的项目大家可以到github去查看:https://github.com/xiaozeiqwe8/OCRDemo,这样也能更加好的结合下一篇内容来看
最后还望各位兄弟姐妹们点个赞,关个注,更多的我理解的内容我还会陆续和大家分享的,谢谢大家!