上空から見下ろした風景写真を、おもちゃのようなミニチュア風の画像に加工してみます。Photoshopを使うのが王道のようです。少し真面目にPythonでその処理をトレースしてみようと思います。
この処理、チルトシフト効果とも呼ばれるようです。世の中にはこれを専門としたツールも広く存在し、パソコンはもちろんスマホでも簡単に実現できてしまいます。Pythonで記述する必然はまるでないのですが、興味です。面白そうだからやってみる。です。
処理
上の写真のオリジナルは、こんな感じです。
面白くもなんともないです。この処理の基本は、
1.彩度、コントラストを強調する。
2.一部分にピントを合わせたかのようなぼかしを入れる。
がどのサイトを見ても紹介されている内容です。これだけでは面白くないので、表面のテクスチャを崩してよりおもちゃ感を出す。ピントが合ったところとぼけたところをスムーズにつなぐ。あたりをこだわってみます。結果フローは
1.テクスチャ崩し(メディアンフィルタ)
2.彩度、コントラスト強調
3.アルファチャネル作成
4.ぼかし画像(ガウシアンフィルタ)とのアルファブレンド
とします。
テクスチャ崩し(メディアンフィルタ)
実際の風景写真では、いろいろモノの表面にはテクスチャがあります。レンガの凹凸や、瓦の模様、服のしわから、木々の葉っぱなどなど。いくら精巧なミニチュアでもこれらの細かいテクスチャを忠実には再現しきれません。というか少し再現できてないくらいがミニチュアらしさを出します。なので、今回あえてこのテクスチャをつぶします。
Photoshopの世界では、「ダスト&スクラッチ」というフィルタでこれを実現できるようです。いわゆるノイズ除去のフィルタのようです。この処理、いわゆる粒粒状のノイズ、スパイクノイズも除去する強力なフィルタなのですが、前後の画像を眺めてみると、とある処理の結果のように見えます。メディアンフィルタのように見えます。
強くかけるとノイズはもちろん取れるのですが、表面のテクスチャも消え、ベタっとした結果になります。が、今回おもちゃっぽく加工するため、それをあえて起こさせます。処理としては単純で、注目画素とその周りのウィンドウ(カーネル)の中の画素値をソートして、その中の中央値(平均値ではない)を出力する。というものです。自前で書いても大した手間ではなさそうですが、OpenCVで1行なので、今回はそれを使うことにします。
import cv2 median = cv2.medianBlur(img,3)
こんな感じで処理終わりです。ウィンドウサイズ3が今回の処理のパラメータとなります。入力の写真の解像度次第で調整が必要となります。結果として、
こんな感じ。左が入力で、右がメディアンフィルタの結果。表面のノイズやテクスチャが取れ、なんとなくミニチュア感が出ます。PhotoShopのダスト&スクラッチの結果となんとなく雰囲気似てます。
左がPhotoshopのダスト&スクラッチ、右がメディアンフィルタ。
彩度・コントラスト強調
続いてコントラスト強調です。これはスプライン曲線でトーンカーブを近似します。これには scipy を使いました。以前これに関しては記事にしていますので詳細はこちらも読んでみてください。同じ処理をトイカメラ風画像加工でも使いました。ココでは画像を0~255から0~1へ正規化してから処理してます。
from scipy.interpolate import splrep, splev img = cv2.imread(filename) img = img/255 xs = [0, 0.25, 0.5, 0.75, 1] ys = [0, 0.15, 0.5, 0.85, 1] img = img / 255 tck = splrep(xs, ys) # スプライン img = splev(img, tck) # スプライン適用
入力のxsに対して、出力のysを通るような曲線をsplrepで近似させています。つまり、この例では入力が0.15の時、出力が0.25を通るような、そんな曲線を得ています。これで得られるトーンカーブは
こんな感じ。コントラストの強調です。処理としてはシンプルです。
続いて彩度強調。これも以前記事にまとめましたので、詳細はそちらを参照いただければと思いますが、処理としてはこれもシンプルで、RGBの色空間からHSVの色空間へ変換し、彩度の成分Sに対してゲインをかける。というものです。
import cv2 img = cv2.imread(filename) # 1.RGBからHSVへの色空間変換 img_hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV) # 2.Sへのゲイン調整(彩度調整) img_hsv[:, :, 1] = np.clip(img_hsv[:, :, 1]*1.5, 0, 255) # 3.HSVからBGRへの色空間変換 img = cv2.cvtColor(img_hsv, cv2.COLOR_HSV2BGR)
これまたOpenCVの力であっという間ですね。この場合この1.5倍というのが処理のパラメータとなります。写真次第ですね。
結果はこんな感じ。
上が入力、下が出力。おもちゃ感は出てきましたかね。
アルファチャネル作成
ミニチュア風写真加工のポイントとなるのが、ピントが合ってシャープな箇所とピントが外れて大きくぼけたところが表現されている点で、これを再現させます。このピントが合っているところと、ぼけているところをじょじょに切り替えるために今回は、アルファチャネルを使った、アルファブレンドを使っていきます。
アルファブレンドとは、2枚の画像の加重平均を取る処理で、その重みをなぜだかアルファと呼びます。画像Aと画像Bを1:1でアルファブレンドする。とかそんな言い方をします。今回、シャープな画像とぼけた画像を、とあるアルファチャネル画像でアルファブレンドします。
イメージとしてはこんな感じ。2つの画像を1:1でアルファブレンドしたものです。これは全面同じ比率で合成していますが、この比率を画素毎に切り替えるのが普通です。例えば文字の箇所だけ1:1で合成して、それ以外は1:0で合成するとか。その合成比率、ブレンド比率を示したマップをアルファチャネル画像とか言ったりします。
通常このミニチュア風写真では、写真の注目すべきポイントに対して横方向の帯状にピントが合っていることを想定して加工されているようです。専用ツールでもそのような加工指示になっています。またモノによっては円形にフォーカスがあっているように見せるものもあるようです。
例えばこのように、黒い箇所がピントが合っているところ、白いところがピントが外れたところ。とすると、そのブレンド比率を示したマップ、アルファチャネル画像は
このようになり、このアルファチャネルを使って、ぼけたものと、ぼけてないものをアルファブレンドした結果は、このように
ぼかした箇所と、そうでない箇所の切り替わりがはっきり見えてしまいます。
2階より上で突然ボケます。残念です。なので、帯の上下をぼかして、このようなマップ(アルファチャネル)を生成させると
その合成結果は
このように、じょじょにボケ画像へシフトしていき、より自然です。このアルファチャネルを発生させるコードを書いてみます。作戦としては、
- 255 ~ 0 ~ 255の1次元配列を作る。
- 必要な区間を切り出す。
- 2次元の配列へ展開する。
- 切り出された配列を0~255へ正規化する。
- 画像サイズへ拡大する。
という流れです。コード(関数)にすると、
import numpy as np import numpy.matlib ## # @brief 帯状のアルファチャネル作成関数 # @param center 中心のy座標(最大高256に対する位置) # @param width 帯の幅(半値までの幅) # @param gain 変化の幅 # @return アルファチャネル配列(256x256) def get_alpha_band(center, width, gain): center = 256 - center ofst = width * gain - 128 norm = max(center-1, 256-center-1) pos1 = np.arange(255, -1, -1) # 255 ~ 0 pos2 = np.arange(0, 256) # 0 ~ 255 pos3 = np.concatenate([pos1, pos2], 0) # 255 ~ 0 ~ 255 pos4 = np.matlib.repmat(pos3[center:center+256].reshape([256,1]), 1, 256) pos4 = (pos4 * 255 / norm) pos4 = np.clip(((pos4 * gain) - ofst), 0, 255) return pos4
こんな感じで作れます。配列をconcatenateでつなげて、範囲をスライスして、最後repmatで2次元に展開しています。
関数の引数で、黒の帯の中心位置(center)、幅(width)だったり、黒から白への切り替わりのスピード(gain)の調整をできるようにしています。
円形も似た感じで実現でき、コードだと
## # @brief 円形のアルファチャネル作成関数 # @param center 中心のxy座標(最大高256に対する位置) # @param width 円の半径(半値までの幅) # @param gain 変化の幅 # @return アルファチャネル配列(256x256) def get_alpha_circle(center, width, gain): center = 256 - center ofst = width * gain - 128 xx, yy = np.meshgrid(np.arange(-255, 255, 1), np.arange(-255, 255, 1)) pos1 = np.clip(np.sqrt(xx**2 + yy**2), 0, 255) pos1 = pos1[center[1]:center[1]+256, center[0]:center[0]+256] norm = np.max(pos1) pos4 = (pos1 * 255 / norm) pos4 = np.clip(((pos4 * gain) - ofst), 0, 255) return pos4
こんな感じでほぼ同じ感じです。先ほどのconcatenate+repmatを、中心を原点としたxy座標を意味するxx,yyへのmeshgridからの、距離画像への変換をして、スライスしているだけです。得られる結果(アルファ画像)も
こんな感じ。最後画像に合わせて拡大を行うので、長方形の画像であれば楕円になります。
写真に応じて、この帯の位置や幅を調整する必要があります。
ぼかし画像(ガウシアンフィルタ)とのアルファブレンド
このようにして作成したアルファチャネル画像を使って、オリジナルとそれをぼかした画像をアルファブレンドしてみます。まずぼかしですが、これもOpenCVを使って簡単に済ませてしまいます。定番のガウシアンフィルタをかけます。
blur = cv2.GaussianBlur(img, (5,5), 4)
このようにして得られたボケ画像blurと、ぼかす前の画像imgを以下の式でブレンドします。
output = (blur*alpha + img*(255 - alpha))
これで先ほど作ったアルファチャネルに応じたブレンドができます。作ったアルファチャネルの画像は0~255なので、この画像(output)は元の255倍の値を持ちますが、imgもblurも1に正規化した画像を使うので、8btで保存するには都合がよいです。
結果
これはよっぽど写真を用意するのが難儀でした。手持ちの写真でなかなか適当なのがなく、見下ろした風景の写真撮りに出かけました。この時感じたのですが、空や自然が多いとおもちゃっぽく見えにくい。車や人、建物が多いとそれっぽい。という傾向です。人工物の方が適していそうです。
まとめ
Pythonのスクリプトで少し真面目におもちゃ風画像が作れました。よっぽど処理そのものを考えるより、この処理に向いたいい写真を撮ったり、選んだりの方が難しかったです。もう少しアップで撮った方がよさそうです。難しく、奥が深そうです。
まだ試していませんが、わざわざPythonで書いたのは、複数枚を連続処理して、おもちゃ風タイムラプスを作りたかったから。今度それも試してみたいと思います。
全コード
最後に全コードを載せておきます。100行未満。
# -*- coding: utf-8 -*- ## # @file miniature.py # @date 2020/3/6 # @brief ミニチュア風写真への加工プログラム import cv2 import numpy as np import numpy.matlib from scipy.interpolate import splrep, splev ## # @brief 帯状のアルファチャネル作成関数 # @param center 中心のy座標(最大高256に対する位置) # @param width 帯の幅(半値までの幅) # @param gain 変化の幅 # @return アルファチャネル配列(256x256) def get_alpha_band(center, width, gain): center = 256 - center ofst = width * gain - 128 norm = max(center-1, 256-center-1) pos1 = np.arange(255, -1, -1) # 255 ~ 0 pos2 = np.arange(0, 256) # 0 ~ 255 pos3 = np.concatenate([pos1, pos2], 0) # 255 ~ 0 ~ 255 pos4 = np.matlib.repmat(pos3[center:center+256].reshape([256,1]), 1, 256) pos4 = (pos4 * 255 / norm) pos4 = np.clip(((pos4 * gain) - ofst), 0, 255) return pos4 ## # @brief 円形のアルファチャネル作成関数 # @param center 中心のxy座標(最大高256に対する位置) # @param width 円の半径(半値までの幅) # @param gain 変化の幅 # @return アルファチャネル配列(256x256) def get_alpha_circle(center, width, gain): center = 256 - center ofst = width * gain - 128 xx, yy = np.meshgrid(np.arange(-255, 255, 1), np.arange(-255, 255, 1)) pos1 = np.clip(np.sqrt(xx**2 + yy**2), 0, 255) pos1 = pos1[center[1]:center[1]+256, center[0]:center[0]+256] norm = np.max(pos1) pos4 = (pos1 * 255 / norm) pos4 = np.clip(((pos4 * gain) - ofst), 0, 255) return pos4 ############################### def main(): img = cv2.imread("IMG_0242.jpg") # 1.メディアンフィルタ img = cv2.medianBlur(img, 3) img = img/255 # 1で正規化する # 2.コントラスト強調 xs = [0, 0.25, 0.5, 0.75, 1] ys = [0, 0.15, 0.5, 0.85, 1] tck = splrep(xs, ys) # スプライン img = splev(img, tck) # スプライン適用 # 2.彩度協調(opencvはfloat64を受けてくれない) img_hsv = cv2.cvtColor(img.astype(np.float32), cv2.COLOR_RGB2HSV) img_hsv[:, :, 1] = np.clip(img_hsv[:, :, 1]*1.2, 0, 1) img = cv2.cvtColor(img_hsv, cv2.COLOR_HSV2RGB) # 3.アルファチャネル作成 h, w = img.shape[:2] alpha = get_alpha_band(200, 80, 2) alpha = cv2.resize(alpha, dsize=(w, h)) alpha = np.stack([alpha, alpha, alpha], 2) # 4.アルファブレンド blur = cv2.GaussianBlur(img, (5,5), 4) output = (blur*alpha + img*(255 - alpha)) cv2.imwrite("mini.jpg", output) if __name__ == '__main__': main()
コメント