加具留矢流余

かぐるやるよ

色を連続的に変化させる方法

色をシームレスに変化させたい。虹色みたいに。ただそれだけ。

f:id:theflyingcat28:20200707003420g:plain
色を連続的に変化させるイメージ

と思っても、実は色についてほとんど知識が無いことに気がつく。RGBは分かるが、それを使ってどう変化させればいいかわからない。RGBのRの値だけを変えても色の赤みが薄くなるか濃くなるかだけで、虹色みたいに変化させるのは難しいのではと思った。

ja.wikipedia.org

困ったときのウィキペディア。世の中には皆がよく知るRGB以外にも色を表現する方法はあるようだ。というかペイントとかでお世話になっていた。 RGBは赤・緑・青の原色を混ぜ合わせることで色を表現する方法だが、HSV色空間は、Hue(色相)、Saturation(彩度)、Value(明度)の3つのパラメータで表現する方法だ。ウィキペディアを見て、色を連続的に変化させたい = 色相を変化させたい、とやりたいことを明確に言語化できた。

ウィキペディアを見るとHSVからRGBの変換方法まで記載されていたので、そのままhsvからRGBの変換関数を作成してみた。

def hsv_to_rgb(h, s, v):
    """ Convert hsv color code to rgb color code.
    Naive implementation of Wikipedia method.
    See https://ja.wikipedia.org/wiki/HSV%E8%89%B2%E7%A9%BA%E9%96%93
    Args:
        h (int): Hue 0 ~ 360
        s (int): Saturation 0 ~ 1
        v (int): Value  0 ~ 1
    """

    if s < 0 or 1 < s:
        raise ValueError("Saturation must be between 0 and 1")
    if v < 0 or 1 < v:
        raise ValueError("Value must be between 0 and 1")

    c = v * s
    h_dash = h / 60
    x = c * (1 - abs(h_dash % 2 - 1))
    rgb_dict = {
        0: (c, x, 0),
        1: (x, c, 0),
        2: (0, c, x),
        3: (0, x, c),
        4: (x, 0, c),
        5: (c, 0, x),
    }
    default = (0, 0, 0)
    rgb = [0, 0, 0]
    for i in range(len(rgb)):
        rgb[i] = (v - c + rgb_dict.get(int(h_dash), default)[i]) * 255
    rgb = map(int, rgb)
    return tuple(rgb)

あとはhを0~360までfor文で変化させたものをgifにすればトップの色が変化していく画像ができあがる。

for i in range(60):
    color = hsv_to_rgb(i*6, 1, 1)
    img = Image.new('RGB', (width, width), (0, 0, 0))
    draw = ImageDraw.Draw(img)
    draw.rectangle((0, 0, width, width), fill=color)
    images.append(img)

images[0].save("./color.gif", save_all=True, append_images=images[1:], optimize=False, duration=5, loop=0)

せっかくだからグラデーションにでもすればもっと見目麗しい画像になった気がする。 あとpillowは簡単にgifが出来て便利。(小並感)

BoundingBoxごと画像を回転・反転・リサイズ

物体検出の前処理だったりデータ水増しを使用とすると、どうしても回転、反転、リサイズなどの処理を行う必要が出てくる。 画像だけであればOpenCV使ったりNumpy使えば、サクっとできるがバウンディングボックスだと若干めんどくさい。(特に回転) ライブラリを作る中で実装したので、それぞれの簡単な実装を紹介する。

前提

BoundingBoxの表現として以下のようなBoxクラスを使う。x、yは画像左上の座標、w, hはBoundingBoxの幅と高さを表している。

画像の読み込みにはopencvを使い(height, width, channel)の形をしたnumpy配列として読み込む。デフォルトだとEXIFオリエンテーション情報を持っている場で合にはそれが適用されてしまうので、今回はcv2.IMREAD_IGNORE_ORIENTATIONで画像の回転に関する情報は無視する。

あと便利なので描画用の関数showを用意する。cv2にはデフォルトで透過した矩形を描く機能が無いので自前で用意する必要がある。

import cv2
import numpy as np


class Box:
    def __init__(self, x, y, w, h):
        self.x = x
        self.y = y
        self.w = w
        self.h = h

