見出し画像

Devil With Angel Heart~ MATLABで綺麗な「ボケ」を作ろう~決め手は対数~巡回畳み込み・直線畳み込み、MATLABでブロック処理



・絞り

カメラのレンズには「絞り」という露光量・被写界深度を調整する機構があります。

IMG_8106 のコピー
レンズの絞り

写真の「ボケ」はこの絞りの形になります。

これは上の7角形絞りレンズで撮った写真です。

画像1
D700 with Nikkor 50mm f/1.4D(モデル:鶯だんご)

こちらは丸絞りレンズで撮った写真。

画像2
D700 with Nikkor 50mm f/1.4G(モデル:あや)

背景のボケが、絞りの形になっているのがお分かりになるかと思います。

絞りの代わりに、レンズの前に何かの形に切り抜いた黒い紙等を付ければ、その形のボケを出すこともできます。

今回はすでに撮った写真のボケを、信号処理でハート型のボケに変換してみましょう。

被写体の一点から発せられた光は拡散し、レンズと絞りを通ってレンズによって(ピントの合った部分は)再び一点に集約されます。このような現象は、信号処理的には被写体と絞りの「畳み込み演算」で再現できます。

画像3
処理前      ->      処理後
(モデル:結衣)

但し、後述しますが、合成マスク(深度マップ相当)の作成にPhotoshopを用いています

必要なツールは以下の通りです。
MATLAB
Image Processing Toolbox:blockproc() (FFT版のみ使用)
(Photoshop:マスク作成用)

・対数変換

こういった処理を行う場合、注意点が二つあります。

一つ目は、RGBの振幅にそのまま処理をしても実際と同じ画像は得られないということです。

フィルムの露光量-濃度グラフは、露光量がlogで示されます。
片側対数グラフでほぼ直線となるからです。これは、フィルムに写る「光の強さ」が(人の感覚もそうですが)対数的ということです。

 参考:ネガフィルムの特性曲線(「Logの歩み」タブ)

そのため、実際と同様の効果を得るには、RGB信号の振幅そのままではなく対数変換してから処理を行う必要があります

実際のカメラによる画像
   
ピントの合った画像            ピントをずらして撮影した画像
上記「ピントの合った画像」を元にしたシミュレーション画像
(深度マップなし)
リニア処理画像              対数処理画像

0~255レベルのRGB信号をxとすると
 x=0:255;
 y=k.^x;
とします。kはフィルム等の特性によって決まる定数です。

私が以前デジ一で実際のレンズ画像と比較したとき、k=1.035^(R/G/B)でほぼ似たような特性が再現できたので、ここではその数値を使います。

画像4
R/G/B値 <-> 光強度 変換特性

戻すときは
 log_1.035(y)=log(y)/log(1.035);
を取ります。
(log(0)は定義されませんので1でリミットをかけます)

この変換特性は、片側 logスケールで直線となります。

画像5
光強度 <-> R/G/B値 変換特性

直線の傾きは、kによって変わります。

デジタルカメラでももちろん、このようなlog特性を使ってます。

参考:Canon Log 3について

・演算量削減

もう一つは、処理量削減のためFFTを用いることを検討するということです。
時間軸上でやろうとすると、画像と絞り形状(以下、カーネルと呼びます)を1ピクセルずつズラしながら、処理の重い「畳み込み演算」を行う必要があります。(折り返し雑音とは?~なぜ車のホイールは逆回転するのか~デジタルはアナログの近似ではない -> 数学的補足 -> 畳み込み(convolution)参照)

畳み込み演算はFFT(DFT)を用いて周波数軸上に変換すれば、単純な掛け算で同じ処理が行えます。ただし、FFT処理が追加になるため、画像・カーネルサイズや処理フレームワークによってどちらが速いかは異なりますので、その都度確認する必要はあります

・巡回畳み込みと直線畳み込み

