本記事は Blender Advent Calendar 2016 12/08 分として寄稿したものです。
Blender上の画像データをPythonで扱う際の基本的な方法とNumPy
やPIL(Pillow)
との併用の仕方について、またBlender Python特有の微妙なハマりどころ等を書いていきます。
はじめに
3DCG統合環境であるBlenderには、3Dだけでなく二次元画像を扱うためのコンポジター/イメージペイント機能も用意されてあります。通常はレンダリングの出力を加工したり、モデルのテクスチャを描くといった具合に3D機能に従属したものとして使用されることの多いこれらの機能ですが、純粋に二次元画像を扱うためのツールとしても活用でき、見方によってはBlenderは画像編集ソフトにもなり得ます。
ところで、BlenderはPython
を内蔵しており、PythonスクリプトによってBlenderを制御することができる仕組みが用意されています。また同じくスクリプトによって今開いているBlenderファイルの使用している様々な内部データへもアクセスができ、その中には画像データも含まれます。ということは、BlenderはPythonで画像処理を行うための環境
にもなり得るということを意味します。
最低限の画像処理を行うには、画像の入力、画像データの持つピクセルデータの読み書き、および処理を施した画像の出力(どれもBlender上で完結)ができればいいので、本記事ではそれを実現するための方法を書いていくことにします。
(ちなみに使用しているBlenderのバージョンは2.78
です)
Blender上でPythonコードを実行する方法の簡単なおさらい
Blender上でPythonスクリプトを実行するにはText Editor
かPython Console
を使用します。これらのエディタを出現させる最も簡単な方法は、標準で用意されているScripting
レイアウトを呼び出すことでしょう:
このような画面に切り替わります。
PythonコードをText Editor
の中に書いた後Run Script
ボタンを押す(もしくはそのショートカットキーであるAlt+P
キーを押す)ことで実行が行えます。
print('hello bpy world!')
print()
での文字列の出力はBlenderのコンソールウィンドウに対して行われるため、コンソールウィンドウが表示されていない場合は予めBlenderメニューのWindow > Toggle System Console
を選択して表示させておいた方が良いでしょう。
Text Editor
とは別にPython Console
からもスクリプトの実行が行えます。こちらはCtrl+スペースキーでオートコンプリートが行えるので、Blender APIのデータ構造を調べる際に便利です。しかしながら(不可能ではないものの)複数行のコードの実行が行いにくいという問題があるため、簡単なテストを行う時に使用すると良いと思います。
画像を用意する
基本的に画像に関する事柄はUV/Image Editor
を通して行います。
外部画像を読み込む
UV/Image Editorのヘッダメニューの Image > Open Image
からローカルにある既存の画像ファイルをBlenderに読み込めます。
ファイル選択後、当該画像がBlender内部で利用できる形になります。
新規に画像を作成する
UV/Image Editorのヘッダメニューの Image > New Image
から新規画像作成ダイアログが表示されます。
画像名・画像サイズ諸々を入力した後OK
を押すことでBlender内に新たに画像が作成されます。
UV/Image Editorには簡易的なペイント機能もついているので0から自前でテスト用画像を描くことも可能です。
.
ちなみに新規画像作成の際、Generated Type
でColor Grid
を選択するとテスト用の画像が生成されます。手頃な画像が無い場合には非常に便利です。
レンダリング結果を使用する
基本的に上の2つのケースとあまり違いは無いのですが、Blender Pythonから利用する際の特有の問題がいくつかある(後述)のでここでは一旦置いておきます。
なので初めは読み込んだ外部画像か新規で作成した画像を使用してください。
Blender Pythonで画像の操作を行う
既に用意された画像をPythonスクリプトによって操作する方法を記していきます。なお、テスト用の画像のサイズはあまり大きすぎない値(最大でも 128x128 程度 )にしておくことをオススメします。
Blenderが使用している画像データにアクセスする
bpy.data.images[<画像名>]
から今開いているBlenderファイルが使用している画像データにアクセスできます。次のコードは対象画像の横幅・縦幅を表示します。
import bpy
blimg = bpy.data.images['A']
width, height = blimg.size
print(width, height)
.size
に画像の横幅と縦幅が格納されています。
<画像名>
の箇所はUV/Image Editor
でその画像に割当てられている名前と同じになります。
ピクセル情報を取得・セットする
import bpy
blimg = bpy.data.images['A']
# ピクセル情報を取得
print(blimg.pixels[0], blimg.pixels[1], blimg.pixels[2], blimg.pixels[3]) # R,G,B,A (1ピクセル目)
print(blimg.pixels[4], blimg.pixels[5], blimg.pixels[6], blimg.pixels[7]) # R,G,B,A (2ピクセル目)
# ピクセル情報をセット
blimg.pixels[0] = 1.0
blimg.pixels[1] = 0.0
blimg.pixels[2] = 0.0
blimg.pixels[3] = 1.0
# => 1ピクセル目が"赤色"に設定される
.pixels
からその画像のピクセル値の配列にアクセスできます。
ピクセル配列は画像の左下から1点ごとにR,G,B,Aの4つの画素値が並んでおり、そのため配列全体のサイズはwidth*height*4
になります。
またそれぞれの画素値は浮動小数点数で、通常なら最小値が0.0
,最大値が1.0
で表現されます。(0~255
の整数でないことに注意。) また0.0~1.0の範囲を逸脱しても許容されます。
ピクセル配列を一括で取得・セットする
各ピクセルに対してイテレーションを行う際、上述のやり方では実はパフォーマンスに問題が出てきます。( 細かい話になりますが、.pixels
はただの配列でなくアクセサとなっていて、例えばblimg.pixels[i]=...
のように代入を行うごとにBlender内部で更新処理が走ってしまいます。)
そこで一旦.pixels
をただの配列として一括で取得しておいて、それに対して処理を加えた後、再び.pixels
にセットするという流れで進めることにします。
import bpy
blimg = bpy.data.images['A']
width, height = blimg.size
pxs = list(blimg.pixels[:]) # 全ピクセル情報をただの配列として一括で取得
for i in range(0, width*height*4, 4):
pxs[i] = 1.0 # R
pxs[i+1] = 0.0 # G
pxs[i+2] = 0.0 # B
pxs[i+3] = 1.0 # A
blimg.pixels = pxs # 処理を加えた配列を一括でセット
また新たに設定するピクセル配列を1から作成しておいて、それを代入するというやり方もあります。
import bpy
blimg = bpy.data.images['A']
width, height = blimg.size
pxs0 = blimg.pixels[:]
pxs = [0] * len(pxs0) # もしくは pxs = [0] * (width * height * 4)
for i in range(0, width*height*4, 4):
pxs[i] = pxs0[i] * 0.5 # R
pxs[i+1] = pxs0[i+1] * 0.5 # G
pxs[i+2] = pxs0[i+2] * 0.5 # B
pxs[i+3] = pxs0[i+3] # A
blimg.pixels = pxs
※(細かい注意点) 上のようにオリジナルのピクセルを参照するだけの場合でも、.pixels
でなく.pixels[:]
のようにして純粋なリスト(もしくはタプル)の形式に変換してからアクセスしておいた方が無難です。既に述べたアクセサの問題で、.pixels
のままアクセスするとパフォーマンスが極めて低下することがあります。
座標を指定してピクセル値をセット
ピクセルのx, y座標を指定して値の取得/セットを行う場合、その配列のインデックス値は(y*width+x)*4
となります。座標を直接指定する場合、その座標が画像のサイズ内にきちんと収まっているかも確認しておく必要があります。
次の例では画像の中の(x, y) = (10, 20)~(10, 40)の範囲の矩形を白色で塗りつぶします。
import bpy
blimg = bpy.data.images['A']
width, height = blimg.size
pxs = list(blimg.pixels[:])
for y in range(10, 40):
for x in range(10, 20):
if 0<=x and x<width and 0<=y and y<height: # x,yが画像内に収まっているかを確認
i = (y*width+x)*4
pxs[i] = 1.0 # R
pxs[i+1] = 1.0 # G
pxs[i+2] = 1.0 # B
pxs[i+3] = 1.0 # A
blimg.pixels = pxs
Box Blur
を愚直に実装してみる
最低限ピクセル操作を行うことが可能となったので、ここいらで少し演習をしてみます。一番単純なぼかしのメソッドであるBox Blur
を実装してみましょう。簡単のために画像のアルファ値は全て1.0
になっているものとします。
Box Blurは対象ピクセル1点ごとに、その周辺の矩形の範囲にある全ピクセルの平均値を新たなピクセルの値として採用すれば良いので、愚直に実装すると次のようになります:
# <!> 簡単のために画像のアルファ値は常に1.0とする
import bpy
blimg = bpy.data.images['A']
width, height = blimg.size
pxs0 = blimg.pixels[:]
pxs = [0] * len(pxs0)
def inside(x,y):
return 0<=x and x<width and 0<=y and y<height
size = 5
for y in range(height):
for x in range(width):
i = (y*width+x)*4
r=0
g=0
b=0
n=0
for v in range(y-size, y+size+1):
for u in range(x-size, x+size+1):
if inside(u,v):
j = (v*width+u)*4
r += pxs0[j]
g += pxs0[j+1]
b += pxs0[j+2]
n += 1
pxs[i] = r/n
pxs[i+1] = g/n
pxs[i+2] = b/n
pxs[i+3] = 1.0
blimg.pixels = pxs
処理効率を一切無視したコードなので実用には向かない(実際遅い)ですが、これで他のプログラミング言語でのピクセル操作とほぼ同じ書き方が行えるようになりました。
Pythonから画像を別名で出力する
今までは入力と同じBlender内画像に対して出力を行っていましたが、通常は元画像に処理を加えた結果は別画像として出力されるのが習わしです。そこでPythonから新たにBlender内に画像を作成しつつそれにピクセルをセットすることで出力画像とします。
bpy.data.images.new()
を呼び出すことでBlender内に新たに画像を作成できます。次のコードは名前がBPY Output
であり白色の新規画像を作成します。
import bpy
imagename = 'BPY Output'
width = 32
height = 32
blimg = bpy.data.images.new(imagename, width, height, alpha=True)
blimg.pixels = [1.0]*(width*height*4)
images.new()
の引数はそれぞれ新規画像名, 横幅, 縦幅, アルファチャンネルの使用の有無
となっています。無用な混乱を避けるためにアルファチャンネルは今のところ使用する設定にしておくことにします。新たにBlender内画像を作成した後、.pixels
にピクセル情報を代入しています。これで画像を入力とは別に出力することができるようになりました。
「Blender Pythonで画像の操作を行う」 まとめ
-
bpy.data.images[<画像名>]
でBlender内画像にアクセスできる -
blimg.pixels
でその画像のピクセル配列の取得/セットができる -
bpy.data.images.new()
で新たなBlender内画像が作成できる
.
.
NumPyを使う
少なくとも現時点で最新のBlender(v2.78)以降ならば、Blender Pythonの中で標準でNumPyが使えるようになっています。
import numpy as np
np.arange(10) # => array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
np.arange(10) * 2 # => array([0, 2, 4, 6, 8, 10, 12, 14, 16, 18])
NumPyを使うことで高速かつ簡単に配列の操作が行えます。そこでBlender画像のピクセルをNumPyを通して処理してみることにします。
ピクセル配列 → numpy arrayへの変換
変換といってもピクセル配列をnp.array()
でラップするだけです:
import bpy
import numpy as np
blimg = bpy.data.images['A']
arr = np.array(blimg.pixels[:])
これでpixels
の内容がnumpy array
オブジェクトであるarr
へ変換され、NumPyの機能が適用できるようになりました。
NumPyの記法を活用する
NumPyの記法を用いると、例えば画像のR成分だけ0にセットするという処理が次のように書けます。(※ 入力画像名=A
,出力画像名=B
)
import bpy
import numpy as np
blimg = bpy.data.images['A']
width, height = blimg.size
arr = np.array(blimg.pixels[:])
arr[0::4] = 0.0 # R成分に一括で0を代入
blimg2 = bpy.data.images.new('B', width, height, alpha=True)
blimg2.pixels = arr
ピクセル配列のうち0から始まり4つ飛ばしの各値、すなわちR成分だけを示すためにarr[0::4]
という書き方が使用されています。対象範囲の取得だけなら元のPythonの配列でも同じ書き方ができますが、同じ記法を拡張して代入や操作も可能となっているのがNumPyの特徴ですね。
また次に挙げていくような書き方もできます。NumPyに関しては解説をされているサイトが無数にあるので、詳細に取り上げていくことはここでは控えます。
arr[0::4] *= 0.5 # R値を一括で0.5倍する
arr[1::4] *= 2.0 # G値を一括で2.0倍する
# R値を同ピクセルのG値で上書きする
arr[0::4] = arr[1::4]
# R値として同ピクセルのG値とB値の平均値を用いる
arr[0::4] = (arr[1::4] + arr[2::4])/2
# グレイスケールに変換
gray = 0.299 * arr[0::4] + 0.587 * arr[1::4] + 0.114 * arr[2::4]
arr[0::4] = gray
arr[1::4] = gray
arr[2::4] = gray
arr # => array([0, .25, .5, 1.0])
# 浮動小数点数のピクセルを0~255の範囲の整数に変換する
b = (arr*255).astype(np.int)
b[b<0] = 0
b[b>255] = 255
b # => array([0, 63, 127, 255])
# 0~255の範囲の整数から0.0~1.0の浮動小数点数に変換する
c = b.astype(np.float)/255.0
c # => array([0.0, 0.247, 0.498, 1.0])
ピクセルを二次元配列と見なす
numpy array
は実体はそのままに1次元配列を2次元配列(行列)に見立ててアクセスするということも可能です。例えばRGBAピクセル配列には次のような下処理をしておけば、R成分のみについて a_R[y][x]
という具合に直感的な形でアクセスすることができるようになります:
import bpy
import numpy as np
blimg = bpy.data.images['A']
W, H = blimg.size
a = np.array(blimg.pixels[:])
b = np.ndarray(len(a))
a.resize(H, W*4)
b.resize(H, W*4)
a_R = a[::, 0::4]
a_G = a[::, 1::4]
a_B = a[::, 2::4]
a_A = a[::, 3::4]
b_R = b[::, 0::4]
b_G = b[::, 1::4]
b_B = b[::, 2::4]
b_A = b[::, 3::4]
for y in range(H):
for x in range(W):
b_R[y][x] = a_G[y][x]
b_G[y][x] = a_B[y][x]
b_B[y][x] = a_R[y][x]
b_A[y][x] = a_A[y][x]
b = b.flatten()
blimg2 = bpy.data.images.new('B', W, H, alpha=True)
blimg2.pixels = b
上のコードでは元の画像のR,G,BをG,B,Rに置換しているだけですが、既にやったようにインデックス値を(y*width+x)*4
としてアクセスするより分かりやすい形でピクセルの参照ができています。最後のb = b.flatten()
では2次元配列から再び1次元に戻しています。
Box Blur 再び
既に書いたBox Blurを、アルゴリズムそのものは愚直なままNumPyを用いた形に書き直してみます。
例によって簡単のため画像のアルファ値は全て1.0
であるとします。
# <!> 簡単のために画像のアルファ値は常に1.0とする
import bpy
import numpy as np
blimg = bpy.data.images['A']
W, H = blimg.size
a = np.array(blimg.pixels[:])
b = np.ndarray(len(a))
a.resize(H, W*4)
b.resize(H, W*4)
a_R = a[::, 0::4]
a_G = a[::, 1::4]
a_B = a[::, 2::4]
b_R = b[::, 0::4]
b_G = b[::, 1::4]
b_B = b[::, 2::4]
size = 5
for y in range(H):
y0 = max(0, y-size)
y1 = min(H-1, y+size)
for x in range(W):
x0 = max(0, x-size)
x1 = min(W-1, x+size)
n = (y1-y0)*(x1-x0)
b_R[y][x] = np.ndarray.sum(a_R[y0:y1, x0:x1]) / n
b_G[y][x] = np.ndarray.sum(a_G[y0:y1, x0:x1]) / n
b_B[y][x] = np.ndarray.sum(a_B[y0:y1, x0:x1]) / n
b[::, 3::4] = 1.0 # Alpha == 1.0
b = b.flatten()
blimg2 = bpy.data.images.new('B', W, H, alpha=True)
blimg2.pixels = b
依然実用には届かないものの、以前のバージョンよりは数倍は高速になりました。
「NumPyを使う」 まとめ
- Blenderでは標準でNumPyがインストールされている
- ピクセル配列を
numpy array
の形式に変換することでより簡潔に処理を記述することができる - 基本的に素のPythonで記述するよりNumPyの機能を活用したほうが高速に動作する
.
.
Pillow (PIL) を使う
画像処理ライブラリであるPillow
をインストールし、Blender上で使ってみます。
Pillowは元々はPythonで広く知られている(らしい)PIL
という画像処理ライブラリのフォーク版ですが、PILの方は更新が停滞しておりBlenderが使用しているPython 3 では使うことが出来ません。よって使用にあたってはPillowの方をインストールする必要があります。
しかしながらインターフェースは(基本的には)両者間で共通のものになっているため、Pillowを利用するにしてもコードやドキュメントはPILのものも活用できると思います。
Blender PythonにPillowをインストールする (Windowsの場合)
Blender上で動作するPythonに、pip
を通して外部ライブラリであるPillow
をインストールする手順を記します。
※下記の手順はOSがWindowsの場合のみの対応になりますが、他OSでもおおよそ同じ手順になるものと思われます。
PillowのインストールをPythonのパッケージ管理システムであるpip
を通して行います。
通常の環境の(BlenderでなくOSにインストールするタイプの)Pythonならば、Pythonのインストールと同時にpipも一緒にインストールされていることが多いと思いますが、BlenderのPythonはその通りではありません。なので一旦手動でpipをインストールし、そのpipを通して外部ライブラリであるPillowをインストールするという運びになります。
-
https://pip.pypa.io/en/latest/installing/ にアクセスし、
get-pip.py
をダウンロードして適当な場所に置きます。ここではD:/temp/get-pip.py
に配置したものとします - Windows コマンドプロンプトを開き、BlenderのPythonがあるディレクトリに移動します。Blender Pythonのディレクトリは例えばBlenderが
D:/blender
にインストールされているとすれば、D:/blender/<Blenderのバージョン>/python
となります。(Blenderのバージョンが2.78ならD:/blender/2.78/python
)
コマンドプロンプトでは
> D:
> cd D:\blender\2.78\python
と入力すればいいです。 - 先程ダウンロードした
get-pip.py
を実行して当該ディレクトリに存在するPythonにpipをインストールします。
> bin\python.exe D:\temp\get-pip.py
成功するとカレントディレクトリにScripts
ディレクトリが作成され、その中にpip.exe
が配置されます。 - pipを通してPillowをインストールします。
> Scripts\pip.exe install pillow
問題なく手順を進めることができたら、Blenderを起動してスクリプトコンソールでimport PIL
と入力してエラーが生じないことを確かめてください。(import Pillow
じゃないことに注意。) 何事もなくロードが行えたらPillowのインストールは完了です。
※ 環境依存の要因でインストールが上手くいかない場合があるようです。その際はpip
でなくeasy_install
を通してのインストールを行うと上手くゆく場合もあるみたいです。 1 2
Blender Python上でPillowを呼び出す
Blender内画像とPillow (PIL) Imageとの相互変換
Blenderの画像データをPillow上の画像データに変換し、Pillow側でフィルタ処理を施した後、再びBlender上の画像データに変換するという流れでPillowを活用してみます。
この相互変換の方法は大きく分けて2通りあります。1つはBlenderのピクセルとPillowのバイト列とを自前で変換する方法と、もう1つはBlender, Pillowのファイルのセーブ/ロード機能を利用して一時ファイルを通しつつ変換する方法です。ここでは後者の一時ファイルを通す方法を採用することにします。
下記の例では入力となるBlender画像データをPillow画像データ(pimg
)に変換し、それにフィルタ処理を施した結果(pimg2
)を再びBlender画像データとして出力しています:
一時ファイルを通して変換を行う
import bpy
import numpy as np
from PIL import Image, ImageFilter
def save_as_png(img, path):
s = bpy.context.scene.render.image_settings
prev, prev2 = s.file_format, s.color_mode
s.file_format, s.color_mode = 'PNG', 'RGBA'
img.save_render(path)
s.file_format, s.color_mode = prev, prev2
blimg = bpy.data.images['A']
W,H = blimg.size
temppath = 'd:/temp/bpytemp.png'
# 一時ファイルに保存(Blender)
save_as_png(blimg, temppath)
# 一時ファイルから読み込み(PIL)
pimg = Image.open(temppath)
# PILのフィルタを適用する(ガウシアンブラー)
pimg2 = pimg.filter(ImageFilter.GaussianBlur(radius=5))
# 一時ファイルに保存(PIL)
pimg2.save(temppath)
# 一時ファイルから読み込み(Blender)
blimg2 = bpy.data.images.load(temppath)
blimg2.name = 'B'
BlenderからPillowのフィルタを利用できています。
Pillowのフィルタを触ってみる
既に使用したガウシアンブラーだけでなく様々なフィルタがPillowには用意されています。そこで早速いくつか試してみることにしましょう。
下記のコードは上記のコードのうちpimg2 = ...
の箇所に置換してから実行してください。
from PIL import ImageEnhance
pimg2 = ImageEnhance.Brightness(pimg).enhance( 1.8 )
.
pimg2 = pimg.transpose(Image.ROTATE_90)
.
from PIL import ImageEnhance, ImageDraw, ImageFont
pimg2 = pimg.transpose(Image.ROTATE_90)
pimg2 = ImageEnhance.Brightness(pimg2).enhance( 0.5 )
pimg2.paste(pimg, (70, 70))
draw = ImageDraw.Draw(pimg2)
font = ImageFont.truetype('arial.ttf', 20)
draw.text((20, 5), 'hogehoge', font=font, fill='#88ff88')
「Pillow (PIL) を使う」 まとめ
- Blender Pythonに外部ライブラリを手動でインストールすることができる
- Pillowのフィルタを使用するためにBlenderとPillowとで画像データを相互に変換する必要がある
.
.
まとめ
駆け足になりましたが、Blender内部の画像データをPythonで制御する基本的な方法、またNumPyやPillowといった外部ライブラリをBlender Python上で活用する方法についてを、ごく初歩的な内容に留まりながらも解説してきました。ここまでくればBlenderでない通常のPythonについての他のサイトの記事も応用してBlender上で動作させられる筈です。
特にPythonはそのままでは非常に低速で、画像処理のような膨大なイテレーションを必要とするプログラムのパフォーマンスを改善するには NumPy に代表される外部ライブラリについての習熟が必須となるでしょう。今回挙げたものの他にも優れた画像処理用ライブラリはいくつかあるので、機能の不足を感じたり、より速度の改善を望むような場合はそれらをあたってみるのもいいかもしれませんね。
.
.
以降は画像のピクセル操作をする際のBlender特有の引っ掛かりどころや、筆者自身の備忘録を含む細かい内容となります。( おそらく随時更新 )
その他細かい部分についてのTips
画像が消えないために
Pythonから画像を新規に作成したり、既存の画像に処理を行ったりすることで画像に何らかの変更を加えたとします。この状態で現在開いているBlenderファイルを保存した後、一旦ファイルを閉じて再び開き直すと、.blend
ファイルはきちんと保存した筈であるにも関わらずその画像への変更が失われてしまいます。
これはPython関係なくBlenderで作業する上で全般的に注意を要する事項となるのですが、基本的にBlenderは.blendファイルの中で使用する画像への変更を自動的には保存してくれません。(保存されるのは画像の使用状態のみ。) 画像へ変更を加えた場合はその保存動作をユーザー自身が行う必要があります。
Save As Image
を使う
UV/Image EditorのImage
メニューからSave As Image
を選択して外部に画像を保存できます。その後Blenderファイルの保存を行うことで、先程保存した外部画像への参照が.blend
ファイルに含まれる形になります。改めてBlenderから先の画像へ変更を加えたら、UV/Image EditorのSave Image
を選択することで外部画像へ変更が上書き保存されます。
Pack As PNG
を使う
UV/Image EditorのImage
メニューからPack As PNG
を選択した後でBlenderファイルの保存を行うと、その画像が.blend
ファイルの内部へ埋め込まれる形で保存されます。この操作によって保存した画像に何らかの変更を加えた場合は、再び Pack As PNG → Blenderファイル上書き保存の手順を行うことで画像への変更を上書き保存することが出来ます。(Save Image
でないことに注意)
Python側で保存処理を行う
Pythonから直接外部画像として保存するというのも一つの手です。以前に定義した関数save_as_png()
を利用して、例えば
blimg0 = ... # 保存する画像
path = "d:/temp/abc.png" # 適当なパス
save_as_png(blimg0, path)
blimg = bpy.data.images.load(path)
blimg.name = '適当な画像名'
のようにすれば、指定したパスに画像が出力された後で現在のBlenderファイルがその画像を開いているという形となるため、後は.blendファイルの保存のみ行えばいいことになります。
この方法を使った場合における.blendファイル内でのその画像の位置付けは*Save As Image
のケース*を通したものと同じものとなります。
画像が消えないために その2
0ユーザー問題
オブジェクトやそのメッシュ、マテリアル、テクスチャ、開いている画像等ひとつのBlenderファイルが使用する各種データ(リソース)にはユーザー
という概念があり、端的に言えばそのリソースがそのBlenderファイル内でどの程度他から利用されているかを示すものです。
特にどこからも利用されていないリソースは0ユーザー
として認識され、この状態でBlenderファイルを保存・再び開き直すという手順を経ると、0ユーザーだったリソースが自動的に削除されます。
この仕様は不要なリソースを保持せず常に最小限のデータ構造を維持できるという点で有用ですが、気を付けないと実験的に作成してみたがすぐには使わない状態にあるリソースまでもが消えてしまいます。とりわけ、この記事で行っているようなPythonから直接新規に作成した画像データはまさしくそれに該当してしまっています。
Fake Userを使う
といっても0ユーザー状態を回避するのは実に簡単です。Fake User
状態にすれば良いだけです。Blenderでの各種データブロックのデータ名の横にF
マークのボタンがありますが、それをONにすればFake User状態となります。Fake User状態となったデータは常にそのユーザー数が実際よりも+1
された状態となるため、0ユーザーにならずに済むことになります。
PythonからFake UserをONにするには、そのデータの.use_fake_user
にTrue
を代入してやればいいです。
blimg = bpy.data.images['A']
blimg.use_fake_user = True
レンダリング結果の画像のピクセルにアクセスする際の注意点
Render Result
のピクセルにアクセスできない
レンダリング結果の画像はUV/Image Editor
の中のRender Result
に格納されますが、Pythonでこの画像にアクセスしても何故かサイズが常に0
を返され、またピクセル配列も空となっておりそのままではアクセスできません。
import bpy
blimg = bpy.data.images['Render Result']
width, height = blimg.size
print(width, height) # => 0, 0
print(len(blimg.pixels)) # => 0
この問題を回避するいくつかの方法があります。
Viewer Node
を使用する
Node Editor
を開きタイプをCompositing
に切り替え、コンポジターを開きます。
Use Nodes
をONにし、Add > Output > Viewer
からViewer
ノードを追加、レンダリング結果に繋げます。
すると UV/Image Editor の中にViewer Node
という名前の画像が作成され、またその内容はRender Result
と同じです。このViewer NodeにPythonからアクセスすれば当該画像のピクセルにアクセスできます。
import bpy
blimg = bpy.data.images['Viewer Node']
width, height = blimg.size
pxs = blimg.pixels[:]
blimg2 = bpy.data.images.new('B', width, height, alpha=True)
blimg2.pixels = pxs
カラーマネジメントにより色が変わる
しかしながら上のコードを実行してみると、単に元の画像をそのまま別画像にコピーしただけのものでありながら、両者間で色味が違ってしまいます。
何が起こっているかというと、Blenderは特定の画像に対してカラーマネジメント
による補正を自動的にかける仕組みになっていて、特に今回のようなレンダリング結果の画像がそれに該当します。
今回のケースではBlenderのカラーマネジメントシステムにより元のピクセルに暗黙的にガンマ補正が掛けられているものが最終的に表示されている(※2)ため、その結果を明示的に得るために自前で同じ補正を掛けてやればよさそうです:
import bpy
import numpy as np
def fix_colormanagement(a):
a[0::4] = np.power(a[0::4]/a[3::4], 1/2.2)
a[1::4] = np.power(a[1::4]/a[3::4], 1/2.2)
a[2::4] = np.power(a[2::4]/a[3::4], 1/2.2)
blimg = bpy.data.images['Viewer Node']
width, height = blimg.size
pxs = blimg.pixels[:]
a = np.array(pxs)
fix_colormanagement(a)
blimg2 = bpy.data.images.new('B', width, height, alpha=True)
blimg2.pixels = a
うまいこと補正されました。
※ ここで補正のためのガンマ値を2.2
に決め打ちしてしまいましたが、もしかしたら環境によっては異なる値を使用しないといけないかもしれません。
※2 ここでの処理はカラーマネジメントの設定がデフォルトの時のみの対応となってます。
カラーマネジメントを無効にする
上記のやり方とは別に、Blenderのカラーマネジメントシステムそのものを無効にしてしまうという手もあります。
プロパティのScene
タブからColor Management
パネルを開き、その中のDisplay Device:
をNone
にする、もしくはView:
をRaw
に変更することでこれを無効にできるようです。
Viewer Node画像の補正がOFFになりました。
一旦ファイルに保存して回避する
レンダリング結果を一旦ファイルとして保存しておき、それをロードすることで上述した各種の問題を回避できます。保存した画像の結果はカラーマネジメントが適用済みのものになります。
またRender Result
そのままでよく、Viewer Node
を通す必要もありません。
import bpy
blimg = bpy.data.images['Render Result']
temppath = 'd:/temp/bpytemp.png'
save_as_png(blimg, temppath)
blimg2 = bpy.data.images.load(temppath)
blimg2.name = 'B'
print(len(blimg2.pixels))
( 関数save_as_png()
はBlender Python上でPillowを呼び出す > 一時ファイルを通して変換を行うのときと同じ )
画像出力を少し楽にするユーティリティ
Blender Pythonから画像の出力を行うには、既に書いたように次のようにすればいいのでした:
imagename = 'New Image'
width = 32
height = 32
pxs = [1.0]*(width*height*4)
blimg = bpy.data.images.new(imagename, width, height, alpha=True)
blimg.pixels = pxs
上記のやり方でも十分といえば十分なのですが、同名の画像を連続で生成すると、重複を避けるためにBlenderが自動的にサフィックスを付けた別名の画像、例えばNew Image.001
という名前の画像が作成されてしまいます。
大抵の場合コードを書き進める過程では試行錯誤のため何度も実行を行うのが常であり、その度に結果を確認するためにUV/Image Editorにて別名に保存された画像を大量の画像の中から選択し直すのは億劫です。そこで、画像を新規作成する際に同名画像があった場合はそれを事前に自動で削除する(もしくは使いまわす)ようにした次のユーティリティ関数 make_blimage()
を使うといいでしょう:
def make_blimage(width, height, pixels, imgname='Py Output'):
# 同名の画像が既に存在し、かつ同サイズだった場合にはそれを使いまわし、サイズが異なれば新たに作成し直す
if imgname in bpy.data.images and bpy.data.images[imgname].size[:] != (width,height):
older = bpy.data.images[imgname]
older.user_clear()
bpy.data.images.remove( older )
if imgname not in bpy.data.images:
img = bpy.data.images.new(imgname, width, height, alpha=True)
img.generated_color = (0,0,0,0)
img.use_fake_user = True
img = bpy.data.images[imgname]
img.pixels = pixels
return img
次のようにして使います。
imagename = 'New Image'
width = 32
height = 32
pxs = [1.0]*(width*height*4)
make_blimage(width, height, pxs, imagename)
画像処理の際にはUV/Image Editorを2つ並べて入力と出力画像をそれぞれに表示しておくと便利ですが、上の関数を使えば出力画像が同サイズである限りはスクリプトの実行後にImage Editorで出力画像を開き直す手間が省けるので楽ができます。
NumPy: プリマルチプライド・アルファ ←→ ストレート・アルファ変換
アルファチャンネルの有効な画像の処理でたびたび必要になるプリマルチプライド・アルファ(Premultiplied Alpha)とストレート・アルファ(Straight Alpha)との変換ですが、NumPyを使えば簡単に行えます。
a = np.array(blimg.pixels[:])
a_R = a[0::4]
a_G = a[1::4]
a_B = a[2::4]
a_A = a[3::4]
## straight to premul alpha
a_R *= a_A
a_G *= a_A
a_B *= a_A
a # => プリマルチプライド・アルファに変換されたカラー
## premul to straight alpha
nonzero_alpha = a_A != 0
a_A_nonzero = a_A[nonzero_alpha]
a_R[nonzero_alpha] /= a_A_nonzero
a_G[nonzero_alpha] /= a_A_nonzero
a_B[nonzero_alpha] /= a_A_nonzero
a # => ストレート・アルファに変換されたカラー
ここでアルファ値は既に0.0~1.0
の範囲に収まっていることに注意。
元のストレートアルファなカラーについて、アルファ値を乗算すればプリマルチプライドに、除算すればストレートになるというだけの話ですが、除算の際は0
で割ることはできないため、アルファ値が0の場合は処理を除外するためにnonzero_alpha
に見られるNumPyのインデックス記法を活用しています。
プリマルチプライド/ストレート・アルファの変換が必要なケースとして、例えば半透明な画像同士の線形補間(クロスフェード)があります。線形補間自体は非常に単純で、 $t$ が0.0~1.0で2つの画像の画素値が $a, b$ とすると、その線形補間は $c = a(1-t)+bt$ です。
def to_premul(a):
a_R = a[0::4]
a_G = a[1::4]
a_B = a[2::4]
a_A = a[3::4]
a_R *= a_A
a_G *= a_A
a_B *= a_A
def to_straight(a):
a_R = a[0::4]
a_G = a[1::4]
a_B = a[2::4]
a_A = a[3::4]
nonzero_alpha = a_A != 0
a_A_nonzero = a_A[nonzero_alpha]
a_R[nonzero_alpha] /= a_A_nonzero
a_G[nonzero_alpha] /= a_A_nonzero
a_B[nonzero_alpha] /= a_A_nonzero
blimg1 = bpy.data.images['img1']
blimg2 = bpy.data.images['img2']
assert blimg1.size[:] == blimg2.size[:]
a = np.array(blimg1.pixels[:])
b = np.array(blimg2.pixels[:])
to_premul(a)
to_premul(b)
t = 0.5
c = a*(1-t)+b*t
to_straight(c)
W, H = blimg1.size
blimg_out = bpy.data.images.new('B', W, H, alpha=True)
blimg_out.pixels = c
上のコードでは事前にto_premul()
で2つの画像のピクセルをプリマルチプライド・アルファのカラーに変換しておき、それらの間で線形補間を行った結果であるc
を最終的にto_straight()
でストレート・アルファに変換しなおしています。
t
を0.0
から1.0
まで動かした結果と、もしto_premul()
, to_straight()
の呼び出しが行われなかったらどうなるかの結果も出力したのが下のアニメーションになります:
左がプリマルチプライド/ストレート・アルファ変換を行わなかった場合の結果で、右がきちんと行ったときの結果です。見ての通り左では補間の中間で結果に不備が生じています。片方の画像のアルファ値が0であるものの実際には残っているRGBのカラー情報が漏れ出している形ですね。
Pillow: Blender画像のピクセルを直接Pillow画像のバイト列に変換する
Pillowの項で軽く触れた「BlenderのピクセルとPillowのバイト列とを自前で変換する方法」についてここに記しておきます。諸々の理由であまり実用的ではありません。(後述)
import bpy
import numpy as np
from PIL import Image, ImageFilter
blimg = bpy.data.images['A']
W,H = blimg.size
px0 = blimg.pixels[:]
# Blender用ピクセルを0~255の整数に変換
a = (np.array(px0)*255).astype(np.int)
a[a<0] = 0
a[a>255] = 255
a = a.astype(np.uint8)
# ピクセルをPIL Imageに変換
import array
pimg = Image.frombytes("RGBA", (W,H), array.array("B", a).tostring() )
# PILのフィルタを適用する(ガウシアンブラー)
pimg2 = pimg.filter(ImageFilter.GaussianBlur(radius=5))
W2,H2 = pimg2.size
# PIL ImageをBlender用ピクセルに変換
pil_bytes2 = np.asarray( pimg2 ).flatten().flatten()
pxs = pil_bytes2 / 255.0
blimg2 = bpy.data.images.new('B', W2, H2, alpha=True)
blimg2.pixels = pxs
(注意) BlenderとPillowとで画像データをやりとりする上での既に挙げた異なる2つの方式のうち、基本的には一時ファイルを通す
方式の方を使ったほうが無難です。Blenderの画像のピクセルデータは画像の左下から始まるのに対し、Pillowの画像は左上から始まるため、ピクセルを直接変換する方式ではPillowのフィルタによっては結果に不備が生じます。(自前で上下反転させるというのも余分なコストになります。)
また大きな画像を扱う際にもファイルを通したほうがパフォーマンスに優れているかもしれません。
.
.
参考
-
Installation — Pillow (PIL Fork) 3.1.2 documentation
http://pillow.readthedocs.io/en/3.1.x/installation.html -
Python 3.5 対応画像処理ライブラリ Pillow (PIL) の使い方 - Librabuch
https://librabuch.jp/blog/2013/05/python_pillow_pil/ -
コンポジターに必要なアルファチャンネルの知識(後編) - コンポジゴク
http://compojigoku.blog.fc2.com/blog-entry-5.html
.
.
この記事は Blender Advent Calendar 12/08 分の記事でした。
明日 (12/09) の記事は psi_ni_phi さんの「キャラクターデザインのちょっとしたTIPSとメイキングを載せます」になります。
-
Installation — Pillow (PIL Fork) 3.1.2 documentation
http://pillow.readthedocs.io/en/3.1.x/installation.html ↩ -
Python 3.5 対応画像処理ライブラリ Pillow (PIL) の使い方 - Librabuch
https://librabuch.jp/blog/2013/05/python_pillow_pil/ ↩