[Flutter] 用相機畫面一小部分做辨識

Claire Liu
Flutter Taipei
Published in
10 min readFeb 20, 2020

--

網路圖片,來自電腦玩物

這篇文章源自於我在工作上第一次選用 Flutter 做開發,中間過程遇到問題在社群發問,最後實做出來的紀錄 🙂

當初看到介面後,就在想要怎麼做,也順帶PO到社群中和大家討論

年初收到工作上的任務,要幫彩妝原料商做一個小專案,內容大致上是一個可以掃描原料罐上的文字,並顯示出原料細節的 App,讓他們可以在二月中到中國參展時使用。

所有介面都在開新專案後兩天內完成了,用 Flutter 做介面真的很快很方便。但要怎麼截出相機的一部分卻一直很困擾我,決定將所有可以使用的資源整理出來,以盡量不要自己做原生開發為原則找到解法。

如果將問題畫成圖來看

Camera

相機的部分無疑地選用 Flutter 官方的 camera。camera 有兩種方式可以取得畫面,takePicturestartImageStream,前者就是一般的拍照,將呼叫 function 當下的畫面直接打包成 file 存在自定義的路徑中,後者則是可以存取到最新的畫面,程式會不斷地回傳 CameraImage

嘗試上述兩種方式,最後我選擇 startImageStream。

takePicture

拍照應該是很快速的做法,只要設時間去呼叫函式,不用再額外困擾 CameraImage 怎麼處理,可以直接進到裁切的部分。但實作上卻遇到一個小問題:拍照的聲音,在 iPhone 6s 上聽到快速連拍的聲音頗尷尬。

startImageStream

串流應該是最適合的辦法,透過 cameraController 呼叫函式時,傳入一個 function 來接 CameraImage,接著就可以在 function 裡面做任何處理。只要呼叫一次,就可以一直獲得最新的影像,想要停止時就呼叫 stopImageStream。

typedef onLatestImageAvailable = Function(CameraImage image);
Future<void> startImageStream(onLatestImageAvailable) async {...}

Image Stream 的運作是每當新的影像準備好後,便會一直傳進來。因此,為了避免雪片般的 TO-DO-List,也避免 ML Vision 一直 call function 超出免費的額度,用一個布林值來判斷是否要接收這次的影像。如果沒有 lock 的設計,在 iOS 上程式會執行幾次後就發生 crash。當初真的卡在這裡很久呢!

bool locked = false;
void todoInStreaming(CameraImage image) async {
if (locked) return;
locked = true;
// do something by using await-async locked = false;
}

Camera Issue

補充這次用 camera package 遇到 resolution 的問題。

相機在初始化時需要指定解析度,在 iPhone 6s、s8 上,將 resolution 調到 medium 是最剛好的,高於 medium 就會出現 lag 的問題;然而,在 Pixel 3a 上,medium 卻無法讀到正常的圖片,印出畫面會如照片中,無法辨識任何文字,除非將解析度指定到 high。

CameraController(
cameraDescription, ResolutionPreset.medium,
enableAudio: false);

這還是個無解的問題,不知道是因為硬體還是作業系統版本的問題?目前將 Resolution 調到 medium,可以適應大多數的手機。

裁切圖片

之前參加 Women Who Code Taipei 的 Flutter 工作坊,就有聽到講師分享在專案上使用強大的 image,我把他的文章補充在下方。查看文件可以找到很多針對圖片的處裡,其中 copyCrop 就很符合我所要的功能;前面透過相機只得到 CameraImage,只要想辦法將該影像轉成 Image,就可以使用了!

copyCrop(Image src, int x, int y, int w, int h)

CameraImage 轉換

相機影像的規格在 Android 和 iOS 上是不一樣的,前者是 YUV_420_888 後者是 BGRA8888,這是從原生相機回傳出來的結果,附上轉換的範例,這裡不贅述這兩種格式的細節。注意範例中 _convertYUV420 輸出的影像是黑白的,並且由 Android 手機轉換出來的圖片都是倒著的,透過 copyRotate 可以將其轉正。

image_converter.dart by Alby-o

因為圖片是要拿來做文字辨識,所以我們可以接受黑色的輸出,省下在顏色處理的資源,如果要輸出帶 RGB 的影像可以參考這裡

// steps for processing image
try {
imglib.Image img;

// 1. convert CameraImage to Image
if (image.format.group == ImageFormatGroup.yuv420) {
img = _convertYUV420(image);
} else if (image.format.group == ImageFormatGroup.bgra8888) {
img = _convertBGRA8888(image);
}

// 2. crop image
img = imglib.copyCrop(img, px, py, w, h);

// 3. save file
final io.Directory extDir = await getApplicationDocumentsDirectory();
final String filePath = '${extDir.path}/pic.jpg';
io.File file = new io.File(filePath);
file.writeAsBytesSync(imglib.encodeJpg(img));

return filePath;
} catch (e) {
print("ERROR:" + e.toString());
}

OCR

以過去在 Android 上做文字辨識的經驗,免費開源的 tesseract 或 Google 自己的 firebase_ml_vision 都是不錯的選項。選擇後者是因為 library 的整合較為完整,雖然辨識英文外的語言需要付費走雲端服務才能使用,但辨識保存的結構很乾淨,保有段落、句子、字詞;而前者輸出單純的 String,實作上如果遇到使用者掃描到非產品名的部分,不好與後台核對。

ML Vision 提供了三種方式辨識文字:fromBytes、fromFile、fromFilePath。Bytes 應該是專為 CameraImage 所設計的,從下方的 sample code 就可以看到,只要將它包裝到 Metadata 就可以做即時辨識。由於影像需要另外做處理,我使用 fromFilePath,將裁切完的部分用 path_provider 存在手機中,再將圖片路徑丟進來,完成辨識的任務!

最終作法

經過上述的問題解析和實作驗證,最終在 streaming 的參數函式中做了下面幾件事,每個步驟用 Future 打包,可以讓邏輯看起來很清晰,若有任何一步出現錯誤 ( Error ),就釋放 locked 接收下一個影像進來做處理;如果找到特定的產品名稱,就停止所有資源,導到細節頁面。

// do something by using await-async
await ImageConverter.convertImagetoPng(image) // get image
.then(OcrRecognizer.recognizeByFilePath) // google vision
.then(queryProduct) // query text
.then((product) { // handle product
if (product != null) {
stopCameraResources();
// navigate to DetailPage
}
}).catchError((error) {
print('Error: ${error.toString()}');
});

其他方式

其他開發者提供了兩個想法也很不錯,簡單來說就是不裁切圖片,而是換個角度思考,在掃描框之外的像素疊上一種顏色,也是一樣的效果。雖然,最後考量到兩點而沒有這麼做,第一是保持原本介面的設計,第二是辨識的圖片尺寸,但我想蓋上遮罩這樣的步驟,在 image package 中應該也有對應的方法可以解。

如果各位讀者有其他的想法,也歡迎在底下留言分享哦!

在 Flutter Taiwan 的提問,得到一位社群網友的回覆

後記

Follow Flutter 這麼久,第一次拿來做專案,還是用在公司的案子上,深刻感覺真的要動手實作才能真正體會學到東西。一開始遇到這些問題都很害怕自己的方向錯誤而不敢繼續實作下去,卡在同樣的地方很長時間,寫久了開始了解這些 Future、library 怎麼查怎麼用。還有一個多國語言的 feature,之後完成有機會再跟各位分享!

--

--