def show(image, boxes, color, alpha):
    image = image.copy()
    for b in boxes:
        overlay = image.copy()
        p1 = (int(b.x), int(b.y))
        p2 = (int(b.x + b.w), int(b.y + b.h))
        cv2.rectangle(overlay, p1, p2, color, -1)
        image = cv2.addWeighted(image, alpha, overlay, 1-alpha, 0)
    cv2.imshow('img', image)
    cv2.waitKey()

image = cv2.imread(filepath, cv2.IMREAD_IGNORE_ORIENTATION | cv2.IMREAD_COLOR)

回転

今回の3つの操作の中だと一番めんどくさい気がする。画像の回転をAffine変換でやれば、そのときに変換行列を計算しているはずなのでBoundingBoxの座標変換にもそれを流用してしまうのが良い。注意点として4隅の座標を変換すると、変換後のBoundingBoxが辺がx, y座標と平行にならない。そのため変換後の座標から画像の辺と平行になるよう再度x, y座標を計算しなおす必要がある。

def rotate(image, boxes, angle):
    img = image.copy()
    # 初期状態での高さと幅
    h, w = img.shape[:2]

    # sinとcosを駆使して回転後の画像サイズを計算
    r = np.radians(angle)
    s = np.abs(np.sin(r))
    c = np.abs(np.cos(r))

    nw = int(c*w + s*h)
    nh = int(s*w + c*h)

    # 回転行列を計算+サイズの変化に伴う補正を加える
    center = (w/2, h/2)
    rot_m = cv2.getRotationMatrix2D(center, angle, 1.0)
    rot_m[0][2] = rot_m[0][2] + (nw - w) // 2
    rot_m[1][2] = rot_m[1][2] + (nh - h) // 2

    # アフィン変換を実行
    img = cv2.warpAffine(img, rot_m, (nw, nh), flags=cv2.INTER_CUBIC)

    new_boxes = []
    for box in boxes:

        # Boxの情報を座標に
        coord_arr = np.array([
            [box.x, box.y, 1],  # Left-Top
            [box.x, box.y+box.h, 1],  # Left-Bottom
            [box.x+box.w, box.y, 1],  # Right-Top
            [box.x+box.w, box.y+box.h, 1],  # Right-Botto
        ])

        # 回転行列と掛け合わせて座標変換
        new_coord = rot_m.dot(coord_arr.T)
        x_ls = new_coord[0]  # x座標だけを抽出
        y_ls = new_coord[1]  # y座標だけを抽出

        # 最小値最大値を用いて、新しい四隅の位置を計算
        x = int(min(x_ls))
        y = int(min(y_ls))
        w = int(max(x_ls) - x)
        h = int(max(y_ls) - y)
        new_box = Box(x, y, w, h)
        new_boxes.append(new_box)
    return img, new_boxes

反転

画像自体の反転はnumpyのflipudとfliplrで簡単にできる。BoundingBoxの計算も、xの値を左端までの距離から右端までの距離に、yの値を上端までの距離から下端までの距離に計算しなおすだけなので簡単。

def flip(image, boxes, flip_x=True, flip_y=False):
    img = image.copy()
    ih, iw = img.shape[:2]

    # 引数に応じてどの軸で反転させるかを判断
    # デフォルトではx, y軸双方で反転させる
    if flip_x:
        img = np.fliplr(img)
    if flip_y:
        img = np.flipud(img)

    # BoundingBoxの座標を計算
    new_boxes = []
    for box in boxes:
        x = box.x
        y = box.y
        if flip_x:
            # 右端からの距離
            x = iw - box.x - box.w
        if flip_y:
            # 下端からの距離
            y = ih - box.y - box.h
        new_box = Box(x, y, box.w, box.h)
        new_boxes.append(new_box)
    return img, new_boxes

リサイズ

def resize(image, boxes, width, height):
    # 現在の高さと幅を取得しておく
    c_height, c_width = image.shape[:2]
    img = cv2.resize(image, (width, height))

    # 圧縮する比率(rate)を計算
    r_width = width / c_width
    r_height = width / c_height

    # 比率を使ってBoundingBoxの座標を修正
    new_boxes = []
    for box in boxes:
        x = int(box.x * r_width)
        y = int(box.y * r_height)
        w = int(box.w * r_width)
        h = int(box.h * r_height)
        new_box = Box(x, y, w, h)
        new_boxes.append(new_box)
    return img, new_boxes

