人生成り行き

立川談志と向井秀徳と押井守を師と仰ぐ画像解析系AIエンジニアの日記

【Webアプリ開発】顔検出、DB保存、加工結果映像リアルタイム表示 Python + OpenCV + SQLAlchemy + FastAPI

概要

タイトルの通り。

前にも記事を書いているようにお気に入りの笑い男GIFで遊ぶ。

ankaji92.hatenablog.com

と今回はFastAPIを使ってOpenCVの加工結果をDBに保存しつつ、Webブラウザ上に表示するアプリを作ってみた。

普段は画像認識系のエンジニア 兼 販促担当をやっているのだけど、プロト開発がサクッと出来るともっと売れそうなのでね。スキル向上を目指してね。

コードについて

公開先

GitHubに公開中。 基本的にはgit cloneして使ってもらえば動くと思う。

著作権的に画像とかは同梱してないので注意。各自ダウンロードのほど。

説明

構成要素

大きく分けると下記の4要素から成る。

  1. 画面
  2. 処理フローとStream API
  3. DBアクセス
  4. 顔検出&顔加工

画面

見えやすいところから噛み砕いていった方が分かりやすいと思うんでまずは画面について。

といってもtemplates/index.htmlでシンプルに記述してるだけ。 Jinja2でパースできるようなフォーマット。

キモなのは

<img src="{{ url_for('video_feed') }}">

のところで、後に説明するFastAPIのスクリプト内で/video_feedでroutingされるところにアクセスすると映像がStreamingで表示される。

<html>
    <head>
        <title>Video Streaming Demonstration</title>
    </head>
    <body>
        <h1>Video Streaming Demonstration</h1>
        <img src="{{ url_for('video_feed') }}">
    </body>
</html>

処理フローとStream API

ほいでここがメインな訳だけど、スクリプト名でいうとapp.pyで、DBアクセス、顔検出&顔加工とHTMLを繋ぐ役割を果たしてる。

importとか

まずはモジュールのimportやら便利ツールのインスタンス化やらをしていきましょう。

ちなみにpyscriptsというのは自作モジュール名。Camera操作とか顔検出&加工とかDB操作とか。

import uvicorn
import cv2

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.templating import Jinja2Templates

from pyscripts.camera import Camera
from pyscripts.laughing_man import LaughingManMaskStream, detect_faces, overlay_lms

from pyscripts.db import insert_faces

app = FastAPI()

templates = Jinja2Templates(directory="templates")

htmlのパース

index関数はまぁ、ページ表示のためのテンプレ関数。ここでさっきのhtmlファイルをJinja2フォーマットでパースしてるのが分かる。

@app.getというのはFastAPIのデコレータ。第一引数でURLのパス指定をして、第二引数でレスポンス種別を指定。asyncはようわからん。

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    return templates.TemplateResponse('index.html', {"request": request})

コア

ほいでここがキモ。

gen関数の中では、下記の処理を行うというフローを記述してる。

  1. WebCameraからフレームを読み込んで、
  2. 描画するgifフレームを読み込んで、
  3. 顔を検出して、
  4. gifフレームを描画して、
  5. DBに検出結果を保存して、
  6. 描画結果をバイト形式にエンコードして、
  7. HTMLレスポンスを生成

そんでもってvideo_feed関数でストリーミングレスポンスとしてデータを返すということになってる。

さっきindex.htmlで記述してた

<img src="{{ url_for('video_feed') }}">

で引っ張ってくるデータがこれなわけだ。

def gen(camera):
    lm_mask_stream = LaughingManMaskStream()
    while True:
        frame = camera.get_frame()
        lm_mask = lm_mask_stream.next()

        faces = detect_faces(frame)
        frame = overlay_lms(frame, faces, lm_mask)
        insert_faces(faces)

        _, jpeg = cv2.imencode('.jpg', frame)
        byte_frame = jpeg.tobytes()

        yield (b'--frame\r\n'
               b'Content-Type: image/jpeg\r\n\r\n' + byte_frame + b'\r\n')

@app.get('/video_feed', response_class=HTMLResponse)
async def video_feed():
    return  StreamingResponse(gen(Camera()),
                    media_type='multipart/x-mixed-replace; boundary=frame')

サーバ起動

で最後↓でサーバを起動する。

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

DBアクセス

ということで、全体の流れは上記のような感じで記述できたので、以降はモジュールの詳しい説明になるわけだけども。

pyscripts/db.pyの中でsqlalchemyを使ったDB定義、初期化、挿入関数定義を行っていく。

