PyTorch SSDで転移学習（応用：ナンバープレートぼかしアプリ作成）

以下記事で紹介したSSDモデル（Single Shot MultiBox Detector）を使って、ナンバープレートを自動でぼかすアプリを作成しました。

https://qiita.com/shima202102/items/a92a28ff277bcea9202f

https://qiita.com/shima202102/items/4e24d77df70448acae1f

# 目次、概要

本記事は、転移学習で作成したSSDモデルを使ったアプリ作成について記載しました。

ナンバープレートを自動でぼかすアプリに特化してはいますが、SSDモデルの弱点をアプリ側でどう補うのか、特に、時系列フィルタにおけるトラッキング、位置推定（カルマンフィルタ）は、他でもよく使われる一般的な手法なので、他アプリ作成の際も参考にしていただけるかと思います。

- 1 SSDモデル概要
- 2 アプリ作成
  - 2.1 誤検出(a)の対策（車との包含関係を利用）
  - 2.2 未検出(b)の対策（時系列フィルタ導入）
    - 2.2.1 トラッキング（前周期の検出物体との紐づけ）
    - 2.2.2 物体管理（検出状態を保持する期間をどうするか）
    - 2.2.3 未検出時の位置推定（カルマンフィルタ適用）
- 3 アプリ実行

実装は、以下githubに公開してますので、ダウンロードしてご利用ください。

https://github.com/Shimasaki2021/pytorch_ssd_trial

# 1. SSDモデル概要

アプリに使ったSSDモデルは、車とナンバープレートを検出する物体検出モデルです。

以下githubで公開されているpytorchで実装されたSSDモデルに対して、転移学習を行って作成しました。pythonバージョンやナンバープレート検出に合うよう、実装を多少カスタマイズしました。詳細は、上述記事、および、githubをご参照ください。

https://github.com/hituji1012/od_test


- 検出対象
  - 車のナンバープレート
  - 車（前面、後面）
- モデル仕様
  - SSD300（VGG16ベース、入力解像度```300x300```）
