基于zbar的,簡單、易用、高性能的掃碼器
1 需求
公司的多款工具類app都用到了相機掃碼功能。近一年來,由于業務的快速發展,業務方對掃碼子產品的性能也有了更高要求,主要是3個方面:
- 由于使用中經常會遇到商品條碼密集排列的情況,是以要求掃碼識别區域要非常精确。舉個例子,比如掃碼界面中展示給使用者的掃碼框是一個200*100的矩形,那麼真正被識别的圖像資料就隻能是這個矩形框中的内容。
- 針對多個條碼連續掃描識别的情形,要求每個條碼的識别時間盡可能地短,這樣使用起來效率更高,使用者體驗更好。
- 掃碼時相機的對焦區域(focus area)應該跟随視圖中掃碼框的位置和大小而變化,而不是始終位于預覽畫面中央(相機的預設設定)。
項目代碼:SimpleScanner
2 優化效果
為了友善檢視掃碼識别速度,我們在相機預覽的回調方法onPreviewFrame中添加了如下代碼來列印耗時:
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
long startTime = System.currentTimeMillis();
...
//data資料處理及識别
...
Log.e("logg", String.format("圖像處理及識别耗時: %d ms", System.currentTimeMillis() - startTime));
}
下面分别就掃描識别條形碼、二維碼,對本項目(SimpleScanner)和目前較為流行的掃碼庫barcodescanner (https://github.com/dm77/barcodescanner) 進行速度對比(測試平台為小米4,cpu為高通801):
條形碼
barcodescanner
E/logg: 圖像處理及識别耗時: 469 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 439 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 434 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 446 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 455 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 439 ms
E/logg: Contents = 1234567890123, Format = CODE128
SimpleScanner
E/logg: 圖像處理及識别耗時: 22 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 20 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 22 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 23 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 23 ms
E/logg: Contents = 1234567890123, Format = CODE128
E/logg: 圖像處理及識别耗時: 22 ms
E/logg: Contents = 1234567890123, Format = CODE128
二維碼
barcodescanner
E/logg: 圖像處理及識别耗時: 448 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 469 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 453 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 473 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 511 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 461 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
SimpleScanner
E/logg: 圖像處理及識别耗時: 58 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 61 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 56 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 62 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 58 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
E/logg: 圖像處理及識别耗時: 62 ms
E/logg: Contents = 草料二維碼, Format = QRCODE
可見SimpleScanner相比barcodescanner基本能有90%左右的性能提升。雖然400ms并不起眼,但在實際使用中,一個是對焦即識别,另一個是對焦後要稍微停頓一下才能識别,對于連續多次掃碼的使用場景,SimpleScanner的使用體驗要明顯更好。
3 項目結構簡介
項目中比較重要的類主要有5個:
ZBarScannerView
zbar掃碼視圖,可以直接在Activity中使用。
主要做了一件事:對相機預覽的回調方法onPreviewFrame中的data(預覽圖像資料)進行一些預處理,并将處理後的資料交給zbar解碼庫去識别。
BarcodeScannerView
基本掃碼視圖,是ZBarScannerView的父類。
主要包含了對相機預覽的相關操作。同時,BarcodeScannerView也是一個視圖容器(本質上是一個FrameLayout),包含了CameraPreview(相機預覽畫面)和ViewFinderView(覆寫在相機預覽畫面上層的掃碼框、陰影遮罩等)。
CameraPreview
這是一個SurfaceView,用于展示相機預覽畫面。
ViewFinderView
覆寫在相機預覽上層的view,包含掃碼框、掃描線、掃碼框周圍的陰影遮罩等。
可以直接修改以下字段來改變ViewFinderView的外觀:
private float widthRatio = 0.8f;//掃碼框寬度占view總寬度的比例
private float heightWidthRatio = 0.5f;//掃碼框的高寬比
private int leftOffset = -1;//掃碼框相對于左邊的偏移量,若為負值,則掃碼框會水準居中
private int topOffset = 200;//掃碼框相對于頂部的偏移量,若為負值,則掃碼框會豎直居中
private boolean isLaserEnabled = true;//是否顯示掃描線
ViewFinderView本身的代碼非常簡單,并且與相機預覽、掃碼識别的邏輯幾乎沒有任何關聯,是以修改起來十分容易。如果需要,可以自由地修改這個類來定制出自己需要的掃碼界面。
ViewFinderView和CameraPreview的寬高都是match_parent,也就是說ViewFinderView和CameraPreview是等大的。
SimpleScannerActivity
ZBarScannerView的使用方法示例。
4 主要優化點簡介
這裡隻對主要的3個優化點做個簡要介紹,具體細節請檢視項目源碼。
(1)精确截取掃碼識别區域
通常做法:
目前市面上大多數app對于掃碼識别區域的處理方式都是:将整個預覽圖像交給核心解碼庫(即zbar或zxing)進行識别。也就是說(如上圖所示),雖然在螢幕偏上方有一個掃碼框,使用者也會自然而然地将要掃的條碼放到掃碼框區域之内,但實際上,在app内部,傳遞給核心解碼庫進行識别的,是整個預覽畫面。
這種處理方式的好處是代碼邏輯非常簡單,而缺點有兩個:
- 首先是上面提到過的那種場景,當有多個排列較為緊密的條碼,而你隻想識别其中的某一個時,操作起來會比較困難。
- 第二,因為傳遞給核心解碼庫的是整個預覽畫面,資料量較大,是以識别速度也會較慢。
優化:
public Rect getScaledRect(int previewWidth, int previewHeight) {
if (scaledRect == null) {
Rect framingRect = mViewFinderView.getFramingRect();//獲得掃碼框區域
int viewFinderViewWidth = mViewFinderView.getWidth();
int viewFinderViewHeight = mViewFinderView.getHeight();
int width, height;
if (DisplayUtils.getScreenOrientation(getContext()) == Configuration.ORIENTATION_PORTRAIT//豎屏使用
&& previewHeight < previewWidth) {
width = previewHeight;
height = previewWidth;
} else if (DisplayUtils.getScreenOrientation(getContext()) == Configuration.ORIENTATION_LANDSCAPE//橫屏使用
&& previewHeight > previewWidth) {
width = previewHeight;
height = previewWidth;
} else {
width = previewWidth;
height = previewHeight;
}
scaledRect = new Rect(framingRect);
scaledRect.left = scaledRect.left * width / viewFinderViewWidth;
scaledRect.right = scaledRect.right * width / viewFinderViewWidth;
scaledRect.top = scaledRect.top * height / viewFinderViewHeight;
scaledRect.bottom = scaledRect.bottom * height / viewFinderViewHeight;
}
return scaledRect;
}
要明白這個方法的含義,首先需要知道的是(如上圖所示):雖然相機預覽畫面(CameraPreview)和上層的ViewFinderView大小一樣,但實際上,系統傳遞給我們的預覽圖像資料(也就是onPreviewFrame方法的data參數),其尺寸與ViewFinderView的尺寸通常是不同的。是以,為了在預覽圖像資料中截取正确的區域,我們必須根據預覽圖像資料和ViewFinderView的尺寸之比對掃碼框區域進行縮放。舉個簡單的例子:假設ViewFinderView的尺寸是
1280*720
,掃碼框的尺寸是
100*200
,而預覽圖像資料的尺寸是
2560*1440
,那麼,要想實際截取的區域與我們所看到的區域一緻,我們就必須用一個
200*400
的矩形框去預覽圖像資料中截取。也就是說,如果預覽圖像資料的尺寸是ViewFinderView的尺寸的兩倍,那麼我們用來截取預覽圖像資料的矩形框的尺寸也應該是ViewFinderView中掃碼框尺寸的兩倍。
上面
getScaledRect(int previewWidth, int previewHeight)
方法所做的事情就是根據ViewFinderView和預覽圖像資料的尺寸之比,對掃碼框的矩形區域進行縮放。
- 方法的參數previewWidth和previewHeight就是預覽圖像資料的寬和高。
-
獲得的是掃碼框在ViewFinderView中的矩形區域。mViewFinderView.getFramingRect()
(2)提高識别速度
事實上,要使實際截取的區域與我們所看到的區域一緻,僅僅像上面那樣縮放掃碼框是不夠的。這是因為,系統傳遞給我們的預覽圖像資料不僅尺寸可能會與ViewFinderView不同,其方向通常也與視窗的方向不同。比如,我們看到的預覽圖像是下面這樣的(圖中的黃色框是我畫的掃碼框示意,你可以把薄荷糖盒子想象成要掃的條碼):
而實際上,系統傳遞給我們的預覽圖像資料很可能是這樣的:
顯然,在這種情況下,如果你直接拿縮放後的矩形框去預覽圖像資料中截取,那就完全截錯了地方。那該怎麼辦呢?有兩種解決辦法可供選擇:
- 将圖像資料順時針旋轉90度
- 将矩形框逆時針旋轉90度
目前我所知的掃碼庫采用的都是第一種辦法,即将圖像資料順時針旋轉90度,代碼如下:
public byte[] rotateData(byte[] data, Camera camera) {
Camera.Parameters parameters = camera.getParameters();
int width = parameters.getPreviewSize().width;
int height = parameters.getPreviewSize().height;
int rotationCount = getRotationCount();
for (int i = 0; i < rotationCount; i++) {
byte[] rotatedData = new byte[data.length];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++)
rotatedData[x * height + height - y - 1] = data[x + y * width];
}
data = rotatedData;
int tmp = width;
width = height;
height = tmp;
}
return data;
}
簡單的說,就是根據位置關系對圖像資料中的每一個點進行映射指派。對于尺寸為
1920*1080
的圖像預覽資料,
rotatedData[x * height + height - y - 1] = data[x + y * width]
這樣的運算要重複200多萬次,這顯然是一個十分耗時的過程。
而另一種選擇就是将矩形框逆時針旋轉90度,其代碼如下:
public Rect getRotatedRect(int previewWidth, int previewHeight, Rect rect) {
if (rotatedRect == null) {
int rotationCount = getRotationCount();
rotatedRect = new Rect(rect);
if (rotationCount == 1) {//若相機圖像需要順時針旋轉90度,則将掃碼框逆時針旋轉90度
rotatedRect.left = rect.top;
rotatedRect.top = previewHeight - rect.right;
rotatedRect.right = rect.bottom;
rotatedRect.bottom = previewHeight - rect.left;
} else if (rotationCount == 2) {//若相機圖像需要順時針旋轉180度,則将掃碼框逆時針旋轉180度
rotatedRect.left = previewWidth - rect.right;
rotatedRect.top = previewHeight - rect.bottom;
rotatedRect.right = previewWidth - rect.left;
rotatedRect.bottom = previewHeight - rect.top;
} else if (rotationCount == 3) {//若相機圖像需要順時針旋轉270度,則将掃碼框逆時針旋轉270度
rotatedRect.left = previewWidth - rect.bottom;
rotatedRect.top = rect.left;
rotatedRect.right = previewWidth - rect.top;
rotatedRect.bottom = rect.right;
}
}
return rotatedRect;
}
旋轉矩形框的原理很簡單,假設矩形框的left、top、right、bottom分别為a、b、c、d,如下圖所示:
以下面這種方向的預覽圖像資料為例:
我們需要得到下面這樣的一個矩形框,也就是将矩形框逆時針旋轉了90度:
由圖可知旋轉前後矩形框的對應關系為:
– | left | top | right | bottom |
---|---|---|---|---|
旋轉前 | a | b | c | d |
旋轉後 | b | height-c | d | height-a |
有了這張表格那麼下面這段代碼就很容易了解了:
if (rotationCount == 1) {//若相機圖像需要順時針旋轉90度,則将掃碼框逆時針旋轉90度
rotatedRect.left = rect.top;
rotatedRect.top = previewHeight - rect.right;
rotatedRect.right = rect.bottom;
rotatedRect.bottom = previewHeight - rect.left;
}
需要注意的是:第二種處理方法(旋轉矩形框,而不旋轉預覽圖像資料)隻适用核心解碼庫為zbar的情形。如果核心解碼庫為zxing,由于zxing不能識别豎直排列的條形碼,是以隻能采用旋轉預覽圖像資料這種方式。
(3)根據掃碼框的位置和大小調整相機的對焦區域
在實際應用中,掃碼頁中除了掃碼框之外,可能還需要放入很多其他内容(如文字、清單、按鈕等),是以有時候掃碼框并不位于界面中央,比如在我們的一個app中,掃碼框就是位于畫面中十分偏上的位置。另一方面,android相機預設是以預覽畫面的中央為目标來調整焦距的,這就會造成一個問題,如下圖所示:
近處是一個香煙盒的側面,上面有一個條形碼;遠處是雨傘和地面。我們将香煙盒放在掃碼框中是想要掃取香煙盒上的條形碼,然而因為地面和雨傘位于畫面中央,是以相機便将焦點放在了地面和雨傘上,結果就是近處的香煙盒因為失焦而模糊,上面的條形碼自然也就無法被正常地識别。
為了解決這一問題,我們需要将掃碼框所在的區域作為相機的對焦區域。Camera.Parameters提供了
setFocusAreas (List<Camera.Area> focusAreas)
方法來實作這一功能。
在定義對焦區域的時候需要注意,這裡坐标系的(0,0)位于預覽畫面的中央,預覽畫面左上角的坐标為(-1000,-1000),預覽畫面右下角的坐标為(1000,1000),如下圖所示:
基于與上面優化點(1)、(2)中一樣的原理,這裡我們也需要通過對掃碼框區域做縮放和旋轉操作來得到對應的對焦區域,但是這樣還不夠,因為對焦區域坐标系的坐标原點在畫面中央,左上角為(-1000,-1000),我們還需要對縮放、旋轉後的區域進行平移。
設定對焦區域的主要代碼如下:
/**
* 設定對焦區域
*/
private void setupFocusAreas() {
/*
* 1.根據ViewFinderView和2000*2000的尺寸之比,縮放對焦區域
*/
Rect framingRect = viewFinderView.getFramingRect();//獲得掃碼框區域
int viewFinderViewWidth = viewFinderView.getWidth();
int viewFinderViewHeight = viewFinderView.getHeight();
int width = 2000, height = 2000;
Rect scaledRect = new Rect(framingRect);
scaledRect.left = scaledRect.left * width / viewFinderViewWidth;
scaledRect.right = scaledRect.right * width / viewFinderViewWidth;
scaledRect.top = scaledRect.top * height / viewFinderViewHeight;
scaledRect.bottom = scaledRect.bottom * height / viewFinderViewHeight;
/*
* 2.旋轉對焦區域
*/
Rect rotatedRect = new Rect(scaledRect);
int rotationCount = getRotationCount();
if (rotationCount == 1) {//若相機圖像需要順時針旋轉90度,則将掃碼框逆時針旋轉90度
rotatedRect.left = scaledRect.top;
rotatedRect.top = 2000 - scaledRect.right;
rotatedRect.right = scaledRect.bottom;
rotatedRect.bottom = 2000 - scaledRect.left;
} else if (rotationCount == 2) {//若相機圖像需要順時針旋轉180度,則将掃碼框逆時針旋轉180度
rotatedRect.left = 2000 - scaledRect.right;
rotatedRect.top = 2000 - scaledRect.bottom;
rotatedRect.right = 2000 - scaledRect.left;
rotatedRect.bottom = 2000 - scaledRect.top;
} else if (rotationCount == 3) {//若相機圖像需要順時針旋轉270度,則将掃碼框逆時針旋轉270度
rotatedRect.left = 2000 - scaledRect.bottom;
rotatedRect.top = scaledRect.left;
rotatedRect.right = 2000 - scaledRect.top;
rotatedRect.bottom = scaledRect.right;
}
/*
* 3.坐标系平移
*/
Rect rect = new Rect(rotatedRect.left - 1000, rotatedRect.top - 1000, rotatedRect.right - 1000, rotatedRect.bottom - 1000);
/*
* 4.設定對焦區域
*/
Camera.Parameters parameters = cameraWrapper.camera.getParameters();
if (parameters.getMaxNumFocusAreas() > 0) {
Camera.Area area = new Camera.Area(rect, 1000);
ArrayList<Camera.Area> areaList = new ArrayList<>();
areaList.add(area);
parameters.setFocusAreas(areaList);
cameraWrapper.camera.setParameters(parameters);
} else {
Log.e(TAG, "不支援設定對焦區域");
}
Log.e(TAG, "對焦區域:" + rect.toShortString());
}
這當中涉及的細節比較多,限于篇幅原因就不再細說了,如果希望更一進步了解的話可以參考Camera的官方文檔中關于對焦區域的部分(這裡有我之前翻譯的一個中文版)以及
Camera.Parameters.getFocusAreas()
方法的官方文檔。
經過以上調整,我們便可以得到一個正确的對焦效果了,如下圖所示:
當掃碼框的位置和大小變化時,對焦區域也會跟着變化。
5 最後
相機掃碼涉及的知識點比較多,限于篇幅無法在此一一講清。建議有興趣深入的朋友先對Camera類、相機預覽機制、各種方向的概念(視窗方向、裝置自然方向、相機方向)等做一個大概了解,然後再去閱讀本項目(SimpleScanner)的源碼。
當然,即便沒有閱讀源碼,你也可以很容易地将本項目移植到你自己的app中使用——隻需要修改ViewFinderView類來定制你需要的掃碼界面即可。具體使用方式參考SimpleScannerActivity類。
參考
- Camera官方文檔:https://developer.android.com/guide/topics/media/camera.html
- barcodescanner:https://github.com/dm77/barcodescanner