from sqlalchemy import Column, Integer, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

SQLALCHEMY_DATABASE_URL = "sqlite:///./data/face_location.db"

engine = create_engine(
    SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}, echo=True
)

Base = declarative_base()

class Face(Base):
    __tablename__ = "face"
    id = Column(Integer, primary_key=True)
    st_x = Column(Integer)
    st_y = Column(Integer)
    width = Column(Integer)
    height = Column(Integer)

Base.metadata.create_all(bind=engine)

def insert_faces(faces):
    SessionClass = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    session = SessionClass()
    for face in faces:
        face_instance = Face()
        face_instance.st_x = int(face[0])
        face_instance.st_y = int(face[1])
        face_instance.width = int(face[2])
        face_instance.height = int(face[3])
        session.add(face_instance)
    session.commit()
    session.close()

顔検出&顔加工

ここの説明はこちらのページに譲ろうかな。

構成要素だけ書くと、下記のような感じ。

  1. LaughingManMaskStreamクラスでgifから1フレームずつ引っ張ってきて描画用に加工
  2. detect_faces関数で顔検出
  3. overlay_lms関数で加工
import os
import cv2
import numpy as np

def _extract_fg(img):
    fg_region = np.zeros(img.shape)
    for v, line in enumerate(img):
        non_white_pxs = np.array(np.where(line < 250))
        if non_white_pxs.size > 0:
            min_h, max_h = np.min(non_white_pxs, axis=1)[0], np.max(non_white_pxs, axis=1)[0]
            fg_region[v, min_h:max_h, :] = 1
    return fg_region

class LaughingManMaskStream:
    def __init__(self, gif_path=os.path.join(os.path.abspath(__file__), "laughing_man.gif")):
        self.cap = cv2.VideoCapture(gif_path)
        self.fg_region = _extract_fg(self.cap.read()[1])
    
    def next(self):
        ret, frame = self.cap.read()
        if not ret:
            self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
            ret, frame = self.cap.read()
        mask = frame * self.fg_region
        return mask
    
    def release(self):
        self.cap.release()

def detect_faces(img):
    PATH_TO_HAARCASCADE = '/haarcascade_frontalface_default.xml' # To Edit
    cascade = cv2.CascadeClassifier(PATH_TO_HAARCASCADE)

    small_img = cv2.resize(img, (int(img.shape[1]/4), int(img.shape[0]/4)))
    faces = np.array(cascade.detectMultiScale(small_img)) * 4

    return faces

def _overlay_lm(frame, face, lm_mask):
    offset = 100
    x = int(max(face[0] - offset / 2, 0))
    y = int(max(face[1] - offset / 2, 0))
    w = int(min(face[2] + offset, frame.shape[1] - x))
    h = int(min(face[3] + offset, frame.shape[0] - y))

    lm_mask = cv2.resize(lm_mask, (w,h))
    mask_idxs = np.where(lm_mask>0)
    overlay_idxs = (mask_idxs[0]+y, mask_idxs[1]+x, mask_idxs[2])

    frame[overlay_idxs] = lm_mask[mask_idxs]
    return frame

def overlay_lms(frame, faces, lm_mask):
    if faces.shape[0] > 0:
        for face in faces:
            frame = _overlay_lm(frame, face, lm_mask)
    return frame

コードの走らせ方

GitHubHow To Startを参照のこと。

まとめ

いかがだったでしょうか。

なんていう、時代錯誤な、ボケなのか何なのかわからない、まとめ方が、真っ先に思いついた俺は、もうダメなのかもしれない。

笑い男GIFで顔隠蔽(OpenCV3+Python3)

動機

pythonで画像処理サービスとか作ってみたいけど、とりあえず面白そうなことやってみようということで、割りと定番の笑い男合成。

以下のサイトがかなり参考になりました。 というかほとんどパクリに近い… pythonのOpenCVでリアルタイムに笑い男 - BlankTar

一応、gifを対象としましたっていうのがこの記事の違い。

事前準備

使うのはPython3とOpenCV3とNumpy。 Homebrew使ってれば基本的には下で環境は整うはず。

brew install python3
pip3 install numpy
brew tap homebrew/science
brew install opencv3 --with-python3 --without-python --with-contrib

コード

GIFの扱い

ここが今回のキモ。 といってもgifを扱うのはimreadでなくて、VideoCaptureです。ってだけ。

対応するのは下の2箇所。