結果

上の操作をすべて実行するとこんな感じで変換されていく。

image = cv2.imread(filepath, cv2.IMREAD_IGNORE_ORIENTATION | cv2.IMREAD_COLOR)
boxes = [Box(83, 108, 267, 124)]

show(image, boxes, (255, 0, 0), 0.5)

# Rotate
image, boxes = rotate(image, boxes, 90)
show(image, boxes, (255, 0, 0), 0.5)

# Tilt
image, boxes = rotate(image, boxes, 3)
show(image, boxes, (255, 0, 0), 0.5)

# Flip
image, boxes = flip(image, boxes)
show(image, boxes, (255, 0, 0), 0.5)

# Resize
image, boxes = resize(image, boxes, 500, 500)
show(image, boxes, (255, 0, 0), 0.5)

f:id:theflyingcat28:20200626211033p:plainf:id:theflyingcat28:20200626211037p:plainf:id:theflyingcat28:20200626211041p:plainf:id:theflyingcat28:20200626211044p:plainf:id:theflyingcat28:20200626211049p:plain
変換の推移(左から右に変換されていっている)

今回の3操作はannt-python にも実装されている。

mikebird28.hatenablog.jp mikebird28.hatenablog.jp

Dropbox上の画像を直接アノテーションできるサービスを作った。Part 2

前回に引き続きanntというサービスを作った話です。 part.2 では主に技術面の話について書いていきます。

目次

フロントエンドの話

今回始めてvue.jsを使ったのですが本当に便利です。何が便利かってサイトを構成するパーツを分けて実装できるのが本当に楽でした。anntはvue-routerを使ってSPAとして作成したのですが、思ったよりぬるぬる動いてこれは良いものだとなりました。実装していく際にも各ページ毎にComponentとして実装していくので、特にファイルが肥大化することもなく読みやすく作れます。勢いで作ったので一部は肥大化してしまいましたが。

これまでjs周りでjquery以上の何かを使ったことが無かったので、個人用のツール作るときは読みづらい+肥大化する⇒放置というパターンに陥っていたのですが、適当な粒度でパーツ毎に切り出していけるのでダイアログとか作るのはすごい楽でした。

f:id:theflyingcat28:20200621212417p:plain
vueで作ったダイアログ

正直Reactなどの他の仮想DOMライブラリとの違いを把握していませんが、何か新しいWebアプリ作りたいってなったら学習コスト含めてもこの手のライブラリを使っていくのが良いのかなぁと思いました。

個人的に気になるのはこの先どのくらいサポートされるのだろうか...という点です。なんかしょっちゅう新しいライブラリが出てきているイメージがあるので、徐々に廃れていって突如開発中止になるのは怖いなぁと思います。まぁでもこれだけ勢いのあるライブラリであればしばらくは心配する必要も無いのでしょうか。

canvas周りについて

実際にアノテーション作業を行う画面はほとんどcanvasでできています。作業の大半の時間はここを作るために費やされた気がします。大変だったのは座標の変換処理とBoundingBoxを作るところでした。

座標の処理を作っている際は発狂していました。addEventListenerでclickなどのeventの座標を取得⇒canvas上の座標に変換⇒画像上の座標に変換、と言葉にしてしまえば3ステップですが、画像を回転させたりすると変換がどんどん複雑になって沼にハマりました。本当はこの辺の座標変換の処理を上手く適当なクラスに隠蔽して画像上の座標だけを意識して作業できるようにすれば良かったんですが、気がついたら後戻りできないところにいた気がしたので力づくで成し遂げました。リファクタリングは今後の課題です。

BoundingBoxはいつか見た下のリンクに非常に参考になる実装があったので、ありがたく勉強させて頂きました。

hashrock.hatenablog.com

これまでペイントツールとかで良く使っていたもののはずが、いざ実装してみるとBoundingBoxのことを全然理解していないということに気が付きました。特に画面外に持っていこうとしたときの処理とか全然作れなくて、結局動いているのかどうか怪しい実装になってしまった気がします。

DropboxAPI