巡回畳み込み」は、DFTの積のIDFTと等価です。DFTは、フレーム長の信号が無限に繰り返し続いているとして処理を行います。

これは離散化に起因します。これも上に上げた折り返し雑音とは?に少し書いてあるのですが、下の図で示したように時間軸と周波数軸の双対性により、時間軸でアナログ(連続)信号をあるフレーム長の窓で切り取り、サンプリングによって離散化すると周波数軸上で同じ周波数成分が無限に繰り返し、それをさらに周波数軸上でサンプリングすると時間軸上でも窓関数により切り取ったフレーム長で時間信号が無限に繰り返すことになります。これはDFTの導出に他なりません。言い方を変えると、デジタル信号処理においては時間軸・周波数軸双方でデータが離散化されている必要があり、その結果、時間軸・周波数軸とも同じデータが無限に繰り返すことになる、とも言えます。

画像14
時間軸・周波数軸での離散化過程

しかしこれはあくまでもデジタルの世界の話。現実世界はこういう繰り返しにはなっていないので、「直線畳み込み」を求める必要があります。

デジタル信号処理で直線畳み込みを行うためには、以下のようなプロセスを踏みます。
 1.周りを0で埋めて2Nx2Nサイズに拡張
 2.FFT
 3.カーネルのFFTと複素掛け算
 4.IFFT+周波数シフト
 5.振幅に戻す
 6.元のNxNサイズを切り出す

N×N点の直線畳み込み結果は2N-1点になるのでそれ以上の長さを取っておけば信号が重ならず、巡回畳み込みとなるDFTを使って直線畳み込みと同一のデータを取り出すことができます。FFTしやすいように、2^n点とすることが多いです。(小さな素数の積でも可)

・ブロック処理

MATLABでは、blockproc() というブロック処理用の関数が用意されています。周辺を拡大して0埋め~結果からは拡大分を削除、が簡単にできます。

具体的には以下のようにします。

block_size = [32 32];
border_size = [16 16];

blk_func = @(block_struct) abs(fftshift(ifft2(fft2(block_struct.data) .* fkernel64)));
bR = blockproc(R_log,block_size,blk_func,'BorderSize',border_size, 'PadPartialBlocks',true);

32x32ブロックに分割~その周辺を0埋めして64x64に拡大~ブロック単位処理~処理結果の中心32x32を再切りだし、を blockproc() の一行で行えます。

そのブロック単位処理の中身(上記1~5)は、block_struct を使ってこれも一行で定義してあります。

・マスクの作成

実際のカメラであれば、フォーカス位置と被写界深度によってボケが異なるため自動的に処理がなされるのですが、後から行う場合はその「深度マップ」に相当するマスクが必要となります。本来はボケの大きさによってカーネルサイズも変えた方が良いのですが、今回は一定サイズとしています。
(画面全体を均一にボカして良いのであればマスク自体必要ありません)

iPhoneのポートレートモードだと深度マップも持っています。
Mac版のPhotoshopでは深度マップも読めるので、それを使っても良いですね。

スクリーンショット 2021-11-04 13.50.24
Mac版Photoshopで読み込んだiPhoneのポートレートモード画像


今回は、マスクはPhotoshopの被写体検出+手作業で作りました。

画像10
マスク画像

・ハートを描く

画像として用意しても良いのですが、せっかくのMATLABなので関数で描いてみましょう。

ハート型を描く関数は何種類も提案されているのですが、今回は、【ハートのグラフ選手権】で優秀賞を取られた、CHARTMANさんのを使わせていただきました。

どうやったらこういうのを考えつくんですかね~?(゚~゚)

t=0で大きさ最大としたかったため、分子はcosに変えています。

・MATLABコード

サンプル画像付きMATLABコード

function()にしてあるので以下のようにして呼び出します。

heartBlurer_conv(sourceFile,maskFile,outputFile,auto,we)
heartBlurer_FFT(sourceFile,maskFile,outputFile,auto,we)

