Pixel4のカメラで学ぶDepth map-3(画像修正)

#はじめに
　この記事は[Note](https://note.com/sv_engineer/m/m98195ca0c6e1)で連載している記事で扱っているCode部分のみを切り出しています。技術背景等にご興味がある方はNoteの記事の方をご参照ください。
　今回は[前回](https://qiita.com/BOPsemi/items/37f5cb253cfc26dd7161)の続きです。前回でDark shadingを補正するGain Mapが生成できました。このGain mapを用いてDark shading補正をした後の画像を用いて、Noise除去、Edge強調処理をしていきます。大半の処理はOpenCVで実装されているので、それらを利用して関数を構築しています。
#画像修正処理
　今回実施する画像修正処理の大まかな流れを簡単に示します。この順番で処理をしています。
1. Noise除去(Bilateral filter)
2. Edge強調(Unsharp mask)
3. 画素値の正規化

###Noise除去
　RAW画像は各画素の特性ばらつき等があり、非常にざらついた画像になっています。例えば平な白い壁を撮影しても拡大してよく見るとばらつきの影響で平には見えません。なので、この画素特性ばらつきを除去するためにNoise除去を行います。
　理想的なNoise除去は、Noiseのみを除去するのですが実際はそうはいきません。画素特性ばらつきはランダムなばらつきなので、簡単にどのレベルからNoiseという風に判断はできないからです。なので隣接画素の情報を用いてNoiseと思われる部分を抽出して、隣接画素特性からNoise部分の値を予測するという処理をしています。今回はよく使われるBilateralという手法を用いてNoise除去をします。
　関数に入力される画像は、他の画像処理との兼ね合いで信号レベルを0.0~1.0のFloatに規格化しています。理由は画像のFormatによっては8bitだったり10bitだったりするため、入力Formatを統一化するためです。本当はgeneric等を用いてするのが良いかと思うのですが、勉強中のため辞めました・・・。
　OpenCVで実装されているbilateral filter関数は入力画像として8bitのRGB-3ch画像しか受け付けないので一旦内部で8bitの画像に変換しています。

```python:dualpd.py
def _bilateral_filter(self, img: np.ndarray, kernel_size: int=3, disp: int = 75) -> np.ndarray:
        """ apply bilateral filter in order to reduce random noise
        Parameters
        -----------
        img: np.ndarray
         target image
        kernel_size: int
         filter kernal size, default size is 3x3
        disp: int
         dispartion parameter, default value is 75

        Returns
        -----------
        cor_img: np.ndarray
         image after bilateral filtering
        """
        # convert data from float to 8bit data
        img_8_bit = img * 255
        img_8_bit_int = img_8_bit.astype(np.uint8)

        cor_img = cv2.bilateralFilter(img_8_bit_int, kernel_size, disp, disp)

        # return
        return cor_img
```

###Edge強調
　Noise除去後の画像は、Filterの影響によりEdgeがボケています。なのでこのEdgeボケを修正する必要があります。色々とEdge強調する方法はありますが、教科書等でよく取り上げられており、かつ実際のカメラ内部でも使用されているUnsharp maskを使用してEdge強調します。
 Noise除去をする前の画像をorg_imgとしてNoise除去後の画像をtarget_imgとしています。Original画像にGaussianBlur filterをかけて、Noise除去後の画像との比較することでEdge部分にどれだけWeightをかけるかを計算しています。
　悩みどころは、org_imgはNoise除去後の画像で良いのではないか？ということです。この点は色々実験した結果Noise除去前の画像を使う方が安定して良い結果が出たため、この案を採用することにしました。

```python:dualpd.py
def _unsharp_mask(self, org_img: np.ndarray, target_img: np.ndarray, sigma: int=2) -> np.ndarray:
        """ Apply unsharm mask to enhance object edge
        Parameters
        -----------
        org_img: np.ndarray
         original image before noise reduction 
        target_img: np.ndarray
         target image after noise reduction
        sigma: int
          filter size of blur

        Returns
        -----------
        cor_img: np.ndarray
         unsharp-mask image
        """
        
        offset = -0.5
        
        blur_img = cv2.GaussianBlur(org_img, (0, 0), sigma)
        cor_image = cv2.addWeighted(target_img, sigma + offset, blur_img, \
                    offset, 0)
        
        # return
        return cor_image

```

###画素値の正規化
　撮影シーンや光源条件により画像間のばらつきが発生します。このばらつきをある程度緩和する処理をしておくと、後の処理が楽になります。機械学習等では一般的に用いられている手法です。ここではこの方法を採用し画像Dataの正規化を行います。

```python:dualpd.py
def _equalize_distribution(self, img: np.ndarray, grid_size: int=3, \
    sigma: int=32, mean_value: int = 128) -> np.ndarray:
        """ Equalize image signal distribution
        Parameters
        -------------
        img: np.ndarray
         image data
        grid_sie: int
         distribution grid size to eqalize the signal distribution
        sigma: int
         sigma value of Gaussian
        mean_value: int
         mean value after equalization

        Returns
        -------------
        eq_img: np.ndarray
         eqalized image data
        """
        # convert RGB -> HSV
        hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
        
        # split hsv into h,s and v
        h, s, v = cv2.split(hsv)
        
        # equalie the distribution
        clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(grid_size, grid_size))
        result = clahe.apply(v)

        # adjust distribution
        result_adj = (result - np.mean(result)) / np.std(result) * \
                    sigma + mean_value

        result_adj[result_adj>255] = 255 # inserted max value pinning 
        result = np.array(result_adj, dtype=np.uint8)
       
        # hsv -> RGB
        hsv = cv2.merge((h,s,result))
        eq_img = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB)
        
        # return
        return eq_img
```
###その他の処理
　これらの関数は前提としてDark Shafing補正をしたものです。以前の投稿でDark shading処理を受け持つdark classを定義しました。このclassを利用してdark shading補正を実施する関数を定義しておきます。
　まずはLeft/RightのGain MapをInitializeする関数です。

```python:dualpd.py
def set_dksh_gain_map(self, gain_map: np.ndarray, left: bool = True) -> bool:
        """ Setter of gain map for dark shading correction
        Parameters
        -----------
        gain_map: np.ndarray
         gain map data
        left: bool
         location indicater, default is true -> left, false -> right
        """
        if left:
            self.left_gain_map = gain_map
            return True
        
        self.right_gain_map = gain_map
        return True
```
　次に実際にGain Mapを画像に適応する関数です。Return前に一旦画像の最大値でNormarizeしています。この方が後々使いやすいからです。

```python:dualpd.py
def _correct_dksh(self, img: np.ndarray, gain_map: np.ndarray) -> np.ndarray:
        """ correct dark shading by multiplying gain_map to raw image
        Parameters
        -----------
        img: np.ndarray
         raw image data
        gain_map: np.ndarrray
         gain map to correct dark shading

        Returns
        ----------
        cor_img: np.ndarray
         corrected image
        """
        # correct dark shading
        gain_map_one = np.ones((self._img_height, self._img_width, 3))
        cor_img = img * gain_map
        #cor_img = img * gain_map_one

        # normarization
        max_val = cor_img.max()
        cor_img /= max_val

        # return
        return cor_img
```

#処理の全体像
　以上で個々の処理内容を示しました。次にこれらの関数を使用し処理の全体像を示します。単純に処理に順に書き出していくだけです。
　Return前に8bitの範囲を超える値及び0以下となる値をBinningする処理をしています。uint8で処理しているので0~255の範囲を超えるとOverflowで画像がおかしくなるのを避けるためです。

```python:dualpd.py
def _core_process(self, img: np.ndarray, gain_map: np.ndarray, \
        bi_kernel_size: int=5, bi_disp: int=75, unsharp_sigma: int=2, \
        eq_grid_size: int=2, eq_sigma: int=32, eq_mean_value: int=128 ) -> np.ndarray:
        """ run all image processes
        Parameters
        ----------
        img: np.ndarray
         raw image, just read from data
        gain_map: np.ndarray
         gain map in order to correct dark shading
        bi_kernel_size: int
         kernel size for bilateral filter, default value is 5
        bi_disp: int
         dispersion value for bilateral filter, default value is 75
        unsharp_sigma: int
         sigma value for unsharp mask, default is 2
        eq_grid_size: int
         grid size for equalization, default value is 2
        eq_sigma: int
         sigma value for equalization, default value is 32
        eq_mean_value: int
         mean value for equalization, default value is 128

        Returns:
        -----------
        processed_img: np.ndarray
         processed image
        """
        # correct dark shading
        cor_img = self._correct_dksh(img, gain_map)

        # apply bilateral filter to reduce noise
        cor_img = self._bilateral_filter(cor_img, bi_kernel_size, bi_disp)

        # apply unsharp mask to enhance edge
        cor_img = self._unsharp_mask(img, cor_img, unsharp_sigma)

        # equalize distribution
        cor_img = self._equalize_distribution(cor_img, eq_grid_size, eq_sigma, eq_mean_value)

        # level pinning
        cor_img[cor_img>255] = 255
        cor_img[cor_img<0] = 0

        # return
        return cor_img
```
 Left/Rightの画像を処理する必要があるので、入力された画像がLeftなのかRightなのかをファイル名で判別しGain Mapを入れ替える必要があります。それらを考慮し実装したものがこちらとなります。どの画像を処理したかを追跡しやすくするために、画像のFolderリストも返すようにしています。

```python:dualpd.py
def run_process(self, bi_kernel_size: int=5, bi_disp: int=75, unsharp_sigma: int=2, \
        eq_grid_size: int=2, eq_sigma: int=16, eq_mean_value: int=128 ) \
        ->([str], [np.ndarray]):
        """ Run process for all images
        Parameters
        -----------
        bi_kernel_size: int
         kernel size for bilateral filter, default value is 5
        bi_disp: int
         dispersion value for bilateral filter, default value is 75
        unsharp_sigma: int
         sigma value for unsharp mask, default is 2
        eq_grid_size: int
         grid size for equalization, default value is 2
        eq_sigma: int
         sigma value for equalization, default value is 32
        eq_mean_value: int
         mean value for equalization, default value is 128

        Returnes
        ----------
        file_path_list: [str]
         processed image file path list
        proc_imgs: [np.ndarray]
         processed image data
        """

        # image data stocker
        proc_imgs = []

        # run process
        for idx, filepath in enumerate(self._filepath_list):
            img = self._raw_imgs[idx]
            loc = helper.loc_detector_from_name(filepath.split('/')[-1])

            if loc:
                # left side
                proc_img = self._core_process(img, self.left_gain_map, \
                    bi_kernel_size, bi_disp, unsharp_sigma, eq_grid_size, \
                    eq_sigma, eq_mean_value)
                
                # stacing
                proc_imgs.append(proc_img)
            else:
                # right side
                proc_img = self._core_process(img, self.right_gain_map, \
                    bi_kernel_size, bi_disp, unsharp_sigma, eq_grid_size, \
                    eq_sigma, eq_mean_value)
                    
                # stacing
                proc_imgs.append(proc_img)
        
        # return
        return self._filepath_list, proc_imgs
```
　最後にこれらの関数をMethodとして持っているDualPixel classの定義を示して今回は終わりにします。

```python:dualpd.py
import helper
import cv2
import numpy as np


class DualPixel(object):
    def __init__(self, filepath: str, ext: str, dsize = (2016, 1512)):
        """ Initialize DualPixel class
        Parameters
        ----------
        filepath :str
         pass project folder path to read raw images
        ext: str
         describe the file extention name
        dsize : (width: int, height: int)
         raw image size after resizing, not orifinal image size
        """
        super().__init__()

        self._filepath = filepath
        self._ext = ext
        
        # image size
        self._img_width, self._img_height = dsize

        # read raw image
        self._filepath_list = helper.make_file_path_list(filepath, ext)
        self._filepath_list.sort()
        self._raw_imgs = helper.read_img_from_path(self._filepath_list, self._img_width, self._img_height)

        # initalize gain_maps
        self.left_gain_map = np.zeros((self._img_height, self._img_width, 3))
        self.right_gain_map = np.zeros((self._img_height, self._img_width, 3))
```
次回は前処理Partの最後となります。