2022/06/19(日) [n年前の日記]
#1 [python] Pythonで画像処理ができそうか勉強中
Python + Pillow (PIL) を使って簡易的な画像処理ができそうか勉強中。Pillow (PIL) モジュールを使うと、画像の読み書きや、ピクセル単位での変更ができるので、その使い方を調べてた。
環境は、Windows10 x64 21H2 + Python 3.9.13 64bit + Pillow 9.1.1。
実験に使った画像は以下。

_mandrill.png
環境は、Windows10 x64 21H2 + Python 3.9.13 64bit + Pillow 9.1.1。
実験に使った画像は以下。

◎ 参考ページ。 :
_PillowのImage.mergeの使い方: 画像のバンドをマージする - なるぽのブログ
_【Python/Pillow(PIL)】画像の輝度値の取得/設定 | イメージングソリューション
_Pythonで画像のピクセル操作 - Qiita
_【Python】PILで高速に全画素アクセスを行う - kuranabeの開発備忘録
_PILでのピクセル単位での操作 - 自分のためのPython Wiki
_【Python/Pillow(PIL)】画像の輝度値の取得/設定 | イメージングソリューション
_Pythonで画像のピクセル操作 - Qiita
_【Python】PILで高速に全画素アクセスを行う - kuranabeの開発備忘録
_PILでのピクセル単位での操作 - 自分のためのPython Wiki
◎ Pillow (PIL)のインストール。 :
Pillow (PIL) は、Python に付属している pip というツールを使って、インターネット経由でインストールできる。
pip install Pillow pip install Pillow -U
- -U をつけると、モジュールを現行版にアップデートできる。
◎ チャンネル別に分解。 :
RGB画像を読み込んで、チャンネル別(R,G,B)に分解してみる。
以下の事例では、チャンネル別に分解した後、各チャンネル(グレースケール)を2値化して、それをまたRGBに結合? 合成? し直している。
_01split.py
py 01split.py で実行。以下が得られた。

各チャンネルを2値化したことで、元画像と比べるとディザがかかった状態になっている。

各行について、簡単に説明。
以下の事例では、チャンネル別に分解した後、各チャンネル(グレースケール)を2値化して、それをまたRGBに結合? 合成? し直している。
_01split.py
from PIL import Image
def main():
im = Image.open("mandrill.png")
width, height = im.size
print("%d x %d" % (width, height))
rc, gc, bc = im.split()
rc1 = rc.convert("1").convert("L")
gc1 = gc.convert("1").convert("L")
bc1 = bc.convert("1").convert("L")
oim = Image.merge("RGB", (rc1, gc1, bc1))
oim.show()
oim.save("output_01split.png")
if __name__ == '__main__':
main()
py 01split.py で実行。以下が得られた。

各チャンネルを2値化したことで、元画像と比べるとディザがかかった状態になっている。

各行について、簡単に説明。
- PIL (Pillow) の Image クラスを使うには、最初のあたりで、from PIL import Image と記述。
- im = Image.open("mandrill.png") で、画像を読み込む。
- im.size には、画像の横幅と縦幅が入っている。w, h = im.size で、横幅と縦幅を w と h にコピー。
- チャンネルを分解するには、.split() を使う。r, g, b = im.split() で、r,g,b に各チャンネルが入る。
- .convert() を使えば画像のモード(?)を変換できる。"1" を指定すれば2値化される。"L" を指定すればグレースケールになる。
- チャンネルを結合? 合成? するには、Image.merge() を使う。oim = Image.merge("RGB", (r, g, b)) で、r,g,bチャンネルを合成して、RGB画像にして返す。
- im.show() で、画像を表示。自分の環境では、png を、画像ビューア IrfanView に関連付けているので、im.show() を呼ぶと IrfanView が起動して画像が表示される。
- im.save("output.png") で画像を保存。ファイル名の拡張子によって、保存に使う画像フォーマットを Pillow (PIL) が自動で選んでくれる。
◎ 1ドットずつ処理をする。 :
画像内から1ドットずつ値を読んで処理をする際は、.getpixle()、.putpixel() を使える。
以下の事例では、RGB画像を読み込んでグレースケールに変換した後、各ドットの値を反転して(255-v)、新規画像に書き込み直している。
_02getpixel.py
py 02getpixel.py で実行。以下が得られた。

ただ、.getpixel()、.putpixel() は、処理が遅いという話も見かけた。自分の手元の環境(Ryzen 5 5600X)では、そんなに遅いようには見えてないのだけど…。
少しでも処理を速くしたい場合は、以下のような書き方もできる模様。Pillow 1.1.6a1 の時点での追加機能、と ChangeLog(?) には書いてあるように見える。
_Pillow/CHANGES.rst at main - python-pillow/Pillow
_03getpixel2.py
py 03getpixel2.py で実行。以下が得られた。

