ゆゆ式 Advent Calendar 2015のオープニングと4コマ切り出しプログラム

(12/1 12:10 追記したり斜めの画像を差し替えたりしました)

オープニング的なご挨拶

お久しぶりです。私です。

去る11月26日にBD-BOXが発売し、来年3月にはイベント開催も決定し、三上小又先生のインタビュー記事( 『ゆゆ式』を作り上げた大切なキーワードとは | アニメイトTV )が出てくるなど、アニメ本放送から2年以上経って新たな盛り上がりを迎える『ゆゆ式』ですが、今年もAdvent Calendarの季節がやってまいりました。

”Advent Calendarは本来、12月1日から24日までクリスマスを待つまでに1日に1つ、穴が空けられるようになっているカレンダーです。WebでのAdvent Calendarは、その風習に習い、12月1日から25日まで1日に1つ、みんなで記事を投稿していくというイベントです。”

おそらく初めて実施したであろう去年は、多くの方に参加していただき、『ゆゆ式』に関するイラスト・漫画・3DCG・考察・その他が集うバラエティ豊かなAdvent Calendarとなり、大いに盛り上がりました。 www.adventar.org

調子に乗って今年も募集してみたところ、この記事を書き始めるちょっと前くらいに25日分埋まったようです。ありがとうございます。 www.adventar.org

去年に引き続いて参加されている方も多い&似たような日付に陣取っていることが多いように見えるのがなるほどと思います。

それでは今年もゆゆ式 Advent Calendarの行く先を見守っていただければ幸いです。

4コマ切り出しプログラム

というところでオープニングは終わりなので私の責務は果たしたような気になっていますが、 せっかくなので去年、大変に好評だったエントリから影響を受けて作ったプログラムをご紹介します。

non117.hatenablog.com

上記がそのエントリですが、技術的にも発想的にも、愛や狂気が見て取れる素晴らしいエントリに仕上がっています。 この中にある枠線の検出方法が目からウロコ&自分でもコマを切り出したいという思いから後追いで作ってみることにしました。

成果物
  • 結論から言うと、記事中のロジック実装は難しくなかったのですが、手元の自炊した画像だと微妙に線が斜めになっているものがあり、それだと切り取るべき枠線がうまく検出できないようでした。
  • 斜めを修正することも考えましたが、色んな画像を切り取ることを考えると一括で処理できそうなものが思いつかなかったので、平均的な切り取り位置を検出して、一括で切り取ることにしました。
  • その際、Paddingを足してあげて広めに切り取ることで、できるだけほしい部分が切り取れているように調整しました。綺麗にする処理を別に作ってもよいかとは思います。
  • また、扉絵があっても気にせず定位置で切り取るので、そこは扉絵とコマを別に切り抜けるようにはしたいところです。

f:id:esuji5:20151201013946j:plain

平均に近い位置で切り抜けている画像

f:id:esuji5:20151201014027j:plain

横に寄った位置で切り抜かれている画像

f:id:esuji5:20151201120914j:plain

斜めがきつくて、うまく切り抜き位置を検出できないけど平均位置切り抜きでなんとかなった画像

Require
  • Python 2.7
  • OpenCV 2.4.12
  • numpy 1.9.2
    • Python以外のバージョンは最新でよいかと思います。
    • Pythonが2.7系なのは私がGoogle App EngineやTensorFlowに縛られているからです。
    • それ以外なら3.4系以上でよいかと思います。日本語でつまづく確率がぐっと減ります。

インストール手順は、記事の反応的に必要そうだったら追記します。1つだけ先に言っておくと、Windows系はOpenCVのインストールでハマる事が多いので最悪VMLinux環境を用意したほうがよいかもしれません。もしくは画像を扱っている部分をpillowに差し替えるなどでもよいと思います。

Code

avg_cut.py

# - * - coding: utf-8 - * -
import os
import sys
import glob
import cv2

import cut
from cut import CP_NUM_X
from cut import CP_NUM_Y

AVG_COUNT = 6


