Help us understand the problem. What is going on with this article?

【Python】例のアニメリスト風の画像を自動生成する。

アニメリストはこちら(10/29更新)

2021冬アニメ(10/29現在)

2020秋アニメ(9/25更新)


Qiitaでは直接usemap属性が使えないのでCodePen経由ですが、クリックで公式サイトを開けます。↓


See the Pen
yLOQNKZ
by Cartelet Cydius (@cartelet-cydius)
on CodePen.



9/25追記

うずらインフォさん本人Twitterにてうずらインフォさんスタイルのフォーマットでのアニメリストの公開を控えてほしい旨のツイートがありましたので、本記事掲載当初よりのサンプルの一枚を除いて、以後公開するアニメリストはオリジナル?のデザインのものとしたいと思います(寄せてはいますが)。
うずらインフォさんスタイル風の画像が欲しい場合は掲載のプログラムを実行してください。

9/27追記

こちらからColab上で生成できます。
例のアニメリスト自動生成スクリプト

はじめに

皆さんはうずらインフォさんをご存知でしょうか?
アニメが好きな方なら、名前を知らなくても1度はお世話になったことがあると思います。
2011年頃から今まで毎クール $↓$ のようなアニメリストを作ってくださっていた方です。

そんなうずらインフォさんですが、今期(2020夏アニメ)をもってアニメリストの制作を終了されるようです。→ 一覧製造終了のお知らせ
今までありがとうございました。

と感謝の念しか湧いてこないのですが、やはり1目で見渡せる一覧の存在は大きく、自分を含め必要に感じている人は多いだろうということで、

勝手にこんな感じの画像を作ってくれるプログラムを書きました.

出来たもの

自分の手間はプログラムを実行するだけなので、忘れない限り、もしくは情報源のanimateTimesが一覧の更新を終了しない限りはこれから毎期更新したいと考えています(更新分はページ最上部にあります)。

2020秋アニメ一覧.png

実装

左上の説明画像
1588994537339st.png
以外はすべてプログラム内で製造しています。(この画像もプログラム内で作れるけど文字の位置調整が面倒なのでフォトショで作った。)

流れ

・ベースとなる画像素材を生成

animateTimesから情報を取得
・情報を辞書型に整形
・1行が長すぎる文を分かち書きして、変な位置で切れないように改行を入れる
・ベース画像を文字サイズに合わせるように変形 → 文字入れ → 目的の形に変形 → もう一つのベース画像の目的の位置に配置
・アドレスから画像を取得
・OpenCVで顔認識(lbpcascade_animeface.xmlが必要)
   参考: OpenCVでアニメの顔検出
・認識された顔の位置の平均が中心に(近く)なるように画像を切り出し
・ベース画像の目的の位置に配置
・上記作業をすべてのアニメについて行いつつ $6\times N$ のグリッド状に配置
・余ったスペースに影を再現
・書き出し

コード

Jupyterで書いたものを関数にまとめただけなので読みにくいかも
9/23 文字入れ周りを改善

from requests import get
import re
from bs4 import BeautifulSoup
from math import ceil
from janome.tokenizer import Tokenizer
from PIL import Image, ImageFont, ImageDraw, ImageFilter
import matplotlib.pyplot as plt
from io import BytesIO
import unicodedata
import numpy as np
import cv2

classifier = cv2.CascadeClassifier('lbpcascade_animeface.xml')
t = Tokenizer()

template = Image.new('RGB', (158, 332), (71, 71, 71))

part = Image.fromarray(np.r_[[[np.linspace(130.5, 84.5, 256)] * 256] *
                             3].T.astype(np.uint8))

aimsize = {
    "タイトル": (157, 39),
    "制作元請": (70, 39),
    "スタッフ": (86, 99),
    "キャスト": (70, 112),
    "放送スケジュール": (86, 52),
    "原作": (157, 37),
}
aimpoint = {
    "タイトル": (0, 103),
    "制作元請": (0, 142),
    "スタッフ": (71, 195),
    "キャスト": (0, 182),
    "放送スケジュール": (71, 142),
    "原作": (0, 295),
}