しかし、処理速度がそれほど変わったようには見えてないのだけど…。もしかして、Pillow (PIL) の現行バージョンでは、.getpixel()、.putpixel() の処理速度は改善済み、というオチだったりしないか…。分からんけど。
以下の事例では、RGB画像を読み込んでグレースケールに変換した後、各ドットの値を反転して(255-v)、新規画像に書き込み直している。
_02getpixel.py
from PIL import Image
def main():
im = Image.open("mandrill.png")
width, height = im.size
print("%d x %d" % (width, height))
gim = im.convert("L")
oim = Image.new("L", (width, height))
for y in range(height):
for x in range(width):
v = 255 - gim.getpixel((x, y))
oim.putpixel((x, y), (v))
oim.show()
oim.save("output_02getpixel.png")
if __name__ == '__main__':
main()
py 02getpixel.py で実行。以下が得られた。
- im = Image.new("L", (w, h)) で、w x h サイズのグレースケール画像を新規作成。"L" を "RGB" にすれば、グレースケールではなくRGB画像を新規作成できる。
- im.getpixel((x, y)) で、(x, y) 座標のドットの値を取得できる。
- im.putpixel((x, y), (r, g, b)) で、(x, y) 座標のドットの値を設定できる。
ただ、.getpixel()、.putpixel() は、処理が遅いという話も見かけた。自分の手元の環境(Ryzen 5 5600X)では、そんなに遅いようには見えてないのだけど…。
少しでも処理を速くしたい場合は、以下のような書き方もできる模様。Pillow 1.1.6a1 の時点での追加機能、と ChangeLog(?) には書いてあるように見える。
_Pillow/CHANGES.rst at main - python-pillow/Pillow
* Added pixel access object. The "load" method now returns a access object that can be used to directly get and set pixel values, using ordinary [x, y] notation:
pixel = im.load()
v = pixel[x, y]
pixel[x, y] = v
If you're accessing more than a few pixels, this is a lot faster than using getpixel/putpixel.
_03getpixel2.py
from PIL import Image
def main():
im = Image.open("mandrill.png")
width, height = im.size
print("%d x %d" % (width, height))
gim = im.convert("L")
oim = Image.new("L", (width, height))
gpixel = gim.load()
opixel = oim.load()
for y in range(height):
for x in range(width):
opixel[x, y] = 255 - gpixel[x, y]
oim.show()
oim.save("output_03getpixel2.png")
if __name__ == '__main__':
main()
py 03getpixel2.py で実行。以下が得られた。
- pixle = im.load() で、ピクセル値を配列っぽい形で取得。
- pixel[x, y] で、各ピクセルを読み書きできる。
しかし、処理速度がそれほど変わったようには見えてないのだけど…。もしかして、Pillow (PIL) の現行バージョンでは、.getpixel()、.putpixel() の処理速度は改善済み、というオチだったりしないか…。分からんけど。
◎ グラデーション画像を作成する。 :
Pillow (PIL) を使って、グラデーション画像を作ってみる。
今回は、矩形の塗りつぶしを繰り返すことで目的を果たした。
_04gradation.py
py 04gradation.py で実行。以下の画像が得られた。0-255までの値で塗り潰されている。

_gradation.png
今回は、矩形の塗りつぶしを繰り返すことで目的を果たした。
_04gradation.py
from PIL import Image, ImageDraw
def make_gradation_image(w, h):
im = Image.new("L", (w, h), (128))
draw = ImageDraw.Draw(im)
for y in range(16):
for x in range(16):
v = y * 16 + x
x0 = x * w / 16
y0 = y * h / 16
x1 = (x + 1) * w / 16 - 1
y1 = (y + 1) * h / 16 - 1
# draw.rectangle((x0, y0, x1, y1), fill=(v), outline=(0))
draw.rectangle((x0, y0, x1, y1), fill=(v), outline=None)
return im
def main():
im = make_gradation_image(512, 512)
im.show()
im.save("gradation.png")
if __name__ == '__main__':
main()
py 04gradation.py で実行。以下の画像が得られた。0-255までの値で塗り潰されている。

