OpenCVで日本語フォントを描画する　を関数化する　を最新にする

# はじめに
私の記事
 - [OpenCVで日本語フォントを描画する　を関数化する](https://qiita.com/mo256man/items/82da5138eeacc420499d)
 - [OpenCVで日本語フォントを描画する　を関数化する　を汎用的にする](https://qiita.com/mo256man/items/b6e17b5a66d1ea13b5e3) 

は今なお多くの方からいいねをいただいているロングセラー。
だが、Pillowの新バージョンではここで使っている一部の関数が廃止され動かなくなってしまう。
そこで来るべき新バージョンに対応した改良版をお届けする。（もちろん現在の最新バージョン9.5.0でも可）

偉そうに書いてはみたが、今回の知見は最初の記事にコメントしてくださった[こーのいけさんがすでに2年前に示している](https://zenn.dev/kounoike/articles/20200111-pillow-textbbox)というのが正直なところ。

# ソース
[最後](#ソースコード)に示します。


# 新旧関数の勉強

## ImageDraw.textsize(text, font)【廃止予定】
`(width, height)`でテキストのサイズを取得する。
そのサイズを持つ四角形がどこにあるのかという情報はないので、 **ImageDraw.text()** の`xy`を基準として使う。
今思うとそれでは不十分なのだが、廃止される関数だからもうどうでもいいや。

さらに、何らかのバグにより正しいサイズが取れていない。
以前の我が関数では高さを修正していたが、実はフォントによっては幅方向も正しくなかったりする。今回のフォントは[Angel Tears（のトライアル版）](https://ja.fonts2u.com/angel-tears-trial.%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88)。
![anchor1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/9c6b85c8-eb32-ee78-bf70-b46669b6bcb5.png)

<details><summary>この部分のソースコード</summary>

```Python:
from PIL import Image, ImageDraw, ImageFont

WHITE = (255,255,255)
RED = (255,0,0)
BLUE = (0,0,255)

font = "ANGELTEARS-trial.ttf"   # ANGEL TEARS trial
size = 100
fontPIL = ImageFont.truetype(font=font, size=size)
x, y = 20, 20
text = "Black\nJack"

imgPIL = Image.new("RGB", (200, 200), WHITE)
draw = ImageDraw.Draw(imgPIL)
draw.text(xy=(x,y), text=text, fill=BLUE, font=fontPIL)
draw.ellipse(xy=[(x-3,y-3), (x+3,y+3)], outline=RED)

w, h = draw.textsize(text, fontPIL)
draw.rectangle([(x,y), (x+w,y+h)], outline=RED)

imgPIL.show()
```
</details>

## ImageDraw.textbbox(xy, text, font)
pillow 8.0.0で追加された。
テキストボックスではなくテキストビーボックス。テキストバウンディングボックス。
`xy`はテキストの座標すなわち **ImageDraw.text()** のそれと同じ値を指定する。
戻り値は`(left, top, right, bottom)`。我がソースコードの中では`xL, yT, xR, yB`という変数に格納している。

**ImageDraw.multiline_textbbox()** という関数もあるのだが、 **ImageDraw.textbbox()** でも普通に複数行の文字列のバウンディングボックスを取得できており、違いはよくわからない。
そもそもテキスト描画関数にも **ImageDraw.text()** と **ImageDraw.multiline_text()** があって、前者も複数行に対応しているんだよなあ。うーん。

![anchor2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/352d30f9-e62d-6924-a3c7-0c365df097dd.png)
<details><summary>この部分のソースコード</summary>

```Python:
from PIL import Image, ImageDraw, ImageFont

WHITE = (255,255,255)
RED = (255,0,0)
BLUE = (0,0,255)

font = "ANGELTEARS-trial.ttf"   # ANGEL TEARS trial
size = 100
fontPIL = ImageFont.truetype(font=font, size=size)
x, y = 20, 20
text = "Black\nJack"

imgPIL = Image.new("RGB", (200, 200), WHITE)
draw = ImageDraw.Draw(imgPIL)
draw.text(xy=(x,y), text=text, fill=BLUE, font=fontPIL)
draw.ellipse(xy=[(x-3,y-3), (x+3,y+3)], outline=RED)

x1, y1, x2, y2 = draw.textbbox((x,y), text, fontPIL)
draw.rectangle([(x1,y1), (x2,y2)], outline=RED)

imgPIL.show()
```
</details>

# タイポグラフィを学ぶ
## アンカーについて

**ImageDraw.textsize()** にはなくて **ImageDraw.textbbox()** では必要になった`xy`について調べた。
`xy`は正確にはアンカーの座標。 **ImageDraw.text()** の`xy`も同様。
私は知らなかったのだが、**ImageDraw.text()** では指定した`xy`がテキストの左上なのか右下なのか中央なのかを指定できるのだ。ただし複数行のテキストでは不可。
アンカーは公式サイト（[ここ](https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html)）で示された指定の仕方をする。

で、このアンカーを、 **ImageDraw.textbbox()** でも指定できるというわけ。
一方、従来の **ImageDraw.textsize()** ではアンカーを指定できないので **ImageDraw.text()** で左上以外のアンカーを指定したときに詰んでしまう。

下の画像は **ImageDraw.textbbox()** でアンカーを None、"lt"、"ms"、"rb" と指定したもの。

![anchor3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/7739b486-7c59-51ff-b154-11448421449d.png)

<details><summary>この部分のソースコード</summary>

```Python:pil_anchor.py
from PIL import Image, ImageDraw, ImageFont

WHITE = (255,255,255)
RED = (255,0,0)
BLUE = (0,0,255)

font = "ANGELTEARS-trial.ttf"   # ANGEL TEARS trial
size = 100
fontPIL = ImageFont.truetype(font=font, size=size)
text = "Python"

imgPIL = Image.new("RGB", (400, 400), WHITE)
draw = ImageDraw.Draw(imgPIL)
for i, anchor in enumerate([None, "lt", "ms", "rb"]):
    x, y = 200, 20+120*i
    draw.text(xy=(x,y), text=text, fill=BLUE, font=fontPIL, anchor=anchor)
    draw.ellipse(xy=[(x-3,y-3), (x+3,y+3)], outline=RED)
    x1, y1, x2, y2 = draw.textbbox((x,y), text, fontPIL, anchor=anchor)
    draw.rectangle([(x1,y1), (x2,y2)], outline=RED)

imgPIL.show()
```
</details>

## アセンダーについて

アンカーのデフォルト値は"la"。lはレフトを、aは[アセンダー](https://ja.wikipedia.org/wiki/%E3%82%A2%E3%82%BB%E3%83%B3%E3%83%80%E3%83%BC)を意味する。
aやxの文字の高さ（エックスハイト）よりも上の部分のことだ。
下画像はWikipediaより。
![](https://upload.wikimedia.org/wikipedia/commons/3/39/Typography_Line_Terms.svg)

え？ だったら、この結果はおかしくないですか？
y基準はアセンダーなのに、文字が描画されているのは基準高さよりずっと下になっている。
どこか間違ってる？
![python_angeltears.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/58f6974f-f55f-5524-d3d6-62d1ff7cddd1.png)


## ベースラインを求める

具体的な文字列は無くとも、フォントとフォントサイズを指定してやればこれらの値は決まるはず。
実際、PILでは次のように算出することができる。
ここで得られる`ascent`は定義通りのアセンダーではなくベースラインからの高さでエックスハイトを含む数字。エックスハイトを求めることはできないが、デザイン上この数値が必要となることはないとの判断だろう。
```Python:
from PIL import ImageFont

ascent, descent = ImageFont.FreeTypeFont(font, size).getmetrics()
```

これを使って、さまざまなフォントでベースラインとアセンダーとディセンダーを求めてみた。
![baselines.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/5f19b96c-8098-1c44-e5af-f8ed2b0d31c0.png)
正しくベースラインを求めることができた。つまり、特に間違っているところはなく「フォントの作成者がそのようにアセンダーとディセンダーを設定した」というのが真相のようだ。

<details><summary>この部分のソースコード</summary>

```Python:pil_anchor.py
from PIL import Image, ImageDraw, ImageFont

WHITE = (255,255,255)
RED = (255,0,0)
GREEN = (0,255,0)
BLUE = (0,0,255)

dics = [{"font":"ANGELTEARS-trial.ttf", "size":100}, # ANGEL TEARS trial
        {"font":"Inkfree.ttf", "size":50},           # Ink Free
        {"font":"timesbd.ttf", "size":50},           # Times New Roman 太字
        {"font":"CONSTAN.TTF", "size":50},           # Constantia 標準
        ]
texts = ["ace", "key", "Japan"]

imgPIL = Image.new("RGB", (400, 400), WHITE)
draw = ImageDraw.Draw(imgPIL)
y = 20
for dic in dics:
    font = dic["font"]
    size = dic["size"]
    fontPIL = ImageFont.truetype(font, size)
    ascent, descent = ImageFont.FreeTypeFont(font, size).getmetrics()
    x = 20
    for text in texts:
        draw.text(xy=(x,y), text=text, fill=BLUE, font=fontPIL)
        draw.ellipse(xy=[(x-3,y-3),(x+3,y+3)], outline=RED)
        x1, y1, x2, y2 = draw.textbbox((x,y), text, fontPIL)
        draw.rectangle([(x1,y1), (x2,y2)], outline=RED)
        draw.line([(x1,y),(x2,y)], GREEN, 1)
        draw.line([(x1,y+ascent),(x2,y+ascent)], GREEN, 3)
        draw.line([(x1,y+ascent+descent),(x2,y+ascent+descent)], GREEN, 1)
        x += 100
    y += ascent + descent + 30

imgPIL.show()
```
</details>

# OpenCV画像に日本語フォントを描画する関数への適用

以上の変化点を我が関数に織り込んでいけばよい。

## アンカーの実装
今回、PILのテキスト描画にアンカーの指定があることを知った。そこで我が関数でもアンカーを実装することにした。
ただし従来の引数`mode`はそのままとする。ここを変更すると我が関数をコピペして使ってくれている人が困ってしまうからだ。

それにしても、`mode`の値によってテキスト描画する座標を指定した座標から修正する部分を

```Python:前回の関数
    offset_x = [0, 0, text_w//2]
    offset_y = [text_h, 0, text_h//2]
    x0 = x - offset_x[mode]
    y0 = y - offset_y[mode]
```

としたのは技巧が先走っていていま見直すとなんとも恥ずかしい。自分で読み返して何をしているのか分からなかったほどだ。
ここは素直に

```Python:修正
    if mode == 0:
        offset_x, offset_y = 0, text_h
    elif mode == 1:
        offset_x, offset_y = 0, 0
    elif mode == 2:
        offset_x, offset_y = text_w//2, text_h//2
    x0 = x - offset_x
    y0 = y - offset_y
```

という表記にした上で改造を進めた。
これまでの苦労でどうしてもベースライン基準を追加したかったので頑張って実装した。複数行のテキストではアンカーを指定できないというPILの仕様を超え、複数行であっても最下行のベースラインを指定できるようにしている。

`mode`と`anchor`で異なる値が指定されていたら？ それはソースコードを読み解いてください。

|mode|anchor|挙動|
|:-|:-|:-|
|指定なし|指定なし|mode=0と同|
|0|"lb"(left bottom)|cv2.PutText()と同様、左下基準|
|1|"la"(left ascender)|ImageDraw.text()と同様、左・アセンダー基準|
|2|"mm"(middle middle)|中央基準|
|3|"lt"(left top)|【新】左上基準|
|4|"ls"(left baseline)|【新】左・ベースライン基準|


## ROIの取得

OpenCVからPILに変換する部分を画像全体ではなくテキスト描画域のみにすることで処理速度向上を図っているのが我が関数の特徴。私が考えついたわけではないけど。
**ImageDraw.textsize()** はテキストボックスのサイズを返していた。
**ImageDraw.textbbox()** はバウンディングボックスの左上座標と右下座標を返してくれる。
この仕様の差および私のレベルアップにより、ROIの取得をよりシンプルにおこなえるようになった。
![bbox.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/b175ccde-4282-9373-2f0d-4e468d438ff1.png)

# ソースコード
サンプルプログラム付き。

```Python:
import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont

def cv2_putText(img, text, org, fontFace, fontScale, color, mode=None, anchor=None):
    """
    mode:
        0:left bottom, 1:left ascender, 2:middle middle,
        3:left top, 4:left baseline
    anchor:
        lb:left bottom, la:left ascender, mm: middle middle,
        lt:left top, ls:left baseline
    """

    # テキスト描画域を取得
    x, y = org
    fontPIL = ImageFont.truetype(font = fontFace, size = fontScale)
    dummy_draw = ImageDraw.Draw(Image.new("L", (0,0)))
    xL, yT, xR, yB = dummy_draw.multiline_textbbox((x, y), text, font=fontPIL)

    # modeおよびanchorによる座標の変換
    img_h, img_w = img.shape[:2]
    if mode is None and anchor is None:
        offset_x, offset_y = xL - x, yB - y
    elif mode == 0 or anchor == "lb":
        offset_x, offset_y = xL - x, yB - y
    elif mode == 1 or anchor == "la":
        offset_x, offset_y = 0, 0
    elif mode == 2 or anchor == "mm":
        offset_x, offset_y = (xR + xL)//2 - x, (yB + yT)//2 - y
    elif mode == 3 or anchor == "lt":
        offset_x, offset_y = xL - x, yT - y
    elif mode == 4 or anchor == "ls":
        _, descent = ImageFont.FreeTypeFont(fontFace, fontScale).getmetrics()
        offset_x, offset_y = xL - x, yB - y - descent

    x0, y0 = x - offset_x, y - offset_y
    xL, yT = xL - offset_x, yT - offset_y
    xR, yB = xR - offset_x, yB - offset_y

    # バウンディングボックスを描画　不要ならコメントアウトする
    cv2.rectangle(img, (xL,yT), (xR,yB), color, 1)

    # 画面外なら何もしない
    if xR<=0 or xL>=img_w or yB<=0 or yT>=img_h:
        print("out of bounds")
        return img

    # ROIを取得する
    x1, y1 = max([xL, 0]), max([yT, 0])
    x2, y2 = min([xR, img_w]), min([yB, img_h])
    roi = img[y1:y2, x1:x2]

    # ROIをPIL化してテキスト描画しCV2に戻る
    roiPIL = Image.fromarray(roi)
    draw = ImageDraw.Draw(roiPIL)
    draw.text((x0-x1, y0-y1), text, color, fontPIL)
    roi = np.array(roiPIL, dtype=np.uint8)
    img[y1:y2, x1:x2] = roi

    return img


def main():
    img = np.full((1200,500,3), (255,255,255), dtype=np.uint8)
    dics = [{"font":"DFLGS9.TTC", "size":30, "text":"日本語も\n可能なり"},       # DF麗雅宋
            {"font":"ANGELTEARS-trial.ttf", "size":60, "text":"Black\nJack"},   # ANGEL TEARS trial
            ]
    x = 80
    for dic in (dics):
        y = 120
        font = dic["font"]
        size = dic["size"]
        text = dic["text"]
        for anchor in (["lb", "la", "mm", "lt", "ls"]):
            img = cv2_putText(img, text, (x,y), font, size, (255,0,0), anchor=anchor)
            cv2.circle(img, (x,y), 3, (0,0,255), -1)
            cv2.putText(img, f"anchor='{anchor}'", (x,y-100), cv2.FONT_HERSHEY_COMPLEX, 0.5, (0,0,0), 1)
            y += 250

        x += 200

    cv2.imshow("", img)
    cv2.waitKey(0)
    cv2.destroyAllWindows()


if __name__ == "__main__":
    main()
```

今回のサンプル。もちろん画面外への対応もなされている。

![sample_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/13f51ab9-2710-8ff9-d87b-956dfc0c86d6.png)

# 終わりに
PySimpleGUIやstreamlitが使えるようになっても、一つの巨大なキャンバスのどの座標に何を配置してという昔ながらのデザイン手法の手軽さを忘れることはできない。
自分のために作った関数だが、多くの人に活用していただけると嬉しいです。