Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
10
Help us understand the problem. What is going on with this article?
@mo256man

OpenCVで日本語フォントを描写する を関数化する を汎用的にする

はじめに

前回の記事「OpenCVで日本語フォントを描写する を関数化する」でコメントをいただいた。大変ありがたいことだ。
しかも私がサボったところがしっかりと見抜かれている。これは頑張らねばいけません。

改善

RGBとBGRについて

目指すものはcv2.imshow()と同等の使い方で日本語を描写できる関数なので、指定する色はOpenCVの並び(BGR)。
前回の記事では関数内でPILで仕事をするにあたり、PILの色の並び(RGB)に変換した。画像もBGRからRGBに変換し、PILの機能で日本語を描写し、BGRに戻した上でOpenCVに再変換した。

色順の反転をもっとエレガントに

色順の反転について上では各要素を取り出して順序変更するというエレガントでない実装の仕方をしたが、普通にスライスが使えるらしい。

>>> (255, 0, 128)[::-1]
(128, 0, 255)

あっれー? いろいろ試行錯誤して、これも試してみたはずなんだけどなー。

というか、色順の反転は不要

で。BGRとRGBの関係を理解した上で、という条件付きではあるが。
画像をPILにとって正しい色に変換せず、文字色もRGBであるべき並びをBGRのままでPILで描写し、それをそのままOpenCVに戻せば、それは結果としてOpenCVとして正しい色になるしそっちのほうが速いよね。というのがコメントしてくださった方の主旨だ。
学校の先生に課題として提出したら「OpenCVとPILは色の並びが逆になってると教えたじゃないか。これでは答えは合っていても途中の計算式が間違っているようなものだ」と言われてしまいそうなテクニックだが、プログラミングにはこういう発想が必要だ。
いやね、私も思いついたんですよ。だけど、せっかく作ったOpenCVとPILを変換する関数が使えなくなってしまうのでそこまでは織り込まなかったのです。言い訳ですけど。

ということで投稿してくださった第3の関数を我が第2関数と比較してみよう。

# 元の関数
def cv2_putText_2(img, text, org, fontFace, fontScale, color):
    x, y = org
    b, g, r = color        # 色はBGRのままでいくのでこの文は不要
    colorRGB = (r, g, b)   # 色はBGRのままでいくのでこの文は不要。スライスすら使わない
    imgPIL = cv2pil(img)   # 色を変換した上でPILにする という独自関数を単にPILにするという処理にする
    draw = ImageDraw.Draw(imgPIL)
    fontPIL = ImageFont.truetype(font = fontFace, size = fontScale)
    w, h = draw.textsize(text, font = fontPIL)
    draw.text(xy = (x,y-h), text = text, fill = colorRGB, font = fontPIL) # 色は関数の引数そのまま
    imgCV = pil2cv(imgPIL) # 色の再変換もなく、
    return imgCV           # 単にOpenCVに変換したものを戻り値とする

# ↓↓↓↓↓↓
# 投稿してくださった関数
def cv2_putText_3(img, text, org, fontFace, fontScale, color):
    x, y = org
    imgPIL = Image.fromarray(img)
    draw = ImageDraw.Draw(imgPIL)
    fontPIL = ImageFont.truetype(font = fontFace, size = fontScale)
    w, h = draw.textsize(text, font = fontPIL)
    draw.text(xy = (x,y-h), text = text, fill = color, font = fontPIL)
    return np.array(imgPIL, dtype = np.uint8)

うーん、素晴らしくシンプルになったな。

ROIについて

ROI(Region of Interest)は関心領域と訳され、画像全体の中で処理のために注目するエリアを意味する。こちらの記事でも使っている。

描写されるテキストのサイズをあらかじめ取得しておいて元画像のうち文字が描写される部分のみPIL化すれば、元画像全体をPIL化するよりもっともっと速いよねというのがその次のコメントだ。頭のいい人は違うなあ…。
ただし、PILのImageDraw.textsize() を使うには ImageDraw.Draw オブジェクトが必要だ。サイズがわからなければベースとなる単色画像が作れない? そんなことはない。サイズが小さくて描写されるテキストがはみ出していようとも、ImageDraw.Draw オブジェクトが準備されてさえいればいいのだ。