gif_cap = cv2.VideoCapture("laughing_man.gif")
_, lm = gif_cap.read()
segmented_lm, mask = segment_lm(lm)
def stream_lm_gif(gif_cap):

    gif_cap.grab()
    gif_cap.grab()
    gif_ret, lm = gif_cap.read()
    if not gif_ret:
        gif_cap = cv2.VideoCapture("laughing_man.gif")
        gif_ret, lm = gif_cap.read()

    return lm, gif_cap

ちなみに、gifの透過画像化ってのはできないみたいだったので、以下関数を入れてる。

# Segment Laughing Man (to make gif transparent)
def segment_lm(lm):
    lm_region = np.zeros(lm.shape)
    for v, line in enumerate(lm):
        non_white_pxs = np.array(np.where(line < 250))
        if non_white_pxs.size > 0:
            min_h, max_h = np.min(non_white_pxs, axis=1)[0], np.max(non_white_pxs, axis=1)[0]
            lm_region[v, min_h:max_h, :] = 1

    segmented_lm = lm_region * lm

    return segmented_lm, lm_region

言い訳

今回はなぜかVideoCaptureのsetメソッドによるgifのリプレイが出来なかったため、最後のフレームまでいったらもう一回VideoCaptureインスタンスを作るってことをやってる。

Mac+PythonでKinect v2 for Windows

WindowsだとKinect SDKで比較的簡単にKinect for windowsが使えるが, Macだとちょっとややこしいので書いておく. 最初にライブラリのインストール方法を書いて,次にPythonでの使用法を書く.

libfreenect2のインストール

Macでも無料で使えるライブラリlibfreenect2を使う. 元ページはここ. GitHub - OpenKinect/libfreenect2: Open source drivers for the Kinect for Windows v2 device

先に依存ライブラリをインストール.今回はhomebrewを使うことを前提とする. それぞれ読み替えて下さい.

brew update
brew install libusb
brew tap homebrew/versions
brew install glfw3

TurboJPEG,CUDA,OPENNI2などを使いたい場合には先にそれらをインストールしておく. 詳しくは元ページ参照.

で,libfreenect2のインストールとテスト. Kinect v2を繋いで(USB接続→ACアダプタ接続という順序じゃないとデバイス認識をしてくれないので注意),

 git clone https://github.com.OpenKinect/libfreenect2.git
 cd libfreenect2
 mkdir build && cd build
 cmake ..
 make
 make install
 ./bin/Protonect

これでKinect v2の取得映像がストリーミング表示されるようになれば成功.

pythonでの使用

pythonからKinect v2を利用したい場合には,pylibfreenect2が使える. 元ページはここ. GitHub - r9y9/pylibfreenect2: A python interface for libfreenect2 for python 2.7, 3,4 and 3.5

このライブラリはpipからインストールできる.

 pip install pylibfreenect2

テストには元ページのexamples/selective_streams.pyを使う. Kinect v2を繋いで(USB接続→ACアダプタ接続の順序),下コードを動かせればOK.

 wget https://raw.githubusercontent.com/r9y9/pylibfreenect2/master/examples/selective_streams.py
 python selective_streams.py

基本的な画像・映像処理が分かってれば,selective_streams.pyを参考にすれば結構簡単に使い方は習得出来そう.

推薦システム概要資料とPythonでの協調フィルタリング参考記事

推薦システム概要

「推薦はとりあえず協調フィルタリングやればいいんでしょ?」という適当な認識を改めるために,手法サーベイ
基本的には朱鷺の杜Wikiでも有名な神嶌先生のこの資料を参考にする.
わかりやすい上にかなり網羅的.

取り敢えず協調フィルタリングが重要なのは間違ってなかったようなので,練習をしてみよう.

協調フィルタリング実装済みのPythonライブラリ

ということでpython協調フィルタリング実装済みのライブラリは無いかなと探してみると,

qiita.com

このサイトでいくつか紹介してくださっている.

基本的には後学の為にSparkをいじってみたいので,Spark + MLlibを使う予定.

Spark+MLlibで協調フィルタリング

qiita.com

そのものズバリの記事を発見.
記事の中で使っているデータセットもMovieLensだし.
これを次回あたり自分でも動かしてみよう.

おまけ

サーベイしててNMFって何だっけ?となったので,調べてみたところ,このサイトの説明が分かりやすかった.
abicky.net

一応画像認識をちょっとかじっている身としては覚えとかないと駄目だったかな...

デュアルブートしていたLinuxのパーティションをWindows7側から削除したら動かなくなった.→ MBRをWindows8.1の回復ドライブの作成で修復

