加具留矢流余

かぐるやるよ

移転しました。

約3秒後に自動的にリダイレクトします。

vue.js+vue-routerで自作サービスの多言語対応 Part.1

以前作ったWebサービスの多言語対応を行った。 anntはvue-routerで動くSPAだったが、URLに応じて適切な言語で表示するように改修した。

多言語対応の方法

初期段階で多言語対応ってそもそもどうやるんだろうと思いながらWeb検索をしていると、だいたいどこのサイトでも以下の3通りがメジャーですよと教えてくれた。
4 Key SEO Tips for Your Multilingual Website in 2020

  • グローバルドメイン .jpや.frなどの国固有のドメインを購入して、ドメイン名によって言語を切り替える。 カッコいいとは思うがクソほどお金がかかるのでパス。

  • サブドメイン jp.example.comのように新たにサブドメインを作成して、サブドメインによって言語を切り替える。 1つのドメインサブドメインを設定していくだけなので、追加コストは0。 ただ余り見慣れないURLな気がして少し違和感を感じた+ドメイン周りの設定いじるのがめんどくさいという理由でスキップ

  • サブディレクト 冒頭に示した"https://annt.ai/ja"のような形式で、パスの先頭の値によって言語を切り替える。 ルーティングの設定だけで言語を切り替えられるので、楽+追加コスト無しということでこれを採用した。

当初は実装面の簡便さの観点からサブディレクトリ方式を選んだが、それ以外の要素ってどうなんだろうと思って調べてみるとSEO的にもサブディレクトリが良さそう。

www.seoblog.com

In these cases, you should almost always use subfolders. Subdomains lose a lot of power in terms of SEO, which makes a huge different with regards to marketing and search visibility. Moz has done repeated tests over the years and has determined that using subfolders is simply better SEO practice.

ほとんどの場合サブフォルダーを使用する必要があります。サブドメインSEOに関して多くの力を失い、マーケティングと検索の可視性に関して大きな違いをもたらします。Mozは何年にもわたって繰り返しテストを行っており、サブフォルダを使用するほうがSEOの実践として優れていると判断しました。

どれだけ信頼のおける情報かは分からないが、個人での検証のしようが無いので信じてみようと思う。

Vue.jsでの実装

言語の切り替え

Vue I18nという多言語対応用のライブラリが存在している。どうもi18nはinternationalizationの略語として使われているようだ。(国際化と地域化 - Wikipediaを参考:Wikipediaは何でも乗っていて本当にすごい。)

ライブラリを調べてみたは良いが使い方調べるのが面倒臭くなって、結局ライブラリは使わず言語の切り替えは自分で実装した。国際化という意味だと言語以外の要素(日にちの表現など)を本来は対応する必要があるのかなと思ったが、用途的に別に使わないと考えてjsonに記載したテキストデータから現在の言語に応じて適切なデータを取得する仕組みを作った。

イメージは↓みたいな感じで、各ページのcreatedのタイミングでURLに応じて言語を切り替えるための初期化処理(initLocale)を呼び出す。初期化後はmsgプロパティから現在の言語のテキストが取れるような仕組み。どのページでも使うのでmixinにしてある。当然タイトルなども変える必要があるので初期化後に書き換える。

// ページが読み込まれた際の初期化処理
  created() {
    try {
      // messagesは各言語での文章が書かれたjsonデータを読み込んだもの
      this.initLocale(messages);
    } catch {
      // Force to redirect route.
      this.$router.push('/');
    }
    common.SetTitle(this.msg.page_title);
    common.SetDescription(this.msg.page_description);
  },

多言語のテキストデータを格納したjson

肝心な初期化処理が割と長くなったので記載はしないが、URLのパスに応じてスイッチ文で切り替えるだけなので実装は非常に簡単。 あとはルーティングの設定やhreflangなどについても諸々変更したが、長くなったのでまた次回。

mikebird28.hatenablog.jp

mikebird28.hatenablog.jp

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

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

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値の計算はかなり高速に動作している。今度なにか機会があったら速度の検証をやろうと思っている。