Help us understand the problem. What is going on with this article?

LINE Cameraの様な画像編集アプリを作りたかった【Flutter, image_editor】

はじめに

Flutter勉強中です。
今日はLINE Cameraの様な画像編集アプリを作ろうとしたけど、想像していたものが完成しなかった話です。
今回はimage_editorライブラリを使ってみたので、もし使おうとしている人の助けになれば幸いです。

ライブラリ

image_editor

今回使用したかったライブラリはimage_editorです。
拡大・縮小やフリップ、画像のミックス、テキスト付与など基本的な画像の編集機能を備えています。
今回は画像のミックスを使って、オリジナル画像に対してスタンプを付与するアプリを作ろうと考えました。

その他

端末のカメラやギャラリーから写真を取ってくるのにimage_pickerを使ってます。
あと、pathを取得するのにpath_providerも使ってます。

構成

lib
│  main.dart
│
├─resource
│      img_path.dart
│
├─screens
│      img_edit_screen.dart
│      index_screen.dart
│      pick_img_screen.dart
│
└─widgets
        image_input.dart

resourceにはスタンプに使いたい画像のパス、screensには各スクリーンの描画、widgetsには画像を取ってくるwidgetが入ってます。

main

main.dart
import 'package:flutter/material.dart';

import './screens/index_screen.dart';
import './screens/pick_img_screen.dart';
import './screens/img_edit_screen.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Image editor',
      theme: ThemeData(
        primaryColor: Colors.pinkAccent,
      ),
      home: IndexScreen(),
      routes: {
        PickImgScreen.routeName: (ctx) => PickImgScreen(),
        ImageEditScreen.routeName: (ctx) => ImageEditScreen(),
      },
    );
  }
}

mainでは、各スクリーンへのルートを指定しており、最初にIndexScreen()を表示するようにしています。

img_path

img_path.dart
class ImgPaths {
  static const String SUNGLASS_PNG = 'assets/sunglass.png';
  static const String SUNGLASS_SVG = 'assets/sunglass.svg';
}

画像へのパスです。画像はassetsに入れてあります。
assetsの画像を使うときは、pubspec.yamlに記載を忘れないように気をつけて下さい(ここを忘れてて30分程度手間取ってしまった…)。

index_screen.dart

index_screen.dart
import 'package:flutter/material.dart';

import 'pick_img_screen.dart';

class IndexScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Image editor'),
        actions: [
          IconButton(
            icon: Icon(Icons.add),
            onPressed: () {
              Navigator.of(context).pushNamed(PickImgScreen.routeName);
            },
          ),
        ],
      ),
    );
  }
}

AppBarのIconButtonから画像を追加するページに移ります。
sqfliteと組み合わせれば編集済みの画像をここにサムネイルとして追加・表示していくことができます。

pick_img_screen.dart, image_input.dart

pick_img_screen.dart
import 'dart:io';

import 'package:flutter/material.dart';

import '../widgets/image_input.dart';

class PickImgScreen extends StatefulWidget {
  static const routeName = '/pick-img-screen';

  @override
  _PickImgScreenState createState() => _PickImgScreenState();
}

class _PickImgScreenState extends State<PickImgScreen> {
  File _pickedImage;