def get_data(url):
    global Title
    html = get(url).text
    soup = BeautifulSoup(html, 'html.parser')

    for i in soup.select("br"):
        i.replace_with("\n")

    Title = re.sub("(\d+)(.+)", "\\1\n\\2",
                   soup.title.text.replace("|", "|").split("|")[0])
#"
    li = []
    headingh2 = soup.find_all('h2', class_='c-heading-h2')
    if headingh2[0].get("id") != "1":
        headingh2.pop(0)
    for i, j in zip(headingh2, soup.find_all('table')):
        a = [k.text for k in j.select("th")]
        a.append(a[0])
        a[0] = i.text
        aa = []
        for e in i.next_elements:
            if e.name == "img":
                aa.append(e["src"])
                break
        for k in a:
            if k:
                if k[0] == "\n":
                    k = k[1:]
                aa.append(k)
        li.append(aa)

    data = {}
    for i in li:
        d = {"img": "", "原作": "", "キャスト": "", "制作元請": "", "放送スケジュール": ""}
        data[i[1]] = d
        d["img"] = i[0]
        d["放送スケジュール"] = i[-1]
        d["キャスト"] = "\n".join(re.findall(".+:(.+)", i[2].replace(":", ":")))
        staff = []
        for j in i[3].splitlines():
            j = j.replace(":", ":")
            if len(j.split(':')) < 2: continue
            if "原作" in j:
                d["原作"] = " ".join(j.split(':')[1:])
            elif "制作" in j:
                d["制作元請"] = j.split(':')[1]
            else:
                staff.append("\n".join(j.split(':')))
        d["スタッフ"] = "\n".join(staff)
        for j in soup.find(text=f"『{i[1]}』最新記事・関連動画一覧").previous_elements:
            if j.name == "a" and "サイト" in j.text:
                data[i[1]]['href'] = j["href"]
                break

    return data


def len_(text):
    count = 0
    for c in text:
        if unicodedata.east_asian_width(c) in 'FWA':
            count += 2
        else:
            count += 1
    return count


def nn(text, w):
    tt = ""
    l = 0
    for j in t.tokenize(text, wakati=True):
        if l + len(j) > w:
            tt += "\n"
            l = 0
        elif j == "\n":
            l = 0
        tt += j
        l += len(j)

    return tt.replace("\n\n", "\n")


def mojiire(text, font_path, tmp, aimsize, aimpoint, case, hopt):
    text = text.replace("\n ", "\n")
    if case == 1:
        tm = Image.new('RGB', (256, 256), (66, 58, 59))
        if text:
            text = nn(text, 14)
    elif case == 2:
        tm = part.copy()
        if text:
            text = nn("\n".join(text.splitlines()[-3:]), 10) + "\n "
    elif case == 3:
        tm = part.copy()
        if text:
            text = nn(text, 20)
            while len_(text) < 20:
                text += " "
    else:
        tm = part.copy()
        if text:
            text = nn("\n".join(text.splitlines()[:8]), 10)
    if text:
        while len(text.splitlines()) < hopt:
            text += "\n "
        font = ImageFont.truetype(font_path, 100)
        draw = ImageDraw.Draw(tm)
        x, y = draw.textsize(text, font=font, spacing=1)
        tm = tm.resize((x + 30, y + 30))
        draw = ImageDraw.Draw(tm)
        draw.text((15, 15), text, font=font, spacing=1)
        tm = tm.resize(aimsize)
        if case == 2:
            draw = ImageDraw.Draw(tm)
            draw.line((0, 39, aimsize[0], 39), fill=(179, 179, 179), width=1)
    else:
        tm = tm.resize(aimsize)
    tmp.paste(tm, aimpoint)