以前から使っていたLet's NoteにデュアルブートしていたArchLinuxのパーティションWindows7側からフォーマットしたところ,起動しなくなった.(GRUB2 rescue画面でbootディレクトリが見つからない...)

GRUB2でデュアルブート処理を行っていたが,そのブートローダがArchLinuxパーティション内に保存されていたらしく,それもまとめて消してしまったらしい.

色々復元方法を試みたが,最終的に参考にして成功したのは以下のサイトの手順.

コマンドプロンプトからWindowsを復旧する4つの方法 (Vista/7/8/8.1/10) - ぼくんちのTV 別館

MBRの回復は修復ディスクが手元になかったので諦めていたが,別PC内のWindows8.1の「回復ドライブの作成」を試しに使ってみようとやってみたらうまく行った.

回復ドライブの作成に関しては以下のサイトが役に立った.

Windows8.1 回復ドライブの作成と復元(リカバリ)手順 : 下手の横好き!

 

初めは「GRUB2のブートローダWindows7パーティション内にインストールすればいいんでしょ?イケるっしょ.」という感じだったが,割りと泥沼化.

その後ArchLinuxのインストールUSBを使ったり,UbuntuのインストールUSBを使ったり.完全に迷子.本質的な問題を把握できてないからこんなことになるんです.

FreeDOSにも手を出したが最後,GRUB2 rescue画面ですら遠い彼方へ.

で結果,今回紹介した方法になんとかたどり着いて助かったけど,もうちょっと早く気付けたかな.(別バージョンの回復ドライブでうまくいくとは.)

演劇「みんなしねばいいのに」- うさぎストライプ 感想

友人に誘われて京都のアトリエ劇研で鑑賞した「みんなしねばいいのに」- うさぎストライプの簡単な感想を.

演劇を観に行ったのは初めてで,雰囲気が楽しめればいいかなぁという軽い気持ちで行った.

演劇に慣れてなくて内容に集中しきれたわけではないので,解釈はかなり間違っていると思いますが,予めご了承を.

あらすじ

霊が出ると曰く付きの,病院の女子寮が舞台.

主人公がふと呟く「みんなしねばいいのに」という言葉に共鳴するかのように,世界が狂気に満ちていく.

女子寮に住む3人の看護婦たちの,その世界への三者三様の反応.

 

本作では世界が狂気に満ちていくことの象徴として,終わらないハロウィンを舞台にしており,その中では仮装して常識から解放された人々が軽々しく他人を傷つけている.

世紀末的な世界を生きながら,主人公はそれまでの息苦しい生活から解き放たれ,それまで自身の心の奥底に押し込めてきた感情を大衆に向かって吐露する.

その一方で,そんな世界の中でも男と結ばれ,幸せを見つける女性と,世界とともに狂っていく彼氏(多分,医者)を持ち,気持ちが荒んでいく女性が描かれている.

ラストでは,主人公自身も命の危機に晒され,死と向き合う.

主人公は部屋に住まう霊との対話の中で,生きているのと死んでいるのはほとんど同じだということを聞く.

感想

世界系みたいな話は映画評論でよく聞くけど,本作はまさにそれで,世界が主人公の希望するものに変容する.

最終的には彼女は地獄のような生活に向き合うことができたのだろうか.

正直つかめていない.

 

ただ個人的な感想としては,メインのストーリーというよりは,この作品がコメディ的に演出されていることが非常に面白く感じた.

特に,主人公が「みんなしねばいいのに」と呟くシーンとかは,強調しなくてはならないはずなのに,一方で同じ舞台の上で非常に笑えるやりとりがなされている.

このシーンには鳥肌が立った.

映画とかだと,セリフは脳内で分割しづらくて,並列的に出来事を並べるというのが出来ないけど,一方で演劇だと舞台が空間的な広がりを持っているせいか,上手く脳内で並列に処理してくれる.

演劇一般に使われる手法なのかもしれないけど,僕には新鮮で,感動した.

word2vecをwikipediaコーパスで学習

作業動機

今更ながら練習として映画推薦サービスを作ってみようかなと思った.

とりあえず

MovieLens | GroupLens

をいじってみようと思ってダウンロード.

内容はこんな感じ.

  • genome-scores.csv: タグと映画の関連性
  • genome-tags.csv: タグID
  • links.csv: 別データ・セットとの映画ID対応表
  • movies.csv: 映画ID・タイトル・ジャンル(複数)
  • ratings.csv: ユーザの,映画に対する評価値(悪0.5~5良)と評価した時刻.
  • tags.csv: ユーザが映画に対してつけたtagとその時刻.

