長秒露光、とはちょっと違いますが、動きのある被写体の背景を、スローシャッターで流して撮る流し撮り。動きのある被写体にスピード感を与える撮影手法です。これをスマホでの連写写真をもとに実現させます。今回もPythonでの画像処理です。

結論から言うと、iPhone限界を感じ始めています…。
流し撮り
スローシャッターで、動きのある被写体を撮影するので、技術的には難しいです。動きのあるものにピントを合わせて、長い時間シャッターを開けるので露出も難しく、明るい屋外だとNDフィルタが必要になるかもしれません。ピントの精度が必要になるので、ある程度絞った方がよいでしょう。
いわゆる一眼レフで撮影する場合には、シャッター速度優先にしたうえで、1/30~1/100程度のシャッター速度にするとよいようです。ただ今度は暗いと絞りを開いてしまうので、その場合は完全にマニュアル設定が必要になってしまいます。オートフォーカスは追従系のもの。もしくはマニュアルで置きピンで撮るのがよいようです。
どちらかというと、乗り物系の剛体の方が撮影しやすいです。通過する場所がわかり切っている電車とかが難易度が低いです。走っている人とか、人もぶれるので。
当方の撮影技術だと、撮影しまくってたまにいいのが撮れた。くらいの成功確率しかないです。が取れるとかっこいいです。
これにもスマホオートの連写で挑戦してみようと思います。
スマホでの撮影
基本的にはカメラ(スマホ)の方を固定しておきます。流し撮りなのに固定です。とはいえあくまで手持ちです。また、あまりに動きの速い被写体だとあっという間に通過してしまうので、その場合は追っかける必要があります。
並進して撮るのもありです。並んで動きながらの撮影です。でもあまりカジュアルではないですね。敷居が上がります。基本的にはオートで適当に撮った写真をもとにしたいと思います。
通常の流し撮りだと、基本的には一発勝負になります。流し撮り失敗したらぼけた写真が撮れているだけ。今回の方法だと、仮に失敗しても流し撮りとしては失敗しているものの、びたっと止まった写真は残るので、保険として使えます。
画像処理
基本はこれまでにもいろいろスマホの連写写真で遊んできたように、位置合わせをして加算合成していきます。テールランプや水面を合成したのと同じです。
今回の作戦ですが、動いている目標となる被写体を複数フレーム間で位置合わせし、その目標が重なった所でその複数の画像を加算します。こうすることで位置合わせができた箇所はぶれず、位置が合わない箇所、今回の場合は背景がぶれる。という作戦です。
またこれだと不十分だったので、合成対象の画像をわざとぼかします。ただぼかすのも芸がないので、移動ブレを模します。また合成の際も単純に加算するのではなく、追跡対象はぼかさず、背景だけぼけるように工夫します。
なのでこれまで紹介してきた処理では、何の指定もなく、画像全面で位置合わせしていましたが、今回は位置合わせの際に目標の位置をある程度指定する必要があります。電車であれば電車を、車であれば車の位置を大まかに指定する必要があります。
今回のソース画像はこんなのを使います。

電車の頭を追いかけます。
追跡位置指定
複数の写真から、ベースとなるフレームの電車の頭の部分で特徴的な領域を選びます。残念ですがここは手動です。開始座標とその矩形幅でもメモっておく必要があります。Photoshopだとかそんな類のviewerが必要です。

この矩形の左上のx,y座標と、この矩形のサイズwidth,heightを覚えておきます。
特徴点検出
この領域の特徴量を取ります。これまでの処理だとこの画像のすべての領域を対象に特徴点探索していましたが、今回の場合は追いかけたい対象に絞って特徴点を取ります。
これを想定した引数が用意されています。maskです。maskとは必要な箇所だけ値を持った1chの画像の事です。今回の場合は、電車の先頭車両の位置をマスクとして指定してあげると、そこだけの特徴点を取ってくれます。これを特徴点算出の際の関数detectAndComputeに渡してあげればよいです。コードとしては
###############################
# AKAZEによるマッチングポイントの探索
def get_matcher_base(img, x, y, width, height):
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
mask = cv2.rectangle(np.zeros_like(gray), (x, y), (x+width, y+height), color=1, thickness=-1)
sift = cv2.AKAZE_create()
# find the keypoints and descriptors with AKAZE
kp2, des2 = sift.detectAndCompute(gray, mask=mask)
return kp2, des2
こんな感じ。今回は矩形マスクですが、任意の形のマスクが渡せます。そのマスクを使って画像に対してAKAZEの特徴点を見つけます。
位置合わせ、合成
これに関しては以前とやっていることは同じです。ベースとなる画像以外の画像は全体に対して特徴点を探し、その特徴点同士のマッチングを取って、一致するもの同士を見つけ、そこへのAffine行列を求めるものです。
全体のイメージは
flist = glob.glob(dirName + "\\*.jpg") # ソースの写真群がある場所
image = Image.open(flist.pop())
base = np.array(image, dtype=np.uint8)
height, width = base.shape[:2]
sums = base
cnt = 1
kp2, des2 = get_matcher_base(base, x, y, w, h) # 特定領域の特徴点
for fname in flist:
current = np.array(Image.open(fname), dtype=np.uint8)
mtx = get_affine_matrix(current, kp2, des2)
if mtx is not None:
current = cv2.warpAffine(current, mtx, (width, height))
sums += current # 加算
cnt += 1
out = (sums / cnt) # 平均化
Image.fromarray(out).save(dstName, "JPEG")
こんな感じです。1枚1枚位置合わせして、加算合成しています。この結果が

