🚽

クソ出す場所を教えてくれるアプリを開発する

2020/12/19に公開

この記事は「クソアプリ2 Advent Calendar 2020」の20日目の記事です。

TL;DR

クソを出す場所が見つからずに困った経験、ありませんか。
そんなとき、助けてくれる味方が欲しいと考え、アプリを作ることにしました。

使い方

アプリを起動する。カメラを振り回してください。
画像からトイレのピクトグラムを見つけた際に、音と画面の両方で教えてくれます。
見つけた箇所にキラキラ光るエフェクトがつきます。
android版は下記リンクからダウンロードできます。
https://play.google.com/store/apps/details?id=com.twinkle_toilet

デモ

以下作るまでの流れ

データ準備

近所のデパートで画像を60枚ほど撮影させていただきました。ここで必要なのは、何より精神力です。
周囲の迷惑をかけず、かつ誤解を招かないよう最新の注意を払いながら撮影しました。
ここのベストプラクティスは、「普段使わないが、決して怪しくない服装」、「人気が少ない時間」、「人気の少ない場所」、「全身でトイレのアイコンのみを撮りに行く」、「人を一切映さない」です。
なお、ここ以降でお伝えできるプラクティスはないです。

前処理

前処理はlabelImageを利用いたしました。ポチポチ枠をつけて、xml形式で保存します。
https://github.com/tzutalin/labelImg

モデル構築

tensorflowのチュートリアルを参考に、モデルを構築しました。Google先生には頭が上がりません。
https://github.com/tensorflow/models/blob/master/research/object_detection/colab_tutorials/eager_few_shot_od_training_tflite.ipynb

xmlを読み込む際につけたラベルは以下みたいな感じで処理しました。

import glob
import xml.etree.ElementTree as ET
files = glob.glob(annotations_dir)

image_width = 640
image_height = 853

gt_boxes = []
image_f_names = []
for i in files:
    tree = ET.parse(i)
    root = tree.getroot()
    image_f_names.append(os.path.basename(root[2].text))
    gt_boxes.append(np.array([[
        int(root[6][4][1].text) / image_width, # x
        int(root[6][4][0].text) / image_height, # y
        int(root[6][4][3].text) / image_width, # w
        int(root[6][4][2].text) / image_height # h
    ]], dtype=np.float32))

アプリを開発する

趣味でflutterを触っていたため、下記リポジトリを参考にアプリ部分を実装しました。
シンプルなアプリですので、やっていることは以下リポジトリと大体同じです。
https://github.com/shaqian/flutter_realtime_detection

音声出力を担うクラス

flutter_ttsというライブラリを使用いたしました。
https://pub.dev/packages/flutter_tts

import 'dart:async';
import 'dart:math' as math;
import 'dart:io' show Platform;
import 'package:flutter_tts/flutter_tts.dart';
import 'package:flutter/foundation.dart' show kIsWeb;

const List<String> customaryEpithet = [
  "Buddy! I found the toilet.",
  "You're lucky. That's the goal.",
  "Maybe it's the toilet.",
  "You can trust me, or you can lose everything here."
];


enum TtsState { playing, stopped, paused, continued }

class Speecher {
  static var _instance;
  factory Speecher() {
    if (_instance == null) {
      _instance = new Speecher._internal();
      _instance.initTts();
    }
    return _instance;
  }

  Speecher._internal();

  FlutterTts flutterTts;
  dynamic languages;
  String language;
  double volume = 0.5;
  double pitch = 1.0;
  double rate = 0.5;
  final random = math.Random();

  TtsState ttsState = TtsState.stopped;

  get isPlaying => ttsState == TtsState.playing;

  get isStopped => ttsState == TtsState.stopped;

  get isPaused => ttsState == TtsState.paused;

  get isContinued => ttsState == TtsState.continued;

  initTts() {
    flutterTts = FlutterTts();

    _getLanguages();

    if (!kIsWeb) {
      if (Platform.isAndroid) {
        _getEngines();
      }
    }
    flutterTts.setVolume(volume);
    flutterTts.setSpeechRate(rate);
    flutterTts.setPitch(pitch);
  }