  void _selectImage(File pickedImage) {
    _pickedImage = pickedImage;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Select picture'),
      ),
      body: Column(
        children: [
          Expanded(
            child: SingleChildScrollView(
              child: Padding(
                padding: EdgeInsets.all(10),
                child: Column(
                  children: [
                    SizedBox(
                      height: 10,
                    ),
                    ImageInput(_selectImage),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

image_input.dart
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart' as syspaths;

import '../screens/img_edit_screen.dart';

class ImageInput extends StatefulWidget {
  final Function onSelectImage;

  ImageInput(this.onSelectImage);

  @override
  _ImageInputState createState() => _ImageInputState();
}

class _ImageInputState extends State<ImageInput> {
  File _storedImage;
  final picker = ImagePicker();

  Future<void> _takePicture() async {
    final imageFile = await picker.getImage(
      source: ImageSource.camera,
    );
    if (imageFile == null) {
      return;
    }
    setState(() {
      _storedImage = File(imageFile.path);
    });
    final appDir = await syspaths.getApplicationDocumentsDirectory();
    final fileName = path.basename(imageFile.path);
    final savedImage = await _storedImage.copy('${appDir.path}/$fileName');
    widget.onSelectImage(savedImage);
  }

  Future<void> _getImageFromGallery() async {
    final imageFile = await picker.getImage(
      source: ImageSource.gallery,
    );
    if (imageFile == null) {
      return;
    }
    setState(() {
      _storedImage = File(imageFile.path);
    });
    final appDir = await syspaths.getApplicationDocumentsDirectory();
    final fileName = path.basename(imageFile.path);
    final savedImage = await _storedImage.copy('${appDir.path}/$fileName');
    widget.onSelectImage(savedImage);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          width: 400,
          height: 480,
          decoration: BoxDecoration(
            border: Border.all(width: 1, color: Colors.grey),
          ),
          child: _storedImage != null
              ? Image.file(
                  _storedImage,
                  fit: BoxFit.cover,
                  width: double.infinity,
                )
              : Text(
                  'No Image Taken',
                  textAlign: TextAlign.center,
                ),
          alignment: Alignment.center,
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Expanded(
              child: FlatButton.icon(
                icon: Icon(Icons.photo_camera),
                label: Text('カメラ'),
                textColor: Theme.of(context).primaryColor,
                onPressed: _takePicture,
              ),
            ),
            Expanded(
              child: FlatButton.icon(
                icon: Icon(Icons.photo_library),
                label: Text('ギャラリー'),
                textColor: Theme.of(context).primaryColor,
                onPressed: _getImageFromGallery,
              ),
            ),
            _storedImage != null
                ? Expanded(
                    child: RaisedButton(
                      child: Text(
                        '選択',
                        style: TextStyle(
                          color: Colors.white,
                        ),
                      ),
                      color: Theme.of(context).primaryColor,
                      onPressed: () {
                        Navigator.of(context).pushNamed(
                            ImageEditScreen.routeName,
                            arguments: _storedImage);
                      },
                    ),
                  )
                : Container()
          ],
        ),
      ],
    );
  }
}

ここでimage_pickerとpath_providerの登場です。
image_pickerを使って、_takePicture_getImageFromGalleryで画像をカメラ、もしくはギャラリーから取ってきています。
カメラorギャラリーの違いはsourceの違いで、使い方は以下のサイトがわかりやすいと思います。
【Flutter】【Dart】Image Pickerで画像を選択する
また、path_providerのsyspathsを使ってFileとして画像を取り扱います。

画像が選択されるとContainer内に画像が表示されます。
Container内ではBoxDecorationとBoxFitを使うことによって、画像を自動で枠内に収まるように縮小しています。

また、同時に”選択”というボタンが表示されるようになっており、ボタンを押すと編集ページ(img_edit_screen.dart)へ移ります。
移る際にNavigatorのargumentsに変数を指定することによって編集用ページに画像を渡します。

img_edit_screen.dart

img_edit_screen.dart
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_editor/image_editor.dart';

import '../resource/img_path.dart';

class ImageEditScreen extends StatefulWidget {
  static const routeName = '/img-edit-screen';

  @override
  _ImageEditScreenState createState() => _ImageEditScreenState();
}

class _ImageEditScreenState extends State<ImageEditScreen> {
  ImageProvider image;
  BlendMode blendMode = BlendMode.srcOver;
  File _selectedImage;

  @override
  void initState() {
    super.initState();
    Future.delayed(Duration.zero, () {
      _selectedImage = ModalRoute.of(context).settings.arguments;
      image = MemoryImage(_selectedImage.readAsBytesSync());
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Edit picture'),
        actions: [
          IconButton(
            icon: Icon(Icons.save),
            onPressed: () {},
          )
        ],
      ),
      body: Column(
        children: [
          Center(
            child: GestureDetector(
                child: Container(
                  width: 400,
                  height: 500,
                  decoration: BoxDecoration(
                    border: Border.all(width: 1, color: Colors.grey),
                  ),
                  child: image != null
                      ? Image(
                          image: image,
                          fit: BoxFit.cover,
                          width: double.infinity,
                        )
                      : Text(
                          'No Image Taken',
                          textAlign: TextAlign.center,
                        ),
                  alignment: Alignment.center,
                ),
                onTapDown: (TapDownDetails details) =>
                    mixImage(context, _selectedImage, details)),
          ),
        ],
      ),
    );
  }

  Future<void> mixImage(
      BuildContext context, File srcImg, TapDownDetails details) async {
    final Uint8List src = srcImg.readAsBytesSync();
    final Uint8List material = await loadFromAsset(ImgPaths.SUNGLASS_PNG);
    final ImageEditorOption optionGroup = ImageEditorOption();

    final Image img = Image.file(srcImg);
    print('${img.width.toString()}, ${img.height.toString()}');

    RenderBox getBox = context.findRenderObject();

    var localPos = getBox.globalToLocal(details.globalPosition);
    int x = localPos.dx.toInt();
    int y = localPos.dy.toInt();

    print("$x,$y");

    optionGroup.outputFormat = const OutputFormat.png();
    optionGroup.addOption(
      MixImageOption(
        x: x,
        y: y,
        width: 150,
        height: 150,
        target: MemoryImageSource(material),
        blendMode: blendMode,
      ),
    );
    final Uint8List result =
        await ImageEditor.editImage(image: src, imageEditorOption: optionGroup);
    image = MemoryImage(result);
    setState(() {});
  }

  Future<Uint8List> loadFromAsset(String key) async {
    final ByteData byteData = await rootBundle.load(key);
    return byteData.buffer.asUint8List();
  }
}

一見、前の画面と同じように見えますが画面内をタップするとサングラスが表示されます(本当はタップした位置に画像を表示したかった)。

これをimage_editorを使うことによって実装しております。
まずinitStateでImageProviderであるimageに選んだ画像を登録します。正直、ImageProviderの挙動はわかってないです。
Providerって記載されているので、画像が登録されると自動で流れていくのかなーというフィーリングで使ってます。

画面のタップ位置はGestureDetectorで取得しています。GestureDetectorのonTapDownでタップ位置の座標を取得することができます。
取得した情報はmixImageに渡されます。

mixImageはほぼサンプルのままですが、今回image_pickerを使って画像をFileとして取り扱っております。
しかし、image_editorはバイトデータとして画像を取り扱っているので変換する必要がありそうです。
そのため、最初に

img_edit_screen.dart
final Uint8List src = srcImg.readAsBytesSync();

によって、FileからUint8Listに変換しています。これがわからず、めちゃくちゃ時間かかりました…

image_editorのMixImage自体の取り扱いはすごく簡単です。
Optionで色々指定して(今回の場合はMixImageOption)、blendModeの部分にどういう風に画像をミックスするか指定します。
今回はソース(元画像)に重ねる形でミックスしたいので、

img_edit_screen.dart
BlendMode blendMode = BlendMode.srcOver;

srcOverを指定しています。他にもオプションはあるのでぜひサンプルを見てみてください。
最後にimageを更新して完了です。

完成品の挙動

image_editor.gif

タップした位置にサングラスが描画されないのはまだ直せるにしても、画像がミックスされるたびに一瞬画面が白くなる(再レンダリングされる?)のが非常に気持ち悪い…もっと精進致します。

bigface00
まぁまぁ顔がでかいほうだと思います。
global_walkers
グローバルウォーカーズでは、AIのコンピュータビジョンによる検知を生かし、カメラによる物体検知、損傷検知、文字認識など、様々な分野でAIを活用したソリューションサービスを展開しております。高精度なAI開発には、高品質かつ大量のデータが必要となる中、弊社では教師データを作成する独自のプラットフォームを有しており、データの作成からシステムの構築までをワンストップでできることを強みとしております。
https://www.globalwalkers.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away