if __name__ == "__main__":
    # inputから指定のディレクトリを取得
    if len(sys.argv) <= 1:
        raise IOError(u'処理対象のディレクトリパスを入力してください')
    image_dir = sys.argv[1]
    if not os.path.exists(image_dir):
        raise IOError(image_dir + u'は存在しません')

    # 書き出し用ディレクトリを作成
    output_path = os.path.join(image_dir, 'cut_images')
    if not os.path.exists(output_path):
        os.mkdir(output_path)
    print '書き出しディレクトリ:', output_path
    # ディレクトリ中の画像ファイルパスを取得
    image_path_list = glob.glob(os.path.join(image_dir, u'*.jpg'))
    image_path_list.extend(glob.glob(os.path.join(image_dir, u'*.png')))

    # 切り出し座標=カットポイント(cp)を探すためのループ
    print '切り出し座標を検出しています'
    odd_cp_list = []  # 奇数indexページのカットポイントを格納
    even_cp_list = []  # 偶数indexページのカットポイントを格納
    for index, image_path in enumerate(image_path_list):
        if len(odd_cp_list) >= AVG_COUNT and index % 2 == 1:
            continue
        if len(even_cp_list) >= AVG_COUNT and index % 2 == 0:
            continue
        img = cv2.imread(image_path)
        cp_dict = cut.search_cut_point(img)
        if len(cp_dict['x']) == CP_NUM_X and len(cp_dict['y']) == CP_NUM_Y:
            if index % 2 == 1:
                odd_cp_list.append(cp_dict)
            else:
                even_cp_list.append(cp_dict)
        if len(odd_cp_list) >= AVG_COUNT and len(even_cp_list) >= AVG_COUNT:
            break

    # 平均カットポイントを算出
    odd_page_cut_point = cut.find_average_point(odd_cp_list)
    even_page_cut_point = cut.find_average_point(even_cp_list)

    # 平均切り出し座標から画像を切り出すループ
    print '画像を切り出しています'
    for index, image_path in enumerate(image_path_list):
        img = cv2.imread(image_path)
        image_path = os.path.join(output_path, os.path.split(image_path)[-1][:-4])

        if index % 2 == 1:
            cut.cutout(img, odd_page_cut_point, image_path=image_path)
        else:
            cut.cutout(img, even_page_cut_point, image_path=image_path)

cut.py

# - * - coding: utf-8 - * -
import math
import cv2
import numpy as np

CP_NUM_X = 4
CP_NUM_Y = 8
DIFF_N = 1  # diffをとる間隔
DIFF_THRESHOLD = 60  # 枠線があるかどうかのdiff値の境界
LINE_WIDTH = 4  # 枠線の範疇と判定する太さ(ピクセル)
PAD_X = 12  # 平均の切り出し座標から余白を横方向に取る(ピクセル)
PAD_Y = 7  # 平均の切り出し座標から余白を縦方向に取る(ピクセル)