電車はトラッキングできていてぶれてないのですが、それ以外の領域がイマイチです。躍動感も何もあったもんじゃないです。
ぼかし(移動)
Photoshopのぼかしフィルタにこんなのがあります。これをPython+OpenCVでパクります。移動方向に向かって画像をぼかすもので、これをフィルタの畳み込みで真似てみようともいます。
まずは移動方向に1が並んでいるようなフィルタカーネルを作ります。例えば横方向に進むのであれば、
kernel = [[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[1, 1, 1, 1, 1],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]]
こんな感じのフィルタを畳み込むと1が立っている方向にだけ画像が平均化されるので、その向きにぼけます。このカーネルを任意の角度に向かって生成させ、それを画像に適用させてみます。コードとしては
##
# @brief ぼかし(移動)
# @param img 入力画像(numpy array)
# @param size カーネルサイズ(大きいほどぼける)
# @param angle 移動角度
# @details 角度向きにカーネルサイズ分の平均化を行う。
# @return 結果画像
def blur_image(img, size, angle):
hsize = size//2
kernel = np.zeros((size, size), np.uint8)
line = np.full((1, size), 255, np.uint8)
kernel[hsize:hsize+1, :] = line
affine = cv2.getRotationMatrix2D((hsize, hsize), angle, 1.0)
kernel = cv2.warpAffine(kernel, affine, (size, size), flags=cv2.INTER_NEAREST)
kernel = kernel/np.sum(kernel) # 1へ正規化
return cv2.filter2D(img, -1, kernel)
全ゼロの初期カーネルを作って、そこに対して、1行分255が入った配列を中心に置きます。例えばsizeが5であれば
kernel = [[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[255, 255, 255, 255, 255],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]]
こんな感じのカーネルができます。これを任意のangleで回転させます。縦方向にボケさせる場合には90度。もちろん斜めもOKです。これにもAffine変換を使います。この際一応補間には、ニアレストネイバーを意味するflag、INTER_NEARESTを使うことにしました。そのうえでフィルタ合計で割り算して、全体を1に正規化しています。正直INTER_NEARESTを使うのであれば、フィルタの値は255ではなく、1でもよいです。
最後のreturn文でOpenCVの畳み込み演算を行っています。ちなみに第二引数の-1は入力と出力の画像のビット数をそろえてね。という意味です。これにより、

このピタっと時が止まった画像が

流れます。ひとまずこの処理としては狙い通りです。この画像に変換してうえで加算合成してみると、

少しマシになりますが、追跡対象の先頭車両もぼけます。ココはぼかしたくないです。
画素差分に応じたぼかし
作戦としては、位置合わせした結果、画像の変化がないところはぼかさず、変化の大きい箇所はぼかす。こうすることで位置合わせがうまく行っている先頭車両はぼけず、背景はぼけることになります。
blur = blur_image(frame, size, angle)
dif = np.clip(np.abs(base.astype(np.uint16) - frame.astype(np.uint16)), 0, 40)
img = (blur * dif + frame * (40 - dif)) / 40
位置合わせが済んだ画像をまずぼかします。そのうえで、ベース画像と位置合わせが済んだ画像の差分を取ります。その差分を40程度にクリップして、重み画像とします。つまりこの差分が大きいほどボケ画像を優先し、小さいほど元の画像を優先するようなアルファブレンドをします。
ここまで頑張って合成すると、

ひとまず合格とします。
作例


