Pythonを使って、複数枚撮影した画像からノイズ除去を行います。いわゆるマルチショットノイズ除去というやつです。一部デジカメやスマホでも搭載されている機能です。以前アラインメントを取ったうえで複数枚画像を重ねる処理を書いてみましたが、今回はその改良です。
近くの被写体、例えばテーブルの上の料理とかをそれなりに適当に連写すると、シフト、回転による位置合わせだけでは補いきれないずれが発生しました。その合成の結果ノイズは取れてもぶれが残る結果になりました。今回それをバイラテラル的な考え方を導入して除去します。
これまではあくまで長秒露光を目的にして、結果としてノイズが取れている。という状況でしたが、今回は完全にノイズ除去だけを目的にした合成処理になります。
アラインメント
いわゆる位置合わせです。同一シーンを連写したところで、カメラの微妙なぶれにより位置ずれが起こります。これを補正する処理です。シフトと回転を仮定しており、アフィン変換での補正で必要十分でした。詳しくは
こちらの記事に載せてあります。遠景を撮影する場合にはこの仮定が成り立つのですが、近くを撮影した場合には、カメラがずれることに起因する画角の微妙な変化で、必ずしもシフトと回転だけでは補いきれないずれが出ます。
例えば、左右片方の目で見た時に、近くのものの方が、遠くのものを見た時に比べ、差が大きいですよね?物の回り込み方も異なり、最悪片方の目では見えていたものが、もうもう一方の目で見た時には、モノに隠れてしまうこともあり得ます。カメラのぶれによる撮影画像の違いもそれに似たことになっており、そうなると片方をシフト回転しただけでは画像が一致しません。
試しにテーブル上の料理を撮影したものです。
別にこの写真そのものはぶれてはいません。このようにして、手持ちで9枚連写してみました。それを先の記事のアラインメントを使って、単純な加算合成をした結果が以下です。
画像中心のキャベツとコーンはしゃっきりしてますが、外のフォークやらグラスやらは完全にぼけてしまっています。
アラインメント失敗です。
近くの被写体へのアラインメントの限界です。これはこれで受け入れます。別の手段でぶれは解消させます。
バイラテラル的合成
バイラテラルフィルタという処理があります。これは簡単に言うと、ガウシアンフィルタを使ったノイズ除去を行う際に、注目画との画素値的な距離が近いものほど重みを大きくする、という考え方でフィルタをかけることで、エッジをぼかすことなくノイズ除去をしてくれる優れものです。以前
でも簡単に考え方を紹介しているので、詳しくはそちらを参照ください。
今回の複数フレームの加算合成においては、その加算の際に、注目画像と加算画像の画素値が近いほど重みを大きくして加算する。という応用をすることで、バイラテラルフィルタと同様にエッジを残しながらノイズを除去する。という作戦です。考え方は同じです。色が違うものは平均化(加算)の仲間に入れない。
具体的な処理ですが、Pythonで書くとあっという間に終わってしまいました…。
この関数は位置合わせが済んだ2枚の画像の差を見て、差が大きいほどbaseより、小さいほどframeよりの合成(アルファブレンド)画像を出力するものです。
############################### # 値が近いものほど平均化の仲間に入れる平均化 def bai_blnd(base, frame): gbase = cv2.cvtColor(base, cv2.COLOR_BGR2GRAY).astype(np.int16) gfrme = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).astype(np.int16) dif = np.abs(gbase - gfrme) dif = np.clip(dif, 0, 80) dif = np.stack([dif, dif, dif], 2) return (base * dif + frame * (80 - dif)) / 80
処理の詳細です。
2枚のRGB画像に対して、まずGray化させます。このソースの場合は、baseとframeの2枚の画像に対して、gray化させたgbaseとgfrmeの2枚の画像を作っています。変換後は後の計算で丸まらないように符号付きの16bitの画像にしています。
続いて、2枚の差の絶対値を取ってます。これで2枚の画像の差が大きいほどこのdifの値が大きくなり、最大値は255となります。
その後値を0~80にクリップしています。この255まで取りうる差のうち、81~255までを80に丸めてしまうという処理です。このクリップのレンジはパラメータにするとよいかもしれません。画素値の差が80以上あったら平均化の仲間から外す。ということになります。これでいわゆる重み係数が画像1面分できたことになります。
本当はこの差の絶対値にガウス関数をかけるのがバイラテラルの基本ですが、面倒なのでそのままの差分値をクリップしています。
続いて、RGB用にこの重みを3ch画像に拡張し、最後returnのところでアルファブレンドしています。最大値80なのでその値を使って、重みが大きいほどbaseが重くなるようにアルファブレンドしています。つまり差が大きいほどbaseが、そうでなければframeが出力されます。
この関数を全フレームに対して処理し加算していくことで、アラインメントが失敗しているような箇所ではボケになるような加算は行われず、うまくアラインメントが行われた箇所だけの加算画像が得られます。
for fname in flist: current = cv2.imread(fname) mtx = get_affine_matrix(current, kp2, des2) # 位置合わせ if mtx is not None: current = cv2.warpAffine(current, mtx, (width, height)) # アフィン変換 sums += bai_blnd(base, current).astype(np.uint8) # 合成+加算
ソースにするとこんなイメージ。アラインメントを取って、アフィン変換して、ベースフレームとのバイラテラル的な合成をした後に、加算。
結果
iPhoneSEで9枚連写撮影したJPEG画像をソースとして、上記のバイラテラル的合成による画像加算によるノイズ除去処理を行った結果が以下の感じです。
こんな感じ。フォークやグラスもぼけていません。
そして肝心のノイズ除去効果ですが、
一目瞭然です。右が1枚もの(ベースとした画像)、左が合成結果。iPhoneで暗い店内で撮影したノイズバリバリの画像を、ぶれることなくきれいな画像へ変換できました。
まとめ
簡単な処理の組み合わせで、それっぽい効果が得られました。暗い場所で近くの被写体を撮影する場合には、連写して合成することで、拡大にも耐えられる画質の写真が作れそうです。主にアラインメントを取る処理ですが、処理はそれなりに重たいので、カメラでリアルタイムだとここまで丁寧な処理はできていないかもしれません。
遠景を取ったり、動いているものをぼかしたり、消したりしたい場合にはこのバイラテラル的合成は使えませんが、ノイズ除去のみを目的とした場合には効果絶大でした。
ただこれは非動体が対象です。人物等の動体を対象とするとさらにもう一段難易度が上がります。被写体も動くし、カメラも動く。今後はそれにも対応していきたいと思います。
全コード
コピペでも動くと思われます。極シンプルなGUIも作っています。ベースは以前の記事のままです。
# 疑似長秒露光 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 ############################### # 値が近いものほど平均化の仲間に入れる平均化 def bai_blnd(base, frame): gbase = cv2.cvtColor(base, cv2.COLOR_BGR2GRAY).astype(np.int16) gfrme = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).astype(np.int16) dif = np.abs(gbase - gfrme) dif = np.clip(dif, 0, 80) dif = np.stack([dif, dif, dif], 2) return (base * dif + frame * (80 - dif)) / 80 ############################### # 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_BGR2GRAY) sift = cv2.AKAZE_create() # find the keypoints and descriptors with AKAZE kp2, des2 = sift.detectAndCompute(gray, None) return kp2, des2 ############################### # 処理開始(スレッドで動く想定) 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) # 逆ガンマ作成(浮動小数点) base = cv2.imread(flist.pop()) height, width = base.shape[:2] sums = lut[base] cnt = 1 kp2, des2 = get_matcher(base) for fname in flist: print(fname) current = cv2.imread(fname) mtx = get_affine_matrix(current, kp2, des2) if mtx is not None: current = cv2.warpAffine(current, mtx, (width, height)) # current = cv2.warpAffine(current, mtx, (width , height), base, flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_TRANSPARENT) sums += lut[bai_blnd(base, current).astype(np.uint8)] # sums += lut[current] cnt += 1 out = (sums / cnt) # 平均化 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(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()
コメント