sourceFile  元画像ファイル名
maskFile    マスク画像ファイル名
outputFile  出力画像ファイル名
auto      true:ハートの大きさをループで変化させて表示
        false:1枚の静止画のみを表示
we      true:静止画とgifファイルを保存
       (ファイル名はコード中で固定)
        false:静止画のみ保存


arguments 宣言しておくとそれだけでデフォルトパラメータとして使えますので、以下のように引数を省略した呼び出しも可能です。

heartBlurer_conv()

heartBlurer_conv("yumemi02a_019.jpg","yumemi02a_019_mask7.jpg")

また、自作関数でもエディター上で引数が表示されるようになります。
So Cool!

画像12

畳み込み版ソースコード

function heartBlurer_conv(sourceFile,maskFile,outputFile,auto,we)
arguments
   sourceFile string = "yumemi02a_019.jpg";
   maskFile string = "yumemi02a_019_mask7.jpg";
   outputFile string = "heartBluer.png";
   auto logical = false;  % auto play
   we logical = false;  % gif file write enable
end

close all

if ~auto
   we = false;
end

step = pi/8 + pi/8*(1-we);
ed = pi + 3*pi*(1-we);

A = double(imread(sourceFile));  % Source image
mask = imread(maskFile);  % mask image
mask = double(mask(:,:,1)) / 255;

gain = 2;  % kernel gain

filename = "heartsBokeh.gif";  % output gif file name

f1 = figure(1);
f1.Color = [0 0 0];  % background color is black
ax1 = gca;

th = 0:pi/50:2*pi;

t2 = 1.57;  % size of a heart

if auto
   t2 = 0:step:ed;
end

for t = t2
   r = (4 + cos(t))./sqrt(1-abs(cos(th)).*sin(th));  % Heart function
   x = r.*cos(th);
   y = r.*sin(th);

   fill(ax1,x,y,'w')  % draw heart
   axis(ax1,'equal')
   axis(ax1,'manual')
   xlim(ax1,[-12 12])
   ylim(ax1,[-12 12])
   axis(ax1,'off');

   kernelF = getframe(ax1);  % get heart as a image

   kernel64 = double(imresize(kernelF.cdata,[64 64])) / 255;
   s =  sum(kernel64(:,:,1),'all');
   kernel64 = kernel64 / s * gain;  % normalize and set gain
   kernel32 = kernel64(16:47,16:47,1);

   % transfer to log
   k = 1.035;
   R_log = k.^A(:,:,1);
   G_log = k.^A(:,:,2);
   B_log = k.^A(:,:,3);

   % take 2D convolutions
   bR = conv2(R_log,kernel32);
   bG = conv2(G_log,kernel32);
   bB = conv2(B_log,kernel32);

   % to linear
   R_out = max(bR,1);
   R_lin = (log(R_out) ./ log(k)) / 255;
   G_out = max(bG,1);
   G_lin = (log(G_out) ./ log(k)) / 255;
   B_out = max(bB,1);
   B_lin = (log(B_out) ./ log(k)) / 255;

   bA = cat(3, R_lin, G_lin, B_lin);  % to color image
   offset = 16 + 2;
   bA = bA(offset:size(A,1)+offset-1, offset:size(A,2)+offset-1,:);  % cut out border redundancy

   blendI = bA;  % set size
   % blend source image and bokeh image using mask
   blendI(:,:,1) = bA(:,:,1) .* mask + A(:,:,1)/255 .* (1-mask);
   blendI(:,:,2) = bA(:,:,2) .* mask + A(:,:,2)/255 .* (1-mask);
   blendI(:,:,3) = bA(:,:,3) .* mask + A(:,:,3)/255 .* (1-mask);

   f3 = figure(3);
   f3.Visible = 'on';  % view outside of Live Script window

   if exist('ax2','var')
       image(ax2,'CData',blendI);  % change data only
   else  % first time
       ax2 = gca;
       imshow(blendI,'Parent',ax2)
   end
   drawnow

   if we
       frame = getframe(f3);  % capture with border frame
       % frame = getframe(gca);  % take image only
       gr = frame2im(frame);
       [bA,map] = rgb2ind(gr,256);
       if (t==0)
           imwrite(bA,map,filename,'gif','LoopCount',Inf,'DelayTime',1/15);
       else
           imwrite(bA,map,filename,'gif','WriteMode','append','DelayTime',1/15);
       end
   end