DropboxAPIを公開しているのでoauthで認可してもらえれば普通に叩けます。ドキュメントがしっかりしているのでそこまで苦労はしませんでしたが、APIによって引数の渡し方が違ったりするとこで少し躓きました。でもドキュメントをしっかり読んでれば回避できたことなので、ドキュメントをしっかり読みましょうという教訓を得ました。あと作りきった後にSDKが存在していることを知りました。下調べをしっかりしましょうという教訓も得ました。ここに関しては反省しかないです。

バックエンドの話

firebaseでjavascriptを使いました。どうしてもクライアント側に持たせたくない処理だけをサーバー側で持つようにしました。なので全部で500行行ってなかったです。firebaseは安いし楽だし便利なのですが、個人的にはKey-Value型のやつがよくわからないのでRDBが使いたかったです。それ以外は満足です。

pythonライブラリの話

特に書くことも無いのですが、こだわったポイントとしてはプロパティを使って, x, y, width, heightという表記でも, left, top, right, bottomという表記でもどちらでも使えるようになっています。widthを書き換えればrightの表記も書き換わるので割と便利だと思います。ぜひ使ってみてください。自分の環境でしかテストが出来ていないので動くかが不安ですが。

画像の読み込みだったり表示だったりはopencvでやっているので全部でこちらも500行くらいでした。READMEだったりsetup.py書く時間の方が長かったです。

まとめ

いろんなライブラリがあって本当に便利な世の中だなぁと思いました。なので作業する前にちゃんと公式のライブラリが無いか探してドキュメントを読む癖をつけましょう。お兄さんとの約束です。作り終わった後に必要だったライブラリ見つけると本当に時間を無駄にした感がすごいです。

mikebird28.hatenablog.jp

mikebird28.hatenablog.jp

Dropbox上の画像を直接アノテーションできるサービスを作った。Part 1

個人開発でサービス作りました。宣伝かねて苦労話・モチベーションとか個人開発に興味がある方に向けて公開します。 まずはPart 1ということで、コンセプトだったり動機だったりとメンタル面の話です。(技術の話も少し触れます)

何を作ったの

anntというブラウザ上で動作するアノテーションツールです。

f:id:theflyingcat28:20200618235123j:plain
anntのアノテーション画面

特徴としては

という点でvottとかlabelimgとかとは差別化できてるのかなと思います。開発期間は約1ヶ月+数日です。

作った動機

一番の動機は自分の技術を具体的な形で世に送り出したかったということだと思います。要するに承認欲求です。今まで割と趣味でプログラムを書いてきたんですけど競馬の予測だったり、自分で使うためのAI用のライブラリだったり自分の中だけで完結していました。そんな中で突如自分が作ってきたものは独りよがりのゴミではとネガティブな感情が湧いてきて気がついたらサービス開発を始めていました。

そんな中、既存のアノテーションツールがイケてないなと思っているうちに(vott使っていました)、気がついたら開発を始めていました。割と勢いって大事だなとこのとき思いました。あとお金ですね。一発うまく当てたら儲かるんじゃないかという下心です。思い返してみると100%下心というかドロドロした理由で開発が始まっていました。

当初の構想と開発の流れ

割と勢いで始まったプロジェクトですが、割と最初にコンセプトは考えていました。vott使っていて微妙だなーと思っていたのが、

  • jsonに画像のパスが絶対パスで書かれていて移動しづらい
  • 出力結果のパーサー作るのめんどい(調べてないだけでライブラリがあるかも)
  • iPad買ったのでiPadでも作業したい

というような点でした。じゃあこれ解決した僕の考えた最強のアノテーションツールを作ればいいってことじゃん!って思ってまずはコンセプトをまとめます。

Step.1 コンセプト設計

  • どこでも作業ができて
  • 作業が終わったらすぐにAIの開発に集中できて
  • 一度アノテーションしたらフォルダのコピーとかも簡単にできる!

これでコンセプト完成!やったぜっ!ってなります。コンセプト考えるのそんなに大事?作りながら考えれば?という意見もありますが、個人的には何をしたいか・譲れない点を明確にしておいたほうが挫折しにくくなると考えています。 一部のスーパープログラマーでも無い限り理想の姿に実装速度は追いつきません。そこで大事になってくるのが機能の実装をいかに上手く諦められるか、だと思います。

