场景:接触Camera是今年3月份的时候,当时公司一个项目的后台审核业务员反馈:部分用户上传的证件照片比较模糊,看不太清楚。由于照片来源是APP端边框识别所得的照片,在经过测试复现后,我接手了该问题。
复现:与测试负责人一起经过初步真机排查,发现该问题只在部分Android老机型中比较明显,如华为P9、OPPO R9等,这也容易理解,毕竟这5年前的手机像素确实不太行。(注:项目中的边框识别是2-3年前,公司开发人员使用github上一个名为card-io-lib开源项目的aar包)。
排查经过:
①:首先是对项目中调用“边框识别”返回照片处理的代码进行分析,发现组内开发者在使用Bitmap.compress方法处理返回的照片流数组时,对第二个quality参数设置了70(注:100为不压缩);于是将其改为了100进行尝试,结果却是我草率了,几乎没有起作用。。。。。
②:按第一步排查的情况,可以初步断定是该边框识别库内部逻辑造成的;由于项目中使用的是aar包,而没有项目源码;于是从github中找到了该库的项目源码,费了好大劲才将这年代久远的项目跑起来。也是在这之中,开始了解并学习了Android Camera相关知识点。
③:经过层层定位,其项目中进行边框识别页面名为CardIOActivity,而负责具体识别逻辑由CardScanner自定义View完成,该组件继承相机预览回调、相机自动聚焦回调、SurfaceHolder回调三个接口,具体的回调方法在后面解析。
④:主要的图像数据处理在Camera的onPreviewFrame回调中,大致原理以及步骤如下:
1. Camera的onPreviewFrame回调一帧一帧持续输出图像流;
2. 将图像流数据,由native层方法进行边框识别,主要通过opencv实现;
3. 跳帧处理,如果上一帧还没解析完,会将当前回调所得的帧直接丢弃;
4. 识别成功后,直接输出该帧的图片流数据。
⑤:猜测与验证:在排查过程中,使用华为P9相机拍照与识别所得的照片进行对比,发现相机拍照所得的照片明明十分清楚,而识别的咋就不行呢?这里猜想:应该是持续输出帧方法中的图像数据量比较小,又或是原生相机有算法优化加持等等。
于是对基于华为P9,打印持续输出帧字节流数组长度,一帧图像约为92w(这个输出数据长度与开启预览前,Camera.addCallbackBuffer()方法所设置的缓冲区有关,因而输出的数据量都一样,相关方法在后面结合该开源项目进行分析);为了验证拍照的数据量有多大,使用Camera的takePicture方法进行测试,发现拍摄一张图片的获得的图像数据长度约为135w,好家伙,这差距一目了然。。。。
⑥:在看到对比结果后,我想到了一种粗暴而不失优雅的解决方案:在边框识别回调成功后,直接调用takePicture方法拍摄一张图片作为输出图。
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
if (data == null) {
return;
}
if (processingInProgress) {
// return frame buffer to pool
numFramesSkipped++;
if (camera != null) {
camera.addCallbackBuffer(data);
}
return;
}
processingInProgress = true;
// TODO: eliminate this foolishness and measure/layout properly.
if (mFirstPreviewFrame) {
mFirstPreviewFrame = false;
mFrameOrientation = ORIENTATION_PORTRAIT;
mScanActivityRef.get().onFirstFrame();
}
//检测信息类,在native会对该类进行赋值,供后续判断
DetectionInfo dInfo = new DetectionInfo();
/** pika **/
nScanFrame(data, mPreviewWidth, mPreviewHeight, mFrameOrientation, dInfo, detectedBitmap, mScanExpiry);
boolean sufficientFocus = (dInfo.focusScore >= MIN_FOCUS_SCORE);
Log.i("FlashTest", "onPreviewFrame:图像数组长度---"+data.length);
if (!sufficientFocus) {
triggerAutoFocus(false);
} else if (dInfo.detected()) {
//识别成功后,调用相机拍照,提高照片清晰度
mCamera.takePicture(null, null, new Camera.PictureCallback() {
@Override
public void onPictureTaken(byte[] bytes, Camera it) {
Log.i("FlashTest", "onPictureTaken:拍照长度---"+bytes.length);
mScanActivityRef.get().onCardTakePicture(bytes);
}
});
// mScanActivityRef.get().onCardDetected(detectedBitmap, dInfo);
}
// give the image buffer back to the camera, AFTER we're done reading
// the image.
if (camera != null) {
camera.addCallbackBuffer(data);
}
processingInProgress = false;
}
⑤:结果对比展示,效果拔群:
⑥:优化:
图片优化:由于该图片上传至后端时,后端要用第三方API进行OCR识别,而OCR识别有5MB大小限制,对所编写的onCardTakePicture方法添加压缩逻辑,主要通过Bitmap实现压缩,阈值设置为21w(由OPPO R9这款老坦克手机的拍照数据流长度 + 2w冗余)。
/**
* 扫描后拍照,提高照片清晰度
* @param bytes
*/
void onCardTakePicture(byte[] bytes){
try {
Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
vibrator.vibrate(VIBRATE_PATTERN, -1);
} catch (SecurityException e) {
Log.e(Util.PUBLIC_LOG_TAG,
"Could not activate vibration feedback. Please add <uses-permission android:name=\"android.permission.VIBRATE\" /> to your application's manifest.");
} catch (Exception e) {
Log.w(Util.PUBLIC_LOG_TAG, "Exception while attempting to vibrate: ", e);
}
mCardScanner.pauseScanning();
mUIBar.setVisibility(View.INVISIBLE);
//原始数据长度
int picSteamLength = bytes.length;
Log.i("Flash=======", "原始图片流长度picSteamLength: "+picSteamLength);
//使用bitmap对流进行压缩 与 旋转
BitmapFactory.Options ontain = new BitmapFactory.Options();
if (picSteamLength > PIC_COMPRESS_THRESHOLD) {
//达到压缩阈值
ontain.inSampleSize = 2;//等比缩放权值
//颜色从默认的RGBA变为RGB
ontain.inPreferredConfig = Bitmap.Config.RGB_565;
}
//旋转90度
Bitmap mBitmap = Util.rotateBitmap(
BitmapFactory.decodeByteArray(bytes, 0, bytes.length, ontain),
90);
//所得照片回调
Intent dataIntent = new Intent();
if (mBitmap != null) {
ByteArrayOutputStream picBytes = new ByteArrayOutputStream();
mBitmap.compress(
picSteamLength > PIC_COMPRESS_THRESHOLD ? Bitmap.CompressFormat.JPEG: Bitmap.CompressFormat.PNG,
picSteamLength > PIC_COMPRESS_THRESHOLD ? 70 : 100,
picBytes);
Log.i("Flash=======", "是否到压缩阈值21w: "+(picSteamLength > PIC_COMPRESS_THRESHOLD));
Log.i("Flash=======", "输出的图片流长度: "+picBytes.toByteArray().length);
dataIntent.putExtra(CardIOActivity.EXTRA_CAPTURED_CARD_IMAGE, picBytes.toByteArray());
}
setResultAndFinish(RESULT_SCAN_SUPPRESSED, dataIntent);
}
包体积优化:由于目前大部分手机的CPU架构基本为armeabi-v7a或arm64-v8a,x86与x86_64很少见到,因而修改项目中的Android.mk文件,在打aar包时剔除对x86、x86_64两个架构的so库的编译。
⑦:以上就是我对线上这个问题的排查与解决全过程,排查、解决、优化,前后大约花了一周时间。4月份上线后到目前为止,再未反馈模糊问题。整体写的可能有点繁琐,但感觉值得记录一下。下面开始对Camera的相关方法进行总结分析。
===================================分割线================================
Camera相关接口、方法分析:
①:Camera.PreviewCallback接口:实现方法:onPreviewFrame,该方法能持续返回Camera帧数据,主要用来配合SurfaceView来实现相机实时预览;
/** @deprecated */
@Deprecated
public interface PreviewCallback {
void onPreviewFrame(byte[] var1, Camera var2);
}
②:Camera.AutoFocusCallBack:实现方法:onAutoFocus,返回聚焦结果值;
/** @deprecated */
@Deprecated
public interface AutoFocusCallback {
void onAutoFocus(boolean var1, Camera var2);
}
③:SurfaceHolder.CallBack:三个实现方法分别用于监听SurfaceView的状态,具体作用看方法名就能明白了。在Camera结合SurfaceView做相机预览功能时,由于Camera的onPreviewFrame不会自动触发,需要手动调用Camera.startPreview()方法,因而通过该方法实现。SurfaceHolder相当于是连接Camera与SurfaceView的桥梁。
public interface Callback {
void surfaceCreated(SurfaceHolder var1);
void surfaceChanged(SurfaceHolder var1, int var2, int var3, int var4);
void surfaceDestroyed(SurfaceHolder var1);
}
④:Camera.addCallbackBuffer设置缓冲区,如:通过计算每一帧需要的大小,开辟一块区域用于放置帧数据,
Camera.Parameters parameters = mCamera.getParameters();
//华为p9,通过打印为17,查表ImageFormat对象可得对应相机预览格式为NV21
int previewFormat = parameters.getPreviewFormat();
// Log.i("FlashTest", "previewFormat: " + previewFormat);
// ImageFormat.getBitsPerPixel, 获取该图片格式下,每个像素点需要的bit数,通过网上资料,NV21 1像素为12位(bit)
//获取当前相机的预览图像格式,计算每个像素点所需要的字节数,
int bytesPerPixel = ImageFormat.getBitsPerPixel(previewFormat) / 8;
//缓冲区大小,预览宽度 * 高度 * 每像素所需字节数 * 3, 这个3应该是一个冗余值吧
int bufferSize = mPreviewWidth * mPreviewHeight * bytesPerPixel * 3;
mPreviewBuffer = new byte[bufferSize];
mCamera.addCallbackBuffer(mPreviewBuffer);
⑤:Camera.setParameters,该方法用于配置相机参数,如果不设置,就默认配置,下面代码是设置了预览图的尺寸大小。
Camera.Parameters parameters = mCamera.getParameters();
//获取Camera所支持的预览尺寸,这里如果支持640*480就设置为该尺寸,否则就用列表第0个
List<Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes();
if (supportedPreviewSizes != null) {
Size previewSize = null;
for (Size s : supportedPreviewSizes) {
if (s.width == 640 && s.height == 480) {
previewSize = s;
break;
}
}
if (previewSize == null) {
previewSize = supportedPreviewSizes.get(0);
previewSize.width = mPreviewWidth;
previewSize.height = mPreviewHeight;
}
}
parameters.setPreviewSize(mPreviewWidth, mPreviewHeight);
mCamera.setParameters(parameters);
⑥:Camera.setPreviewCallback(this)和setPreviewCallbackWithBuffer(this),在预览开启前设置。前者没一预览帧都会在内存中开启新的缓冲区,效率低;后者,在预览回调onPreviewFrame方法中,配合Camera.addCallbackBuffer()方法能对预览前开辟的缓冲区进行复用,每一帧输出都会在当前缓冲区内,以达到节约内存的目的。