見出し画像

pillow(PIL)で絵文字混じりの文章を画像へ書き込む便利な方法


画像に文字を書き込む時にPillowを使うと便利です。様々な例も紹介されています。筆者はAIキャラクタを動かしながら会話(チャット)を文字で画像に書き込みながら動画フレームを作成しています。このところの高性能なLLMの登場でLLMの出力文に絵文字が多数含まれるようになりました。絵文字はPillowで扱えないためにどうやって表示させようか色々と模索していました。手法はいくつかあるのですが、最もスマートなやり方はimagetext-py
を使うことではないかと思います。

imagetext-py

Pillowのように文字を画像に書き込むユーティリティーです。多数の絵文字にも対応していて筆者の要求にぴったりです。ですが、ドキュメントがほとんどなく、試行錯誤で使えるようにしました。単純な使い方と説明は以下のサイトにあります。このサイトの説明を参考にクラス関数を定義しました。

参考サイト

imagetext-pyのインストール

pip install imagetext-py

これだけです。もちろんPillowは必要です。

フォントのダウンロード

説明通り、googleフォントをダウンロードします。
Noto Sans Japanese
です。以下からサウンロードします。

ダウンロード後に解凍して

NotoSansJP-Medium.ttf

を適当なフォルダーに移動(コピー)します。筆者の場合はFastAPIアプリホルダーにfontフォルダーを作成して管理しています。

作成したクラスコード

    def _draw_txt_formed(self,image,ctx_txt,posx=5,posy=5,font_size=20,col_count=20,low_line=20,line_spacing=1.0,txt_color='#000000',stroke_color='#ffffff'):   
            FontDB.SetDefaultEmojiOptions(EmojiOptions(parse_discord_emojis=True))
            FontDB.LoadFromPath('NotoSansJP', './font/NotoSansJP-Medium.ttf')
            font = FontDB.Query('NotoSansJP')
            cv = Canvas.from_image(image)
            ctx_txt = ctx_txt.replace("\n\n", "\n")  # \n\n"を\nに置換  
            ctx_list=ctx_txt.splitlines()
            ctx_list_line=[]
            for ctx in ctx_list:
                ctx_list_line=self._TextDivide_colcount(ctx,ctx_list_line, col_count)
                if len(ctx_list_line)>low_line:
                   ctx_list_line = ctx_list_line[(len(ctx_list_line) - low_line):]
            ctx_txt= "\n".join(ctx_list_line)
            font_size=font_size+5
            width = font_size*col_count
            draw_text_multiline(canvas=cv,
                              lines=ctx_txt.split('\n'),
                              x=posx , y=posy,
                              ax=0, ay=0,
                              size= font_size,
                              width=width,
                              font=font,
                              fill=Paint.Color((0, 0, 0, 255)),
                              align=TextAlign.Left,
                              stroke=5,
                              stroke_color=Paint.Color((255, 255, 255, 255)),
                              line_spacing=line_spacing,
                              draw_emojis=True)
            image: Image.Image = cv.to_image()
            return image

    def _TextDivide_colcount(self,input_txt,list_line, col_count):
        if col_count < 0 or col_count > len(input_txt):
            list_line.append(input_txt)
            return list_line
        else:
            list_line.append(input_txt[:col_count])
            list_line = self._TextDivide_colcount(input_txt[col_count:],list_line, col_count)
        return list_line

普通に関数として呼び出して構いません。(selfは削除すること)
ユーティリティはメインで
from imagetext_py import *
を記述するか、クラスとして定義するのであればクラスファイルに記述します。

本体プログラムからの呼び出し

draw.multiline_text(ctxpos, chr_name, font=ctx_font, fill=chr_color, stroke_width=ctx_edge_width, stroke_fill=ctx_edge_color)

このように呼び出します。現在のバージョンではtxt_color='#000000',stroke_color='#ffffff'
は対応していません。

コードの説明

            FontDB.SetDefaultEmojiOptions(EmojiOptions(parse_discord_emojis=True))
            FontDB.LoadFromPath('NotoSansJP', './font/NotoSansJP-Medium.ttf')
            font = FontDB.Query('NotoSansJP')

フォント関係を読み込んでいます。

 cv = Canvas.from_image(image)

画像を準備しています

            ctx_txt = ctx_txt.replace("\n\n", "\n")  # \n\n"を\nに置換  
            ctx_list=ctx_txt.splitlines()

入力文字列の改行コードの繰り返しを単一の改行に置き換え、改行で区切ってリスト化しています。

            ctx_list_line=[]
            for ctx in ctx_list:
                ctx_list_line=self._TextDivide_colcount(ctx,ctx_list_line, col_count)
                if len(ctx_list_line)>low_line:
                   ctx_list_line = ctx_list_line[(len(ctx_list_line) - low_line):]
           

行幅に収まるようにリストの要素を区切って追加し、最後に指定行数が超えた場合は先頭から超えた分の行を削除しています。
ctx_list_line=self._TextDivide_colcount(ctx,ctx_list_line, col_count)
ここは以下の関数です。再帰的な処理になっています。

    def _TextDivide_colcount(self,input_txt,list_line, col_count):
        if col_count < 0 or col_count > len(input_txt):
            list_line.append(input_txt)
            return list_line
        else:
            list_line.append(input_txt[:col_count])
            list_line = self._TextDivide_colcount(input_txt[col_count:],list_line, col_count)
        return list_line