例に取るとanntの当初の機能要件は

  • DropboxとOneDriveとGoogleDriveで動作して
  • PC・スマホタブレットでも動作して
  • 出力は独自形式はもちろんのことPACAL VOCとかもサポートして
  • BoundingBoxはもちろん他のタスクにも対応して
  • 専用pythonライブラリでは基本的な前処理もサポート etc...

というキラキラの夢物語でした。実際にはDropboxでしか使えなくて、基本はPC上(一部タブレットも)、出力は独自形式。というような感じのものになったのでいかに夢に向かって歩く中で要らない機能が切り捨てられたかがわかるかと思います。人は強くないので最初にコンセプトを明確にして、あとで辛くなったときに必要の無い機能は実装しないという逃げ道を用意しておくのがいいと思います。

Step.2 使用技術の選定

使ったのはfirebase+vueです。すべてjavascriptです。今までjavascriptなんて最低限しか触ったことが無かったので最初は大変でした。

じゃあ何でjavascriptにしたの?って話になりますが、主に作業負荷の軽減です。まず最初にコスト的な面でfirebaseを採用するのは決めていました。小さいアプリであればスケールもして安いfirebase等のサービスを使うに限ります。firebase使うとなるとあまり複雑な処理はサーバー側でしたくないなと思ってSPAにすることにしました。じゃあvueでいいやとノリで決めて、サーバー再度も頭の中で言語切り替えるのめんどくさいのでjavascriptでいいやとなりました。適当ですね。あまり後悔していないのでまぁいいかと思ってます。

Step.3 もくもくと開発

あとは正直もくもくと開発していくだけです。開発の細かい話はまた今度書けたらと思っています。

モチベーションの維持

完成に近づいてない実感が一番モチベーションが折れます。完成に近づいてない気がしてくると、このサービスゴミでは...他の競合があるので使ってくれないのでは...という魔が入り込んできます。ほっておくとシャドウになってゲームオーバーになるので、まずは最短で何かしら完成するのがいいのかなと思っています。ちなみに無事公開を終えた今は晴れ晴れとした気持ちです。

折れそうになったら他の人の開発記とか読むとMPが回復します。 例) uyamazak.hatenablog.com

www.memory-lovers.blog

shinyorke.hatenablog.com

逆に似たことやってる人見つけるとMPが削れるので注意してください。 例)企業なので比にならないレベルで良さげなものを作っています。

engineer.dena.com

個人開発やってみたいと思っている人へ

自分で実際にやってみて思ったのは、考えているだけじゃなくてまずは手を出してみること、終わらない...もうマジ無理...ってなったときは切り捨てることだと思います。実はanntの前にサービスを作っていたのですが、割と重めのバックエンドでの処理+画像認識モデル3つが必要になるサービスで70%ぐらい完成したところで諦めました。実際に作り始めてみると開発はなかなか進みません。なのでまずは現実的なゴールを設定して走り切るのが大事なのかなと思っています。まだ生まれたてのサービスしか作ってない癖に偉そうなこと言って申し訳ないですが、ぜひ個人開発やってみてください。

さいごに

締めと見せかけて宣伝です。anntは現在プロジェクトを3つまで作成できますが。将来的には無料枠は1プロジェクト、それ以上は有料という形式にしようとしています。ただ今始めていただければ将来的にも3プロジェクトまで無料で使えるようにしようと考えているので、ぜひ皆さん登録してください!(必死)

mikebird28.hatenablog.jp

mikebird28.hatenablog.jp

パッケージを作成してpypiにアップロード(2020/06)

作成したライブラリをpypiに公開しようとしたが情報過多で迷子になった。
やるべきことをトップダウンに書き出せば後世の人々が救われる気がしたのでメモを残す。
困ったことがあれば公式のチュートリアルを見るのが一番。

docs.python.org

  1. ライブラリの準備
  2. 必要なファイルの準備

    • setup.pyを作成
    • setup.cfgの作成
    • LICENCEファイルの準備
    • READMEの準備
  3. 配布物の作成

    • sdistの準備
    • bdist, wheelの準備
  4. 公開の準備

    • pypiに登録
    • 配布物のアップロード