ぱっと思い浮かんだのは,ユーザに幾つかの映画に対する評価をしてもらい,ratingから似た評価をしているユーザを取ってきて,そのユーザの評価が高い映画を薦めるという能動学習的な方法.

その方法だときっちりユーザ登録型のWebサイト運営出来てないとキツいと思うので,出来ればその他のTwitterのツイートとか見せてもらうとかしてユーザの趣向を取ってきて,推薦したい(出来なさそうだけどとりあえず).

ということで自然言語処理のノウハウが必要になってくるけど,とりあえずはWord2Vecでお茶を濁す.日本語wikipediaのデータを学習してみる.

参考にしたWebサイトは以下の通り.


qiita.com


tjo.hatenablog.com

作業手順

WikipediaのデータをWord2Vecで使える形式に変換

まずwikipediaのデータを取ってくる.

$ curl https://dumps.wikimedia.org/jawiki/latest/jawiki-latest-pages-articles.xml.bz2 -o jawiki-latest-pages-articles.xml.bz2

このままだとテキストデータではないので,変換する必要がある.

python環境でやっていく予定なので,WikiExtractor.pyが使える.

$ git clone https://github.com/attardi/wikiextractor
$ python3 wikiextractor/WikiExtractor.py jawiki-latest-pages-articles.xml.bz2

ここまでやるとtextディレクトリが作成されており,それらの下部には各ページのテキストが保存されている.

それらを一つにまとめるために,以下の処理を行う.

$ cat text/*/* > jawiki.txt

でword2vecに読み込ませる前にWord単位のセグメンテーションを行っておく必要がある.そこで,MeCabをhomebrewで入れてそれを使う.

$ brew install mecab
$ mecab -Owakati jawiki.txt -o data.txt

Word2Vecのインストールと実行

$ pip3 install gensim 

でipyhtonを使って学習.

from gensim.models import word2vec
data = word2vec.Text8Corpus("data.txt")
model = word2vec.Word2Vec(data, size=200)

modelが学習されるのに結構時間が掛かりそうなのでとりあえず今はここまで書いておく.

出力

後日,出力の確認をしてみた.
よく知られている例で,イチロー - 野球 + 本田 → サッカーっていう挙動をするってのがあるけど,今回のWikipediaのデータセットのみではそんな出力は出なかったので一応報告.
米googleの研究者が開発したWord2Vecで自然言語処理(独自データ) - Qiita
もちろん上サイトではfacebookデータセットを独自に作ってるみたいなので,挙動が異るのは当たり前.
実際にTwitterとかから映画のレビューを取ってくるときにはそれなりのデータセットを用意する必要がある.

out = model.most_similar(positive=["イチロー", "本田"], negative=["野球"])
for x in out:
    print(x[0], x[1])
天谷 0.5703838467597961
中嶋 0.5688130259513855
中澤 0.5668359398841858
赤木 0.565341055393219
青木 0.5472140908241272
梶谷 0.5470311641693115
斉藤 0.5417250990867615
佐山 0.541033923625946
石毛 0.5390052199363708
松井 0.5352978706359863

その他にもいくつか.

out = model.most_similar(positive=["女性", "王"], negative=["男"])
for x in out:
    print(x[0], x[1])
王族 0.5936665534973145
国王 0.5321540832519531
王室 0.5042459964752197
君主 0.49709588289260864
スルターン 0.4776388108730316
ムスリム 0.47091352939605713
王妃 0.4708373248577118
異教徒 0.4697801470756531
アノーヤター 0.4674013555049896
諸侯 0.4593404531478882

out = model.most_similar(positive=["イスラム教", "旧約聖書"], negative=["コーラン"])
KeyError: "word '旧約聖書' not in vocabulary"

前半の例では王妃が割りと上位に来てて期待通り.
そうでなくても,"王"関連のワードが出て来るWord2Vecの性能に驚き.
自然言語処理は僕の専門分野ではないので,どの程度の性能がでるのかは知らなかったけど,思っていたよりはよかったかな.
更にデータセット増やせば結構良い結果が得られそう.
一方後半の例ではエラーが発生.
ユダヤ教とかが出てほしかったけど,まさか"旧約聖書"が無いとは...
あれかな.多分MeCab分かち書きの分割が上手く行っていないorデータセットにそもそも含まれていないって感じだと思う.
前者の方があり得るかな.その辺も勉強しないとな.