疑似長秒露光による撮影 その2

python

 三脚固定から解放された疑似的な長秒露光画像を作ってみようと思います。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のバージョンだと使えなくなっているみたいです。

estimateAffinePartial2D and estimateAffine2D - OpenCV Q&A Forum
Hi all, Can anyone explain me what is the difference between these two: estimateAffinePartial2D and estimateAffine2D? Are they better/different from estimateRig...

 英語は嫌いですか?僕は嫌いです。

 今回の場合だと、

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)

結果

 この処理のソース画像と、一連の処理で得られた結果の拡大がこれ。前回の記事とは違う、画素数の少ないカメラのソースを使っています。

ソース画像のうちの1枚 (400x300pixelを2倍拡大)
結果画像(400x300pixelを2倍拡大)

 少し壁紙のテクスチャが消えている部分もありますが、ノイズ除去効果は抜群です。単純に重ねるとブレブレだった画像も、ピッタリ一致しました。あまり数を試せてませんが、デジカメ手持ち連写からのJPEG合成でここまでできればひとまず満足です。

まとめ

 手持ち連写画像から、疑似的な長秒露光画像を作ることができました。Python、OpenCVおそるべし。これでスマホやデジカメのノイズ除去処理により発生する気持ち悪いテクスチャも抑制することができそうです。むしろリアル長秒画像より手持ちできる分手軽といえば手軽かもしれません。

 ただしソースがすでにデノイズされたJPEGなので、限界もあります。ノイズ除去処理を抑えたJPEGを記録するカメラアプリも見つかりません。ただ、軽く調べてみると、PythonでRAW現像もできそうです。RAW記録できるアプリならありそうです。

 というわけで次は、カメラ、スマホにRAW記録させて、ノイズ除去をキャンセルした画像をソースに同じことをしてみようかと思います。

python画像処理
スポンサーリンク
キャンプ工学

コメント

タイトルとURLをコピーしました