end

imwrite(blendI,outputFile)
end

FFT版ソースコード

function heartBlurer_FFT(sourceFile,maskFile,outputFile,auto,we)
arguments
   sourceFile string = "yumemi02a_019.jpg";
   maskFile string = "yumemi02a_019_mask7.jpg";
   outputFile string = "heartBluer_FFT.png";
   auto logical = false;  % auto play
   we logical = false;  % gif file write enable
end

close all

if ~auto
   we = false;
end

step = pi/8 + pi/8*(1-we);
ed = pi + 3*pi*(1-we);

A = double(imread(sourceFile));  % Source image
mask = imread(maskFile);  % mask image
mask = double(mask(:,:,1)) / 255;

gain = 2;  % kernel gain

filename = "heartsBokeh.gif";  % output file name

f1 = figure(1);
f1.Color = [0 0 0];  % background color is black
ax1 = gca;

th = 0:pi/50:2*pi;

t2 = 1.57;  % size of a heart

if auto
   t2 = 0:step:ed;
end

for t = t2
   r = (4 + cos(t))./sqrt(1-abs(cos(th)).*sin(th));  % Heart function
   x = r.*cos(th);
   y = r.*sin(th);

   fill(ax1,x,y,'w')  % draw heart
   axis(ax1,'equal')
   axis(ax1,'manual')
   xlim(ax1,[-12 12])
   ylim(ax1,[-12 12])
   axis(ax1,'off');

   kernelF = getframe(ax1);  % get heart as a image

   kernel64 = double(imresize(kernelF.cdata,[64 64])) / 255;
   s =  sum(kernel64(:,:,1),'all');
   kernel64 = kernel64 / s * gain;  % normalize and set gain

   % frequency domain kernel
   fkernel64 = fft2(kernel64(:,:,1));

   % transfer to log
   k = 1.035;
   R_log = k.^A(:,:,1);
   G_log = k.^A(:,:,2);
   B_log = k.^A(:,:,3);

   % block processing parameters
   block_size = [32 32];
   border_size = [16 16];

   % define core function
   blk_func = @(block_struct) abs(fftshift(ifft2(fft2(block_struct.data) .* fkernel64)));

   % block processing
   bR = blockproc(R_log,block_size,blk_func,'BorderSize',border_size, 'PadPartialBlocks',true);
   bG = blockproc(G_log,block_size,blk_func,'BorderSize',border_size, 'PadPartialBlocks',true);
   bB = blockproc(B_log,block_size,blk_func,'BorderSize',border_size, 'PadPartialBlocks',true);

   % to linear
   R_out = max(bR,1);
   R_lin = (log(R_out) ./ log(k)) / 255;
   G_out = max(bG,1);
   G_lin = (log(G_out) ./ log(k)) / 255;
   B_out = max(bB,1);
   B_lin = (log(B_out) ./ log(k)) / 255;

   bA = cat(3, R_lin, G_lin, B_lin);  % to color image
   bA = bA(1:size(A,1), 1:size(A,2),:);  % cut out border redundancy

   blendI = bA;  % set size
   % blend source image and bokeh image using mask
   blendI(:,:,1) = bA(:,:,1) .* mask + A(:,:,1)/255 .* (1-mask);
   blendI(:,:,2) = bA(:,:,2) .* mask + A(:,:,2)/255 .* (1-mask);
   blendI(:,:,3) = bA(:,:,3) .* mask + A(:,:,3)/255 .* (1-mask);

   f3 = figure(3);
   f3.Visible = 'on';  % view outside of Live Script window

   if exist('ax2','var')
       image(ax2,'CData',blendI);  % change data only
   else  % first time
       ax2 = gca;
       imshow(blendI,'Parent',ax2)
   end
   drawnow

   if we
       frame = getframe(f3);  % capture with border frame
       % frame = getframe(gca);  % take image only
       gr = frame2im(frame);
       [bA,map] = rgb2ind(gr,256);
       if (t==0)
           imwrite(bA,map,filename,'gif','LoopCount',Inf,'DelayTime',1/15);
       else
           imwrite(bA,map,filename,'gif','WriteMode','append','DelayTime',1/15);
       end
   end

