任意のアニメキャラを一筆書きで数式にするやつ (Pythonで)

TL;DR

任意のアニメキャラを一筆書きで数式にできる (うまくいけば)

アニメキャラの数式

一昔前になりますが、アニメのキャラクターを数式化するのが流行ったことがあると思います。

こういうやつ↓

nlab.itmedia.co.jp

こういう初音ミクの数式みたいなアニメキャラの数式を作りたいな~と思っていたのですが、Mathematicaで作る方法しかなかったりとあまり楽な方法がなかったので、 Pythonでそれっぽいものが再現しようというのが今回の試みになります。

方法

おおよそMathematicaに習いつつ以下の方法で行います

  • アニメキャラの画像から輪郭を抽出
  • 抽出した輪郭から線分となる部分に分割
  • 分割した線分の交点を求めグラフを構築
  • 中国人郵便配達問題を考えて一筆書きのルートを構築
  • フーリエ変換してごにょごにょ

一つ一つ説明していきます

輪郭の抽出

今回はPythonで処理を行っていくのでOpenCVを用いて輪郭の抽出を行っていきます。

今回はVtuber赤井はあとちゃん (通称: はあちゃま) を例に数式にしていきます。

まずは画像を読み込みます

orig_img = cv2.imread("./haato.png", cv2.IMREAD_UNCHANGED)
orig_img = cv2.cvtColor(orig_img, cv2.COLOR_BGR2RGB)
plt.figure(figsize=(10, 10))
plt.imshow(Image.fromarray(orig_img))

f:id:cucmberium:20200609194121p:plain

次に読み込んだ画像を白黒に変換します

gray_img = cv2.cvtColor(orig_img, cv2.COLOR_RGB2GRAY)

f:id:cucmberium:20200609194252p:plain

こうして変換した白黒画像に対して覆い焼きカラーといわれるレイヤー合成手法を使って輪郭を抽出します。

the2g.com

layer_img = 255.0 - gray_img
layer_img = cv2.GaussianBlur(layer_img, (0, 0), 1)
curve_img = np.clip(gray_img.astype(np.float32) * 255 / (255 - layer_img), 0, 255).astype(np.uint8) # 覆い焼きカラー

f:id:cucmberium:20200609194726p:plain

これだと色が薄いので、閾値を設定して線を濃くします。

threshold = 240
curve_img[curve_img >= threshold] = 255
curve_img[curve_img < threshold] = 0

f:id:cucmberium:20200609194914p:plain

ここまでPythonで頑張って変換してきましたが、どうしても元画像の性質によって線が太くなったり、二重になったりする部分があると思います。 ここをPythonで完全にきれいにする方法がいまいち思いつかず、今回は手動でのクリーニングを行っています。

画像によってはこの工程はいらないかもしれません。 (シンプルな線画等)

結果として以下のようにペイントで修正しました。

f:id:cucmberium:20200609195245p:plain

抽出した輪郭から線分を抽出

輪郭から線分を抽出する部分はMathematica版の元ネタをPythonに移植して用いました。

aomoriringo.hateblo.jp

コードは長くなるのでgistにあげたJupyter Notebookを参照してください。

結果として以下のように複数の線分が抽出できます。

f:id:cucmberium:20200609195738p:plain:w300

この抽出した線分についてグラフを構築するために線と線が交わる交点について線を分割します。(ここら辺からコードが汚くなってきます)

split_points_data = []
for line in tqdm.tqdm(lines):
    split_points = []
    for point in line:
        for line2 in lines:
            if line == line2:
                continue
            
            if len(split_points) > 0:
                distances = np.linalg.norm(np.array(split_points) - np.array(point), axis=-1)
                nearest_points_length = (distances <= neighborhood_size).sum()
                if nearest_points_length > 0:
                    continue
            
            distances = np.linalg.norm(np.array(line2) - np.array(point), axis=-1)
            nearest_points_length = (distances <= 1.5).sum()
            if nearest_points_length > 0:
                split_points.append(point)
                break
                
    split_points_data.append(split_points)

線分を分割すると大体以下のようになります。

f:id:cucmberium:20200609200149p:plain:w300

分割した線分の交点を求めグラフを構築

線と線が交わる点を抽出してグラフを構築します。 グラフの構築には便利なnetworkxというライブラリを使います。

point_to_node_dict = {}

cnt = 0

for line in lines:
    start_point = line[0]
    for point in point_to_node_dict:
        distance = np.linalg.norm(np.array(point) - np.array(start_point), axis=-1)
        if distance <= 4:
            point_to_node_dict[start_point] = point_to_node_dict[point]
            break
    else:
        point_to_node_dict[start_point] = cnt
        cnt += 1

for line in lines:
    end_point = line[-1]
    for point in point_to_node_dict:
        distance = np.linalg.norm(np.array(point) - np.array(end_point), axis=-1)
        if distance <= 4:
            point_to_node_dict[end_point] = point_to_node_dict[point]
            break
    else:
        point_to_node_dict[end_point] = cnt
        cnt += 1

G = nx.Graph()
G.add_nodes_from([x for x in range(max(point_to_node_dict.values()) + 1)])
for e, line in enumerate(lines):
    G.add_edge(point_to_node_dict[line[0]], point_to_node_dict[line[-1]], weight=len(line))

target_nodes = max(nx.connected_components(G), key=len)

この段階でほかの線と交わっていない線が除外されます。

中国人郵便配達問題を解く

中国人郵便配達問題は以下のようなものです (Wikipediaより)

Gを連結な無向グラフとし、Gの各辺には距離が割り当てられている。このとき、Gの辺をすべて通るような閉路のうち、距離の合計が最小になるものを求めよ。

「グラフの辺」、すなわち線画における各線分をすべて通る閉路で最も短いものを選べば、比較的きれいな一筆書きになるのではという考えです。

今回はortoolpyという中国人郵便配達問題をとけるライブラリがあるのでそちらを先ほど作成したグラフに適用します。

from ortoolpy import chinese_postman
_, path = chinese_postman(G)

実際に数式にする

フーリエ変換して係数を取り出して数式にします。 実際にはフーリエ変換する前に適当にreparameterizeしたりしているのですが、ここでは割愛…

x_fft = fft([point[1] for point in reparameterized_single_stroke_line])
y_fft = fft([point[0] for point in reparameterized_single_stroke_line])

def anime_character_equation(t, n):
    k = np.arange(n)
    c = np.ones(n)
    c[0] = 0.5
    x = np.sum(c * x_fft.real[:n] * np.cos(k * t) + c * x_fft.imag[:n] * np.sin(k * t))
    y = np.sum(c * y_fft.real[:n] * np.cos(k * t) + c * y_fft.imag[:n] * np.sin(k * t))
    return x, y

ここでのnは次数を表しており、nを大きくすればするほど元の輪郭に近い線が得られます。 実際にnを増やしていくと輪郭がより正確になっていくことが以下の画像で分かると思います。

f:id:cucmberium:20200609233611g:plain

複雑な数式の場合、数式として表せないくらい大きな次数でないとうまく再現できないのですが、シンプルなものであれば少ない次数でも表せるかなと思います。 (上のgifはn=2000まで近似)

まとめ

次数が高くないとうまく表現できなかったり、線の交点の抽出が甘くてうまくいかなかったりと微妙なのですが、うまく改良できればよりよくアニメキャラが数式に変換できるかなと思います。

強い人に改良してほしいですね…

任意のアニメキャラを一筆書きで数式にするやつ (Pythonで) · GitHub

おわり

参考