  Future _getLanguages() async {
    languages = await flutterTts.getLanguages;
  }

  Future _getEngines() async {
    var engines = await flutterTts.getEngines;
    if (engines != null) {
      for (dynamic engine in engines) {
        print(engine);
      }
    }
  }

  Future speakText() async {
    if (ttsState == TtsState.stopped) {
      ttsState = TtsState.playing;
      await flutterTts.awaitSpeakCompletion(true);
      await flutterTts.speak(customaryEpithet[random.nextInt(customaryEpithet.length)]);
      ttsState = TtsState.stopped;
    }
    return;
  }
}

キラキラのエフェクトをつけるwidget

glittersというライブラリを使用して実装しました。キラキラの表示範囲を絞る際の枠計算は元のものとほとんど同じです。
https://pub.dev/packages/glitters

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:glitters/glitters.dart';
import 'package:quntum_mind/resources/models/results.dart';
import 'package:quntum_mind/widgets/components/speecher.dart';

class BndBox extends StatelessWidget {
  final BndBoxResult bndboxresult;

  BndBox(this.bndboxresult);

  
  Widget build(BuildContext context) {
    Size screen = MediaQuery.of(context).size;
    final List<dynamic> results = bndboxresult.results;
    final int previewH = bndboxresult.height;
    final int previewW = bndboxresult.width;
    final double screenH = math.max(screen.height, screen.width);
    final double screenW = math.min(screen.height, screen.width);

    List<Widget> _renderBox() {
      if (results.length == 0) return [];
      if (results[0]["detectedClass"] != "toilet") return [];
      var _x = results[0]["rect"]["x"];
      var _w = results[0]["rect"]["w"];
      var _y = results[0]["rect"]["y"];
      var _h = results[0]["rect"]["h"];
      double scaleW, scaleH, x, y, w, h;

      if (screenH / screenW > previewH / previewW) {
        scaleW = screenH / previewH * previewW;
        scaleH = screenH;
        var difW = (scaleW - screenW) / scaleW;
        x = (_x - difW / 2) * scaleW - 10;
        w = _w * scaleW + 20;
        if (_x < difW / 2) w -= (difW / 2 - _x) * scaleW;
        y = _y * scaleH - 10;
        h = _h * scaleH + 20;
      } else {
        scaleH = screenW / previewW * previewH;
        scaleW = screenW;
        var difH = (scaleH - screenH) / scaleH;
        x = _x * scaleW - 10;
        w = _w * scaleW + 20;
        y = (_y - difH / 2) * scaleH - 10;
        h = _h * scaleH + 20;
        if (_y < difH / 2) h -= (difH / 2 - _y) * scaleH;
      }

      Speecher().speakText();
      return [
        Positioned(
          left: math.max(0, x),
          top: math.max(0, y),
          width: w,
          height: h,
          child: Container(
            padding: EdgeInsets.only(top: 5.0, left: 5.0),
            child: Stack(
              children: const <Widget>[
                Glitters(
                  minSize: 8.0,
                  maxSize: 20.0,
                  interval: Duration.zero,
                  maxOpacity: 0.7,
                ),
                Glitters(
                  minSize: 10.0,
                  maxSize: 25.0,
                  interval: Duration(milliseconds: 20),
                  color: Colors.lime,
                ),
                Glitters(
                  minSize: 10.0,
                  maxSize: 25.0,
                  duration: Duration(milliseconds: 200),
                  inDuration: Duration(milliseconds: 500),
                  outDuration: Duration(milliseconds: 500),
                  interval: Duration(milliseconds: 30),
                  color: Colors.white,
                  maxOpacity: 0.7,
                ),
                Glitters(
                  minSize: 14.0,
                  maxSize: 30.0,
                  interval: Duration(milliseconds: 40),
                  color: Colors.orange,
                ),
              ],
            ),
          ),
        )
      ];
    }

    return Stack(
      children: _renderBox(),
    );
  }
}

雑感

クソアプリは毎年楽しみにしていたので、参加できて良かったです。
なお、アプリは年始にandroid版をリリースしております。
もしご興味のある方は、こんなクソでもインストールいただけると嬉しいです。

Discussion