長秒露光写真の定番の一つに、夜間の車のヘッドライト、テールランプから、その光だけが流れて線のように撮影されるあれがあります。これをスマホで手持ちで実現させます。今回もPythonでの画像処理です。

画像処理はもちろん、もう少し撮影場所に気合を入れた方がよさそうです…。これまでノイズ除去が主目的でしたが、今回は趣を変えています。
長秒露光
光の軌跡だけを写真に収めるために、シャッターを数秒開きっぱなしにして、光の軌跡をとらえる撮影手法が知られています。車のヘッドライト、テールランプのそれが定番で、頑張ると星の軌跡もとらえられます。
撮影された写真は幻想的で、なかなかクールなのですが、撮影には三脚での固定や、そもそもシャッター開きっぱなしにできるようなカメラが必要になります。マニュアル撮影から、たまにしか使わないような長秒露光の設定を探し出して、シャッターが落ちる振動すらケアしながら撮影するのはなかなか敷居が高いです。
これをやはりPython様を活用して、カジュアルに撮影したスマホ写真をソースにして試してみようと思います。
処理
基本的には、以前より紹介している手法の拡張です。これまで基本的にはノイズの除去を目的としていろいろ工夫をしてきましたが、今回はそれを応用して光の軌跡を残してみようと思います。
この処理をベースにします。流れとしては、複数枚連写写真から、1枚ベースとなる画像を選び、そのベース画像と各フレームでの写真の位置合わせを行い、その位置での画像を加算合成する。というものです。
ここでの位置合わせでは、シフトだけでなく、回転、拡大縮小も考慮しています。また局所的な位置ずれに対応させるために、小ブロック単位で処理を行い、手振れが大きくても、そこそこの精度が出せています。またここでは、ノイズを取ることを目的としているので、ベースとなるフレームからの差分が大きいところに対しては、合成の重みを落とすような処理(バイラテラル的な合成)もしています。詳細は上記の記事を見てください。コードも丸っと載せています。
今回のソース画像はiPhoneSEで撮影した60枚の画像。設定は連写設定以外はフルオート。手持ち。JPEG撮影。EXIFによると1枚の写真の露光時間は1/15秒。単純に足し算すると、およそ4秒露光の画像に相当します。こんなサンプル群です。

タクシーのテールランプがどうなるか。というところです。最後に以下で試した処理の全コードを残しておきます。
加算合成
まず手始めに、
シンプルな上記の記事のコードから、合成を単純な足し算にして合成してみます。位置合わせしたのちに、画像をアフィン変換し、加算合成するものです。
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)
少し説明をはしょるためにコードは簡素化しています。 get_matcher(base)やら、get_affine_matrix(current, kp2, des2)やらの2枚の画像の位置合わせの関数の詳細は、前記事参照してください。すみません。ここでは割愛します。
加算時には必要かなぁと思って、2.2乗の逆ガンマもかけてから加算合成しています。最後平均化しています。この処理での結果がこれ、

タクシーはもとより、テールランプも消え去っています。そりゃそうです。60枚の平均なので、テールランプの光も薄まります。ノイズは消えてきれいになってます。

が、これが目的じゃないです。単純な加算合成ではだめです。
比較明合成
長秒露光からの光軌跡を撮影する人には定番の複数画像合成処理のようです。まずはこれをパクります。処理の内容としてはシンプルで、複数枚画像の明るいところを選びつなぎ合わせる処理です。なので一度明るく発光した画素はその明るさが残ります。光が通ったところは明るく残ります。
この処理には、numpyのmaximum関数を使います。
sums = np.maximum(current,sums)
これで、2枚の画像の明るいところだけが選択された画像が作られます。先ほどのコードをちょい変更して、
flist = glob.glob(dirName + "\\*.jpg") # ソースの写真群がある場所
fname = flist.pop()
image = Image.open(fname)
base = np.array(image, dtype=np.uint8)
height, width = base.shape[:2]
sums = base.astype(np.uint32)
kp2, des2 = get_matcher(base)
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 = np.maximum(current,sums) # ここで比較明合成!
Image.fromarray(sums).save(dstName, "JPEG", quality=95)
こんな感じで比較明合成させます。すると、

うん、明らかに改善。ただ今回の撮影方法がまずく、単純に明るい方だけ取ると道路よりも車のボンネットや屋根の方が明るく、そのシルエットが残ってしまっています。少しダサいです。あと車の横を通ったチャリンコの背中がぽつぽつと…。これも少し想定と違う…。
また当たり前ですが、平均化されないのでノイズはそのまま残ります。加えて位置合わせの微妙なエラーでずれた残像がしっかり残ります。