- Pillow (PIL) で何かしらを描画する際は、ImageDraw が使える。最初のあたりで、from PIL import Image, ImageDraw と記述。
- draw = ImageDraw.Draw(im) で、描画のためのクラス? グラフィックコンテキスト? を取得。
- draw.rectangle((x0, y0, x1, y1), fill=(v), outline=None) で、矩形塗り潰し。(x0, y0) が左上の座標。(x1, y1) が右下の座標。fill=(r, g, b) で塗り潰し色を指定。outline=None で境界線無しを指定。outline=(r, g, b) で境界線の色を指定。
◎ ディザリングを試す。 :
諧調を持った画像を0/1のみで疑似的に表現する際に、ディザリングという手法が利用できる。ディザリングの種類は色々あるけど、その中の Ordered dithering というのを試してみた。
_Ordered dithering - Wikipedia
_配列ディザリング - Wikipedia
_Unityでディザリングシェーダを作ってみた - WonderPlanet Developers’ Blog
_105AI研究所: ベイヤーディザフィルタ(BayerDitherFilter)
_ImageMagick/thresholds.xml at main - ImageMagick/ImageMagick
上記の参考ページを眺めてみたけど、処理としては以下のような簡単な処理で実現できるらしい。
以下は、グレースケール画像を読み込んで、2x2, 3x3, 4x4, 8x8 のディザをかけて保存する例。
_05ordered_dithering.py
py 05ordered_dithering.py で実行。4つの画像が生成される。
先ほど作成した gradation.png を読み込んで変換してみた。
元画像。256段階の諧調が含まれている。
2x2。5段階の疑似諧調を表現できる。
3x3。10段階の疑似諧調を表現できる。
4x4。17段階の疑似諧調を表現できる。
8x8。65段階の疑似諧調を表現できる。
猿(マンドリル)画像でも試してみる。
2x2。
3x3。
4x4。
8x8。
どれもそれっぽく変換できたような気がする。
さて、グレースケール画像を0/1で表現することはできたけど…。複数の色を指定して変換するにはどうしたらいいのだろう…。
RGB8色のみを使って表現するだけでも良いのであれば、RGBチャンネル別にディザリングをかけて、最後に合成すれば済みそうな気がする。しかし、一般的な減色処理として行いたいなら、もうちょっと何かやらないといけないはず…。画像内で使われている色の中から残したい色を決定して、その残したい色では表現できない部分についてはディザを使って疑似表現、ということになるはずで…。どうすればいいんだろうな…。
_Ordered dithering - Wikipedia
_配列ディザリング - Wikipedia
_Unityでディザリングシェーダを作ってみた - WonderPlanet Developers’ Blog
_105AI研究所: ベイヤーディザフィルタ(BayerDitherFilter)
_ImageMagick/thresholds.xml at main - ImageMagick/ImageMagick
上記の参考ページを眺めてみたけど、処理としては以下のような簡単な処理で実現できるらしい。
- ディザ配列から、しきい値の配列を作る。
- x, y の座標値を、ディザ配列の数で割って余りを求める。
- 得られた余りを使って、しきい値の配列から、しきい値を取得。
- そのドットの値と、しきい値を比較して、0 or 1 (0 or 255) で置き換える。
以下は、グレースケール画像を読み込んで、2x2, 3x3, 4x4, 8x8 のディザをかけて保存する例。
_05ordered_dithering.py
from PIL import Image
odtbl2x2 = [
[1, 3],
[4, 2]
]
odtbl3x3 = [
[3, 7, 4],
[6, 1, 9],
[2, 8, 5]
]
# odtbl3x3 = [
# [7, 9, 5],
# [2, 1, 4],
# [6, 3, 8]
# ]
odtbl4x4 = [
[1, 9, 3, 11],
[13, 5, 15, 7],
[4, 12, 2, 10],
[16, 8, 14, 6]
]
odtbl8x8 = [
[1, 33, 9, 41, 3, 35, 11, 43],
[49, 17, 57, 25, 51, 19, 59, 27],
[13, 45, 5, 37, 15, 47, 7, 39],
[61, 29, 53, 21, 63, 31, 55, 23],
[4, 36, 12, 44, 2, 34, 10, 42],
[52, 20, 60, 28, 50, 18, 58, 26],
[16, 48, 8, 40, 14, 46, 6, 38],
[64, 32, 56, 24, 62, 30, 54, 22]
]
odtbls = {
"2x2": [5, odtbl2x2],
"3x3": [10, odtbl3x3],
"4x4": [17, odtbl4x4],
"8x8": [65, odtbl8x8]
}
def get_dither_table(odtype):
d, tbl = odtbls[odtype]
w = len(tbl[0])
h = len(tbl)
odtbl = [[0 for i in range(w)] for j in range(h)]
for y in range(h):
for x in range(w):
odtbl[y][x] = tbl[y][x] * 256 / d
return w, h, odtbl
def get_dither_image(odtype, im):
width, height = im.size
oim = Image.new("L", im.size)
tw, th, od = get_dither_table(odtype)
src_pixel = im.load()
dst_pixel = oim.load()
for y in range(height):
dy = y % th
for x in range(width):
dx = x % tw
if src_pixel[x, y] < od[dy][dx]:
dst_pixel[x, y] = 0
else:
dst_pixel[x, y] = 255
return oim
def main():
# im = Image.open("mandrill.png").convert("L")
im = Image.open("gradation.png")
for odtype in ["2x2", "3x3", "4x4", "8x8"]:
oim = get_dither_image(odtype, im)
oim.save("output_dither_%s.png" % odtype)
if __name__ == '__main__':
main()
py 05ordered_dithering.py で実行。4つの画像が生成される。
先ほど作成した gradation.png を読み込んで変換してみた。
元画像。256段階の諧調が含まれている。

