[Flutter] 用相機畫面一小部分做辨識
這篇文章源自於我在工作上第一次選用 Flutter 做開發,中間過程遇到問題在社群發問,最後實做出來的紀錄 🙂
年初收到工作上的任務,要幫彩妝原料商做一個小專案,內容大致上是一個可以掃描原料罐上的文字,並顯示出原料細節的 App,讓他們可以在二月中到中國參展時使用。
所有介面都在開新專案後兩天內完成了,用 Flutter 做介面真的很快很方便。但要怎麼截出相機的一部分卻一直很困擾我,決定將所有可以使用的資源整理出來,以盡量不要自己做原生開發為原則找到解法。
Camera
相機的部分無疑地選用 Flutter 官方的 camera。camera 有兩種方式可以取得畫面,takePicture 和 startImageStream,前者就是一般的拍照,將呼叫 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 可以將其轉正。
因為圖片是要拿來做文字辨識,所以我們可以接受黑色的輸出,省下在顏色處理的資源,如果要輸出帶 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 中應該也有對應的方法可以解。
如果各位讀者有其他的想法,也歡迎在底下留言分享哦!
後記
Follow Flutter 這麼久,第一次拿來做專案,還是用在公司的案子上,深刻感覺真的要動手實作才能真正體會學到東西。一開始遇到這些問題都很害怕自己的方向錯誤而不敢繼續實作下去,卡在同樣的地方很長時間,寫久了開始了解這些 Future、library 怎麼查怎麼用。還有一個多國語言的 feature,之後完成有機會再跟各位分享!