ライブラリの準備

以下3つのライブラリが最低限必要 * setuptools * wheel * twine 脳死で以下のコマンドを実行すればOK。

python -m pip install setuptools wheel twine

必要なファイルの準備

LICENCEやREADMEなどは正直用意して配置するだけなので取り立てての説明は必要に無いと思うが、setup.pyとsetup.cfgに関しては歴史的な経緯で見るサイトによって様々な方法が書いてあるように思う。2020年6月の時点ではsetup.pyは最低限にして、setup.cfgに必要な情報を記載していくのがいいようだ。

setup.py

かつてのdistutilやsetuptoolsではsetup.py内に必要なメタデータをすべて記載しなければならなかったが、setuptoolsのバージョン30あたりを皮切りに自動でsetup.cfgに記載されているデータを読み込んでくれるようになったようだ。なのでsetup.pyは以下のように最低限必要な内容(3行)にして、静的な情報はsetup.cfgに記載するのが管理しやすいように思う。素人なのでよくわからないがインストール時に動的に変更したい内容などがある場合にはsetup.pyに記載するのがいいのだろうか。

#!/usr/bin/env python
from setuptools import setup
setup()

setup.cfg

[metadata]
name = project name
version = 0.0.1
author = Taro Tanaka
author_email = example@example.com
description = project short description
long_description = file:README.md
long_description_content_type = text/markdown
url = https://mikebird28.hatenablog.jp/
licence = file: LICENCE
python_requires = >=3.5
classifiers = 
  Development Status :: 2 - Pre-Alpha
  License :: OSI Approved :: MIT License
  Programming Language :: Python :: 3.5
  Programming Language :: Python :: 3.6
  Programming Language :: Python :: 3.7
  Programming Language :: Python :: 3.8
  Programming Language :: Python :: 3.9

上みたいな感じで必要な情報を記載していけばいい。long_descriptionやlicenceなど情報量が多くなりそうなところは、file: という表記を使って外部ファイルを読み込むことができる。分かりづらいのはclasifiersとlong_descriptionだろうか。

classifiersに指定した情報はユーザーがpypi内で検索するときなどに使われる。Classifiers · PyPIに使用可能なclassifier一覧があるのでここからコピーして貼り付けて行けばいい。種類がありすぎてざっとしか目を通せなかったが、開発状況(アルファ、ベータなど)、自然言語(日本語、英語など)、プログラミング言語pythonのバージョンだけでなく、CやRなどの他の言語も指定できる)、ライセンス、その他トピックなどが指定できるようだ。

long_descriptionには直接setup.cfgに記載してもfile:でREADMEを読み込んでもどちらでも大丈夫だが、MARKDOWNファイルの場合にはlong_description_content_typeでファイルの形式を明示的に指定する必要がある。

README.md

README。気合入れて書く。この記事参考にして頑張った。 deeeet.com

LICENCE

ライセンスを選ぶ。GPLBSD、MIT等々好きなやつを選んでLICENCE.txtに記載する。

配布物の準備

これまでの作業で必要なファイルの準備が整ったので、公開に向けてパッケージを配布可能な形式に変換する。pypiでライブラリを公開するには以下2種類のアーカイブを用意する必要がある

これらのファイルをpypiにアップロードすると現在のpipではwheel形式を優先的にインストールする。

sdist

python setup.py sdist

bdist

python3 setup.py bdist_wheel

dist/以下にアーカイブされた配布用ファイルが配置されている。

  • dist/package_name.whl
  • dist/package_name.tar.gz

最悪sdistのみで配布して、ユーザー側でインストールするときにビルドしてもらえば最悪大丈夫?
ただwheel形式で配布することで不利益を被ることは無さそうなので、作成しない選択肢は無さそう。

pypiへの登録

pypiは本番環境とテスト環境の2つがあるので、それぞれアカウントを作成する。

アカウントの準備が整ったらtwineを使ってテスト環境にパッケージをアップロードする

テスト環境へのアップロード