- 転移学習概要
  - パラメータ初期値：[vgg16_reducedfc.pth](https://s3.amazonaws.com/amdegroot-models/vgg16_reducedfc.pth)
  - 学習画像
    - 解像度：```300x300```、```350x350```、```600x500```
    - 枚数：598（ドラレコ映像（```1280x720```）から切り出し）
    - 物体数（枠の数）：1539（車：922、ナンバープレート：617）
  - epoch：500
  - optimizer： AdamW

検出結果例
![検出結果例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1299377/82104d7f-9d41-42a6-97ea-a5986c235111.jpeg)

# 2. アプリ作成

車載映像に映る車のナンバープレートを自動でぼかすアプリです。
入力映像（mp4、解像度```1280x720```（※））から、車がいそうな領域を切り出し、上述のSSDモデルでナンバープレート領域を検出し、検出した領域を平均値フィルタでぼかします。

（※）解像度は、```1280x720```以外でも、ソース内で定義している切り出し領域（以下）を調整すれば対応可能です。フォーマットはmp4のみのサポートです。

```python: app_carnumber_auto_blur.py 抜粋
# 検出範囲(1280x720、真ん中or右車線走行シーン、駐車場シーン用)
"img_procs"    : [ImageProc(0, 250, 350, 600), 
                  ImageProc(250, 200, 550, 500), 
                  ImageProc(480, 200, 780, 500), 
                  ImageProc(730, 200, 1030, 500), 
                  ImageProc(930, 250, 1280, 600)],
```

SSDモデルがナンバープレート領域を完璧に検出してくれるなら、検出された領域をぼかすだけで事足りるのですが、そんな完璧なモデルはなかなか作れません。今回学習したSSDモデルは、以下課題がありました。

- (a) 周囲の看板等をナンバープレートとして誤検出することがある
- (b) 数字等を視認できるナンバープレートでも、未検出が時折発生

認識系アプリにおける課題の典型例、未検出と誤検出です。
どちらも、学習データを増やせば、ある程度の改善は見込めるのですが、機械学習モデルの学習は、文字通り機械まかせなので、原因分析してそれに基づいて対策を検討、といった理詰めの改善は難しい部分があります。

なので今回も、<ins>モデルの改善はある程度のところで見切りをつけて、アプリでフォローする</ins>、という方針ですすめました。

## 2.1 誤検出(a)の対策（車との包含関係を利用）

周囲の看板等の誤検出例は下図です。右の黄色い看板をナンバープレートと誤検出しています。おそらく、軽自動車のナンバープレートと間違えてます。

![F00637_number誤検出例.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1299377/555a03f5-1a38-45bd-a6ae-f8f0bb5e6811.jpeg)

これに対しては、車の検出結果を利用して、以下対策を施しました。
これは、SSDモデルの検出対象に「車」を加えた際に、想定していた使い方です。

:::note info
車に包含されたナンバープレートのみ有効化
（包含されていないものは誤検出として棄却）
:::

包含されているかどうかの判定は、車の枠（外接矩形）と、ナンバープレートの枠（外接矩形）の重なりで以下判定しました。

- [重なりが大きい（閾値より大きい）] 包含されている
- [重なりが小さい（閾値以下）] 包含されていない

重なりは、以下式で算出しました。
車とナンバープレートでは元々の大きさに差があるため、IoUは採用しませんでした。

![車とナンバーの包含関係算出.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1299377/e0c0d42b-5829-44e6-949c-537446a2a46e.png)

ナンバープレートは、物理的には車に完全に包含されています。
したがって、本来は、完全に包含されているかどうかで判定すればよいはずです。完全に包含されているかの判定であれば、上述のような算出をしなくても、枠の左上／右下頂点の位置関係だけで判定可能です。

ただ、SSDモデルが完璧ではなく、検出ぶれの影響で下図のように完全包含されないケースがどうしても現れてしまうため、下図例のように多少はみ出してもよいよう、上述の処理にしました。

![F00719_number_car_包含しきれていない.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1299377/1fdb4f86-5fa3-42e0-a53a-92579d00ae17.jpeg)

完璧な物体検知モデルを作成できることはまずないので、上述のような、<ins>モデルの多少の検出ぶれを許容するアルゴリズムを採用</ins>することは、物体検知モデルを応用したアプリを作成する上では重要なポイントです。

これら処理を含んだ、ナンバープレートを包含する車を検出結果の集合から検索するソースは以下です。

```python:common_ssd.py抜粋（DetResult.calcOverlapAreaBBox()）
    @staticmethod
    def calcOverlapAreaBBox(bbox1:np.ndarray, bbox2:np.ndarray) -> float:
        # 重なり部分の面積を算出（ない場合は0）
        bbox_overlap_area = 0.0
        bbox_inter = np.concatenate([np.maximum(bbox1[:2], bbox2[:2]), np.minimum(bbox1[2:], bbox2[2:])])

        if (bbox_inter[0] < bbox_inter[2]) and (bbox_inter[1] < bbox_inter[3]):
            bbox_overlap_area = float((bbox_inter[2] - bbox_inter[0]) * (bbox_inter[3] - bbox_inter[1]))

        return bbox_overlap_area
```

```python:app_carnumber_auto_blur.py抜粋（DetNumberPlate.searchOwnerCar()）
    @staticmethod
    def searchOwnerCar(obj_number:DetResult, det_objs:List[DetResult], own_car_rate_th:float) -> DetResult:
        # ナンバープレート（obj_number）を所有（包含）する車の検出結果を探す
        #    「重なり矩形面積／ナンバープレートの面積」が閾値以上の場合に、「所有する（包含する）」と判定
        # 複数見つかった場合は、信頼度が一番大きな車を返す
        obj_car:DetResult = None
        
        bbox_n_area = float(obj_number.bbox_area_)

        if bbox_n_area > 0.0:

            for det_obj in det_objs:

                if det_obj.class_name_ == "car":

                    # 重なりを算出
                    own_rate = DetResult.calcOverlapAreaBBox(obj_number.bbox_, det_obj.bbox_) / bbox_n_area

                    if own_rate > own_car_rate_th:
                        # [車（det_obj）がナンバープレート(obj_number)を所有（包含）する、と判定された場合]

                        if obj_car is not None:
                            # [既に１つ以上車が見つかっている場合] 信頼度が大きい車を選択
                            if det_obj.score_ > obj_car.score_:
                                obj_car = copy.deepcopy(det_obj)
                        else:
                            # [まだ車が見つかっていない場合] det_objを選択
                            obj_car = copy.deepcopy(det_obj)

        return obj_car
```

## 2.2 未検出(b)の対策（時系列フィルタ導入）

ここでは、未検出の中でも、

:::note warn
今まで検出できていたナンバープレートが急に未検出になる
:::

というケース（下図例）に対して、アプリで対策を施しました。
ずっと未検出というケースは、アプリではフォローできないので、SSDモデルの改善で検出できるようにするしかありません。

![F01313_number未検出例.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1299377/17823099-90a0-4182-b3c6-95698fb4ae61.jpeg)
※掲載の都合上、ナンバープレート部分を緑塗りつぶししてますが、実際には4桁の数字がはっきり見えています。

上図例では、少し前までは安定して検出できていたのが、なぜかこの距離感になると急に検出が不安定になります。検出できていたものが急に未検出になると、ぼけていた領域が急にクリアになり、とても目立ちます。

この未検出に対する対策は以下です。

:::note info
未検出になってもすぐには消さない
＝　瞬間的に発生する未検出（ちらつき）をフィルタリングし、一定期間、検出状態を保持
:::

これを実現するため、以下処理を行う時系列フィルタを導入しました。

- トラッキング（前周期の検出物体との紐づけ）
- 物体管理（検出状態を保持する期間をどうするか）
- 未検出時の位置推定（カルマンフィルタ）

### 2.2.1 トラッキング（前周期の検出物体との紐づけ）

時系列的な処理を導入しようとすると、前周期と今周期の検出物体の紐づけ（同一物体かどうかの判定）は必須です。こういう紐づけ処理は、一般的にはトラッキングと呼ばれています。

今回は、以下方法で実現しました。

:::note info
ナンバープレートを所有（包含）する車の枠同士に、一定以上重なりがある場合は、同一物体（ナンバープレート）とみなす
:::

ナンバープレートが同一かどうか、の判定は、人間であれば、ナンバープレート内の数値、文字の一致／不一致で判定しますが、これを実現するには、ナンバープレート内の数値、文字の認識が必要です。
今回作成したモデルには、数値／文字の認識機能はないので、数値／文字の認識を行う新たなモデルを追加で作成する必要があります。ただこれは、かなり大変です。

よって、枠の重なりで簡易判定する処理にしました。
同じ物体なら、同じ位置 or 一周期（33ms想定）分の微小な移動量なので、枠の重なりが大きくなるはず、という前提です。

ナンバープレートの枠ではなく、車の枠、としたのは、ナンバープレートはかなり小さい物体なので、一周期（33ms想定）分の移動でも重なりが極小となるリスクがあるからです。

車同士の枠の重なりは、IoU（下式）で算出しました。
このように、トラッキングの実現手法として、<ins>枠の重なり（IoU）による簡易判定</ins>、という手法は、時系列データ（動画等）を入力とする認識応用アプリでよく見られます。

![車同士の重なりiou算出.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1299377/dab1c88a-e1e7-4875-a3f8-07ca3816c9db.png)

トラッキングのソースは以下です。
IoU算出は、SSDモデル本体コードの実装に```jaccard_numpy()```があったので、それを利用しました。

```python:utils/data_augumentation.py抜粋
def intersect(box_a, box_b):
    max_xy = np.minimum(box_a[:, 2:], box_b[2:])
    min_xy = np.maximum(box_a[:, :2], box_b[:2])
    inter = np.clip((max_xy - min_xy), a_min=0, a_max=np.inf)
    return inter[:, 0] * inter[:, 1]

def jaccard_numpy(box_a, box_b):
    """Compute the jaccard overlap of two sets of boxes.  The jaccard overlap
    is simply the intersection over union of two boxes.
    E.g.:
        A ∩ B / A ∪ B = A ∩ B / (area(A) + area(B) - A ∩ B)
    Args:
        box_a: Multiple bounding boxes, Shape: [num_boxes,4]
        box_b: Single bounding box, Shape: [4]
    Return:
        jaccard overlap: Shape: [box_a.shape[0], box_a.shape[1]]
    """
    inter = intersect(box_a, box_b)
    area_a = ((box_a[:, 2]-box_a[:, 0]) *
              (box_a[:, 3]-box_a[:, 1]))  # [A,B]
    area_b = ((box_b[2]-box_b[0]) *
              (box_b[3]-box_b[1]))  # [A,B]
    union = area_a + area_b - inter
    return inter / union  # [A,B]
```

```python:app_carnumber_auto_blur.py抜粋
    def trackingCar(self, obj_car:DetResult, same_cur_iou_th:float, obj_cars_bbox:List[np.ndarray]) -> int:
        # 今回のobj_carが、obj_cars_bboxの中にあるかどうかを確認（トラッキング）
        #   - もしあれば、indexを返す
        #   - 複数該当する場合は、最も重なりの大きな車のindexを返す
        #   - ない場合は、-1を返す
        # 同一かどうかは、車同士の外接矩形の重なり(iou)＞閾値(same_cur_iou_th) で判定
        ret_idx = -1

        if len(obj_cars_bbox) > 0:
            # 重なり(iou)を算出（全登録分の車との重なりを一括で算出）
            obj_car_ious = jaccard_numpy(np.array(obj_cars_bbox), obj_car.bbox_)

            # 外接矩形の重なり(iou)が閾値以上を抽出
            # 閾値以上が複数存在する場合は、重なり(iou)最大のものを抽出
            iou_max = same_cur_iou_th

            for buf_idx, obj_car_iou in enumerate(obj_car_ious):
                if obj_car_iou > iou_max:
                    ret_idx = buf_idx
                    iou_max = obj_car_iou

        return ret_idx
```

### 2.2.2 物体管理（検出状態を保持する期間をどうするか）

ここでやりたいことは以下です。

:::note info
ナンバープレートが未検出となっても、一定期間、検出状態を保持
1. 検出できていた期間が長く、信頼度が高かったナンバープレートは、期間を長くしたい
1. 信頼度が低い車に包含されているナンバープレートは、誤検出の可能性が高いので、期間を短くしたい
1. フレームアウトした場合は、極力速やかに削除したい
:::

上記を実現するため、ナンバープレート検出時の信頼度の累積値（累積信頼度）を、検出状態を保持する期間の制御に利用しました。具体的には下表処理を行い、累積信頼度≦0となったら物体を削除しました。

| | 今周期の検出有無 | 累積信頼度 |
|:-:|:-------|:------------|
|a| ナンバープレートを検出  | ナンバープレートの信頼度を加算（上限あり）  |
|b| ナンバープレート未検出＆車（※1）を検出  | （MAX信頼度+α（※2）－車（※1）の信頼度）を減算  |
|c| ナンバープレート＆車（※1）の両方を未検出（フレームアウト等） | MAX信頼度（※2）を減算  |

（※1）「車」は、ナンバープレートを所有（包含）する車
（※2）MAX信頼度＝1.0。αは微小量（0.1）

要するに以下です。
- ナンバープレートが検出された場合：　累積信頼度を加算（上表a）
- ナンバープレート未検出の場合：　累積信頼度を減算（上表b,c）

上表cは、bの特殊ケース（車の信頼度＝0）に該当します。上表aで、「上限あり」としたのは、上述3（フレームアウトした場合は、極力速やかに削除したい）を満たすためです。

関連ソースは以下です。

```python:app_carnumber_auto_blur.py抜粋（DetNumberPlateクラス）
    def updateAccumConf(self, conf_val:float):
        # 累積信頼度の更新
        self.accum_conf_ += conf_val

        # 上限でクリップ
        if self.accum_conf_ > self.param_aconf_max_:
            self.accum_conf_ = self.param_aconf_max_

        if self.accum_conf_ < 0.0:
            # [下限（＝0）を下回った場合] 無効化
            self.is_valid_ = False

        return
```

```python:app_carnumber_auto_blur.py抜粋（DetNumberPlateMngクラス）
    def updateCycle(self):
        # 個々の物体（ナンバープレート＆車）の時系列管理
        #   - 今周期にナンバープレート検出ありの場合は、累積信頼度を加算
        #   - 今周期にナンバープレート検出なしの場合は、累積信頼度を減算
        #   - 累積信頼度が高いものを残す（低くなった物体（検出されなくなったもの）を削除）
        obj_reg_buf_updated: List[DetNumberPlate] = []

        for obj_reg in self.det_obj_buf_:

            if obj_reg.is_valid_ == True:

                if obj_reg.obj_number_.is_det_cur_ == True:
                    # [今周期にナンバープレート検出ありの場合] 
                    #  累積信頼度を加算。そのまま残す
                    obj_reg.updateAccumConf(obj_reg.obj_number_.score_)
                    obj_reg_buf_updated.append(obj_reg)

                else:
                    # [今周期にナンバープレート検出なしの場合] 
                    #  累積信頼度を減算。低くなったら無効化
                    dec_conf = -1.0
                    if obj_reg.obj_car_.is_det_cur_ == True:
                        # 所有する車の信頼度が高いほど、減少がゆるやかになるようにする。車が未検出の場合は減少を加速（-1.0）
                        dec_conf =  obj_reg.obj_car_.score_ - 1.1 # ※1.1: 減少量を0にはしない

                    obj_reg.updateAccumConf(dec_conf)

                    if obj_reg.isValid() == True:
                        #  [無効化されていない場合] 残す
                        obj_reg_buf_updated.append(obj_reg)

        self.det_obj_buf_ = obj_reg_buf_updated

        # 登録物体（残すと決まったもののみ）の位置推定
        for obj_reg in self.det_obj_buf_:
            obj_reg.updatePos()

        return
```

### 2.2.3 未検出時の位置推定（カルマンフィルタ適用）

時系列方向にフィルタリングする際の定番、カルマンフィルタを適用し、移動中のナンバープレートが未検出となっても、これまでの移動方向等を加味して、 **枠（外接矩形）の中心位置** を推定しました。

カルマンフィルタは、センサーにノイズが載っているときに、ノイズを除去して真値を推定する、といった目的で適用されることが多いかと思いますが、今回は、<ins>未検出時の外挿に特化した処理</ins>にしました。

今回のアプリで「センサー」に相当するのはSSDモデルであり、決して完璧（ノイズ＝０）というわけではないですが、ナンバープレートの位置が半分だけずれる、といった中途半端な「ノイズ」（位置ずれ）はほとんど発生しないので、信頼度が高めの検出結果は信じたほうがよい場合が多いです。

こういう「センサー」特性を加味し、

- ナンバープレート検出時は、カルマン推定値を使わない
  （推定値自体も検出値でリセット）
- ナンバープレート未検出時のみ、推定値を使う（外挿する）

という処理にしました。

観測値と推定値どちらを信じるかは、カルマンフィルタのパラメータ（カルマンゲイン）の調整で対応するのが一般的です。ただ今回の場合、せっかくモデルがちょうどいい位置を検出してくれたのに、カルマンフィルタで少しだけずれてしまい、ナンバープレートの数字が一部だけ見えてしまう、という事象が収まらなかったことから、上述の処理にしました。

このように、カルマンフィルタを適用する際は、<ins>センサー特性にあった適用方法を採用する</ins>、のが重要なポイントです。

ソースは以下です。
カルマンフィルタ本体のソースは、以下より拝借＆カスタマイズさせていただきました。

https://qiita.com/matsui_685/items/16b81bf0ad9a24c54e52

```python:common_ssd.py抜粋
# 2次元値（例：位置(x,y)）を推定するカルマンフィルタ
#   参考: https://qiita.com/matsui_685/items/16b81bf0ad9a24c54e52
class KalmanFilter2D:
    def __init__(self, fps:float):
        # 観測値入力有無（一度でも観測値を入力したらTrue）
        self.is_input_measurement_ = False 

        # 計測間隔 [sec/cycle]
        self.dt_ = 1.0 / fps 

        # 推定値: 4次元ベクトル(x, y, dx/dt, dy/dt)
        self.x_ = np.array([[0.], [0.], [0.], [0.]]) 

        # プロセスノイズ
        self.u_ = np.array([[0.], [0.], [0.], [0.]]) 

        # 共分散行列
        self.P_ = np.array([[100.,   0.,   0., 0.],
                            [  0., 100.,   0., 0.],
                            [  0.,   0., 100., 0.], 
                            [  0.,   0.,   0., 100.]]) 

        # 状態遷移行列
        self.F_ = np.array([[1., 0., self.dt_, 0.],
                            [0., 1., 0.,       self.dt_],
                            [0., 0., 1.,       0.],
                            [0., 0., 0.,       1.]])
        # 観測行列
        self.H_ = np.array([[1., 0., 0., 0.], 
                            [0., 1., 0., 0.]])

        # 観測ノイズ
        self.R_ = np.array([[0.1, 0.],
                            [0.,  0.1]]) 

        # 4次元単位行列
        self.I_ = np.identity((len(self.x_)))
        return

    def predict(self):
        # 予測ステップ
        if self.is_input_measurement_ == True:
            self.x_ = np.dot(self.F_, self.x_) + self.u_
            self.P_ = np.dot(np.dot(self.F_, self.P_), self.F_.T)
        return

    def update(self, measurement:np.ndarray):
        # 更新ステップ
        if self.is_input_measurement_ == True:

            Z = np.array([measurement])
            y = Z.T - np.dot(self.H_, self.x_)
            S = np.dot(np.dot(self.H_, self.P_), self.H_.T) + self.R_
            K = np.dot(np.dot(self.P_, self.H_.T), np.linalg.inv(S))
            self.x_ = self.x_ + np.dot(K, y)
            self.P_ = np.dot((self.I_ - np.dot(K, self.H_)), self.P_)
        else:
            # 観測値入力初回は、初期状態の設定のみ
            self.x_ = np.array([[measurement[0]], [measurement[1]], [0.], [0.]])
            self.is_input_measurement_ = True

        return

    def resetPredict(self, measurement:np.ndarray):
        # 推定値(x,y)を観測値で上書き　※変動量成分(dx/dt, dy/dt)はリセットしない
        self.x_[0][0] = measurement[0]
        self.x_[1][0] = measurement[1]
        return
    
    def getEstimatedValue(self) -> np.ndarray:
        # 推定値(x,y)を返す（変動量成分(dx/dt, dy/dt)は返さない）
        return self.x_.reshape((4,))[:2] 

    def getEstimatedStdev(self) -> np.ndarray:
        # 推定値(x,y)の標準偏差を返す
        return np.array([math.sqrt(self.P_[0][0]), math.sqrt(self.P_[1][1])]) 
```

```python:app_carnumber_auto_blur.py抜粋（DetNumberPlateクラス）
    def updatePos(self):
        # 位置更新（ナンバープレート外接矩形の中心座標の推定実行）
        if self.isValid() == True:

            self.pos_estimator_.predict() # 予測ステップ

            if self.obj_number_.is_det_cur_ == True:
                # [ナンバープレート検出時（観測値が得られた場合）] 
                measure = self.obj_number_.getBboxCenter().astype(float)
                self.pos_estimator_.update(measure) # 更新ステップ

                # 一般的ではないが、今回の場合、未検出のときだけカルマンFで外挿をかけたいので、
                # 観測値が得られた場合は推定値を観測値でリセット（位置成分のみ）
                self.pos_estimator_.resetPredict(measure)
        return

    def getEstimatedNumberBBox(self) -> np.ndarray:
        # ナンバープレートの外接矩形（推定値）を返す
        ret_bbox = np.zeros((4,)).astype(int)

        if self.isValid() == True:
            # カルマンフィルタ推定値（外接矩形の中点位置）を取得
            est_pt_center = self.pos_estimator_.getEstimatedValue()

            # 推定値（中点位置）のばらつきを取得
            #   3σ（小数切り上げ）
            est_3sigma    = np.ceil(self.pos_estimator_.getEstimatedStdev() * np.array([3.0, 3.0])) 

            # 推定値（中点位置）のばらつき（3σ）分だけ、外接矩形の幅／高さを増やす
            # （極端に大きくならないよう（増加量が元の幅／高さ以上にならないよう）クリップ）
            bbox_w_inc = int(est_3sigma[0] * 2.0) # 2.0：左右
            bbox_h_inc = int(est_3sigma[1] * 2.0) # 2.0：上下
            if bbox_w_inc > self.obj_number_.bbox_w_:
                bbox_w_inc = self.obj_number_.bbox_w_
            if bbox_h_inc > self.obj_number_.bbox_h_:
                bbox_h_inc = self.obj_number_.bbox_h_
            
            bbox_w = self.obj_number_.bbox_w_ + bbox_w_inc
            bbox_h = self.obj_number_.bbox_h_ + bbox_h_inc

            # 中点→左上座標に変換
            est_pt_leftup = (est_pt_center - np.array([float(bbox_w) / 2.0, float(bbox_h) / 2.0])).astype(int)

            # 画像の幅、高さでクリップ
            left_up_max_x = self.frame_w_ - 1 - bbox_w
            left_up_max_y = self.frame_h_ - 1 - bbox_h

            if est_pt_leftup[0] > left_up_max_x:
                est_pt_leftup[0] = left_up_max_x
            if est_pt_leftup[1] > left_up_max_y:
                est_pt_leftup[1] = left_up_max_y

            # 0でクリップ
            if est_pt_leftup[0] < 0:
                est_pt_leftup[0] = 0
            if est_pt_leftup[1] < 0:
                est_pt_leftup[1] = 0

            ret_bbox[0] = est_pt_leftup[0]
            ret_bbox[1] = est_pt_leftup[1]
            ret_bbox[2] = ret_bbox[0] + bbox_w
            ret_bbox[3] = ret_bbox[1] + bbox_h

        return ret_bbox
```

# 3. アプリ実行

ターミナルで以下を実行します。

```sh
# ubuntuターミナルの場合
./app_carnumber_auto_blur.py 

# Anaconda Powershell Promptの場合
python app_carnumber_auto_blur.py
```

実行すると、ファイルダイアログ（下図例）が表示されるので、動画ファイル（mp4）を選択します。

![app_autoblur_filedialog.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1299377/2eb43bf5-cf9d-45ba-8614-190a031df0d4.png)

結果例は以下です。

<iframe width="560" height="315" src="https://www.youtube.com/embed/Ztcks1uRez0?si=RkxaB1q3B54Vzbw_" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

まだ、看板等の誤検出は残ってますし、コマ送り等でじっくり見ると、ナンバープレートの未検出も少しだけ残っていたりします。ただ、youtubeに動画公開してみてもらう分には、十分に使えるレベルかと思います。

このアプリを実際に使って、動画作成したものが以下になります
（音声／BGMが入っているので再生時にお気をつけください）。

<iframe width="560" height="315" src="https://www.youtube.com/embed/_LEK8KDjKTA?si=-MlpHUXuYC87v-Dt" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

使ったのは、前半の片側2車線の渋滞シーン（2:10～9:45）のみです。後半の片側1車線の渋滞シーン（9:45～）は、編集ソフトaviutilを使って手動でぼかしを入れました。渋滞未発生シーンにはぼかしを入れてません。

片側2車線の渋滞シーンと、片側1車線の渋滞シーン（9:45～）を見比べてもらえるとわかりますが、本アプリを使うと、ナンバープレート領域のみをぼかすことができてます。手動編集シーンは、車領域も含めてぼかしを入れてます。手動でナンバープレート領域のみぼかすのは、１フレームずつ位置調整が必要になり非常に手間がかかるので、実質不可能です。

全シーンにこのアプリを使わなかったのは、処理時間がかかりすぎるからです。
自分の以下PC環境で、1分の動画を処理するのに約15分かかってしまいます。この動画は全シーン30分なので、7.5時間かかる計算になります。動画編集の前処理でそんな長時間待つのはどうか、と思い、今回は適用を見送りました。

- PC環境
  - CPU: AMD Ryzen 7 3700X (3.60 GHz)
  - GPU: NVIDIA GeForce GTX 1660 SUPER
  - OS: Windows 11 Home (24H2） , WSL2 + Ubuntu24.04

GPUをアップグレードしたりすればだいぶ早くなるんだろうとは思いますが、ソフト屋としてはあまり満足感が得られないので、SSDモデルのベースをmobilenetにして転移学習に再チャレンジしようと考えてます。成果が出たら記事にUPしようと思います。

（追記）mobilenet-v2-liteベースのSSDモデルの転移学習を実施しました。こちらはかなり高速です。以下記事をぜひご覧ください。

https://qiita.com/shima202102/items/e14468739ef7f93b8b77