2x2。5段階の疑似諧調を表現できる。

3x3。10段階の疑似諧調を表現できる。

4x4。17段階の疑似諧調を表現できる。

8x8。65段階の疑似諧調を表現できる。

猿(マンドリル)画像でも試してみる。
2x2。

3x3。

4x4。

8x8。

どれもそれっぽく変換できたような気がする。
さて、グレースケール画像を0/1で表現することはできたけど…。複数の色を指定して変換するにはどうしたらいいのだろう…。
RGB8色のみを使って表現するだけでも良いのであれば、RGBチャンネル別にディザリングをかけて、最後に合成すれば済みそうな気がする。しかし、一般的な減色処理として行いたいなら、もうちょっと何かやらないといけないはず…。画像内で使われている色の中から残したい色を決定して、その残したい色では表現できない部分についてはディザを使って疑似表現、ということになるはずで…。どうすればいいんだろうな…。
◎ 余談。レナさん画像が懐かしい。 :
半日ほど作業して思ったのですが。猿の画像では学習モチベーションが上がらない…。やっぱりレナさん画像のようなテスト画像を使いたいなあ…。
この手の実験をする時って、何十回、何百回、何千回と、その画像を拡大表示しながら「ここのドットが変だな…」「このドットの色はおかしくないか…」とチェックを繰り返すわけでして。なのに、その画像が、猿の画像ですよ。猿ですよ、猿。いくら「カラフルで奇麗な画像だよねー」って言ってみたところで猿は猿なんだよ! 下手すると何千回も猿を見つめ続けるわけですから当然精神的には苦痛なわけですよ。こんなん頭がおかしくなるわ。少し上の猿4連発を眺めただけでも自分ちょっと嫌な感じの汗出てきましたもん。コレ、ボディブローのように地味に効いてくるわ…。
どうせなら美しさを感じる画像を ―― 何度凝視したって全然ウンザリしてこない画像を使って実験したい。だから皆、こぞってレナさん画像で実験してたわけでね…。レナさんの御姿なら、256回でも65536回でも余裕で凝視できますわ。それぐらいの美しさがレナさん画像にはある。その美しさのおかげで心が折れずにトライアンドエラーを繰り返せる。レナさん画像がテクノロジーの発展に寄与してきたというのはガチですわ。猿に睨まれてたら挫けてた。レナさんがあの眼差しでこっちを見つめてくれて、こっちもソレを延々見つめ返したから、画像処理という分野はここまで来れたわけですよ。
でも、御本人が「もうやめろや」と仰られているのでは…ねえ…。代替画像、無いのかなあ…。
この手の実験をする時って、何十回、何百回、何千回と、その画像を拡大表示しながら「ここのドットが変だな…」「このドットの色はおかしくないか…」とチェックを繰り返すわけでして。なのに、その画像が、猿の画像ですよ。猿ですよ、猿。いくら「カラフルで奇麗な画像だよねー」って言ってみたところで猿は猿なんだよ! 下手すると何千回も猿を見つめ続けるわけですから当然精神的には苦痛なわけですよ。こんなん頭がおかしくなるわ。少し上の猿4連発を眺めただけでも自分ちょっと嫌な感じの汗出てきましたもん。コレ、ボディブローのように地味に効いてくるわ…。
どうせなら美しさを感じる画像を ―― 何度凝視したって全然ウンザリしてこない画像を使って実験したい。だから皆、こぞってレナさん画像で実験してたわけでね…。レナさんの御姿なら、256回でも65536回でも余裕で凝視できますわ。それぐらいの美しさがレナさん画像にはある。その美しさのおかげで心が折れずにトライアンドエラーを繰り返せる。レナさん画像がテクノロジーの発展に寄与してきたというのはガチですわ。猿に睨まれてたら挫けてた。レナさんがあの眼差しでこっちを見つめてくれて、こっちもソレを延々見つめ返したから、画像処理という分野はここまで来れたわけですよ。
でも、御本人が「もうやめろや」と仰られているのでは…ねえ…。代替画像、無いのかなあ…。
[ ツッコむ ]
以上、1 日分です。