オリジナルの1行が長い時に分割しています。

            ctx_txt= "\n".join(ctx_list_line)
            font_size=font_size+5
            width = font_size*col_count

リストを文字列に変換し、文字列部分の幅をフォントサイズと1行の文字数から計算しています。

            draw_text_multiline(canvas=cv,
                              lines=ctx_txt.split('\n'),
                              x=posx , y=posy,
                              ax=0, ay=0,
                              size= font_size,
                              width=width,
                              font=font,
                              fill=Paint.Color((0, 0, 0, 255)),
                              align=TextAlign.Left,
                              stroke=5,
                              stroke_color=Paint.Color((255, 255, 255, 255)),
                              line_spacing=line_spacing,
                              draw_emojis=True)
            image: Image.Image = cv.to_image()

実際に画像に文字を書き込む部分です。変数については以下を参照ください

基本のサンプルコード

githubのコードです。

from PIL import Image
from imagetext_py import *

FontDB.SetDefaultEmojiOptions(EmojiOptions(parse_discord_emojis=True))
FontDB.LoadFromDir(".")

font = FontDB.Query("coolvetica japanese")

with Image.new("RGBA", (512, 512), "white") as im:
    with Writer(im) as w:
        w.draw_text_wrapped(
            text="hello from python 😓 lol, <:blobpain:739614945045643447> " \
                 "ほまみ <:chad:682819256173461522><:bigbrain:744344773229543495> " \
                 "emojis workin",
            x=256, y=256,
            ax=0.5, ay=0.5,
            width=500,
            size=90,
            font=font,
            fill=Paint.Color((0, 0, 0, 255)),
            align=TextAlign.Center,
            stroke=2.0,
            stroke_color=Paint.Rainbow((0.0,0.0), (256.0,256.0)),
            draw_emojis=True
        )
    im.save("test.png")
FontDB.SetDefaultEmojiOptions(EmojiOptions(parse_discord_emojis=True))
FontDB.LoadFromDir(".")

この部分で絵文字フォントを読み込んでいます。

with Writer(im) as w:
 w.draw_text_wrapped(
            text="hello from python 😓 lol, <:blobpain:739614945045643447> " \
                 "ほまみ <:chad:682819256173461522><:bigbrain:744344773229543495> " \
                 "emojis workin",
            x=256, y=256,
            ax=0.5, ay=0.5,
            width=500,
            size=90,
            font=font,
            fill=Paint.Color((0, 0, 0, 255)),
            align=TextAlign.Center,
            stroke=2.0,
            stroke_color=Paint.Rainbow((0.0,0.0), (256.0,256.0)),
            draw_emojis=True
        )

ここで実際に画像に書き込んでいます。
ここの変数の説明がなく、苦労しました。

変数の説明

im
 書き込む画像です。
text=text,
表示する文字列です。絵文字が入っていてもかまいません。
x=945, y=620,
 表示位置です。ここの数値と下のax,ayで位置が決まります。ax,ayは
 位置のアンカーがどこになるかを指定するので、0であればpillowと同様
 にw=0,h=0を起点にxとyで位置を指定します。
ax=0.5, ay=0.5,
 表示位置のアンカー指定です。0はオフセットなし、0.5は幅と高さの
 中央、1はエンドを起点にすることができます。
size=90,
 フォントのサイズ
width=800,
 書きたいエリアの幅(ピクセル数です)
font=font,
 フォントの指定
fill=Paint.Color((0, 0, 0, 255)),
 フォントの色の指定
align=TextAlign.Center,
 1行中の文字列の位置です Left、 Center、 Rightが選べます
stroke=2.0,
 文字の周囲のエリア幅
stroke_color=Paint.Color((0, 0, 0, 255)),
 周囲のエリア部分の色指定
draw_emojis=True,
 絵文字の出力の許可
wrap_style=WrapStyle.Character
 改行を無視して行幅いっぱいに書き込みます

文字列中の改行を忠実に行う場合は
draw_text_multiline
を使います。このとき
wrap_style=WrapStyle.Characterは付けません。

参考にさせて頂いた山下 遼河様のdraw_text_multilineのコード

シンプルに記述されています。
cvが画像です。

image = Image.open('./template.png')
cv = Canvas.from_image(image)  

text = '圧倒的感謝👍ワンころ🐶\n気まぐれ🐈カフェイン☕\nball🥎ハリネズミ🦔'

draw_text_multiline(canvas=cv,
                    lines=text.split('\n'),
                    x=945, y=620,
                    ax=0.5, ay=0.5,
                    size=90,
                    width=800,
                    font=font,
                    align=TextAlign.Center,
                    fill=Paint.Color((0, 0, 0, 255)),
                    line_spacing=1.5,
                    draw_emojis=True)

まとめ

絵文字を扱う機会も多いと思います。スターの数が少ないリポジトリですが、便利に使えるので助かります。