python -m twine upload --repository testpypi dist/*

本番環境へのアップロード

python3 -m twine upload --repository pypi dist/*

最後にpypiにログインして正しくアップロードができているかを確認すれば終了。 ちなみに今回自分が作成したパッケージは↓なので興味があれば見てみてください。現時点では使い物にならないです。 pypi.org

参考文献

Python パッケージングの標準を知ろう - Tech Blog - Recruit Lifestyle Engineer

過去に計算したDataFrameと内容が同じか高速に判定したい

同じ前処理を実行するたびに数十分取られるのが非常に辛く感じる。 特に同じ入力を入れているのに実行するたびに計算をしなおすのが馬鹿らしく感じる。 かといってDataFrameを丸々キャッシュして、関数呼び出すたびに前回のDataFrameと同じか検証するものアホらしい。

もやもやしつつfeatherとhashでうまいことやれば高速に検証できね?って思ったので頑張ってみた。

コンセプトは以下の通り 1. featherを使ってDataFrameをbytesに変換 2. 高速なハッシュアルゴリズムxxhashを使ってbytesのハッシュ値を計算。 3. 初回はハッシュ値とDataFrameを何かしらの形でファイルに保存する。 4. 2回目以降はハッシュ値を計算して、前回のハッシュ値と同じだったら保存されている計算結果を読み込み。

pyarrowを使うとfeatherの出力先にfile-like objectが使えるので、シリアライズしたDataFrameをメモリ上(io.BytesIO)に吐き出せて良い。

以下実装

import io
import pyarrow.feather
import xxhash
import os
import gc
import pandas as pd

def get_hash(df):
    fp = io.BytesIO()
    pyarrow.feather.write_feather(df, fp)
    b = fp.getvalue()
    fp.close()
    h = xxhash.xxh64(b).hexdigest()
    return h

def dfcacher(func, df, name, path="./", verbose=False):
    h = get_hash(df)
    h_path = os.path.join(path, name+".xxhash")
    f_path = os.path.join(path, name+".feather")
    
    # 前回のハッシュ値を読み込み
    try:
        with open(h_path, "r") as fp:
            saved_h = fp.read().rstrip('\n')
    except FileNotFoundError:
        saved_h = ""
        
    if h == saved_h:
        # 前回とハッシュ値が変わらないのであれば計算結果をキャッシュから読み込み
        if verbose: print(f"Hash value of  {name} is not changed. Load dataframe from cache.")
        del(df)
        gc.collect()
        df = pyarrow.feather.read_feather(f_path)
    else:
        if verbose: print(f"Hash value of  {name} is changed.")
        # ハッシュ値が変わっていれば計算して計算結果を保存
        df = func(df)
        with open(h_path, "w") as fp:
            fp.write(h)
        df.to_feather(f_path)
    return df

# テストケース1
df1 = pd.DataFrame([[1, 2, 3], [4, 5, 6]])
df2 = pd.DataFrame([[1, 2, 3], [4, 5, 6]])
assert get_hash(df1) == get_hash(df2), "ハッシュ値が異なります"

# テストケース2
df1 = pd.DataFrame([[1, 2, 3], [4, 5, 6]])
df2 = pd.DataFrame([[1, 2, 4], [4, 5, 6]])
assert get_hash(df1) != get_hash(df2), "ハッシュ値が等しいです"

# テストケース3
def test_func1(df):
    df = df.apply(lambda x: x+2)
    return df

df1 = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["c1", "c2", "c3"])
df2 = pd.DataFrame([[1, 2, 4], [4, 5, 6]], columns=["c1", "c2", "c3"])
df = dfcacher(test_func1, df1, "test_func1", verbose=True)
df = dfcacher(test_func1, df1, "test_func1", verbose=True)
df = dfcacher(test_func1, df2, "test_func1", verbose=True)

問題点としては高速にシリアライズするためにfeatherを使ってるので、DataFrameがfeatherフォーマットの条件を満たしてないとうまく動かない。 あと余りしっかり検証していないこと。まだ実際に使ってないので使ってる途中になにか問題が出てきそうな気がする。 ただ体感としてhash値の計算はかなり高速に動作している。今度なにか機会があったら速度の検証をやろうと思っている。

深層学習+Hough変換で紙の領域検出

kaggleやるよ何て名前をつけておいて1年半くらいkaggleやってませんでした。

その間何やってたかって言うとAI使ったWebサービスを作ろうとして挫折してを繰り返してました。 今のところ何の生産物も無いっていう悲惨な状況なんですけど、しょうもないノウハウ達は手に入れたのでせめてもの手向けとして公開しようかなと思う。

① 紙の領域検出

画像の中に含まれる領域を検出するメジャーな方法としてはCanny特徴量でエッジ検出+Hough変換で直線の検出っていう組み合わせがメジャーだと思います。 ただCanny特徴量は紙以外の物体が入っていたり紙面に色々描かれていたりすると、余分なエッジまでも検出しちゃってその後の工程が失敗しちゃう可能性があったりします。

f:id:theflyingcat28:20191109214911j:plain
Cannyでのエッジ検出の例
なので深層学習とかで都合よく検出できたりしないかなーって探していたら、偉大なDropboxが技術ブログを公開してました。

blogs.dropbox.com

今のChromeには素晴らしい翻訳機能があるので紹介するまでもないとは思いますが、彼らがやってることとしては、

  1. 何らかの深層学習の方法(各ピクセルのエッジらしさを0~1の小数で返す)でエッジを検出
  2. 検出したエッジに対してHough変換で直線を検出
  3. 得られた直線から構成可能な四角形の候補をリストアップ
  4. DNNの出力(0~1の実数)を四角形の辺に沿って平均してスコアとする。
  5. スコアの最も高かった四角形を、紙の領域として提案

というような感じでした。 ただ肝心な「何からの深層学習の方法」ということが示されていなかったので適当にCNNを使ったエッジ検出方法を見ていたら何個か見つかりました。

HED(Hostically-Nested Edge Detection)

画像をCNNで畳み込んで行き、各レイヤーの出力をSide Layerと呼ばれる層で逆畳み込みしてまとめて結合して出力という方法。画像中に含まれるエッジのピクセルの数は非エッジのピクセルに比べて圧倒的に少ないので、エッジピクセルの数に応じて誤差関数を重み付けしたりエッジ検出に特化した工夫がなされている。詳細は参考URLを見てください。この手法のメリットはVGG16を転移学習して使うので学習コストが軽い点だと思います。逆にデメリットはネットワークの構造的に画像の情報を余り深く理解できていなさそうなとこ。ただ紙の認識では雑に紙のエッジを検出してくれれば大丈夫なので、そんなデメリットは気にしなくていい気もします。高みを目指したい人はネットワークの構造をM2DetみたいにPyramid構造とかに変更したら精度あがるんじゃないかと思う(妄想)。

f:id:theflyingcat28:20191014235129p:plain
HEDの論文から引用

論文URL: https://arxiv.org/abs/1504.06375

参考URL: http://blog.livedoor.jp/tmako123-programming/archives/51956636.html

CASENet

HEDは画像の各ピクセルに対してエッジかそうでないかを学習する手法でしたが、CASENetはラベルが付けられた複数種類のエッジを検出するための手法です(Semantic Edge Detection)。HEDは二値分類問題を前提としているので、複数種類の矩形物体を検知したい場合にはこちらを検討することになると思います。自分では試してないので何とも得いませんが、論文を読む感じ基本的なネットワークのコンセプトはHEDに近しいものを感じます(当然細部は全く異っていますが)。 参考URL: https://arxiv.org/abs/1705.09759

HEDは2015年、CASENetは2017年の論文なので最新の論文を漁ればもっと良さげな手法もあるかと思いますが、とりあえず紙を検出するくらいであれば、この辺の手法で行けるのではと思います(CASENetの方は試してないので何とも言えませんが)。

試してみた

HEDの中間層を適当に変えたり出力層のチャネル数変えたり色々改造したもの(細かい条件は忘れた)を使って実際の紙を認識できるか試してみました。 f:id:theflyingcat28:20191109215322p:plain Cannyは紙だけでなくペンのエッジも拾ってるのがわかります。 一方、HED使って予測したものはペンのエッジは検出されず、紙の輪郭だけをしっかり拾っているのが確認できます(ペンの部分を補完することはできていませんが)。今回はシンプルな画像で試しましたが色々写り込んでいてCannyでうまくエッジの矩形の検出ができないときは、深層学習+Hough変換でもいいかもねっていう記事でした。