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

<details><summary>アニメリストはこちら（6/5更新）</summary>
<div>
<details><summary>2025秋アニメ(6/5現在)</summary>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/d2c4a055-b88c-410b-92b3-1b884a3201b2.jpeg" />
<div>


</div></details>

<h3>2025夏アニメ(6/5現在)</h3>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/d12dea00-57bb-46a0-bd16-5bbdd568a940.jpeg" />
Qiitaでは直接`usemap`属性が使えないのでCodePen経由ですが、クリックで公式サイトを開けます。↓
<p class="codepen" data-height="850" data-theme-id="dark" data-default-tab="result" data-user="cartelet-cydius" data-slug-hash="yLOQNKZ" style="height: 850px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="yLOQNKZ">
  <span>See the Pen <a href="https://codepen.io/cartelet-cydius/pen/yLOQNKZ">
  yLOQNKZ</a> by Cartelet Cydius (<a href="https://codepen.io/cartelet-cydius">@cartelet-cydius</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>
</div></details>

# 9/25追記
うずらインフォさん本人Twitterにてうずらインフォさんスタイルのフォーマットでのアニメリストの公開を控えてほしい旨のツイートがありましたので、本記事掲載当初よりのサンプルの一枚を除いて、以後公開するアニメリストはオリジナル？のデザインのものとしたいと思います（寄せてはいますが）。
うずらインフォさんスタイル風の画像が欲しい場合は掲載のプログラムを実行してください。
### 9/27追記
こちらからColab上で生成できます。
[例のアニメリスト自動生成スクリプト](https://colab.research.google.com/github/Cartelet0423/animeListGen/blob/master/%E4%BE%8B%E3%81%AE%E3%82%A2%E3%83%8B%E3%83%A1%E3%83%AA%E3%82%B9%E3%83%88%E8%87%AA%E5%8B%95%E7%94%9F%E6%88%90%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88.ipynb)

#はじめに
皆さんは[うずらインフォ](https://uzurainfo.han-be.com/)さんをご存知でしょうか?
アニメが好きな方なら、名前を知らなくても１度はお世話になったことがあると思います。
２０１１年頃から今まで毎クール　$↓$　のようなアニメリストを作ってくださっていた方です。
<a href="https://uzurainfo.han-be.com/20sm.html"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/ecd399d5-f8f0-f26d-f719-75429c98f514.png" width=50%/></a>

そんなうずらインフォさんですが、今期（2020夏アニメ）をもってアニメリストの制作を終了されるようです。→　[一覧製造終了のお知らせ](https://uzurainfo.han-be.com/makuai.html)
今までありがとうございました。

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/e4d8f67d-6b8e-e288-7462-3b9a67ae2283.png" alt="2020秋アニメ一覧.png" />


# 実装
左上の説明画像
![1588994537339st.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/db8c2e73-782e-d001-9ec0-00739ec21228.png)
以外はすべてプログラム内で製造しています。（この画像もプログラム内で作れるけど文字の位置調整が面倒なのでフォトショで作った。）

### 流れ
・ベースとなる画像素材を生成
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/ba8ed930-49a1-7ac0-79b7-83f31f96d066.png" width = 10%/>     <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501281/68b88bf1-9f82-9fc9-ac81-50a1f541413c.png" width = 10%/>

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

### コード
Jupyterで書いたものを関数にまとめただけなので読みにくいかも
9/23 文字入れ周りを改善
3/11 サイト構造の変化により微修正

```python
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)
```

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

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