Blenderではじめる画像処理

  • 26
    いいね
  • 0
    コメント

本記事は Blender Advent Calendar 2016 12/08 分として寄稿したものです。

Blender上の画像データをPythonで扱う際の基本的な方法とNumPyPIL(Pillow)との併用の仕方について、またBlender Python特有の微妙なハマりどころ等を書いていきます。

はじめに

3DCG統合環境であるBlenderには、3Dだけでなく二次元画像を扱うためのコンポジター/イメージペイント機能も用意されてあります。通常はレンダリングの出力を加工したり、モデルのテクスチャを描くといった具合に3D機能に従属したものとして使用されることの多いこれらの機能ですが、純粋に二次元画像を扱うためのツールとしても活用でき、見方によってはBlenderは画像編集ソフトにもなり得ます。

ところで、BlenderはPythonを内蔵しており、PythonスクリプトによってBlenderを制御することができる仕組みが用意されています。また同じくスクリプトによって今開いているBlenderファイルの使用している様々な内部データへもアクセスができ、その中には画像データも含まれます。ということは、BlenderはPythonで画像処理を行うための環境にもなり得るということを意味します。

最低限の画像処理を行うには、画像の入力、画像データの持つピクセルデータの読み書き、および処理を施した画像の出力(どれもBlender上で完結)ができればいいので、本記事ではそれを実現するための方法を書いていくことにします。

(ちなみに使用しているBlenderのバージョンは2.78です)

Blender上でPythonコードを実行する方法の簡単なおさらい

Blender上でPythonスクリプトを実行するにはText EditorPython Consoleを使用します。これらのエディタを出現させる最も簡単な方法は、標準で用意されているScriptingレイアウトを呼び出すことでしょう:

image

このような画面に切り替わります。

image

PythonコードをText Editorの中に書いた後Run Scriptボタンを押す(もしくはそのショートカットキーであるAlt+Pキーを押す)ことで実行が行えます。

print('hello bpy world!')

image

image

print()での文字列の出力はBlenderのコンソールウィンドウに対して行われるため、コンソールウィンドウが表示されていない場合は予めBlenderメニューのWindow > Toggle System Consoleを選択して表示させておいた方が良いでしょう。

Text Editorとは別にPython Consoleからもスクリプトの実行が行えます。こちらはCtrl+スペースキーでオートコンプリートが行えるので、Blender APIのデータ構造を調べる際に便利です。しかしながら(不可能ではないものの)複数行のコードの実行が行いにくいという問題があるため、簡単なテストを行う時に使用すると良いと思います。

画像を用意する

基本的に画像に関する事柄はUV/Image Editorを通して行います。

newimage-selectimageeditor

newimage-imageeditor.png
UV/Image Editor

外部画像を読み込む

UV/Image Editorのヘッダメニューの Image > Open Image からローカルにある既存の画像ファイルをBlenderに読み込めます。

newimage-open newimage-openedimage

ファイル選択後、当該画像がBlender内部で利用できる形になります。

新規に画像を作成する

UV/Image Editorのヘッダメニューの Image > New Image から新規画像作成ダイアログが表示されます。

newimage-new newimage-dialog

画像名・画像サイズ諸々を入力した後OKを押すことでBlender内に新たに画像が作成されます。
UV/Image Editorには簡易的なペイント機能もついているので0から自前でテスト用画像を描くことも可能です。

newimage-selectpaint newimage-paint

.

ちなみに新規画像作成の際、Generated TypeColor Gridを選択するとテスト用の画像が生成されます。手頃な画像が無い場合には非常に便利です。

newimage-typecolorgrid

newimage-colorgrid.png
このような画像が自動で作成されます

レンダリング結果を使用する

基本的に上の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)

image

.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からその画像のピクセル値の配列にアクセスできます。

pixelstructure

ピクセル配列は画像の左下から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 # 処理を加えた配列を一括でセット

setpixels-1
実行結果

また新たに設定するピクセル配列を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

setpixels-3

※(細かい注意点) 上のようにオリジナルのピクセルを参照するだけの場合でも、.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

setpixels-2

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

image

処理効率を一切無視したコードなので実用には向かない(実際遅い)ですが、これで他のプログラミング言語でのピクセル操作とほぼ同じ書き方が行えるようになりました。

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)

outputpixels-1

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

numpy-1

ピクセル配列のうち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-boxblur

依然実用には届かないものの、以前のバージョンよりは数倍は高速になりました。

「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をインストールするという運びになります。

  1. https://pip.pypa.io/en/latest/installing/ にアクセスし、get-pip.pyをダウンロードして適当な場所に置きます。ここではD:/temp/get-pip.pyに配置したものとします
  2. 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
    と入力すればいいです。
  3. 先程ダウンロードしたget-pip.pyを実行して当該ディレクトリに存在するPythonにpipをインストールします。
    > bin\python.exe D:\temp\get-pip.py
    成功するとカレントディレクトリにScriptsディレクトリが作成され、その中にpip.exeが配置されます。
  4. pipを通してPillowをインストールします。
    > Scripts\pip.exe install pillow

問題なく手順を進めることができたら、Blenderを起動してスクリプトコンソールでimport PILと入力してエラーが生じないことを確かめてください。(import Pillowじゃないことに注意。) 何事もなくロードが行えたらPillowのインストールは完了です。

環境依存の要因でインストールが上手くいかない場合があるようです。その際はpipでなくeasy_installを通してのインストールを行うと上手くゆく場合もあるみたいです。 1 2

Blender Python上でPillowを呼び出す

:black_small_square: Blender内画像とPillow (PIL) Imageとの相互変換

Blenderの画像データをPillow上の画像データに変換し、Pillow側でフィルタ処理を施した後、再びBlender上の画像データに変換するという流れでPillowを活用してみます。