そこを踏まえて投稿してくださった第4の関数を読み解いてみよう。

def cv2_putText_4(img, text, org, fontFace, fontScale, color):
    x, y = org
    fontPIL = ImageFont.truetype(font = fontFace, size = fontScale)

    # サイズ(0,0)のベタ画像を作りそのDrawオブジェクトを作る
    dummy_draw = ImageDraw.Draw(Image.new("RGB", (0,0)))

    # 指定したフォント・サイズで指定した文を描写した際の矩形の幅と高さを取得する
    w, h = dummy_draw.textsize(text, font = fontPIL)
    """
    後でここに追加する
    """

    # 元画像の一部を今得たサイズでトリミングし、そこのみPIL画像とする
    imgPIL = Image.fromarray(img[y-h:y,x:x+w,:])

    # それのDrawオブジェクトを作る
    draw = ImageDraw.Draw(imgPIL)

    # それにあらためて文字を描写する トリミング済なので基準は(0,0)
    draw.text(xy = (0,0), text = text, fill = color, font = fontPIL)

    # トリミングの逆 元画像の該当部分を文字描写済画像に置き換える
    img[y-h:y,x:x+w,:] = np.array(imgPIL, dtype = np.uint8)

    return img

ほお~なるほどねえ。
ただしImageDraw.textsize()のバグにより、取得した高さをそのまま使うと下が少し切れてしまう。
cv_jp_font_test_4.png

だったらどうするか。そんなのROIをテキトーに拡大してやればいいんですよ。
こんな感じで。

    b = int(0.1 * h) # ベースラインより下に確保する高さを適当に定めて、

    # imgPIL = Image.fromarray(img[y-h:y,x:x+w,:]) # を
    imgPIL = Image.fromarray(img[y-h:y+b,x:x+w,:]) # にする

画像外への描写への対応

OpenCVの画像はnumpy.ndarrayのかたちでデータ格納されているのでトリミングで一部分を取り出すのは img[y:y+h, x:x+w] と書けばよい。これは私がOpenCVを勉強してもっとも感動した事実のひとつで、何度も何度も書いている。
だが、これには欠点がある。配列なので当然といえば当然だが、画像外を指定することができないのだ。
もちろんOpenCVが用意しているcv2.putText()や図形描写関数は元画像の外にはみ出ても問題ないよう作られているが、我が関数は現時点ではエラーになってしまう。うまくトリミングさせなくては。

PILのトリミングは自分の記事としては書いていないのだが、OpenCVとは違い画像範囲外を指定することができる。
https://note.nkmk.me/python-pillow-image-crop-trimming/
だが、せっかくコメントでROIを指定する方法を示してくださったし、トリミングするために画像全部をPILにするのもこれまでの考えから逆行しているようだし、そもそも私はOpenCVの勉強をしているのであってPILのトリミングは使いたくない。

こんな考え方でどうだろう。
1. テキスト描写サイズをあらかじめ取得し、その大きさの単色画像を作る ①
2. それを元画像の指定した位置に置いたときにはみ出るかどうかを確認する
  テキスト描写域が完全に画像外なら何もしない
3. 単色画像の全部もしくは一部を元画像に置き換える ②
4. PILでテキストを描写する Rの輝度とBの輝度が入れ替わった状態だが見せなければ問題ない ③
5. OpenCVに戻す ④
6. 元画像があったエリアのみトリミングする ⑤
7. 元画像の該当エリアを、文字が描写されたものに更新する ⑥

1.png 2.png 3.png 4.png 5.png
cv_jp_font_test_7.png

さらに機能追加

左下に限らず右上や中央の座標を指定して描写できるようにする

cv2.putText()と同等の使い方で日本語フォントを扱えるようにするというのが当初からの目的だが、もうひとつ機能追加したくなった。
cv2.putText()orgで指定する座標は文字の左下。前回も書いたが、これ、使いにくいと思うのよね。
そこで、orgで指定する座標をcv2.putText()と同じく左下とするか、PILと同じく左上とするか、はたまた中央とするかを指定できるようにした。

