easyocrを使用して名前消しをやってみた
イントロダクション
昨今の学校はホームページに生徒の活躍を載せることが多々あります。例えば体育大会などでの活用の様子はコロナの影響で観覧ができない保護者にとってとても大事なコンテンツになっています。
一方で体育服についている名前は生徒の個人情報を脅かす存在であり、ホームページにあげる際には消す必要があります。この名前を消す作業がとても大変なのでpython
で自動化してみたいと思います。
ちなみに顔に関しては、私が赴任している学校では生徒が特定できないよう顔が特定できないような写真に限定しています。
OCRについて
画像上の文字を認識しテキストに変えたりする技術をOCR(光学文字認識)と言います。
pythonを使用したOCRの場合、tesseractというフリーソフトを使うことが多いみたいです。tesseractは紙に書かれた文字のようにある程度文章の体裁を持っている画像には有効ですが、今回のように写真の中の一部にある文字を検出するのには向いていませんでした。
なので今回は、easyocrを使用して文字の検出をしてみようと思います。
環境構築
私は基本的に開発環境をDockerコンテナを使用して構築しています。ディレクトリ構成は以下のようになります。
% tree .
.
├── Dockerfile
├── README.md
├── docker-compose.yml
├── requirements.txt
└── src
├── detector
│ └── easy_detect.py
└── img_src
├── detected.png
└── example.jpeg
ファイルの準備
続いてファイルの中身は以下のような記述になります。
FROM python:3.9
RUN apt-get update
RUN apt-get -y install locales && \
localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
RUN apt-get install -y vim less
RUN apt-get update
# 注1
RUN apt-get install -y libgl1-mesa-dev
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm
RUN mkdir -p /root/src
RUN echo "alias p='python3'" >> /root/.bashrc
WORKDIR /root/src
ADD requirements.txt /root/src
# 注2
RUN pip install --upgrade requests
RUN pip install -r requirements.txt
version: '3'
services:
app:
container_name: 'python3'
build: .
volumes:
- ./src:/root/src
tty: true
# 画像編集
pillow
# 文字認識
easyocr
# 算術
numpy
# プロット
matplotlib
# アプリケーションファイル作成
pyinstaller
# GUI作成
PyQt5
Dockerの設定
easyocr
ではpytorch
という機械学習用のライブラリを使用します。そのためDockerに与えられるメモリのリソースを増やしておく必要があります。
preferences -> Resources -> Memory
今回はメモリの割り当てを8GBにしました。他にもいくつかライブラリをインストールしているのでイメージのサイズは5GBになりました。
-
注1
libgl1-mesa-dev
をしておかないとインポートエラーが発生します。
ImportError: libGL.so.1: cannot open shared object file: No such file or directory
-
注2
pytorch
のインストールの際のエラーを回避するためにおこないます。
コンテナの起動
docker compose build
でコンテナをビルドします。
% docker compose build
[+] Building 2075.2s (21/21) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 743B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.9 4.7s
=> [internal] load build context 0.0s
=> => transferring context: 38B 0.0s
=> [ 1/16] FROM docker.io/library/python:3.9@sha256:f265c5096aa52bdd478d2a5ed097727f51721fda20686523ab1b3038cc7d6417 0.0s
=> CACHED [ 2/16] RUN apt-get update 0.0s
=> CACHED [ 3/16] RUN apt-get -y install locales && localedef -f UTF-8 -i ja_JP ja_JP.UTF-8 0.0s
=> CACHED [ 4/16] RUN apt-get install -y vim less 0.0s
=> CACHED [ 5/16] RUN apt-get update 0.0s
=> CACHED [ 6/16] RUN apt install tesseract-ocr -y 0.0s
=> CACHED [ 7/16] RUN apt install libtesseract-dev -y 0.0s
=> CACHED [ 8/16] RUN apt install tesseract-ocr-jpn tesseract-ocr-jpn-vert -y 0.0s
=> CACHED [ 9/16] RUN apt install tesseract-ocr-script-jpan tesseract-ocr-script-jpan-vert -y 0.0s
=> [10/16] RUN apt-get install -y libgl1-mesa-dev 34.6s
=> [11/16] RUN mkdir -p /root/src 0.4s
=> [12/16] RUN echo "alias p='python3'" >> /root/.bashrc 0.4s
=> [13/16] WORKDIR /root/src 0.0s
=> [14/16] ADD requirements.txt /root/src 0.0s
=> [15/16] RUN pip install --upgrade requests 4.5s
=> [16/16] RUN pip install -r requirements.txt 2014.2s
=> exporting to image 16.2s
=> => exporting layers 16.2s
=> => writing image sha256:4d57a45187da9ceefcea9ebef5c3fbd4fd7a5790e397fe56f41b8d3e0f86ffb5 0.0s
=> => naming to docker.io/library/name_remover_app
何度かビルドを行っているので一部はキャッシュされたものから呼び出していますが、概ね上のような処理が行われると思います。
ビルドが終わったらコンテナを立ち上げて、コンテナ内に入って作業します。
% docker compose up -d
[+] Running 2/2
⠿ Network name_remover_default Created 3.9s
⠿ Container python3 Started 2.3s
% docker compose exec app bash
root@2e9a3451421d:~/src# ls
detector example img_src
root@2e9a3451421d:~/src# cd detector/
root@2e9a3451421d:~/src/detector# ls
easy_detect.py
root@2e9a3451421d:~/src/detector#
実行
ではeasy_detect.py
に処理を記述していきましょう。
モジュールの呼び出し
まずはモジュールの呼び出します。
from PIL import Image, ImageDraw, ImageFilter
import easyocr
文字の検出
次に画像から文字を検出します。検出された情報は
[[座標], [文字列], [精度]]
という形でリスト型で返されるので、dict型に変換して使いやすくしています。
なので実質画像検出の処理はresults = reader.readtext(img_path)
で終わっていることになります。
img_path = "/root/src/img_src/example.jpeg"
"""
easyocrを使用した文字の検出
"""
# easyocrの設定
reader = easyocr.Reader(['ja','en'])
# テキストの検出
results = reader.readtext(img_path)
# 検出したデータの配列を扱いやすいようにdict型に変換
detects = []
for result in results:
detect = {
"position" : (
(int(result[0][0][0]), int(result[0][0][1])),
(int(result[0][1][0]), int(result[0][1][1])),
(int(result[0][2][0]), int(result[0][2][1])),
(int(result[0][3][0]), int(result[0][3][1]))
),
"text" : result[1],
"confident": result[2]
}
detects.append(detect)
print(detect)
画像の加工
次に、上で検出した文字の座標を元にぼかしをかけていきます。やり方としては、オリジナルの画像とオリジナルの画像にぼかしを入れた画像を用意して検出した場所の部分だけをぼかしを入れた画像でマスクしてあげるという処理になります。
img_mask
はオリジナルと同じサイズの真っ黒な画像で、文字検出で取得した座標の多角形の範囲のみを白で塗りつぶします。
そうすることによって白で塗りつぶされた部分飲みがぼかしを入れた画像になります。
"""
pillowを使用して検出した文字をぼかす
"""
# オリジナルの画像
img_original = Image.open(img_path)
# オリジナルの画像にぼかしを入れたもの
img_blur = img_original.filter(ImageFilter.GaussianBlur(30))
# マスクで使用する画像
img_mask = Image.new("L", img_original.size, 0)
draw = draw = ImageDraw.Draw(img_mask)
for d in detects:
draw.polygon(d["position"], fill=255)
img_mask_blur = img_mask.filter(ImageFilter.GaussianBlur(5))
img_original.paste(img_blur, (0, 0), img_mask)
img_original.save("/root/src/img_src/detected.png")
結果
実行結果は次のようになります。うちの生徒の画像を使用するわけにはいかなかったのでネット上にあった適当な標識の画像で試しています。
この実行結果では載っていませんが、最初の実行時にはモデルのインストールが入ります。
root@2e9a3451421d:~/src/detector# p easy_detect.py
CUDA not available - defaulting to CPU. Note: This module is much faster with a GPU.
{'position': ((267, 0), (335, 0), (335, 15), (267, 15)), 'text': 'さきク', 'confident': 0.0015810342556950715}
{'position': ((339, 0), (429, 0), (429, 13), (339, 13)), 'text': 'じ ‥りり', 'confident': 0.0031218582775691794}
{'position': ((266, 12), (430, 12), (430, 38), (266, 38)), 'text': 'Toshincho ENTRANCE', 'confident': 0.8674711316050111}
{'position': ((85, 97), (271, 97), (271, 141), (85, 141)), 'text': '東名名古屋', 'confident': 0.9999303742223085}
{'position': ((630, 72), (732, 72), (732, 124), (630, 124)), 'text': '豊橋', 'confident': 0.9998223978000269}
{'position': ((994, 66), (1101, 66), (1101, 123), (994, 123)), 'text': '豊橋', 'confident': 0.9998809793744611}
{'position': ((633, 115), (730, 115), (730, 147), (633, 147)), 'text': 'Toyohashi', 'confident': 0.9995169453550252}
{'position': ((999, 113), (1096, 113), (1096, 144), (999, 144)), 'text': 'Toyohashi', 'confident': 0.9998065934440934}
{'position': ((88, 134), (258, 134), (258, 163), (88, 163)), 'text': 'TOME_Nagoya', 'confident': 0.6490018953321035}
{'position': ((564, 146), (738, 146), (738, 198), (564, 198)), 'text': '鶴舞公園', 'confident': 0.9996743202209473}
{'position': ((930, 144), (1106, 144), (1106, 196), (930, 196)), 'text': '鶴舞公園', 'confident': 0.9996959567070007}
{'position': ((92, 168), (266, 168), (266, 220), (92, 220)), 'text': '東山公園', 'confident': 0.9943031072616577}
{'position': ((568, 192), (670, 192), (670, 218), (568, 218)), 'text': 'Tsuruma', 'confident': 0.9999240331748864}
{'position': ((678, 192), (734, 192), (734, 218), (678, 218)), 'text': 'Park', 'confident': 0.9997870922088623}
{'position': ((936, 190), (1038, 190), (1038, 216), (936, 216)), 'text': 'Tsuruma', 'confident': 0.999827382296354}
{'position': ((1046, 190), (1104, 190), (1104, 216), (1046, 216)), 'text': 'Park', 'confident': 0.9998428821563721}
{'position': ((100, 214), (260, 214), (260, 246), (100, 246)), 'text': 'HigashiyamaPaik', 'confident': 0.8978773726053678}
{'position': ((232, 250), (266, 250), (266, 276), (232, 276)), 'text': '錦', 'confident': 0.998011862950321}
{'position': ((285, 253), (315, 253), (315, 273), (285, 273)), 'text': '通', 'confident': 0.9651659743848349}
{'position': ((106, 334), (180, 334), (180, 362), (106, 362)), 'text': '東新町駅', 'confident': 0.04488504305481911}
{'position': ((105, 367), (175, 367), (175, 385), (105, 385)), 'text': '東山公園', 'confident': 0.8608129024505615}
{'position': ((104, 380), (175, 380), (175, 393), (104, 393)), 'text': '子に』P', 'confident': 0.016945913434028625}
{'position': ((109, 395), (181, 395), (181, 413), (109, 413)), 'text': '東名名古屋', 'confident': 0.9993061157037837}
{'position': ((348, 382), (402, 382), (402, 410), (348, 410)), 'text': '豊橋', 'confident': 0.9998477689771967}
{'position': ((410, 381), (502, 381), (502, 409), (410, 409)), 'text': '鶴舞公園', 'confident': 0.9996257424354553}
{'position': ((614, 379), (765, 379), (765, 410), (614, 410)), 'text': '豊橋 鶴舞公園', 'confident': 0.7248653015808539}
{'position': ((915, 378), (1044, 378), (1044, 410), (915, 410)), 'text': '名古屋駅 栄', 'confident': 0.9850347565604769}
{'position': ((107, 409), (137, 409), (137, 421), (107, 421)), 'text': 'き・I', 'confident': 0.0009564038911551133}
{'position': ((142, 410), (176, 410), (176, 418), (142, 418)), 'text': 'WWuym', 'confident': 0.017093450657328847}
{'position': ((351, 407), (401, 407), (401, 421), (351, 421)), 'text': 'Toyahash', 'confident': 0.7690416159613187}
{'position': ((415, 405), (467, 405), (467, 419), (415, 419)), 'text': 'Tsuruma', 'confident': 0.4555804020140865}
{'position': ((471, 405), (501, 405), (501, 419), (471, 419)), 'text': 'Park', 'confident': 0.7679798603057861}
{'position': ((617, 405), (667, 405), (667, 419), (617, 419)), 'text': 'Toyohasli', 'confident': 0.5488303825864915}
{'position': ((679, 405), (731, 405), (731, 419), (679, 419)), 'text': 'Tsuiuma', 'confident': 0.6171367714444911}
{'position': ((735, 405), (765, 405), (765, 419), (735, 419)), 'text': 'Park', 'confident': 0.8726313710212708}
{'position': ((926, 404), (997, 404), (997, 422), (926, 422)), 'text': 'Nagoya Sia', 'confident': 0.6282114671883097}
{'position': ((1015, 405), (1047, 405), (1047, 419), (1015, 419)), 'text': 'Shag', 'confident': 0.4576537311077118}
{'position': ((166, 544), (234, 544), (234, 570), (166, 570)), 'text': '優國西', 'confident': 0.0009304758570743297}
{'position': ((355, 545), (409, 545), (409, 559), (355, 559)), 'text': '加公日', 'confident': 0.062277657927828564}
{'position': ((513, 545), (567, 545), (567, 559), (513, 559)), 'text': 'の撫公園', 'confident': 0.006709682755172253}
{'position': ((200, 560), (222, 560), (222, 568), (200, 568)), 'text': 'Wい', 'confident': 0.010621919900354639}
{'position': ((358, 558), (408, 558), (408, 566), (358, 566)), 'text': 'Touii ・', 'confident': 0.02443447381863205}
{'position': ((619, 583), (659, 583), (659, 595), (619, 595)), 'text': '山町町!', 'confident': 0.028604017570614815}
{'position': ((957, 553), (975, 553), (975, 619), (957, 619)), 'text': '』', 'confident': 0.0054136979733832025}
{'position': ((315, 546), (347, 543), (348, 557), (317, 560)), 'text': 'や', 'confident': 0.017806130949784782}
{'position': ((456, 546), (506, 541), (508, 558), (458, 563)), 'text': '・の町', 'confident': 0.0048286940881980015}
{'position': ((513, 556), (544, 559), (543, 567), (512, 564)), 'text': '・', 'confident': 0.24740200231096665}
加工前
加工後
若干検出できていないところがありますが、概ね良好です。実際の体育大会の画像でもほとんどの名前を自動で消すことができました。
まとめ
今回は実装よりも環境構築でエラーが多発しました。
環境構築の参考にでもなればと思います。
Discussion