この相互変換の方法は大きく分けて2通りあります。1つはBlenderのピクセルとPillowのバイト列とを自前で変換する方法と、もう1つはBlender, Pillowのファイルのセーブ/ロード機能を利用して一時ファイルを通しつつ変換する方法です。ここでは後者の一時ファイルを通す方法を採用することにします。

下記の例では入力となるBlender画像データをPillow画像データ(pimg)に変換し、それにフィルタ処理を施した結果(pimg2)を再びBlender画像データとして出力しています:

:white_small_square: 一時ファイルを通して変換を行う
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'

pillow-filtered

BlenderからPillowのフィルタを利用できています。

Pillowのフィルタを触ってみる

既に使用したガウシアンブラーだけでなく様々なフィルタがPillowには用意されています。そこで早速いくつか試してみることにしましょう。
下記のコードは上記のコードのうちpimg2 = ...の箇所に置換してから実行してください。

ブライトネスを調整する
from PIL import ImageEnhance
pimg2 = ImageEnhance.Brightness(pimg).enhance( 1.8 )

pillow-filter1

.

反時計回りに回転する
pimg2 = pimg.transpose(Image.ROTATE_90)

pillow-filter2

.

回転、ブライトネス調整、元の画像を貼り付け、テキストを入力
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-filter3

「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ファイル内でどの程度他から利用されているかを示すものです。

fakeuser-1

特にどこからも利用されていないリソースは0ユーザーとして認識され、この状態でBlenderファイルを保存・再び開き直すという手順を経ると、0ユーザーだったリソースが自動的に削除されます。

fakeuser-2

この仕様は不要なリソースを保持せず常に最小限のデータ構造を維持できるという点で有用ですが、気を付けないと実験的に作成してみたがすぐには使わない状態にあるリソースまでもが消えてしまいます。とりわけ、この記事で行っているようなPythonから直接新規に作成した画像データはまさしくそれに該当してしまっています。

Fake Userを使う

といっても0ユーザー状態を回避するのは実に簡単です。Fake User状態にすれば良いだけです。Blenderでの各種データブロックのデータ名の横にFマークのボタンがありますが、それをONにすればFake User状態となります。Fake User状態となったデータは常にそのユーザー数が実際よりも+1された状態となるため、0ユーザーにならずに済むことになります。

fakeuser-3

PythonからFake UserをONにするには、そのデータの.use_fake_userTrueを代入してやればいいです。

blimg = bpy.data.images['A']
blimg.use_fake_user = True

レンダリング結果の画像のピクセルにアクセスする際の注意点

:black_small_square: 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

この問題を回避するいくつかの方法があります。

:white_small_square: Viewer Nodeを使用する

Node Editorを開きタイプをCompositingに切り替え、コンポジターを開きます。

userenderresult-compbutton

Use NodesをONにし、Add > Output > ViewerからViewerノードを追加、レンダリング結果に繋げます。

userenderresult-connectviewer

すると 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
:white_small_square: カラーマネジメントにより色が変わる

しかしながら上のコードを実行してみると、単に元の画像をそのまま別画像にコピーしただけのものでありながら、両者間で色味が違ってしまいます。

userenderresult-differs

何が起こっているかというと、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

userenderresult-fixed

うまいこと補正されました。

※ ここで補正のためのガンマ値を2.2に決め打ちしてしまいましたが、もしかしたら環境によっては異なる値を使用しないといけないかもしれません。
※2 ここでの処理はカラーマネジメントの設定がデフォルトの時のみの対応となってます。

:white_small_square: カラーマネジメントを無効にする

上記のやり方とは別に、Blenderのカラーマネジメントシステムそのものを無効にしてしまうという手もあります。

userenderresult-propscene

プロパティのSceneタブからColor Managementパネルを開き、その中のDisplay Device:Noneにする、もしくはView:Rawに変更することでこれを無効にできるようです。

userenderresult-cmoff

Viewer Node画像の補正がOFFになりました。

:black_small_square: 一旦ファイルに保存して回避する

レンダリング結果を一旦ファイルとして保存しておき、それをロードすることで上述した各種の問題を回避できます。保存した画像の結果はカラーマネジメントが適用済みのものになります。
また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という名前の画像が作成されてしまいます。

makeiteasy-1

大抵の場合コードを書き進める過程では試行錯誤のため何度も実行を行うのが常であり、その度に結果を確認するために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で出力画像を開き直す手間が省けるので楽ができます。

makeiteasy-2


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()でストレート・アルファに変換しなおしています。
t0.0から1.0まで動かした結果と、もしto_premul(), to_straight()の呼び出しが行われなかったらどうなるかの結果も出力したのが下のアニメーションになります:

crossfade

左がプリマルチプライド/ストレート・アルファ変換を行わなかった場合の結果で、右がきちんと行ったときの結果です。見ての通り左では補間の中間で結果に不備が生じています。片方の画像のアルファ値が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のフィルタによっては結果に不備が生じます。(自前で上下反転させるというのも余分なコストになります。)
また大きな画像を扱う際にもファイルを通したほうがパフォーマンスに優れているかもしれません。

.
.


参考


.
.


この記事は Blender Advent Calendar 12/08 分の記事でした。
明日 (12/09) の記事は psi_ni_phi さんの「キャラクターデザインのちょっとしたTIPSとメイキングを載せます」になります。


  1. Installation — Pillow (PIL Fork) 3.1.2 documentation
    http://pillow.readthedocs.io/en/3.1.x/installation.html 

  2. Python 3.5 対応画像処理ライブラリ Pillow (PIL) の使い方 - Librabuch
    https://librabuch.jp/blog/2013/05/python_pillow_pil/