本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!前端
年初写了一篇CameraX的使用文章,帮到了一些朋友,也收到了一些建议。正值最近了解到华为
ScanKit
在扫码场景下的优秀表现,决定集成该方案,并进行一些功能改进。java
以前作的Demo略显简陋,本次改进也对UI进行了调整。主要是给顶部操做栏添加了半透明背景,同时给切换按钮添加了半透明边框以提升对比度。另外对拍摄和录制场景的一些配色作了改动。android
ScanKit能够提供便捷的二维码与条形码扫描、解析、生成能力,帮助您快速构建应用内的扫码功能。git
它拥有诸多优点,包括支持多达13种码格式,在反光、污损、畸变、模糊等复杂场景下亦能良好识别,在远距离扫码的状况下能自适应放大码体,还支持多码识别功能等等。github
ScanKit给开发者提供了四种集成模式,包括固定扫码界面的Default View Mode
,自定义扫码界面的Customized View Mode
,以及彻底由开发者自定义画面和扫码流程的Bitmap Mode
和MultiProcessor Mode
。后端
前两种模式的扫码流程均由ScanKit控制,其内部采用Camera1
实现。若是要集成到CameraX
上的话,只能选择后两种模式。MultiProcessor Mode
适用于多码识别的场景,本次先集成单码识别的Bitmap Mode
。数组
华为ScanKit更加详细的资料可查阅官网:安全
developer.huawei.com/consumer/cn…微信
以及易冬大神的完整演示:markdown
以前的扫码方案采用的是Zxing
,本次集成ScanKit
以后,为了对比学习将Zxing
也进行了保留。在点击扫码按钮以后,底部会弹出扫码方案的选择Fragment,选择以后经过ViewModel将对应的方案告知CameraX的ImageAnalysis。
※ Google ML Kit是一个更为强大的OCR解决方案,后面也将集成进来
你们可能比较关心ScanKit相较于Zxing的优点,能够参考以下这篇测评文章: developer.huawei.com/consumer/cn…
这篇文章里提到ScanKit在远距离扫码、码体倾斜、模糊扫码等场景下的识别速度和成功率都要优于Zxing。你们也可使用本文的Demo,分别选择Zxing和ScanKit两个方案,实际对比一下扫码体验。
在project的gradle文件里添加ScanKit的仓库地址,app的gradle文件里添加依赖,便可快速集成。※Demo依赖了识别能力更为出色的scanplus依赖包
// build.gradle
buildscript {
repositories {
...
mavenCentral()
maven {url 'https://developer.huawei.com/repo/'}
}
}
allprojects {
repositories {
...
mavenCentral()
maven {url 'https://developer.huawei.com/repo/'}
}
}
复制代码
// app/build.gradle
dependencies {
...
// Huawei scan kit
implementation 'com.huawei.hms:scanplus:1.3.2.300'
}
复制代码
CameraX图像分析ImageAnalysis
回传的图像实例ImageProxy
是YUV格式的,须要先经过YuvImage
将其转换为Bitmap,以后再调用ScanKit的Bitmap扫码模式。
private fun proxyToBitmap(image: ImageProxy): Bitmap {
val planes: Array<ImageProxy.PlaneProxy> = image.planes
val yBuffer: ByteBuffer = planes[0].buffer
val uBuffer: ByteBuffer = planes[1].buffer
val vBuffer: ByteBuffer = planes[2].buffer
val ySize: Int = yBuffer.remaining()
val uSize: Int = uBuffer.remaining()
val vSize: Int = vBuffer.remaining()
val nv21 = ByteArray(ySize + uSize + vSize)
yBuffer.get(nv21, 0, ySize)
vBuffer.get(nv21, ySize, vSize)
uBuffer.get(nv21, ySize + vSize, uSize)
val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null)
val out = ByteArrayOutputStream()
yuvImage.compressToJpeg(Rect(0, 0, yuvImage.width, yuvImage.height), 75, out)
val imageBytes = out.toByteArray()
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
var bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, opt)
return bitmap
}
复制代码
建立下ScanKit专用的扫码参数,并将转换获得的Bitmap实例传递给ScanUtil
,便可开始识别。返回的识别结果包括内容、坐标、四角位置等信息,被封装到HmsScan
对象里。ScanUtil识别完成后实际返回的是HmsScan数组,其第一个元素即为单码的识别结果。HmsScan对象的originalValue
属性则是解析出来的内容。
class HuaweiScanAnalysis: RealTimeAnalysis {
override fun analyzeContent(imageProxy: ImageProxy, context: Context): AnalysisResult {
val bitmap = proxyToBitmap(imageProxy)
imageProxy.close()
// 建立ScanKit扫码的参数
val options = HmsScanAnalyzerOptions.Creator()
.setHmsScanTypes(HmsScan.ALL_SCAN_TYPE)
.setPhotoMode(false)
.create()
// 获得扫码结果
val result = ScanUtil.decodeWithBitmap(
context,
bitmap,
options
)
val content = if (result != null && result.isNotEmpty() && result[0].originalValue != null)
result[0].originalValue else ""
...
// 将扫码结果封装为咱们自定义的实例
return AnalysisResult(content, scale, rect)
}
}
复制代码
当远距离扫码或码体太小,ScanKit会计算获得适合的放大倍率,并赋值到HmsScan对象的zoomValue
属性里。能够利用该数值及时通知CameraX调整图像采集的倍率,进而提高后续的识别率。实现思路很是简单,使用CameraControl
提供的setZoomRatio
放大图像预览和分析的倍率便可。为不影响下次的扫码体验,在扫码完成后须将倍率置。
class MyAnalyzer(...): Analyzer {
override fun analyze(image: ImageProxy) {
viewModel.analysePicture(image).also {
if (Constants.DEFAULT_ZOOM_SCALE != it.zoomScale
&& Constants.MIN_ZOOM_SCALE != it.zoomScale
) {
callback.onZoomPreview(it.zoomScale)
} else {
callback.onAnalyzeResult(it)
}
}
}
}
fun onAnalyzeGo(view: View?) {
if (mAnalyzer == null) {
mAnalyzer = MyAnalyzer(viewModel, object : AnalyzeCallback {
override fun onZoomPreview(scale: Double) {
mCamera.cameraControl.setZoomRatio(scale.toFloat())
}
...
})
}
}
复制代码
为提升用户体验,能够在扫码成功的同时播放预设的提示音或震动反馈,能够利用开源的BeepManager
工具类来实现。
fun onAnalyzeGo(view: View?) {
if (mAnalyzer == null) {
mAnalyzer = MyAnalyzer(viewModel, object : AnalyzeCallback {
override fun onAnalyzeResult(result: AnalysisResult) {
synchronized(isAnalyzing) {
showQRCodeResult(result.content)
...
}
}
})
}
}
private fun showQRCodeResult(result: String) {
stopAnalysis()
beepManager.playBeepSoundAndVibrate()
...
}
复制代码
扫码成功的瞬间,微信和支付宝App会在二维码上展现一个圆点,这样的提示设计比较好。HmsScan类的borderRect
属性表明码体的矩形框位置,经过计算获得的centerX和centerY能够帮忙获取码体的中心,在该位置能够展现一个指示View。
须要留意的是,竖屏模式下Analyse的图片会有90度的误差,因此须要额外转换下位置坐标。固然若是Bitmap实例已经作过了90度旋转的处理的话,borderRect数值就不须要额外转换了。有些遗憾的是,坐标计算会有些偏差,很难保证每次都将指示位置绘制在中心。
override fun onAnalyzeResult(result: AnalysisResult) {
synchronized(isAnalyzing) {
showQRCodeResult(result.content)
val centerPoint = Utils.convertRectToPoint(result.rect, binding.previewView)
showPointView(centerPoint)
}
}
private fun showPointView(point: Point) {
val popupWindow = PopupWindow(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
val imageView = ImageView(this)
runOnUiThread {
popupWindow.contentView = imageView
imageView.setImageResource(R.drawable.ic_point_view)
popupWindow.showAsDropDown(binding.previewView, point.x, point.y)
binding.previewView.postDelayed({ popupWindow.dismiss() }, 1000)
}
}
复制代码
在识别过程或成功的时候也能够展现二维码的边框辅助提示。尽管这样的设计并不十分必要,咱们能够试着实现看看。
borderRect属性的原始数值就是框体的宽高,再依据上面的中心位置就能够在上面绘制一个矩形框。事实上除了borderRect,cornerPoints
属性能够拿到码体四角的确切位置,也能够做为绘制框体的数据来源。
override fun onAnalyzeResult(result: AnalysisResult) {
synchronized(isAnalyzing) {
showQRCodeResult(result.content)
val centerPoint = Utils.convertRectToPoint(result.rect, binding.previewView)
showRectView(centerPoint, result.rect)
}
}
private fun showRectView(point: Point, rect: Rect) {
val popupWindow = PopupWindow(
rect.height(),
rect.width()
)
val imageView = ImageView(this)
runOnUiThread {
popupWindow.contentView = imageView
imageView.setImageResource(R.drawable.ic_rect_view)
imageView.scaleType = ImageView.ScaleType.FIT_XY
try {
popupWindow.showAsDropDown(binding.previewView,
point.x - (rect.width() / 2), point.y)
} catch (e: Exception) {}
binding.previewView.postDelayed({ popupWindow.dismiss() }, 1000)
}
}
复制代码
※ 不知道拍摄角度的问题仍是ScanKit的识别存在偏差,框体的绘制位置总有些误差,官方Demo绘制的框体位置也不许确
以前的Demo主要集中在CameraX的API使用上,忽略了支持必要的手势,本次一并加入经常使用的手势支持。
CameraControl提供的setLinearZoom() API能够将拍摄的视野线性地缩放,比较适合双击或者滑动缩放视图的场景。它接受的参数数值介于0~1之间,具体以下:
经过监听双击手势,让拍摄的画面在原始比例0f和0.5F中间比例之间切换。
private fun listenGesture() {
binding.previewView.setOnTouchListener { view, event ->
...
// Zoom when double click.
doubleClickZoom(event)
true
}
}
private fun doubleClickZoom(event: MotionEvent) {
if (doubleClickDetector == null) {
doubleClickDetector = GestureDetector(this@NewCameraXActivity,
object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent?): Boolean {
cameraZoomState.value?.let {
val zoomRatio = it.zoomRatio
val minRatio = it.minZoomRatio
// Ratio parameter from 0f to 1f.
if (zoomRatio > minRatio) {
mCamera.cameraControl.setLinearZoom(Constants.MIN_ZOOM_SCALE.toFloat())
} else {
mCamera.cameraControl.setLinearZoom(Constants.MIDDLE_ZOOM_SCALE.toFloat())
}
}
return true
}
})
}
doubleClickDetector?.onTouchEvent(event)
}
复制代码
CameraControl提供的setZoomRatio API在线性缩放的基础之上提供了更为准确的缩放比率,能够实现捏合手势的缩放场景。
private fun listenGesture() {
binding.previewView.setOnTouchListener { view, event ->
...
// Listen to zoom gesture.
scalePreview(event)
true
}
}
private fun scalePreview(event: MotionEvent) {
if (scaleDetector == null) {
scaleDetector = ScaleGestureDetector(this@NewCameraXActivity,
object : SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
cameraZoomState.value?.let {
val zoomRatio = it.zoomRatio
mCamera.cameraControl.setZoomRatio(zoomRatio * detector.scaleFactor)
}
return true
}
})
}
scaleDetector?.onTouchEvent(event)
}
复制代码
以前是在Touch(ACITON_DOWN)的时候依据坐标进行手动聚焦,引入缩放手势的支持以后,缩放的过程当中会误触对焦操做。改善方法在于将对焦的时机限制在SingleTap
手势,即只有单击操做才会触发对焦。
private fun listenGesture() {
binding.previewView.setOnTouchListener { view, event ->
...
// Singe tap for focus.
singleTapForFocus(event)
true
}
}
private fun singleTapForFocus(event: MotionEvent) {
if (singleTapDetector == null) {
singleTapDetector = GestureDetector(this@NewCameraXActivity,
object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
focusOnPosition(event.x, event.y, true)
return super.onSingleTapConfirmed(e)
}
})
}
singleTapDetector?.onTouchEvent(event)
}
复制代码
改进前不少逻辑都堆在了Activity里,现将各个UseCase的实现拆分出去,减轻Activity的负担。同时对CameraX使用的一些问题进行了改进。
展现相机预览的控件PreviewView还没有添加到视图Tree的时候,若是执行CameraX的绑定操做的话,会发生问题。现象上表现为拍摄画面结束后再次打开的时候会发生Crash。解决思路很简单:监听PreviewView控件的attach时机,在attach成功的回调里才执行CameraX的绑定操做。
override fun onCreate(savedInstanceState: Bundle?) {
...
setContentView(binding.root)
startCameraWhenAttached()
}
private fun startCameraWhenAttached() {
binding.previewView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener{
override fun onViewAttachedToWindow(v: View?) {
ensureCameraPermission()
}
})
}
private fun ensureCameraPermission() {
...
setupCamera(binding.previewView)
}
复制代码
记得在画面不可见的时候结束图像分析的调用,节省内存。
override fun onStop() {
super.onStop()
mImageAnalysis?.clearAnalyzer()
}
复制代码
快速点击视频录制和中止的状况下偶尔会发生以下的crash。
java.lang.IllegalStateException: Failed to stop the muxer
看了CameraX的源码,录制开始和结束时Audio实例的请求和释放发生了错乱。本此改进加入了录制视频的状态控制,在录制开始的500ms内禁止终止录制,以缓解这种现象。
但在极快的录制和中止的反复操做下,录制的部分文件可能会发生损坏。因为CameraX的视频录制API仍处在实验性阶段,因此耐心等待CameraX的解决吧。
private fun videoRecordingPrepared() {
isCameraXHandling = false
// Keep disabled status for a while to avoid fast click error with "Muxer stop failed!".
binding.capture.postDelayed({ binding.capture.isEnabled = true }, 500)
}
复制代码
CameraX拍摄的照片默认是镜像的,在拍摄前告知CameraX作下镜像反转,作到所见即所得。
private fun takenPictureInternal(isExternal: Boolean) {
...
// Mirror image
ImageCapture.Metadata().apply {
isReversedHorizontal = true
}
mImageCapture?.takePicture(outputFileOptions, lightExecutor, MyCaptureCallback(picCount, this))
}
复制代码
不少设备的先后并不止一个镜头,好比疫情期间很是流行的安全码和体温一体化检测设备。因此有时候镜头切换不能是简单地先后切换,而须要按镜头的ID指定切换。
private fun bindPreview(...) {
// Select specified camera.
val cameraSelector = CameraSelector.Builder().addCameraFilter(AllCameraFilter()).build()
...
}
class AllCameraFilter: CameraFilter {
override fun filter(cameraInfos: MutableList<CameraInfo>): MutableList<CameraInfo> {
val result: MutableList<CameraInfo> = mutableListOf()
for (cameraInfo in cameraInfos) {
val id = (cameraInfo as CameraInfoInternal).cameraId
// Specify the camera id that U need, such as front camera which id is 0.
if (CameraSelector.LENS_FACING_FRONT.equals(id)) {
result.add(cameraInfo)
}
}
return result
}
}
复制代码
实际上CameraX最新版提供了新API(CameraInfo#getCameraSelector()),可返回某镜头对应的选择器实例。
整理一下CameraX使用的主要API,供你们快速查阅。
管理相机实例的接口或实现 | 做用 |
---|---|
CameraController | 获取和管理相机实例的接口 |
LifecycleCameraController | 经过LifecycleOwner实现生命周期管理Camera实例的接口 |
ProcessCameraProvider | LifecycleOwner的实现类,用以单例模式管理Camera实例 |
访问镜头功能和属性的API | 做用 |
---|---|
Camera | 提供镜头操做的主要接口 |
CameraControl | 用以执行镜头缩放、聚焦等操做的接口,经过Camera接口获取实例 |
CameraInfo | 用以获取镜头参数的IF,好比缩放比率、是否有闪光灯等,其实例一样由Camera接口提供 |
CameraConfig | 用以获取Camera使用配置信息的接口,也经过Camera接口获取实例 |
CameraSelector | 过滤并匹配对应镜头的类,在CameraController执行的时候传入实例以初始化对应的镜头 |
场景UseCase类的实现类 | 做用 |
---|---|
Preview | 预览场景 |
ImageAnalysis | 图像分析 |
ImageCapture | 图像拍摄 |
VideoCapture | 视频录制 |
相机效果的扩展类 | 做用 |
---|---|
PreviewExtender | 展现预览扩展效果,实现类有美颜的BeautyPreviewExtender、夜拍的NightPreviewExtender等 |
ImageCaptureExtender | 展现拍摄扩展效果,一样有美颜等效果的实现类 |
以及ScanKit的部分API:
API | 做用 |
---|---|
ScanUtil | Bitmap扫描码模式、压缩Bitmap等功能支持的工具类 |
HmsScanAnalyzerOptions | 指定扫码格式等参数类 |
HmsScan | 扫码结果封装类,包括内容、码体坐标、四角位置等信息 |
华为ScanKit
的集成仍是很是简单流畅的,在扫码技术选型的时候能够大胆尝试一下。对于识别率或速度担忧的朋友能够下载ScanKit
和Zxing
的官方Apk进行体验和对比。
Scankit官方Sample下载地址:
developer.huawei.com/consumer/en…
Zxing官方Sample下载地址:
但愿针对CameraX的扫码集成和实用的改进,对你们有所帮助。