左が比較明合成、右が加算合成。左の進入禁止の標識がぶれて見えます。に対して右はまるでぶれはなく、きれいです。比較明合成だと1枚でもずれた標識画像があればその比較で明るいところを取られてしまうのでこんな結果になります。
このような被写体を想定した合成手段ではないので、あきらめるところなのかもしれません。この合成手法。被写体を選びそうです。
疑似アンダー露出からの加算合成
冒頭の写真はこの合成をしたものです。こいつが本命です。疑似的に高階調アンダー露出の写真を作って、その写真で加算合成し、階調を落とすイメージです。
テールランプの箇所ですが、基本的には値がサチッています。おおよそ255に張り付いています。本来8bitのレンジでなければこのテールランプは張り付かなかったわけで、その場合どの程度の明るさになっていたかを適当に推定します。トーンカーブとしては、今回極端にこんな形を使いました。

最大値を4095にしています。255に対しておよそ16倍です。下も0に張り付かせていません。浮動小数点で扱っています。こんな感じのトーン調整をした後に加算合成をさせます。結果として、このカーブで、12bitの極端なアンダー露出をしたイメージを作ります。このこのカーブですが、いわゆるガンマの逆補正を極端にかけたもので、式としては、
$$out = in^{8.8}$$
としたものです。別に8.8に深い意味はないです…。これと2.2乗したもののうち大きい方を採用するようなLUTを作って、そのカーブでトーン調整するようにしました。
lut = np.arange(0, 256)
lut = 255 * ((lut / 255) ** 2.2) # 8bit 逆ガンマ作成(浮動小数点)
lut2 = 4095 * ((lut / 255) ** 8.8) # 12bit アンダー露出ガンマ(浮動小数点)
lut = np.maximum(lut1, lut2) # 大きい方採用
今回はこれであってこのやり方が正解とは思いません。イメージです。最後逆補正して整えます。コードは
flist = glob.glob(dirName + "\\*.jpg") # ソースの写真群がある場所
lut = np.arange(0, 256)
lut = 255 * ((lut / 255) ** 2.2) # 8bit 逆ガンマ作成(浮動小数点)
lut2 = 4095 * ((lut / 255) ** 8.8) # 12bit アンダー露出ガンマ(浮動小数点)
lut = np.maximum(lut1, lut2) # 大きい方採用
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:
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 = np.clip(out, 0, 255) # 8bitへクリップ
out = (255 * ((out / 255) ** (1 / 2.2))).astype(np.uint8)
Image.fromarray(out).save(dstName, "JPEG", quality=95)
こんなイメージ。最初の単純な加算合成との差分は最初に12bitアンダー露出画像を作っている箇所と、最後に値を255にクリッピングしている箇所のみ。それ以外は寸分たがわないです。この結果

こんな感じでいいとこどりができました。暗部は加算合成なので、ノイズも取れています。ぶれも最小限です。それなりに拡大にも耐えられると思います。

左が比較明合成、右が今回の合成。暗部ノイズはきれいになっています。

ぶれもなくなってます。が、なんか色がおかしい。RGB全体を独立に露出補正してるからかなぁ。ホワイトバランスが崩れてしまっているのかもしれません。明度だけうまく調整できれば色には影響しないかもしれません。
本来疑似アンダーではなく、リアルアンダーで撮りたいところですが、iPhoneなんでしょうがない。データが大きく欠損してるわけでもないのでひとまずよしとします。
今回は60枚の写真がソースだったので、この計算でうまく行きましたが、枚数次第でこのテーブルが最適かどうかは変わってくると思います。写真次第で調整が必要です。
まとめ
手持ちで、スマホで連写した写真をソースにしたと思えばまぁまぁ上出来かなと思います。もう少し真面目なカメラで連写させてもいいかもしれません。あとさすがに60枚ともなると結構時間がかかります。処理走らせてから歯磨きくらいはできそうです。
光が線でなく、破線なのはしょうがないです。だって時間的に連続してないんだもん。あともう少し写真はちゃんと用意した方がいいかな。もう少し真面目に撮影してもよいと思います。今度は花火とかそんな被写体でも試してみたいところです。
コード
全コードです。簡単なUI付けてます。あとベース画像のEXIFを保存時にくっつけるようにしています。やってみて気づいたのですが、どうもこのEXIFにはサムネイル情報もついているようで、サムネイルと実画像が一致しない問題は内包しています。
あと比較明合成の部分はコメントで残しています。
# -*- coding: utf-8 -*-
##
# @file bulb.py
# @date 2020/3/10
# @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)
lut1 = 255 * ((lut / 255) ** 2.2) # 8bit 逆ガンマ作成(浮動小数点)
lut2 = 4095 * ((lut / 255) ** 8.8) # 12bit アンダー露出ガンマ(浮動小数点)
lut = np.maximum(lut1, lut2) # 大きい方採用
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] # 加算合成
# sums = np.maximum(lut[current],sums) #
cnt += 1
out = (sums / cnt) # 平均化
out = np.clip(out, 0, 255)
# out = sums
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()
コメント