def main(url, font_title, font_main):
    data = get_data(url)
    titles = list(data.keys())
    inList = True
    for x in range(ceil((len(titles) + 1) / 6)):
        for y in range(6):
            i = x * 6 + y - 1
            tmp = template.copy()
            if i == -1:
                tmp = Image.open("左上の説明画像のパス").convert("RGB")
                font = ImageFont.truetype(font_main, 20)
                draw = ImageDraw.Draw(tmp)
                draw.text((2, 2), Title, (96, 167, 200), font=font, spacing=1)
                tmp = np.array(tmp)
            elif i < len(titles):
                for kw in aimsize.keys():
                    if kw == "タイトル":
                        case = 1
                        hopt = 1
                    elif kw == "放送スケジュール":
                        case = 2
                        hopt = 3
                    elif kw == "原作":
                        case = 3
                        hopt = 2
                    else:
                        case = 0
                        if kw == "制作元請":
                            hopt = 2
                        else:
                            hopt = 6
                    mojiire(titles[i] if kw == "タイトル" else data[titles[i]][kw],
                            font_title if kw == "タイトル" else font_main, tmp,
                            aimsize[kw], aimpoint[kw], case, hopt)
                try:
                    img = Image.open(
                        BytesIO(get(
                            data[titles[i]]["img"]).content)).convert("RGB")
                    gray_image = cv2.cvtColor(np.array(img),
                                              cv2.COLOR_BGR2GRAY)
                    faces = classifier.detectMultiScale(gray_image)
                    h, w = img.height, img.width
                    if len(faces):
                        x_, y_ = (
                            np.r_[[faces[:, 3]**2 /
                                   (faces[:, 3]**2).sum()]].T *
                            (faces[:, :2] + faces[:, 2:] * .5)).sum(axis=0,
                                                                    dtype=int)
                    else:
                        x_, y_ = 0.5 * w, 0.45 * h
                    if w > 1.5 * h:
                        cropped_image = img.crop(
                            (max(0, int(x_ - .75 * h)) -
                             max(0,
                                 int(x_ + .75 * h) - w), 0,
                             min(w, int(x_ + .75 * h)) +
                             max(0, -int(x_ - .75 * h)), h))
                    else:
                        cropped_image = img.crop(
                            (0, max(0, int(y_ - (1 / 3) * w)) -
                             max(0,
                                 int(y_ + (1 / 3) * w) - h), w,
                             min(h, int(y_ + (1 / 3) * w)) +
                             max(0, -int(y_ - (1 / 3) * w))))
                    tmp = np.array(tmp)
                    tmp[:103, :-1] = np.array(cropped_image.resize((157, 103)))
                except Exception as e:
                    print(e)
            elif inList:
                foundation = np.array(template)
                foundation[20:, :10] = 0
                foundation[:2] = 0
                tmp = np.array(
                    Image.fromarray(foundation).filter(
                        ImageFilter.GaussianBlur(10.0)))
                inList = False
            else:
                foundation = np.array(template)
                foundation[:2] = 0
                tmp = np.array(
                    Image.fromarray(foundation).filter(
                        ImageFilter.GaussianBlur(10.0)))
            try:
                line = np.r_["1", line, tmp]
            except:
                line = tmp.copy()
        try:
            image = np.r_["0", image, line]
        except:
            image = line.copy()
        del line

    plt.imsave(f"{''.join(Title.splitlines())}.png", image)


if __name__ == "__main__":
    url = "https://www.animatetimes.com/tag/details.php?id=5947" #animateTimesのURL
    font_title = "C:\\Windows\\Fonts\\YuGothB.ttc" #作品名部分のフォントのパス
    font_main = "C:\\Windows\\Fonts\\YuGothM.ttc" #その他のフォントのパス
    main(url, font_title, font_main)

課題

・情報源のサイトの構造の変化に弱い点
・顔として認識されなかった場合や、顔が散らばった構図だと切り取りが悲惨になることがある点

まとめ

細かな部分は手作りには遠く及びませんが、最低限一覧としての必要な情報は抑えられたと思います。

Cartelet
趣味でやったことや勉強したことをメモ感覚でまとめたり紹介したり
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away