下の画像は、独自関数の中で同じy座標を指定している(cv2.MARKER_STARのy座標が同じ)が、設定の違いによって異なる高さに描写されているということを示している。
左からcv2.putText()と同じ左下基準、PILのImageDraw.text()と同等の右上基準、そして中央基準だ。
cv_jp_font_test_8.png

文字を傾けて描写する

勘弁してください。

以上を織り込んだ関数

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

import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont

def cv2_putText_5(img, text, org, fontFace, fontScale, color, mode=0):
# cv2.putText()にないオリジナル引数「mode」 orgで指定した座標の基準
# 0(デフォ)=cv2.putText()と同じく左下 1=左上 2=中央

    # テキスト描写域を取得
    fontPIL = ImageFont.truetype(font = fontFace, size = fontScale)
    dummy_draw = ImageDraw.Draw(Image.new("RGB", (0,0)))
    text_w, text_h = dummy_draw.textsize(text, font=fontPIL)
    text_b = int(0.1 * text_h) # バグにより下にはみ出る分の対策

    # テキスト描写域の左上座標を取得(元画像の左上を原点とする)
    x, y = org
    offset_x = [0, 0, text_w//2]
    offset_y = [text_h, 0, (text_h+text_b)//2]
    x0 = x - offset_x[mode]
    y0 = y - offset_y[mode]
    img_h, img_w = img.shape[:2]

    # 画面外なら何もしない
    if not ((-text_w < x0 < img_w) and (-text_b-text_h < y0 < img_h)) :
        print ("out of bounds")
        return img

    # テキスト描写域の中で元画像がある領域の左上と右下(元画像の左上を原点とする)
    x1, y1 = max(x0, 0), max(y0, 0)
    x2, y2 = min(x0+text_w, img_w), min(y0+text_h+text_b, img_h)

    # テキスト描写域と同サイズの黒画像を作り、それの全部もしくは一部に元画像を貼る
    text_area = np.full((text_h+text_b,text_w,3), (0,0,0), dtype=np.uint8)
    text_area[y1-y0:y2-y0, x1-x0:x2-x0] = img[y1:y2, x1:x2]

    # それをPIL化し、フォントを指定してテキストを描写する(色変換なし)
    imgPIL = Image.fromarray(text_area)
    draw = ImageDraw.Draw(imgPIL)
    draw.text(xy = (0, 0), text = text, fill = color, font = fontPIL)

    # PIL画像をOpenCV画像に戻す(色変換なし)
    text_area = np.array(imgPIL, dtype = np.uint8)

    # 元画像の該当エリアを、文字が描写されたものに更新する
    img[y1:y2, x1:x2] = text_area[y1-y0:y2-y0, x1-x0:x2-x0]

    return img

def main():
    img = np.full((200,400,3), (160,160,160), dtype=np.uint8)
    imgH, imgW = img.shape[:2]

    fontPIL = "Dflgs9.TTC" # DF麗雅宋
    size = 30
    text = "日本語も\n可能なり"
    color = (255,0,0)

    positions = [(-imgW,-imgH),                 # これは画像外にあり描写されない
                 (0,0), (0,imgH//2), (0,imgH),
                 (imgW//2,0), (imgW//2,imgH//2), (imgW//2,imgH),
                 (imgW,0), (imgW,imgH//2), (imgW,imgH)]

    for pos in positions:
        img = cv2.circle(img, pos, 60, (0,0,255), 3)
        img = cv2_putText_5(img = img,
                            text = text,
                            org = pos,          # 円の中心と同じ座標を指定した
                            fontFace = fontPIL,
                            fontScale = size,
                            color = color,
                            mode = 2)           # 今指定した座標は文字描写域の中心だぞ

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

if __name__ == "__main__":
    main()

自分で作っておいて何だが、中央基準はcv2.circle()と相性がよく、使いでがありそうだ。
cv_jp_font_test_9.png

終わりに

前回の記事でコメントをいただいたkounoikeさんおよびこの場を提供してくださったQiitaにお礼申し上げます。

10
Help us understand the problem. What is going on with this article?
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
mo256man
日曜プログラマー。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
10
Help us understand the problem. What is going on with this article?