長秒露光、スローシャッター写真の定番の一つに、湖面の水面の波うちを消し去った、静かな湖面の写真があります。滝なんかの流れや、雲の流れをスローシャッターで撮るのも定番ですね。これをスマホで手持ちで実現させます。今回もPythonでの画像処理です。
これも写真を撮りに出かけないといけませんね。結果自体はまぁ狙い通りなのですが、近所の小川じゃなんとも趣が出ない。
露光(シャッター速度)
シャッター速度が早ければ 被写体がぶれずに、一瞬を切り出したような画像が撮れ、逆に遅ければ流れをつけたような写真が撮れます。が、ぶれます。どっちがいいという話ではないです。
通常1/30秒とかそんな単位でシャッターが落ちます。手持ちだとこれがギリギリってところかなと思います。これを長秒露光の世界では秒単位まで伸ばしてこれらの撮影を楽しみます。
長秒露光の面白さと難しさ
長秒露光で写真を撮ると、普段肉眼では見られないような風景が撮れます。いろいろ定番の写真があります。
以前同じように試したのは、やはりスローシャッターの定番の夜間、車のテールランプを光の軌跡として取るものです。うまく行くと、光のスジだけがきれいに残り、ちょっとかっちょいい写真になります。
これを真面目にカメラのシャッタースピードを落として撮ろうとするといろいろ面倒です。三脚で固定することが必須です。露出を決めるのもそれなりに手間です。
夜間の撮影では通常カメラを三脚などで固定してシャッター開けっ放し。これで露出を適正にして取ればまぁまだよいのですが、日中の長秒露光だとこれだと明るすぎて露出オーバーになります。日中の方が面倒です。
今回ターゲットとしているような日中長秒露光には、通常NDフィルターというあえて暗くするフィルタを通す必要が出てきます。いわゆるサングラスですね。
また当然日中は光が多いので、レンズは絞る必要が出てきます。なので開放で手前奥がぼけたいわゆる一眼レフっぽい写真で、かつ湖面を静かに。というとそれはもうかなり暗いNDフィルタが必要になります。
なので日中の湖面や滝の撮影には、カメラと三脚とNDフィルタ。これらが必要になるというわけです。一眼レフ持ってる人だって、三脚買っても、NDフィルタをあえて買わないですよね。(自分は持ってますが…まぁ趣味なんで)
スマホ写真でカジュアルにこれを実現させることを試みます。
処理
基本的には、ヘッドライトを扱った前記事で試したそれを微調整して使いました。最後に全コードを載せますが、前回のそれとほとんど変わっていないです。
流れとしては、複数枚連写写真から、1枚ベースとなる画像を選び、そのベース画像と各フレームでの写真の位置合わせを行い、その位置での画像を加算合成する。というものです。ここでの位置合わせでは、シフトだけでなく、回転、拡大縮小も考慮しています。
今回のソース画像はiPhoneSEで撮影した複数枚の画像。設定は連写設定以外はフルオート。手持ち。JPEG撮影。EXIFによると1枚の写真の露光時間は1/845秒。こんなサンプルです。
近くの小川なのでなんとも流れが穏やか…。本来は激流の川や滝を撮りたいところですが、そんなサンプルは持ち合わせておりませんでした。近所の散歩のついでです。
加算合成
今回は、複数枚の写真の間で位置さえ合っていれば単純な加算合成でうまく行きます。コードとしては、
flist = glob.glob(dirName + "\\*.jpg") # ソースの写真群がある場所 lut = np.arange(0, 256) lut = 255 * ((lut / 255) ** 2.2) # 逆ガンマ作成(浮動小数点) fname = flist.pop() image = Image.open(fname) base = np.array(image, dtype=np.uint8) height, width = base.shape[:2] sums = lut[base] cnt = 1 kp2, des2 = get_matcher(base) 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 += lut[current] # ここで加算合成! cnt += 1 out = (sums / cnt) # 平均化 out = (255 * ((out / 255) ** (1 / 2.2))).astype(np.uint8) Image.fromarray(out).save(dstName, "JPEG", quality=95)
これでうまく行きました。最近のPhotoshopだったら自動でやってくれるかも。
画像ファイルのリスト(flist)から、1枚ベースとなる写真(base)を取得し、その画像の特徴量(kp2, des2)を取得しておきます。その後ファイルのリストをループで回し、一致する特徴点のマッチングを取り、そのマッチングが取れた点間のアフィン変換行列(mtx)を求め、その行列を使ってアフィン変換を行い、ベース画像と位置と合わせます。その後加算合成を行って行きます。最後ループを抜けた後に加算画像を画像数で割り算して平均化し、保存する。といった流れです。
get_matcher(base)やら、get_affine_matrix(current, kp2, des2)やらの2枚の画像の位置合わせの関数の詳細は、前記事参照してください。AKAZEという手法で特徴点を見つけ、それを2枚の写真の間でマッチング取ってます。すみません。ここでは割愛します。
明るさを加算するので、一応、2.2乗の逆ガンマもかけてから加算合成しています。最後平均化しています。この処理で冒頭の写真加工ができました。
結果
30枚合成です。この程度の時間では雲は流れてくれません。
このサンプルは真面目なカメラで絞り開放で撮りました。がイマイチ。
別の場所です。池ですね。寒くて凍ってます。撮影はiPhoneです。
まとめ
元写真と風景にイマイチ感はありますが、手持ちで、スマホで連写した写真をソースにしたと思えばまぁまぁ上出来かなと思います。 今度旅行でいい風景を見かけたときは連写しておこう。カメラのそれとも比較してみよう。
しかし処理時間があほみたいに長いです。オリジナルの解像度で、画像全域を探索してマッチングを取っているのでそこがボトルネックだろうと思ってはいるのですが。高速化も考えたいなぁ。
コード
前回のコードから3行削除しただけです…。
# -*- coding: utf-8 -*- ## # @file bulb.py # @date 2020/3/18 # @brief 疑似長秒露光画像作成プログラム(静かなる水面合成用) 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 img1の特徴点と一致する、keypointからアフィン行列を求める # @param img1 画像 # @param pk2 元画像特徴点 # @param des2 元画像の特徴量 # @return アフィン変換行列 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] ## # @brief AKAZEによるマッチングポイントの探索 # @param img 画像 # @return pk2 画像特徴点 # @return des2 画像の特徴量 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 ## # @brief 処理開始(スレッドで動く想定) def process(): global dirName global nowprocess global dstName flist = glob.glob(dirName + "\\*.jpg") # ソースの写真群がある場所 nowprocess = True lut = np.arange(0, 256) lut = 255 * ((lut / 255) ** 2.2) # 8bit 逆ガンマ作成(浮動小数点) 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 = lut[base] cnt = 1 kp2, des2 = get_matcher(base) 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 += lut[current] # 加算合成 cnt += 1 out = (sums / cnt) # 平均化 out = (255 * ((out / 255) ** (1 / 2.2))).astype(np.uint8) # 逆補正 Image.fromarray(out).save(dstName, "JPEG", exif=exif, quality=95) button2["text"] = "実行" nowprocess = False ## # @brief フォルダ選択ボタン def button1_clicked(): if nowprocess: return global dirName dirName = tk.filedialog.askdirectory() ## # @brief 実行ボタン 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()
コメント