この記事は全部俺 Advent Calendar 2018の14日目の記事です。
やること
こんな感じの画像から文字おこしを行ったり、画像に写ってるものの認識を行うものをFlutter + Firebase MLKitで実現します。
自分で用意したカスタムモデルを使用せず、デフォルトの組み込みモデルを使用するだけでも、以下のことができます。
- 画像からの文字起こし
- 画像に写っているもののラベリング
- バーコードスキャン
- 顔画像取り出し
注意
mlkitを使用するために使うライブラリは、公式のfirebase_ml_visionではなく、flutter_mlkitにしています。
これは、公式が未だにカスタムモデルをサポートしていないためです。(すごく長くなるので、この記事ではカスタムモデルは扱いません。カスタムモデルについてはこちらで別途記事にしています!)
公式にカスタムモデルがサポートされた場合は、そちらを使用したほうが良いと思いますし、むしろカスタムモデルを使用しない前提なら、公式のfirebase_ml_visionを使用したほうが良いと思います。
flutter_mlkitの作者様も、「公式のfirebase_ml_visionを使用することを検討してください」と言っています。
ただし、現状公式リポジトリのfirebase_ml_visionの更新が長いこと止まっており、今後カスタムモデルを使用するつもりがあるのでここではflutter_mlkitを使用することにしました。
公式がカスタムモデルをサポートしたら、また記事を上げるつもりです。
おそらくFirebase MLKitがβじゃなくなってからサポートされるのでは?と思っています。
Flutterのプロジェクト作成
先に、Flutterのプロジェクトを作成しておきます。
名前は何でもいいのですが、今回はProject名をmlkit_sample
、Company Domainをmlkit
として、パッケージ名がmlkit.mlkitsample
という名前になるように設定しました。
以下、この名称を使用しますが、変更する場合は適宜読み替えてください。
firebaseコンソール上での準備
Android
firebaseコンソールにログインし、Project Overview
右のから「プロジェクトの設定」を選びます。
「Android アプリに Firebase を追加」を押し、Androidパッケージ名(ここではmlkit.mlkitsample
)を入れてgoogle-services.json
をダウンロードしてきます。
ダウンロードしてきたgoogle-services.json
は、Flutterプロジェクトのandroid/app/
以下に配置しておきます。
「次へ」を押して出てくるパッケージの依存関係を定義します。
buildscript {
dependencies {
// Add this line
classpath 'com.google.gms:google-services:4.0.1'
}
}
dependencies {
// Add this line
implementation 'com.google.firebase:firebase-core:16.0.1'
}
...
// Add to the bottom of the file
apply plugin: 'com.google.gms.google-services'
ここまで来たら、Android側の準備はOKです。
iOS
Androidのときと同様にProject Overview
右のから「プロジェクトの設定」を選びます。
「アプリを追加」を押し、「iOS アプリに Firebase を追加」を選びます。
iOSバンドルIDに先程のパッケージ名(ここではmlkit.mlkitsample
)を入れて、GoogleService-Info.plist
をダウンロードします。
Xcodeでios
を開き、Runner
ディレクトリ以下にGoogleService-Info.plist
を配置します。
それ以降の作業(Flutter SDKの準備やpod installなど)はしてはいけません!!
その後、ios/Podfile
からuse_frameworks!
という行を削除します。
これでiOS側の準備もOKです。
Flutterソースコード
ソースコードは、こちらからお借りしたものをもとに編集しました。
全量は以下です。
※長くなったので折りたたみました。
lib/main.dart
import 'package:mlkit_sample/ml_detail.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
const String TEXT_SCANNER = 'TEXT_SCANNER';
const String BARCODE_SCANNER = 'BARCODE_SCANNER';
const String LABEL_SCANNER = 'LABEL_SCANNER';
const String FACE_SCANNER = 'FACE_SCANNER';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
home: new MLHome(),
);
}
}
class MLHome extends StatefulWidget {
MLHome({Key key}) : super(key: key);
@override
State<StatefulWidget> createState() => _MLHomeState();
}
class _MLHomeState extends State<MLHome> {
static const String CAMERA_SOURCE = 'CAMERA_SOURCE';
static const String GALLERY_SOURCE = 'GALLERY_SOURCE';
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
File _file;
String _selectedScanner = TEXT_SCANNER;
@override
Widget build(BuildContext context) {
final columns = List<Widget>();
columns.add(buildRowTitle(context, 'Select Scanner Type'));
columns.add(buildSelectScannerRowWidget(context));
columns.add(buildRowTitle(context, 'Pick Image'));
columns.add(buildSelectImageRowWidget(context));
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
centerTitle: true,
title: Text('MLKit Demo'),
),
body: SingleChildScrollView(
child: Column(
children: columns,
),
));
}
Widget buildRowTitle(BuildContext context, String title) {
return Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
child: Text(
title,
style: Theme.of(context).textTheme.headline,
),
));
}
Widget buildSelectImageRowWidget(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: RaisedButton(
color: Colors.blue,
textColor: Colors.white,
splashColor: Colors.blueGrey,
onPressed: () {
onPickImageSelected(CAMERA_SOURCE);
},
child: const Text('Camera')),
)),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: RaisedButton(
color: Colors.blue,
textColor: Colors.white,
splashColor: Colors.blueGrey,
onPressed: () {
onPickImageSelected(GALLERY_SOURCE);
},
child: const Text('Gallery')),
))
],
);
}
Widget buildSelectScannerRowWidget(BuildContext context) {
return Wrap(
children: <Widget>[
RadioListTile<String>(
title: Text('Text Recognition'),
groupValue: _selectedScanner,
value: TEXT_SCANNER,
onChanged: onScannerSelected,
),
RadioListTile<String>(
title: Text('Barcode Scanner'),
groupValue: _selectedScanner,
value: BARCODE_SCANNER,
onChanged: onScannerSelected,
),
RadioListTile<String>(
title: Text('Label Scanner'),
groupValue: _selectedScanner,
value: LABEL_SCANNER,
onChanged: onScannerSelected,
),
RadioListTile<String>(
title: Text('Face Scanner'),
groupValue: _selectedScanner,
value: FACE_SCANNER,
onChanged: onScannerSelected,
)
],
);
}
Widget buildImageRow(BuildContext context, File file) {
return SizedBox(
height: 500.0,
child: Image.file(
file,
fit: BoxFit.fitWidth,
));
}
Widget buildDeleteRow(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: RaisedButton(
color: Colors.red,
textColor: Colors.white,
splashColor: Colors.blueGrey,
onPressed: () {
setState(() {
_file = null;
});
;
},
child: const Text('Delete Image')),
),
);
}
void onScannerSelected(String scanner) {
setState(() {
_selectedScanner = scanner;
});
}
void onPickImageSelected(String source) async {
var imageSource;
if (source == CAMERA_SOURCE) {
imageSource = ImageSource.camera;
} else {
imageSource = ImageSource.gallery;
}
final scaffold = _scaffoldKey.currentState;
try {
final file = await ImagePicker.pickImage(source: imageSource);
if (file == null) {
throw Exception('File is not available');
}
Navigator.push(
context,
new MaterialPageRoute(
builder: (context) => MLDetail(file, _selectedScanner)),
);
} catch (e) {
scaffold.showSnackBar(SnackBar(
content: Text(e.toString()),
));
}
}
}
lib/ml_detail.dart
import 'package:flutter/material.dart';
import 'dart:io';
import 'dart:async';
import 'package:mlkit/mlkit.dart';
import 'package:mlkit_sample/main.dart';
class MLDetail extends StatefulWidget {
final File _file;
final String _scannerType;
MLDetail(this._file, this._scannerType);
@override
State<StatefulWidget> createState() {
return _MLDetailState();
}
}
class _MLDetailState extends State<MLDetail> {
FirebaseVisionTextDetector textDetector = FirebaseVisionTextDetector.instance;
FirebaseVisionBarcodeDetector barcodeDetector =
FirebaseVisionBarcodeDetector.instance;
FirebaseVisionLabelDetector labelDetector =
FirebaseVisionLabelDetector.instance;
FirebaseVisionFaceDetector faceDetector = FirebaseVisionFaceDetector.instance;
List<VisionText> _currentTextLabels = <VisionText>[];
List<VisionBarcode> _currentBarcodeLabels = <VisionBarcode>[];
List<VisionLabel> _currentLabelLabels = <VisionLabel>[];
List<VisionFace> _currentFaceLabels = <VisionFace>[];
Stream sub;
StreamSubscription<dynamic> subscription;
@override
void initState() {
super.initState();
sub = new Stream.empty();
subscription = sub.listen((_) => _getImageSize)..onDone(analyzeLabels);
}
void analyzeLabels() async {
try {
var currentLabels;
if (widget._scannerType == TEXT_SCANNER) {
currentLabels = await textDetector.detectFromPath(widget._file.path);
if (this.mounted) {
setState(() {
_currentTextLabels = currentLabels;
});
}
} else if (widget._scannerType == BARCODE_SCANNER) {
currentLabels = await barcodeDetector.detectFromPath(widget._file.path);
if (this.mounted) {
setState(() {
_currentBarcodeLabels = currentLabels;
});
}
} else if (widget._scannerType == LABEL_SCANNER) {
currentLabels = await labelDetector.detectFromPath(widget._file.path);
if (this.mounted) {
setState(() {
_currentLabelLabels = currentLabels;
});
}
} else if (widget._scannerType == FACE_SCANNER) {
currentLabels = await faceDetector.detectFromPath(widget._file.path);
if (this.mounted) {
setState(() {
_currentFaceLabels = currentLabels;
});
}
}
} catch (e) {
print("MyEx: " + e.toString());
}
}
@override
void dispose() {
// TODO: implement dispose
super.dispose();
subscription?.cancel();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(widget._scannerType),
),
body: Column(
children: <Widget>[
buildImage(context),
widget._scannerType == TEXT_SCANNER
? buildTextList(_currentTextLabels)
: widget._scannerType == BARCODE_SCANNER
? buildBarcodeList<VisionBarcode>(_currentBarcodeLabels)
: widget._scannerType == FACE_SCANNER
? buildBarcodeList<VisionFace>(_currentFaceLabels)
: buildBarcodeList<VisionLabel>(_currentLabelLabels)
],
));
}
Widget buildImage(BuildContext context) {
return Expanded(
flex: 2,
child: Container(
decoration: BoxDecoration(color: Colors.black),
child: Center(
child: widget._file == null
? Text('No Image')
: FutureBuilder<Size>(
future: _getImageSize(
Image.file(widget._file, fit: BoxFit.fitWidth)),
builder:
(BuildContext context, AsyncSnapshot<Size> snapshot) {
if (snapshot.hasData) {
return Container(
foregroundDecoration: (widget._scannerType ==
TEXT_SCANNER)
? TextDetectDecoration(
_currentTextLabels, snapshot.data)
: (widget._scannerType == FACE_SCANNER)
? FaceDetectDecoration(
_currentFaceLabels, snapshot.data)
: (widget._scannerType == BARCODE_SCANNER)
? BarcodeDetectDecoration(
_currentBarcodeLabels,
snapshot.data)
: LabelDetectDecoration(
_currentLabelLabels, snapshot.data),
child:
Image.file(widget._file, fit: BoxFit.fitWidth));
} else {
return CircularProgressIndicator();
}
},
),
)),
);
}
Widget buildBarcodeList<T>(List<T> barcodes) {
if (barcodes.length == 0) {
return Expanded(
flex: 1,
child: Center(
child: Text('Nothing detected',
style: Theme.of(context).textTheme.subhead),
),
);
}
return Expanded(
flex: 1,
child: Container(
child: ListView.builder(
padding: const EdgeInsets.all(1.0),
itemCount: barcodes.length,
itemBuilder: (context, i) {
var text;
final barcode = barcodes[i];
switch (widget._scannerType) {
case BARCODE_SCANNER:
VisionBarcode res = barcode as VisionBarcode;
text = "Raw Value: ${res.rawValue}";
break;
case FACE_SCANNER:
VisionFace res = barcode as VisionFace;
text =
"Raw Value: ${res.smilingProbability},${res.trackingID}";
break;
case LABEL_SCANNER:
VisionLabel res = barcode as VisionLabel;
text = "Raw Value: ${res.label}";
break;
}
return _buildTextRow(text);
}),
),
);
}
Widget buildTextList(List<VisionText> texts) {
if (texts.length == 0) {
return Expanded(
flex: 1,
child: Center(
child: Text('No text detected',
style: Theme.of(context).textTheme.subhead),
));
}
return Expanded(
flex: 1,
child: Container(
child: ListView.builder(
padding: const EdgeInsets.all(1.0),
itemCount: texts.length,
itemBuilder: (context, i) {
return _buildTextRow(texts[i].text);
}),
),
);
}
Widget _buildTextRow(text) {
return ListTile(
title: Text(
"$text",
),
dense: true,
);
}
Future<Size> _getImageSize(Image image) {
Completer<Size> completer = Completer<Size>();
image.image.resolve(ImageConfiguration()).addListener(
(ImageInfo info, bool _) => completer.complete(
Size(info.image.width.toDouble(), info.image.height.toDouble())));
return completer.future;
}
}
/*
This code uses the example from azihsoyn/flutter_mlkit
https://github.com/azihsoyn/flutter_mlkit/blob/master/example/lib/main.dart
*/
class BarcodeDetectDecoration extends Decoration {
final Size _originalImageSize;
final List<VisionBarcode> _barcodes;
BarcodeDetectDecoration(List<VisionBarcode> barcodes, Size originalImageSize)
: _barcodes = barcodes,
_originalImageSize = originalImageSize;
@override
BoxPainter createBoxPainter([VoidCallback onChanged]) {
return _BarcodeDetectPainter(_barcodes, _originalImageSize);
}
}
class _BarcodeDetectPainter extends BoxPainter {
final List<VisionBarcode> _barcodes;
final Size _originalImageSize;
_BarcodeDetectPainter(barcodes, originalImageSize)
: _barcodes = barcodes,
_originalImageSize = originalImageSize;
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
final paint = Paint()
..strokeWidth = 2.0
..color = Colors.red
..style = PaintingStyle.stroke;
final _heightRatio = _originalImageSize.height / configuration.size.height;
final _widthRatio = _originalImageSize.width / configuration.size.width;
for (var barcode in _barcodes) {
final _rect = Rect.fromLTRB(
offset.dx + barcode.rect.left / _widthRatio,
offset.dy + barcode.rect.top / _heightRatio,
offset.dx + barcode.rect.right / _widthRatio,
offset.dy + barcode.rect.bottom / _heightRatio);
canvas.drawRect(_rect, paint);
}
canvas.restore();
}
}
class TextDetectDecoration extends Decoration {
final Size _originalImageSize;
final List<VisionText> _texts;
TextDetectDecoration(List<VisionText> texts, Size originalImageSize)
: _texts = texts,
_originalImageSize = originalImageSize;
@override
BoxPainter createBoxPainter([VoidCallback onChanged]) {
return _TextDetectPainter(_texts, _originalImageSize);
}
}
class _TextDetectPainter extends BoxPainter {
final List<VisionText> _texts;
final Size _originalImageSize;
_TextDetectPainter(texts, originalImageSize)
: _texts = texts,
_originalImageSize = originalImageSize;
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
final paint = Paint()
..strokeWidth = 2.0
..color = Colors.red
..style = PaintingStyle.stroke;
final _heightRatio = _originalImageSize.height / configuration.size.height;
final _widthRatio = _originalImageSize.width / configuration.size.width;
for (var text in _texts) {
final _rect = Rect.fromLTRB(
offset.dx + text.rect.left / _widthRatio,
offset.dy + text.rect.top / _heightRatio,
offset.dx + text.rect.right / _widthRatio,
offset.dy + text.rect.bottom / _heightRatio);
canvas.drawRect(_rect, paint);
}
canvas.restore();
}
}
class FaceDetectDecoration extends Decoration {
final Size _originalImageSize;
final List<VisionFace> _faces;
FaceDetectDecoration(List<VisionFace> faces, Size originalImageSize)
: _faces = faces,
_originalImageSize = originalImageSize;
@override
BoxPainter createBoxPainter([VoidCallback onChanged]) {
return _FaceDetectPainter(_faces, _originalImageSize);
}
}
class _FaceDetectPainter extends BoxPainter {
final List<VisionFace> _faces;
final Size _originalImageSize;
_FaceDetectPainter(faces, originalImageSize)
: _faces = faces,
_originalImageSize = originalImageSize;
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
final paint = Paint()
..strokeWidth = 2.0
..color = Colors.red
..style = PaintingStyle.stroke;
final _heightRatio = _originalImageSize.height / configuration.size.height;
final _widthRatio = _originalImageSize.width / configuration.size.width;
for (var face in _faces) {
final _rect = Rect.fromLTRB(
offset.dx + face.rect.left / _widthRatio,
offset.dy + face.rect.top / _heightRatio,
offset.dx + face.rect.right / _widthRatio,
offset.dy + face.rect.bottom / _heightRatio);
canvas.drawRect(_rect, paint);
}
canvas.restore();
}
}
class LabelDetectDecoration extends Decoration {
final Size _originalImageSize;
final List<VisionLabel> _labels;
LabelDetectDecoration(List<VisionLabel> labels, Size originalImageSize)
: _labels = labels,
_originalImageSize = originalImageSize;
@override
BoxPainter createBoxPainter([VoidCallback onChanged]) {
return _LabelDetectPainter(_labels, _originalImageSize);
}
}
class _LabelDetectPainter extends BoxPainter {
final List<VisionLabel> _labels;
final Size _originalImageSize;
_LabelDetectPainter(labels, originalImageSize)
: _labels = labels,
_originalImageSize = originalImageSize;
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
final paint = Paint()
..strokeWidth = 2.0
..color = Colors.red
..style = PaintingStyle.stroke;
final _heightRatio = _originalImageSize.height / configuration.size.height;
final _widthRatio = _originalImageSize.width / configuration.size.width;
for (var label in _labels) {
final _rect = Rect.fromLTRB(
offset.dx + label.rect.left / _widthRatio,
offset.dy + label.rect.top / _heightRatio,
offset.dx + label.rect.right / _widthRatio,
offset.dy + label.rect.bottom / _heightRatio);
canvas.drawRect(_rect, paint);
}
canvas.restore();
}
}
これを追加してビルドして実行すれば、冒頭のgifのような動作を実現できるはずです!
ハマりどころ
Android
Androidで以下のようなエラーが出ることがあります。
'com.android.support:appcompat-v7' has different version for the compile (26.1.0) and runtime (27.1.1) classpath
これは、ここのIssueを参考に、projectのbuild.gradleを以下のように変更すれば直りました。(subprojects
以下にproject.configurations.all
を追記しています。)
buildscript {
ext.kotlin_version = '1.2.71'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
classpath 'com.google.gms:google-services:4.0.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
jcenter()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
project.configurations.all {
resolutionStrategy.eachDependency { details ->
if (details.requested.group == 'com.android.support'
&& !details.requested.name.contains('multidex') ) {
details.useVersion "27.1.1"
}
}
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
iOS
iOSで以下のようなエラーが出ることがあります。
Error output from CocoaPods:
↳
[!] Automatically assigning platform `ios` with version `8.0` on target `Runner` because no platform was specified. Please specify a platform for this target in your Podfile. See `https://guides.cocoapods.org/syntax/podfile.html#platform`.
Error running pod install
これは、Xcode上でDeployment Target
を9.0に上げたら直ります。