こりゃあきません。それっぽく車は追えているのですが、フレーム間で車が移動しすぎで、背景がきれいに流れてくれません。ホイールもきれいに回ってくれません。iPhoneの連写性能の限界です。
まとめ
ひとまず、トラッキング、位置合わせ、ぼかし(移動)、画素差分に応じたぼかし。これらを駆使してそれっぽい流し撮りを模した処理はできました。結果はPhotoshopでのレタッチのようなもので、イマイチですが、Pythonを使って半自動で、ってあたりが面白いかな。
また車や電車を被写体にするとフレーム間隔があまりに長いです。撮影時いい感じでカシャカシャ言ってくれてたのですが、こうして並べてみるとどうにもならないです。もう少しスローな被写体を選ぶ必要がありそうです。もしくは動画をソースにするか?
コード
今回も全コードを残しておきます。
# 疑似長秒露光
import cv2
from PIL import Image
import glob
import numpy as np
import tkinter as tk
import tkinter.filedialog
import threading
dirName = ""
dstName = ""
nowprocess = False
##
# @brief ぼかし(移動)
# @param img 入力画像(numpy array)
# @param size カーネルサイズ(大きいほどぼける)
# @param angle 移動角度
# @details 角度向きにカーネルサイズ分の平均化を行う。
# @return 結果画像
def blur_image(img, size, angle):
hsize = size//2
kernel = np.zeros((size, size), np.uint8)
line = np.full((1, size), 255, np.uint8)
kernel[hsize:hsize+1, :] = line
affine = cv2.getRotationMatrix2D((hsize, hsize), angle, 1.0)
kernel = cv2.warpAffine(kernel, affine, (size, size), flags=cv2.INTER_NEAREST)
kernel = kernel/np.sum(kernel)
return cv2.filter2D(img, -1, kernel)
##
# @brief 変化が大きいところほどぼかす
# @param base ベース画像(numpy array)
# @param frame 位置合わせした画像(numpy array)
# @param size カーネルサイズ(大きいほどぼける)
# @param angle 移動角度
# @return 結果画像
def blur_blnd(base, frame, size, angle):
blur = blur_image(frame, size, angle)
gbase = cv2.cvtColor(base, cv2.COLOR_RGB2GRAY).astype(np.int16)
gfrme = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY).astype(np.int16)
dif = np.abs(gbase - gfrme)
dif = np.clip(dif, 0, 40)
dif = np.stack([dif, dif, dif], 2)
return (blur * dif + frame * (40 - dif)) / 40
###############################
# img1とimg2から、img2にマッチするようなimg1の変換行列を求める
def get_affine_matrix(img1, kp2, des2):
kp1, des1 = get_matcher(img1)
if len(kp1) == 0 or len(kp2) == 0:
return None
# Brute-Force Matcher生成
bf = cv2.BFMatcher()
matches = bf.knnMatch(des1, des2, k=2)
# store all the good matches as per Lowe's ratio test.
good = []
for m, n in matches:
if m.distance < 0.7 * n.distance:
good.append(m)
if len(good) == 0:
return None
target_position = []
base_position = []
# x,y座標の取得
for g in good:
target_position.append([kp1[g.queryIdx].pt[0], kp1[g.queryIdx].pt[1]])
base_position.append([kp2[g.trainIdx].pt[0], kp2[g.trainIdx].pt[1]])
apt1 = np.array(target_position)
apt2 = np.array(base_position)
# 行列の推定
return cv2.estimateAffinePartial2D(apt1, apt2)[0]
###############################
# AKAZEによるマッチングポイントの探索
def get_matcher(img):
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
sift = cv2.AKAZE_create()
# find the keypoints and descriptors with AKAZE
kp2, des2 = sift.detectAndCompute(gray, None)
return kp2, des2
###############################
# AKAZEによるマッチングポイントの探索 マスク付き
def get_matcher_base(img, x, y, width, height):
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
mask = cv2.rectangle(np.zeros_like(gray), (x, y), (x+width, y+height), color=1, thickness=-1)
sift = cv2.AKAZE_create()
# find the keypoints and descriptors with AKAZE
kp2, des2 = sift.detectAndCompute(gray, mask=mask)
return kp2, des2
###############################
# 処理開始(スレッドで動く想定)
def process():
global dirName
global nowprocess
global dstName
flist = glob.glob(dirName + "\\*.jpg") # ソースの写真群がある場所
nowprocess = True
fname = flist.pop()
print("base file:", fname)
image = Image.open(fname)
exif = image.info["exif"] # exif情報取得
base = np.array(image, dtype=np.uint8)
height, width = base.shape[:2]
sums = np.zeros(base.shape, np.float64)
cnt = 0
kp2, des2 = get_matcher_base(base, 1749, 651, 400, 320)
for fname in flist:
print(fname)
current = np.array(Image.open(fname), dtype=np.uint8)
mtx = get_affine_matrix(current, kp2, des2)
if mtx is not None:
current = cv2.warpAffine(current, mtx, (width, height))
sums += blur_blnd(base, current, 31, 0)
cnt += 1
print(cnt)
out = (sums/cnt).astype(np.uint8) # 平均化
Image.fromarray(out).save(dstName, "JPEG", exif=exif, quality=95)
button2["text"] = "実行"
nowprocess = False
####################################
## フォルダ選択ボタン
def button1_clicked():
if nowprocess:
return
global dirName
dirName = tk.filedialog.askdirectory()
####################################
## 実行ボタン
def button2_clicked():
if nowprocess:
return
global dstName
dstName = tk.filedialog.asksaveasfilename(filetypes=[("JPEG file", ".jpg")])
if dstName != "":
button2["text"] = "実行中..."
thread = threading.Thread(target=process)
thread.start()
####################################
## main
if __name__ == '__main__':
root = tk.Tk()
root.geometry('200x100')
button1 = tk.Button(text="選択", command=button1_clicked, width=20)
button1.place(x=30, y=20)
button2 = tk.Button(text="実行", command=button2_clicked, width=20)
button2.place(x=30, y=60)
root.mainloop()
コメント