グルメ画像自動分類器を作った話 - 5月 31, 2019 こんにちは、ぐぐりら(@guglilac)です。 今回は、最近趣味で作っていた飯テロ画像自動分類器についての記事を書こうと思います。 飯テロ画像とここで呼んでいるのはご飯とかお酒の画像のことです。 僕のTwitterをフォローしてくださっている方はご存知と思いますが、僕はよく美味しいお店を開拓してよくご飯やお酒の写真をとってツイートしています。 技術関連のアカウントとして運営していこうというつもりなのですが、半分くらいはこの飯テロツイートに費やされている現状です。 そんな飯テロ系エンジニアのぐぐりらですが、どこかマメなところがあるので撮ったグルメ画像をgoogle driveにいちいちアップロードしてiphoneから削除するということをこつこつやっています。 大学はいってからなのでもう5年以上はこの生活をしているんですね、今気づいたけど恐ろしい。 そこで今回は「退屈なことはpythonにやらせよう」ということで、この「ご飯やお酒の画像だけgoole driveのグルメフォルダにアップロードする」 という処理を自動化します。 いろいろやってみた結果、最終的にやったことはかなり簡単なことだけだったので大したことはないのですが、せっかくなのでまとめてみます。 ## 構成 iphoneで撮影した画像を分類してgoogle driveのグルメフォルダにuploadするわけなので、 * iphoneで撮影した画像をとってくる * 画像を分類する * 分類した画像をgoogle driveに入れる ぐらいのcomponentに分解しました。 iphone上で機械学習モデルを回すこともできるのでしょうがそこまでしたくなかったし、どこかサーバーを借りてそこでモデルを回すなどもお金かかりそうだったので見送り。 今回は自分のPCに画像を持ってきて分類することにしました。 iphoneの画像をmacに持ってくる(しかもわざわざそのための操作はしたくない)という部分はgoogle photoを使うことで実現しました。 google photoはgoogle drive上のフォルダとして扱うことができるので、一度iphoneの写真をgoogle photoに同期すればあとはgoogle driveの操作だけで済みます。 結局 iphoneの写真 <-> google photo <-> google drive <-> mac という感じの構成です。 この時点ではmacでgoogle driveをpythonで操作すればええやろと思っていました。 ## 分類partの実装 与えられた画像がご飯やお酒の画像なのか、それ以外なのかを判定できる必要があります。 今回はResnetを転移学習させたものを用いることにしました。 グルメ画像の種類を分類するタスクもあるぐらいなので、グルメ画像かどうかを判定するぐらいなら簡単なのではと目処を立てました。 自分はGPUを使える環境にない(研究室もそっち系ではないのでGPUサーバーはありません、、、ほしい)ので、モデルの学習にはgoogle colaboratoryを使いました。 以下、コードの一部を載せます。 まずモデルのクラスです。Kerasで書きました。 画像のdata augmentationをしたかったのでgeneratorでデータを渡せるように`fit_generator`などのメソッドで実装しています。 `MyImageDataGenerator`はdata augmentationを行う自作のクラスで、あとでコードを載せます。 本当はgeneratorはモデルクラスに含めたくないですが、generatorをfit_generatorの引数に渡すように設計すると、 `steps_per_epoch`にサンプル数を使う際にどこから与えればいいのかわからず困りました。(sample数も引数に渡せばいいのはわかるのですがgenerator自体がサンプル数の情報を持ってる方が綺麗な気がする) KerasのSequenceクラスをgeneratorに渡すとこの辺が綺麗にかけて楽なのですが、KerasのImageDataGeneratorはpythonのgeneratorを返すので今回は仕方なくこういう実装にしています。generatorを使っても綺麗にかける方法があればぜひ知りたいです。 ```python class GourmetResnet: def __init__(self,height=224,width=224,channels=3,trainable=True,dropout_rate=0.2,out_dim=2,med_dim=256,lr=1e-3, decay=1e-6, momentum=0.9,load_path=None): if load_path is None: self.height=height self.width=width self.channels=channels self.trainable=trainable self.dropout_rate=dropout_rate self.med_dim=med_dim self.lr=lr self.out_dim=out_dim self.decay=decay self.momentum=momentum self.model=self.create_model() else: self.model=self.load_model(load_path) def create_model(self): input_tensor = Input(shape=(self.height,self.width,self.channels)) resnet = ResNet50(include_top=False, weights='imagenet', input_tensor=input_tensor) resnet.trainable=self.trainable top_model = Sequential() top_model.add(Flatten()) top_model.add(Dense(self.med_dim)) top_model.add(Activation("relu")) top_model.add(Dropout(self.dropout_rate)) top_model.add(Dense(self.out_dim)) top_model.add(Activation("softmax")) model = Model(inputs=resnet.input, outputs=top_model(resnet.output)) model.compile(loss='categorical_crossentropy', optimizer=SGD(lr=self.lr, decay=self.decay, momentum=self.momentum), metrics=['accuracy']) return model def load_model(self,path): return load_model(path) def save_model(self,path): self.model.save(path) def fit(self,X,y,epochs,batch_size=32,validation_data=None): return self.model.fit(X,y,epochs=epochs,validation_data=validation_data) def fit_generator(self,X,y,epochs,batch_size=32,validation_data=None,random_crop=True,horizontal_flip=True): X_valid,y_valid=validation_data gen=MyImageDataGenerator(random_crop=random_crop,horizontal_flip=horizontal_flip,rescale=1./255) gen_train=gen.flow(x=X_train,y=y_train,batch_size=batch_size) gen_valid=gen.flow(x=X_valid,y=y_valid,batch_size=len(X_valid)) return self.model.fit_generator(generator= gen_train,epochs=epochs, validation_data=gen_valid,steps_per_epoch=len(X)//batch_size,validation_steps=1) def predict_proba(self,X): return self.model.predict(X) def predict(self,X): proba=self.predict_proba(X) return np.argmax(proba,axis=1) def predict_proba_generator(self,X): gen=MyImageDataGenerator(rescale=1./255) gen_test=gen.flow(X,batch_size=len(X),shuffle=False) return self.model.predict_generator(gen_test, steps=1) def predict_generator(self,X): proba=self.predict_proba_generator(X) return np.argmax(proba,axis=1) def evaluate(self,X,y): result=self.model.evaluate(X,y) return {"loss": result[0], "acc": result[1]} def evaluate_generator(self,X,y): gen=MyImageDataGenerator(rescale=1./255) gen_test=gen.flow(x=X,y=y,batch_size=len(X),shuffle=False) result=self.model.evaluate_generator(gen_test,steps=1) return {"loss": result[0], "acc": result[1]} ``` 次が`MyImageDataGenerator`の実装です。これはkerasにある`ImageDataGenerator`を継承したクラスで、random cropを実装したものになります。(デフォルトではrandom cropはない) ```python class MyImageDataGenerator(ImageDataGenerator): """画像の前処理を行うImageDataGeneratorにrandom cropを実装したクラス.""" def __init__(self, featurewise_center=False, samplewise_center=False, featurewise_std_normalization=False, samplewise_std_normalization=False, zca_whitening=False, zca_epsilon=1e-06, rotation_range=0.0, width_shift_range=0.0, height_shift_range=0.0, brightness_range=None, shear_range=0.0, zoom_range=0.0, channel_shift_range=0.0, fill_mode='nearest', cval=0.0, horizontal_flip=False, vertical_flip=False, rescale=None, preprocessing_function=None, data_format=None, validation_split=0.0, random_crop=False, expand_rate=1.2): # 親クラスのコンストラクタ super().__init__(featurewise_center, samplewise_center, featurewise_std_normalization, samplewise_std_normalization, zca_whitening, zca_epsilon, rotation_range, width_shift_range, height_shift_range, brightness_range, shear_range, zoom_range, channel_shift_range, fill_mode, cval, horizontal_flip, vertical_flip, rescale, preprocessing_function, data_format, validation_split) self.random_crop = random_crop self.expand_rate = expand_rate def scale_random_crop(self, original_img, seed): """一枚の画像を拡大してrandom cropする. :params original_img: (height,width,channels)をshapeにもつnumpy array :params seed: random cropする際の場所に関するrandom seed :return cropped image: sizeはoriginal_imgと同じになるようにcropする. """ np.random.seed(seed) assert original_img.shape[2] == 3 # the number of channels dy, dx = original_img.shape[0:2] expanded_img = img_to_array(array_to_img(original_img).resize((int(dy * self.expand_rate), int(dx * self.expand_rate)))) # 0 ~ 255に戻る height, width = expanded_img.shape[0:2] x = np.random.randint(0, width - dx + 1) y = np.random.randint(0, height - dy + 1) return expanded_img[y:(y + dy), x:(x + dx), :] def flow(self, x, batch_size,y=None, shuffle=True, sample_weight=None, seed=None, save_to_dir=None, save_prefix='', save_format='png', subset=None): batches = super().flow(x=x, y=y, batch_size=batch_size, shuffle=shuffle, sample_weight=sample_weight, seed=seed, save_to_dir=save_to_dir, save_prefix=save_prefix, save_format=save_format, subset=subset) # 拡張処理 while True: if y is None: batch_x = next(batches) else: batch_x,batch_y = next(batches) # Scale Random crop if self.random_crop: x = np.zeros(batch_x.shape) for i in range(batch_x.shape[0]): x[i] = self.scale_random_crop(batch_x[i], seed) batch_x = x * self.rescale if self.rescale is not None else x # random cropするときに0~255に戻った分を再度rescale if y is None: yield batch_x else: yield batch_x, batch_y ``` データセットは、これまで自分がコツコツgoogle driveにアップロードしてきたグルメフォルダに入っているグルメ画像約800枚と、それ以外の画像約1300枚 を用いました。フォルダに分けて管理してきていたのでラベルつける必要がなくて楽でいいですね。 画像が少ないですが転移学習だしタスクも簡単なのでいけるっしょ!という気持ちで学習してもらいました。 もちろんグルメ画像データセットとかも公開されているのでそちらでもいいのですが、自分で集めた画像でやってみたかったのと、自分の撮る画像を分類 するので癖とかがあるかもなーという理由がありました。 google driveからcolaboratoryにデータを引っ張ってくる方法はいろんな記事があるのでそれらを参考にしました。 これを書くとマウントできます。 ```python from google.colab import drive drive.mount('/content/gdrive') ```` ## 学習結果 いくつか分類結果を載せてみました。 うまく分類できています! 学習曲線の図はこんなかんじになりました。10epochほど回しました。 testデータではaccuracyが98%ほどになったのでいいかんじです。 ROCカーブも出してみました。かなり精度よく当てられています。 原点付近を拡大したもの ## google driveの操作 画像の分類をpythonでやるので、google driveの操作もpythonでできればシームレスでいいなとおもって調べてみると pydriveというライブラリを見つけました。 このへんの記事を参考にカタカタ書いていると、確かにアップロードやダウンロードはできて楽しいのですが、致命的な欠点として > pydrive経由で作ったファイル、フォルダ以外は認識してくれない というものがあることがわかりました。 認識したフォルダやファイルはダウンロード、削除などできるのですが。。。 今回はgoogle photoにある画像をとってきて分類する必要があるため、ここがpydriveでは実現できません。困った。。 結局、google driveをmacと同期して、macのディレクトリ内でごにょごにょやるという方法にしました。 pydrive使わなくて良くて結局pathlibとかを使って頑張るだけになりました。 実行した日付から直近n日以内にgoogle driveに追加された画像を分類の対象としてロードし、学習済みモデルで予測。グルメ画像と判定された画像を グルメフォルダに移します。 ## 定期実行 以上のスクリプトを定期的に自動で実行してくれればオッケー! 定期実行はcrontabというものしか知らなかったので今回もそれでやろうかなと思って調べていると、macだとlaunchd というものがあるらしく、そちらが推奨とのことだったので使ってみました。 こちらの記事を参考にすると結構簡単に使えたのでいい感じ。 ただmacがスリープ状態の時には実行してくれないみたいなので、そこだけ注意ですね。。。本当はPC開いてなくても定期実行してほしい。 ## まとめ グルメ画像を自動で分類してgoogle driveにアップロードするコードを書いたというお話でした。 分類のタスク自体は簡単ですが、実際に機械学習を使ったツールが作れると楽しいですね。 研究としての機械学習(や数学)も楽しいですが、そういう技術を使ってツールやアプリを作ることも好きなので積極的にこういう自由研究をやっていきたいです。 企業でやるデータの蓄積を行う基盤みたいなのはもちろんこんなもんじゃないんだろうけれど、どうやってデータを貯めたり管理したりするのが効率良くて便利なのか、みたいなのも考えると面白いんだろうなあと思ったり。 興味は広く持っていきたいなあと思う自由研究でした。読んでくださってありがとうございました。 ## 追記(2019/06/14) これでしばらく運用していたのですが、 GoogleフォトとGoogleドライブの自動同期、7月10日に終了「混乱を招いていたので」 というように、google driveとgoogle photoを自動同期するのをgoogleがやめてしまうらしいので、iphoneからmacへ画像を自動で送る部分をどうにかしないといけなくなりました。 結局、icloud写真経由で自動で同期するようにし、mac側で`写真ライブラリ.photoslibrary`の中にある画像をとってきて分類器にかける、という方法で対応しました。 写真.appの中身は`写真ライブラリ.photoslibrary`のなかにあります。`写真ライブラリ.photoslibrary`は基本的にはピクチャにあると思います。 `写真ライブラリ.photoslibrary`の中のディレクトリ構成は、finderで`写真ライブラリ.photoslibrary`を右クリックして「パッケージの内容を表示」をクリックすると見ることができます。 中身がわかれば普通にfileを扱うのとおなじなのでpythonでもなんでもつかってがりがり読める、という感じです。 ## 追記(2020/04/19) これまでは、分類結果を次のように標準出力に吐いていて、launchdではそれをlogファイルに出力させるようにタスクを書いていたのですが、ちゃんと分類してアップロードできているのか確認しに行くのが面倒です。 ``` [2020/04/19 00:58] 4 files are found. 1 files are classified as gourmet image and moved. ``` なので、分類結果と、グルメ画像と判定された画像をSlackにポストする部分を追加しました。 Slack APIをPythonから叩く方法もメモっておきます。 ```python from slack import WebClient slack_token = "xxxxxxxxxxxxxxxxxxxxxxxx" client = WebClient(slack_token) result = client.api_call("channels.list")["channels"] // channelのリストを取得 client.chat_postMessage(channel="hoge_channel", text="<@user_id> hogehoge", username="My Bot") // メッセージを送信. メンションもこのようにして送ることができる client.files_upload( channels="hoge_channel", file="test.png", title="Test upload" ) // test.pngをアップロード ``` ちょこっと調べて出てきた記事には、 ```bash pip install slackclient ``` として、 ```python from slackclinet import SlackClient ``` とimportしていましたが、これだとModuleNotFoundErrorがでてしまうので、 ```python from slack import WebClient ``` としましょう。 ([参考](https://github.com/slackapi/python-slackclient/issues/183)) 他にも、いくつか古い記事が多くてところどころ詰まったので、ドキュメントを見に行きましょう。 こんなかんじで。Slackにpostしてくれるようになりました。 (余談 : 個人用のSlackワークスペースではアカウントを海馬瀬人にしています。社長気分。) この記事をシェアする Twitter Facebook Google+ B!はてブ Pocket Feedly コメント
コメント
コメントを投稿