三脚固定から解放された疑似的な長秒露光画像を作ってみようと思います。OpenCVとPythonの力でアラインメント処理を加えて、手持ち連写画像から疑似長秒露光の画像を作ってみようと思います。
疑似長秒露光
以前記事にしましたが、何のことはない連写した画像をひたすら加算合成するもので、三脚固定を前提とすれば単純な処理で、かなりいい感じでノイズが取れたきれいな写真を取得することに成功しました。
長秒露光の課題
しかしながら、完全に撮影位置が固定されていることが前提で、あまり現実的ではないです。何よりカジュアルじゃないです。いろいろなサイトを見てみても、HDRに代表されるような複数画像の合成には、アラインメントの処理が必須です。三脚ですらアラインメントの処理をしているようです。
試しに手持ちで撮影して、アラインメント無しで12枚の画像を合成してみると、見事にエッジがブレブレの画像になりました。
こんな感じ
これでは話になりません。要は手振れを起こした画像が取れているようなものです。1枚1枚は露光時間が短いため、ぶれていいません。工夫次第でうまく行く可能性があります。
アラインメント
かっこよく書いてますが、位置合わせです。前のフレームと画像をびったり位置合わせしてから合成処理を行います。この位置合わせですが、どの程度ぶれがあるかでその手段が変わってきます。
シフトぶれの補正
水平垂直の移動を前提にしていれば、このぶれさえキャンセルすればよいので、処理としてはかなり簡単になります。要はΔXとΔYだけを考えればよいことになります。
この検出には前フレームに対して、現在フレームを平行移動させ、相関を見て一番高いところを検出すればよさそうです。Python上では、OpenCVの関数を使って、
(dx,dy), _ = cv2.phaseCorrelate(np.float32(img1[:,:,1]), np.float32(img2[:,:,1]))
こんな感じで求めることができます。恐ろしく簡単ですね。このようにして求めたdx,dy
を使って、画像をシフトさせてあげると、理想的には位置が一致します。が、手持ちではこれではうまく行きませんでした。
回転ぶれの補正
三脚ならまだしも手持ちだと、これに加えて微小な回転のぶれが加わります。ほんのちょっと傾くだけで、数画素のオーダーでずれます。ここまで行くとこの簡単な記述では賄いきれません。次の作戦を考える必要があります。
作戦としては、一気に難易度が上がりますが、SIFTベースの処理を使って2枚の画像の位置合わせをしてやります。SIFTとは、画像のスケール、回転等幾何変化に対して強い特徴変換を求めてあげる方法の事で、それを2枚の画像に対して行い、同一の特徴を持つ位置同士を突き合わせることで、その2枚の画像の変化を追うやり方です。とはいえ中身がよくわからなくてもOpenCVさえあれば簡単に記述できてしまいます。
今回はAKAZEと呼ばれる特徴量を使って、2枚の画像の位置を特定し、Affine変換で位置合わせされた画像を作ることにします。SIFTはOpenCVのデフォルト状態では特許の関係なのか、使えなかったので。(カメラに前後の回転があると、射影変換の方が有利かもしれませんが、今回Affine変換でうまく行ったのでそれにします。)
コード
一気に長くなりました…。正直getAffineMatrix関数はほぼブラックボックスでもいいかも。自分もそこいらのサンプルソースのつぎはぎで作ったので、どこぞで見たのと同じようなコードになっているかと思います…。
import cv2 from PIL import Image import glob import numpy as np ############################### # img1とimg2から、img2にマッチするようなimg1の変換行列を求める def getAffineMatrix(img1, img2): #エラー処理用の無変換行列 errmtx = cv2.getRotationMatrix2D((0,0), 0.0, 1.0) gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY) gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY) sift = cv2.AKAZE_create() # find the keypoints and descriptors with AKAZE kp1, des1 = sift.detectAndCompute(gray1,None) # query kp2, des2 = sift.detectAndCompute(gray2,None) # train if len(kp1) == 0 or len(kp2) == 0: return errmtx # 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 errmtx 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) #行列の推定 tform = cv2.estimateAffinePartial2D(apt1, apt2)[0] if tform is None: return errmtx return tform #################################### ## main if __name__ == '__main__': flist = glob.glob("source/*.jpg") #ソースの写真群がある場所 lut = np.arange(0, 256) lut = 255 * ((lut / 255) ** 2.2) #逆ガンマ作成(浮動小数点) base = cv2.imread(flist.pop()) height, width = base.shape[:2] sums = lut[base] for fname in flist: current = cv2.imread(fname) mtx = getAffineMatrix(current,base) base = cv2.warpAffine(current, mtx, (width , height), base, flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_TRANSPARENT) sums += lut[base] out = (sums / (len(flist) + 1)) #平均化 out = (255 * ((out / 255) ** (1 / 2.2))).astype(np.uint8) image = Image.open(flist[-1]) # 最終画像を再取得 exif = image.info["exif"] # exif情報取得 Image.fromarray(out[:, :, [2, 1, 0]]).save("source\marge_exif.jpg", "JPEG", exif=exif, quality=95)
解説
getAffineMatrix
getAffineMatrix関数が今回の特徴的な処理のすべてです。詳しくは解説しません。というか自分も深く理解せずに使ってます。ざっくりいうと、
グレースケールの画像から、detectAndCompute
でそれぞれの特徴とその場所を計算します。もしそのkeyがなければエラー処理。
その後総当たりcv2.BFMatcher()
で2つの特徴量を突き合わせて、いい感じに合うやつを取得します。さらにそこから選りすぐりのものgoodを抽出してます。(このあたりの閾値0.7とかサンプルのままです…。)もしこれもなければエラー処理。
その後そのgoodなペアのx,y座標を取り出して、apt1,apt2
に格納して、その座標群からestimateAffinePartial2D
でアフィン行列を推定しています。この行列が見つからなければエラー処理。この行列推定ですが、昔はestimateRigidTransform
関数だったようですが、新しいOpenCVのバージョンだと使えなくなっているみたいです。
英語は嫌いですか?僕は嫌いです。
今回の場合だと、
tform = cv2.estimateRigidTransform(apt1, apt2, False)
と同じことが
tform = cv2.estimateAffinePartial2D(apt1, apt2)[0]
で実現できていそうです。精度の検証はしていませんが。
エラー処理はすべて、座標変換無しの行列cv2.getRotationMatrix2D((0,0), 0.0, 1.0)
を返す仕様にしています。
main
main側は以前のコードからそれほど変わり映えしないです。処理の詳細や意味必然は以前の記事
を読んでいただければと思います。読み込んでから、先の関数で求めた行列を使って、Affine変換してから合成しています。
この時、アフィン変換で変換すると、デフォルトでは領域外に黒領域がはみ出してきます。まぁ少しなら目をつぶれると思いましたが、このwarpAffine
には領域外として、cv2.BORDER_TRANSPARENT
というまさにこのためにあるのではないかと思ってしまうようなオプションがありました。
これは領域外に引数で指定した画像を使うというもので、今回の場合、前フレームの画像を背景として使うことで、領域外にも、位置があったもっともらしい画像が残ります。
この画像に対して、右下に画角がずれた
この画像を位置合わせすると、デフォルトだと
こんな感じに外が黒くなってしまいます。黒が加算合成されると周りが暗くなってしまいますが、その黒の代わりに前の画像を使うと
こんな感じになります。今回わかりやすくするために背景の画像は薄くしていますが、さも何も画角が変化していないような結果を得ることができます。今回はそのオプションを使うことにしました。
cv2.warpAffine(current, mtx, (width , height), base, flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_TRANSPARENT)
結果
この処理のソース画像と、一連の処理で得られた結果の拡大がこれ。前回の記事とは違う、画素数の少ないカメラのソースを使っています。
少し壁紙のテクスチャが消えている部分もありますが、ノイズ除去効果は抜群です。単純に重ねるとブレブレだった画像も、ピッタリ一致しました。あまり数を試せてませんが、デジカメ手持ち連写からのJPEG合成でここまでできればひとまず満足です。
まとめ
手持ち連写画像から、疑似的な長秒露光画像を作ることができました。Python、OpenCVおそるべし。これでスマホやデジカメのノイズ除去処理により発生する気持ち悪いテクスチャも抑制することができそうです。むしろリアル長秒画像より手持ちできる分手軽といえば手軽かもしれません。
ただしソースがすでにデノイズされたJPEGなので、限界もあります。ノイズ除去処理を抑えたJPEGを記録するカメラアプリも見つかりません。ただ、軽く調べてみると、PythonでRAW現像もできそうです。RAW記録できるアプリならありそうです。
というわけで次は、カメラ、スマホにRAW記録させて、ノイズ除去をキャンセルした画像をソースに同じことをしてみようかと思います。
コメント