240
156

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2020-09-19
アニメリストはこちら(11/17更新)
2025春アニメ(11/17現在)

2025冬アニメ(9/12現在)

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 文字入れ周りを改善
3/11 サイト構造の変化により微修正

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).content
    soup = BeautifulSoup(html, "html.parser", from_encoding="utf-8")

    Title = re.sub(
        "(\d+)(.+?)", "\\1\n\\2", soup.title.text.replace("|", "").split("")[0]
    ).replace("まとめ", "")

    data = {}
    titles = soup.select("h2[id]")
    for title in titles:
        content = next(
            (i for i in title.next_siblings if str(i).startswith('<div align="left">'))
        )
        tbl = content.table
        info = {k.text: v.text.splitlines() for k, v in zip(tbl("td"), tbl("th"))}
        d = {"img": "", "原作": "", "キャスト": "", "制作元請": "", "放送スケジュール": ""}
        data[info["作品名"][0]] = d
        d["img"] = content("img")[0]["src"] if content("img") else None
        #d["放送スケジュール"] = "\n".join(info["放送スケジュール"])
        d["放送スケジュール"] = "\n".join(info["スケジュール"])
        d["キャスト"] = "\n".join([i[i.index("") + 1 :] for i in info["キャスト"] if "" in i])
        staff = []
        for j in info["スタッフ"] if info.get("スタッフ") else []:
            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)
        d["href"] = (
            [""] + [i["href"] for i in content("a", string=re.compile("サイト"))]
        )[-1]

    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:
                    try:
                    src = data[titles[i]]["img"]
                    if src:
                        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)))
                    else:
                        tmp = np.array(tmp)
                        tmp[:103, :-1] = 255
                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)

課題

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

まとめ

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

240
156
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
240
156

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?