8
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-06-11

はじめに

私の記事

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

偉そうに書いてはみたが、今回の知見は最初の記事にコメントしてくださったこーのいけさんがすでに2年前に示しているというのが正直なところ。

ソース

最後に示します。

新旧関数の勉強

ImageDraw.textsize(text, font)【廃止予定】

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

さらに、何らかのバグにより正しいサイズが取れていない。
以前の我が関数では高さを修正していたが、実はフォントによっては幅方向も正しくなかったりする。今回のフォントはAngel Tears(のトライアル版)
anchor1.png

この部分のソースコード
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()

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

この部分のソースコード
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()

タイポグラフィを学ぶ

アンカーについて

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

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

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

anchor3.png

この部分のソースコード
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()

アセンダーについて

アンカーのデフォルト値は"la"。lはレフトを、aはアセンダーを意味する。
aやxの文字の高さ(エックスハイト)よりも上の部分のことだ。
下画像はWikipediaより。

え? だったら、この結果はおかしくないですか?
y基準はアセンダーなのに、文字が描画されているのは基準高さよりずっと下になっている。
どこか間違ってる?
python_angeltears.png

ベースラインを求める

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

from PIL import ImageFont

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

これを使って、さまざまなフォントでベースラインとアセンダーとディセンダーを求めてみた。
baselines.png
正しくベースラインを求めることができた。つまり、特に間違っているところはなく「フォントの作成者がそのようにアセンダーとディセンダーを設定した」というのが真相のようだ。

この部分のソースコード
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()

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

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

アンカーの実装

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

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

前回の関数
    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]

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

修正
    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の仕様を超え、複数行であっても最下行のベースラインを指定できるようにしている。

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

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

ソースコード

サンプルプログラム付き。

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

終わりに

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

8
9
2

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
8
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?