end

imwrite(blendI,outputFile)
end

処理に多少時間が掛かる(私の環境で1フレーム1秒弱)ので、gifファイル書き込み時とそうでない時(weで制御)でstep, edの設定を変えています。

t2でハートの大きさが制御できます。
ライブスクリプトで、t2を数値スライダーでコントロール可能にしても良いですね。

gain でボケの明るさが制御できます。派手目にしたい場合は大きい値にしてみてください。

カーネル(ハート)は、あらかじめ輝度0のボーダーを含んだ中央部分に書かれるようサイズ調整してあります。

画像18
kernel64

・処理結果

画像8
サンプル1(再掲)
画像10
サンプル2(モデル:綾夏)
画像9
サンプル3(マスクなし)
画像11
サンプル1 gif版
(黄色が飽和して見えたりするのはgifの色数削減のせいです)
(gif版用マスクは左のポール部分も除外しています)
画像13
サンプル2 gif版 

畳み込みだと処理にかなり時間が掛かるだろうから、とりあえず畳み込みで作って確認して、後で分割ブロックのFFTで高速化しようかとも思っていたのですが、なんと、畳み込みの方が速い!

まさに、最初の方に書いた、
画像・カーネルサイズや処理フレームワークによってどちらが速いかは異なりますので、その都度確認する必要はあります」でした・・。

一応コードは両方載せておきました。カーネルサイズが小さいということもあるのでしょうが、最近のMATLABは余計なコトしない方が速いですね。(;¬_¬)

カーネルのFFTはあらかじめ一回だけしておけば良いので、カーネルサイズが大きい場合等は、FFTした方が早くなる場合があると思います。

まあ、直線畳み込み・巡回畳み込み、MATLABでのブロック処理の参考にはなるかと。(^-^;

音声のような1次元データであれば、MATLABの DSP System Toolbox に一般的な Overlap-Save/Add 法が関数として用意されています。

Overlap-Save/Addの説明はMATLAB(DSP System Toolbox)のドキュメントの図が一番分かりやすいかと思いますので引用しておきます。

画像16
Overlap-Save 法
画像17
Overlap-Add 法

dsp.FrequencyDomainFIRFilter より引用

この関数を使うと自動的に周波数領域に変換して処理を行うため高速化が見込めます。また、普通にやると上記図において FFTLength – length(Numerator) + 1 のレイテンシーが生じますが、低レイテンシーモードも用意されています(同機能のSimulinkブロックもあります)。


冬は空気が澄んで、夜景が綺麗に撮れる季節です。

MATLABでさらにもうひと盛りしてませんか!?( ̄ー ̄)


編集履歴

2021/12/07
・「被写体の~信号処理的には被写体と絞りの「畳み込み演算」で再現できます。」部分追加
・実際のカメラ画像とシミュレーション画像比較を追加
・畳み込み版ソースコードのカーネルサイズが1ピクセル違っていたのを修正(結果に影響はありません)


この記事が参加している募集

つくってみた

やってみた

この記事が気に入ったらサポートをしてみませんか?