# 横方向に使う言葉: x, width
# 縦方向に使う言葉: y, height
def search_cut_point(img, image_path='', idx=''):
    def get_row_avg(x):
        return sum([img[yi - 1, x, 0] for yi in y_plot]) / height

    def get_col_avg(y):
        return sum([img[y, xi - 1, 0] for xi in x_plot]) / width

    def find_cut_point(big_diff_list):
        cp_list = []
        recent_point = 0
        # 座標位置の差分が設定した線の太さより大きいときにカットポイントを設定
        for cut_index in big_diff_list:
            # カットポイントの要素数が偶数。白から黒、最初の点
            if len(cp_list) % 2 == 0 and cut_index[1] <= 0 and cut_index[0] - recent_point >= LINE_WIDTH:
                cp_list.append(cut_index[0])
                recent_point = cut_index[0]
            # カットポイントの要素数が奇数。黒から白、最後の点
            elif len(cp_list) % 2 == 1 and cut_index[1] >= 0 and cut_index[0] - recent_point >= LINE_WIDTH:
                cp_list.append(cut_index[0])
                recent_point = cut_index[0]
        return cp_list

    def define_cut_point():
        cp_list = []
        for i in range(0, len(cp_y)):
            if i % 2 == 0:
                try:
                    cp_list.append([cp_y[i], cp_y[i + 1], cp_x[0], cp_x[1]])
                    cp_list.append([cp_y[i], cp_y[i + 1], cp_x[2], cp_x[3]])
                except IndexError:
                    pass
        return cp_list

    height, width = img.shape[0], img.shape[1]
    y_plot, x_plot = np.arange(1, height, 1), np.arange(1, width, 1)

    row_avg_list = [get_row_avg(x - 1) for x in x_plot]
    col_avg_list = [get_col_avg(y - 1) for y in y_plot]

    # avgのdiffを取り、境界値より大きな座標とそのdiff値をbig_diffに保持
    row_avg_diff = np.diff(row_avg_list, n=DIFF_N)
    col_avg_diff = np.diff(col_avg_list, n=DIFF_N)
    row_avg_big_diff = [[i, row_avg_diff[i - 1]] for i in range(1, len(row_avg_diff)) if math.fabs(row_avg_diff[i - 1]) >= DIFF_THRESHOLD]
    col_avg_big_diff = [[i, col_avg_diff[i - 1]] for i in range(1, len(col_avg_diff)) if math.fabs(col_avg_diff[i - 1]) >= DIFF_THRESHOLD]

    # カットポイントを定義
    cp_x = find_cut_point(row_avg_big_diff)
    cp_y = find_cut_point(col_avg_big_diff)

    return {'x': cp_x, 'y': cp_y}


# 渡した画像とカットポイントでimage_pathに切り出す
def cutout(img, cut_point, image_path='trim'):
    cp_x = cut_point['x']
    cp_y = cut_point['y']
    for i in range(0, len(cp_y)):
        if i % 2 == 0:
            im_trim = img[cp_y[i] - PAD_Y:cp_y[i + 1] + PAD_Y, cp_x[0] - PAD_X:cp_x[1] + PAD_X]
            cv2.imwrite(image_path + '-' + str(i / 2 + 5) + '.jpg', im_trim)
            im_trim = img[cp_y[i] - PAD_Y:cp_y[i + 1] + PAD_Y, cp_x[2] - PAD_X:cp_x[3] + PAD_X]
            cv2.imwrite(image_path + '-' + str(i / 2 + 1) + '.jpg', im_trim)


# カットポイントの平均を算出
def find_average_point(cp_list):
    average_list_x = [0 for i in range(CP_NUM_X)]
    average_list_y = [0 for i in range(CP_NUM_Y)]
    for cut_point in cp_list:
        for index, cp_value in enumerate(cut_point['x']):
            average_list_x[index] += cp_value
        for index, cp_value in enumerate(cut_point['y']):
            average_list_y[index] += cp_value
    average_cp_x = [i / len(cp_list) for i in average_list_x]
    average_cp_y = [i / len(cp_list) for i in average_list_y]
    return {'x': average_cp_x, 'y': average_cp_y}
usage

avg_cut.pyとcut.pyを同じディレクトリに置き、端末から

$ python avg_cut.py path/to/image/dir を実行します。

「path/to/image/dir」は画像があるディレクトリを指定してください。

うまくいけば、しばらくした後、path/to/image/dir/cut_imagesに切り抜かれた画像が保存されます。

このコードだと画像群がないと多分動かないので、単一の画像で試す場合は

$ python
>>> import cv2
>>> import cut
>>> img = cv2.imread('path/to/image/hoge.jpg')
>>> cp = cut.search_cut_point(img)  #cp['x']が4つ、cp['y']が8つ返ってくればOK
>>> cut.cutout(img, cp)  # 実行ディレクトリにtrim-1.jpg~trim-8.jpgが生成される

で良いかと。

これで一挙に『ゆゆ式』のコマ画像が手に入ったので、最近興味を持っている機械学習なんかの分析にも手を広げていければと思います。