以下記事で、VGG16ベースのSSDの転移学習を実施しましたが、SSDのベースネットワークをVGG16→mobilenet(v2-lite)に変えて、転移学習を実施しました。
実装は、以下githubに公開してますので、ダウンロードしてご利用ください。
背景
以下記事で、VGG16ベースのSSDに対して転移学習を行い、ナンバープレートぼかしアプリを作成したのですが、SSDの検知が遅く(※1)(※2)、約30分ぐらいの動画全てを処理するのに7.5時間かかってしまう有様でした。
この処理時間の課題をなんとかしたいと思い、今回、mobilenetベースの転移学習に挑戦してみました。
(※1)下記PC環境で2fps程度。1分の動画を処理するのに14,5分ほどかかる。
- PC環境
- CPU: AMD Ryzen 7 3700X (3.60 GHz)
- GPU: NVIDIA GeForce GTX 1660 SUPER
- OS: Windows 11 Home (24H2) , WSL2 + Ubuntu24.04
(※2)補足:SSDの後処理の部分(non maximum suppression)をGPU実行→CPU実行に変更すると、処理速度が約2.5倍ほどに大きく向上しました(約2fps → 約5fps)。non maximum suppressionには、ソートなどが入っているので、GPU実行には不向きなようです。
目次
作成したいモデル仕様(検知対象=車&ナンバープレートetc.)や、pytorch環境構築は、VGG16ベースのSSDと共通です。以前の記事(以下)を参照願います。
- SSD実装(mobilenetベースSSD移植)
- 転移学習実行
- 検知(推論)実行、評価
- 小さな物体の精度向上
1. SSD実装(mobilenetベースSSD移植)
VGGベースSSDの転移学習時に作成したソース一式(pytorch_ssd_trial
(以下github))に、Hao Gao様のgithub(Copyright (c) 2019 Hao Gao)で公開されているmobilenetベースSSD実装を移植しました。
ここには、色々なバージョンのSSDが実装されています。mobilenet(v1,v2,v3)ベースのSSDだけでなく、定番のVGG16ベースのSSDもありました。
この中から、mobilenet-v2-liteベースのSSD実装を選択しました。mobilenet-v3が一番新しいのですが、v2と比較して精度は上がるが処理時間がややかかる、という評価のようなので、最速のv2-liteを選択しました。
Hao Gao様のgithubから、上図赤枠内のmobilenet-v2-lite-ssdのモデル実装ソースのみ拝借し、データローダーや前処理、損失関数の実装は、既存のものをそのまま流用する方針で移植を行いました。
移植で必要となった主な作業は以下です。
- 順伝播のI/F適合
- DefaultBoxサイズ調整
1.1 順伝播のI/F適合
順伝播は、以下I/Fの相違があったので、それを、移植先の既存実装(pytorch_ssd_trial
)に合わせました。
学習時の順伝播(SSD.forward()
)
学習時の順伝播(SSD.forward()
)のI/F(入力、出力)の相違は下表です。
入力 | 出力 | |
---|---|---|
移植元 | 前処理後の複数枚(batch)画像(torch.Tensor ) |
batch数分のオフセット情報loc、信頼度conf |
移植先(pytorch_ssd_trial ) |
※同上(前処理後の複数枚(batch)画像(torch.Tensor )) |
batch数分のオフセット情報loc、信頼度conf、DefaultBox |
入力は同じです。
出力の相違は、DefaultBox
の有無だけです。なので、SSD.forward()
の出力にDefaultBox
を追加するだけの変更になってます。
移植元と移植先で、DefaltBoxの変数名が違っていたので((移植元)priors, (移植先)dbox_list
)、対応を取るのが難しかったですが、それさえわかれば、後は追記するだけなので楽でした。
あと、学習時に、mobilenetベースSSD固有の関連として、本節冒頭のクラス図には、MultiboxLoss(損失関数)
とAdamW(Optimizer)
からも線(緑の線)が延びてます。なので、ここにも手を入れる必要があるように思えてしまいますが、ここへの手入れは不要でした。
特に、MultiboxLoss(損失関数)
は、SSDモデルの勾配パラメータとどうつながっているのかが、pytorchの外側からは全く見えないので、手の入れようがありませんでした。とはいえ、手を入れなくてもよさげに動いているので、現状、良しとしてます。
これは、今回のmobilenetベースSSD固有ではなく、前回のVGG16ベースSSDのコードでも理解できていない部分です。ご存じの方がいらっしゃいましたら、教えて下さると助かります。
検知時の順伝播(Predictor.predict()
)
検知時の順伝播(Predictor.predict()
)のI/F(入力、出力)の相違は下表です。
入力 | 出力 | |
---|---|---|
移植元 | 前処理前の画像1枚(numpy.ndarray ) |
batch数分の信頼度conf、クラス番号(ラベル), BoundingBox(bbox) |
移植先(pytorch_ssd_trial ) |
前処理後の複数枚(batch)画像(torch.Tensor ) |
batch数&クラス数分の信頼度conf、BoundingBox(bbox) |
こちらは、入力、出力共に、かなり相違があります。
出力は、どちらも実質同じ情報を返しており、上表からは違いがわからないですが、実装レベルで、返り値の型に以下相違があります。
- 移植元:
Tuple[torch.tensor[batch,4(bbox)], torch.tensor[batch](class), torch.tensor[batch](conf)]
- 移植先:
torch.tensor[batch,class,top_k,5(※)]
(※)5:[conf, 4(bbox)]
ソースは以下です。
既実装のutils.ssd_model.Detect.forward()
からソースを引っ張ってきて、移植元の大半を置き換えました。ついでに、呼び出し元のソースを書き換えなくてもいいよう、メソッド名もforward() → __call__()
に変更しました。
# 移植元(Predict.forward())
def predict(self, image, top_k=-1, prob_threshold=None):
cpu_device = torch.device("cpu")
height, width, _ = image.shape
image = self.transform(image)
images = image.unsqueeze(0)
images = images.to(self.device)
with torch.no_grad():
self.timer.start()
scores, boxes = self.net.forward(images)
print("Inference time: ", self.timer.end())
boxes = boxes[0]
scores = scores[0]
if not prob_threshold:
prob_threshold = self.filter_threshold
# this version of nms is slower on GPU, so we move data to CPU.
boxes = boxes.to(cpu_device)
scores = scores.to(cpu_device)
picked_box_probs = []
picked_labels = []
for class_index in range(1, scores.size(1)):
probs = scores[:, class_index]
mask = probs > prob_threshold
probs = probs[mask]
if probs.size(0) == 0:
continue
subset_boxes = boxes[mask, :]
box_probs = torch.cat([subset_boxes, probs.reshape(-1, 1)], dim=1)
box_probs = box_utils.nms(box_probs, self.nms_method,
score_threshold=prob_threshold,
iou_threshold=self.iou_threshold,
sigma=self.sigma,
top_k=top_k,
candidate_size=self.candidate_size)
picked_box_probs.append(box_probs)
picked_labels.extend([class_index] * box_probs.size(0))
if not picked_box_probs:
return torch.tensor([]), torch.tensor([]), torch.tensor([])
picked_box_probs = torch.cat(picked_box_probs)
picked_box_probs[:, 0] *= width
picked_box_probs[:, 1] *= height
picked_box_probs[:, 2] *= width
picked_box_probs[:, 3] *= height
return picked_box_probs[:, :4], torch.tensor(picked_labels), picked_box_probs[:, 4]
# 移植先(Predict.__call__())
def __call__(self, images:torch.Tensor, top_k=200, prob_threshold=None):
if not prob_threshold:
prob_threshold = self.filter_threshold
with torch.no_grad():
# self.timer.start()
scores, boxes = self.net.forward(images)
# print("Inference time: ", self.timer.end())
# boxes: [num_batch,3000,4]
# scores: [num_batch,3000,21]
# CPU実行のほうが高速
cpu_device = torch.device("cpu")
boxes = boxes.to(cpu_device)
scores = scores.to(cpu_device)
# 出力の型を作成する。テンソルサイズは[minibatch数, num_classes, top_k, 5(score,bbox[4])]
num_batch = images.size(0)
num_classes = scores.size(2)
output = torch.zeros(num_batch, num_classes, top_k, 5)
for batch_idx in range(num_batch):
for class_index in range(1, num_classes):
probs = scores[batch_idx, :, class_index]
mask = probs > prob_threshold
probs = probs[mask]
if probs.size(0) == 0:
continue
subset_boxes = boxes[batch_idx, mask, :]
# 3. Non-Maximum Suppressionを実施し、被っているBBoxを取り除く
# ids :confの降順にNon-Maximum Suppressionを通過したindexが格納
# count:Non-Maximum Suppressionを通過したBBoxの数
ids, count = nm_suppression(subset_boxes, probs, self.iou_threshold, top_k)
# outputにNon-Maximum Suppressionを抜けた結果を格納
output[batch_idx, class_index, :count] = torch.cat((probs[ids[:count]].unsqueeze(1),
subset_boxes[ids[:count]]), 1)
return output
1.2 DefaultBoxサイズ調整
移植元のmobilenet-v2-lite-ssdでは、DefaultBoxサイズの最小値=60pixelとなっていたのですが(以下ソース)、これだと、ナンバープレートが全く検知できませんでした。
そこで、ナンバープレートを検知できるよう、DefaultBoxサイズを小さめに調整しました。幸い、以下ソースの数値を変更するだけで対応できました。
# 変更前
specs = [
SSDSpec(19, 16, SSDBoxSizes(60, 105), [2, 3]),
SSDSpec(10, 32, SSDBoxSizes(105, 150), [2, 3]),
SSDSpec(5, 64, SSDBoxSizes(150, 195), [2, 3]),
SSDSpec(3, 100, SSDBoxSizes(195, 240), [2, 3]),
SSDSpec(2, 150, SSDBoxSizes(240, 285), [2, 3]),
SSDSpec(1, 300, SSDBoxSizes(285, 330), [2, 3])
]
# 変更後
specs = [
SSDSpec(19, 16, SSDBoxSizes(21, 60), [2, 3]),
SSDSpec(10, 32, SSDBoxSizes(60, 150), [2, 3]),
SSDSpec(5, 64, SSDBoxSizes(150, 195), [2, 3]),
SSDSpec(3, 100, SSDBoxSizes(195, 240), [2, 3]),
SSDSpec(2, 150, SSDBoxSizes(240, 285), [2, 3]),
SSDSpec(1, 300, SSDBoxSizes(285, 330), [2, 3])
]
上記ソースを見ると、DefaultBoxのサイズ以外にも、特徴マップの解像度も書かれており、最大解像度は19(=19x19)
となってます。
ナンバープレートのような小さな物体だと、解像度が粗すぎる(小さすぎる)と特徴がつぶれてしまうので、この解像度も大きくしたかった(せめてVGGベースSSDと同じ「38」)のですが、残念ながら上記ソースの数値を変えるだけだと、内部でテンソルサイズが合わなくなるらしく、エラーになりました。
上述の数値だけでなく、ネットワークの構造も変更する必要がありそうですが、そこを変更できるほどSSD実装を理解できてないので、断念しました。
2. 転移学習実行
1章で移植したmobilenetベースSSDのパラメータを学習します。
今回の初期パラメータは、以下を使わせていただきました。
-
mb2-imagenet-71_8.pth
これを上述URLからダウンロードし、pytorch_ssd_trial/weights/
に格納します。
学習データセットは、データローダーを既存のものを流用したので、前回のVGGベースSSDの転移学習で使用したデータセット(labelImgでアノテーション)をそのまま使えます。
コマンドラインから以下入力すると、学習が開始します。
# ubuntuターミナルの場合
./train_ssd.py ([epoch数])
# Anaconda Powershell Promptの場合
python train_ssd.py ([epoch数])
以下のように、epoch毎にloss値をコンソール出力しながら、学習が進みます。このあたりは、前回のVGGベースの転移学習と全く同じです。
今回は、レイヤのfreezeは行いませんでした。
どうもVGGのように低レイヤに負荷が集中しているわけでもなさそうで、freezeを行っても学習時間がそれほど短くはなりませんでした。なので、学習時間よりも精度を重視し、freezeはなしとしました。
学習にかかる時間は、PC環境や学習データ数に大きく依存しますが、今回、以下PC環境、学習データ数で、約2時間でした。VGGベースSSDの転移学習時と同じPC環境、学習データセットですが、約1時間早く学習が終わりました。パラメータ数が少ない分、学習も早いようです。
-
PC環境
- CPU: AMD Ryzen 7 3700X (3.60 GHz)
- GPU: NVIDIA GeForce GTX 1660 SUPER
- OS: Windows 11 Home (24H2) , WSL2 + Ubuntu24.04
-
学習データセット(※検証用(1割)込み)
- 画像数: 598
- 物体数(枠の数): 1539 (車:922、ナンバープレート:617)
学習中のtrain_Loss、val_Loss
のグラフは下図でした。out weight
は、パラメータ出力時の値です。
3. 検知(推論)実行、評価
続いて、検知(推論)です。
コマンドラインから以下入力すると、検知(推論)が実行されます。
# ubuntuターミナルの場合
./predict_ssd.py [動画(mp4) or 画像ファイルパス]
# Anaconda Powershell Promptの場合
python predict_ssd.py [動画(mp4) or 画像ファイルパス]
結果例は以下です(左:mobilenetベースSSD、右:VGGベースSSD)。
今回転移学習したmobilenetベースSSDだと、一番手前の銀色の車とナンバープレートは検知できていますが、やや遠方の黒い車、赤い車が検知できていません。この例だけ見ると、残念ながら、精度面では、VGGベースSSDより劣るようです。
もう少し客観的に比較評価ができるよう、論文等でよく出てくる、mAP(Mean Average Precision)も算出してみました。学習データをそのまま使って算出したので数値が高めです。あくまで、下記2モデルの比較用の数値です。
モデル | mAP(IoU=0.4) | AP(車) | AP(ナンバープレート) |
---|---|---|---|
mobilenetベースSSD | 0.84 | 0.89 | 0.78 |
VGGベースSSD | 0.90 | 0.96 | 0.84 |
やはり、VGGベースのSSDと比較すると、mobilenetベースSSDは少し低めの数値となっており、精度はやや劣っています。
ちなみに、mAPについて、今回勉強した内容を以下にまとめてみましたので、興味のある方はぜひご覧ください。
次に処理速度の比較です。1分の動画を処理したときの、1秒あたりの処理frame数(fps)で比較しました。
モデル | fps |
---|---|
mobilenetベースSSD | 36.3 |
VGGベースSSD | 4.9 |
こちらは、mobilenetベースSSDが圧倒的に速いです。リアルタイムの処理速度が出ています。
上述の精度と速度が、具体的にどれくらいのものなのか、検知中のウィンドウ表示を動画キャプチャーしてみました。こちらは、ナンバープレート自動ぼかしアプリの処理の様子をキャプチャしています。VGGベースSSDとmobilenetベースSSDを並べてみました。
比べると、mobilenetベースSSDの速さが体感できるかと思います。
懸念の精度も、アプリ側で実装している時系列処理である程度はカバーできており、動画公開用に、目立つナンバープレートを隠すだけなら、十分使えそうです。
4. 小さな物体の精度向上
※2025.9加筆
3章で示した駐車場シーンでは比較的上手くいったものの、その後、使っていくうちに、ナンバープレートが隠し切れず目立ってしまうシーンも散見されたため、精度向上のために、もう一工夫加えてみました。加えた内容は以下です。
一度検知実行した後、大きな物体(車)の検出枠で入力画像を切り取り、切り取った画像に対してサイズ正規化(300x300にリサイズ)し、もう一度検知実行する(下図赤枠処理を追加)
→ サイズ正規化により、大きな物体(車)に含まれる小さな物体(ナンバープレート)が拡大されるため、小さな物体の精度向上が期待できる
適用可能なケースが、大きな物体に包含される小さな物体、という包含関係のあるケースに限定されますが、こういうケースは、例えば、顔の中の目、鼻、口、など、そこそこあるかと思われます。
追加処理のソースは以下です。今回のケース(車、ナンバープレート検出)以外にも適用できるよう、できるだけ汎用的な書き方にしました。
# SSDModelDetector.predictDetail() 抜粋
def predictDetail(self, det_results:List[List[DetResult]], imgs:List[np.ndarray], min_bbox_size:int, num_batch:int, area_class:str, conf=0.5, overlap=0.45) -> List[List[DetResult]]:
""" 検知(推論)実行(検出結果の中を詳細検知)
- 検出結果(area_class)の矩形内に対してSSDモデルの検知を実行
- 包含関係にある物体において、大きい物体から小さい物体を検出するのに有効(例:車の中からナンバープレートを検出、顔の中から目を検出etc.)
Args:
det_results (List[List[DetResult]]) : 検出結果(複数) [img_num, obj_num, 検出結果]
imgs (List[np.ndarray]) : 画像(複数) [img_num,h,w,ch(BGR)]
min_bbox_size (int) : 矩形最小サイズ[px]
num_batch (int) : バッチ数(predict()実行時のバッチ数)
area_class (str) : 検出範囲となるクラス
conf (float, optional) : 信頼度conf下限閾値. Defaults to 0.5.
overlap (float, optional) : 重複有無の判定閾値(IoU). Defaults to 0.45.
Returns:
List[List[DetResult]]: 検出結果(複数) [img_num, obj_num, 検出結果]
"""
num_img = len(imgs)
img_procs:List[List[ImageProc]] = []
for img_no in range(num_img):
img_procs.append(list())
if num_img > 0:
# 検出結果から検出領域を作成(area_classの枠を、検出領域として切り出し)
num_area = 0
for img_no, img_org in enumerate(imgs):
for det_obj in det_results[img_no]:
if det_obj.class_name_ == area_class:
img_proc = ImageProc()
img_proc.initFromDet(img_org, det_obj, min_bbox_size)
if img_proc.is_no_proc_ == False:
# サイズがmin_bbox_size以上の領域のみ採用
img_procs[img_no].append(copy.deepcopy(img_proc))
num_area += 1
if num_area >= num_batch:
# num_batchを超えない数で詳細検出をかける
break
else:
continue # 内側ループが正常に抜けたときは、外側ループを続ける
break # 内側ループがbreakで抜けたときのみ、外側ループも抜ける
# print(f"\nIn predictDetail(): img:{num_img}, area:{num_area}")
if num_area > 0:
imgs_trans:List[np.ndarray] = []
for img_no, img_org in enumerate(imgs):
for img_proc in img_procs[img_no]:
# 検出範囲切り出し
img_det = img_proc.clip(img_org)
# 画像前処理
img_trans = self.transImage(img_det)
# batch化(リストに追加(SSDモデルにあうようデータ配置組み替えもあわせて実施))
imgs_trans.append(img_trans[:, :, (2, 1, 0)]) # [h,w,ch(BGR→RGB)]
if self.device_.type != "cpu":
# GPU実行時は、処理時間を安定化させるため、batch数を揃える(不揃いだと、たまに処理速度が極端に低下する)
# → 検出範囲数がnum_batchに達するまでdummy画像(黒画像)を加える
# CPU実行時は、画像が増えた分だけ遅くなるので、実施しない(不揃いでも処理速度低下は発生しない)
while len(imgs_trans) < num_batch:
imgs_trans.append(self.img_dummy_)
# batch化(リスト→torchテンソル型に変換(SSDモデルにあうようデータ配置組み替えもあわせて実施))
imgs_trans_np = np.array(imgs_trans) # [batch_num, h, w, ch(RGB)]
img_batch = torch.from_numpy(imgs_trans_np).permute(0,3,1,2) # [batch_num, ch(RGB), h, w]
# for idx,img in enumerate(img_batch):
# torchvision.utils.save_image(img, f"img_batch{idx}.jpg")
# 入力画像をデバイス(GPU or CPU)に送る
img_batch = img_batch.to(self.device_)
# print(f"img_batch = {img_batch.shape}")
# 推論実行
torch.backends.cudnn.benchmark = True
outputs = self.net_(img_batch)
# SSDモデルの出力を閾値処理(確信度confが閾値以上の結果を取り出し)
outputs = outputs.cpu().detach().numpy() # (batch_num, label, top200, [conf,xmin,ymin,xmax,ymax])
find_index = np.where(outputs[:, :, :, 0] >= conf) # (batch_num, label, top200)
outputs = outputs[find_index]
# 抽出した物体数分ループを回す
for i in range(len(find_index[1])):
batch_no:int = find_index[0][i] # batch index
img_no, area_no = calcIndexesFromBatchIdx(img_procs, batch_no)
if img_no >= 0 and area_no >= 0:
label_no = find_index[1][i] # ラベル(クラス)番号
if label_no > 0:
# [背景クラスでない場合] 結果を取得
# 確信度conf
sc = outputs[i][0]
# クラス名
cls_name = self.voc_classes_[label_no-1]
# Bounding Box: 入力画像上での座標値に変換
bb_i = img_procs[img_no][area_no].convBBox( outputs[i][1:] )
if cls_name != area_class:
# area_class以外の検出結果のみ採用
det_results[img_no].append(DetResult(cls_name, bb_i, sc))
for img_no in range(num_img):
# 元の検出枠と重複する枠を取り除く
det_results[img_no] = self.nmSuppression(det_results[img_no], overlap)
return det_results
この追加処理により、未検出だったナンバープレートが検出できるようになった結果例は下図です。
mAP、APは下表です。
狙い通り、mobilenetベースSSDのAP(ナンバープレート)が上昇してます。
VGGベースSSDも試しに実行してみましたが、APの数値は変化なしでした。検出結果の違いを見てみると、未検出解消したものもあったのですが、誤検出が新たに発生しており、このトレードオフで結果的にAPの数値がほぼ同じになってました。
モデル | 追加処理の有無 | mAP(IoU=0.4) | AP(車) | AP(ナンバープレート) |
---|---|---|---|---|
mobilenetベースSSD | なし | 0.84 | 0.89 | 0.78 |
mobilenetベースSSD | あり | 0.87 | 0.89 | 0.86 |
VGGベースSSD | なし | 0.90 | 0.96 | 0.84 |
VGGベースSSD | あり | 0.90 | 0.96 | 0.84 |
続いて、処理速度(fps)は下表でした。
SSDモデルの検知を2回実行するので、当然ながら処理速度は低下します。ただ、測定したのが駐車場のシーン(3章の動画シーン)で車が比較的多いにも関わらず、3割程度の低下で済んでいるのがちょっと意外でした。これなら、概ねリアルタイムと言える速度かと思います。
モデル | 追加処理の有無 | fps |
---|---|---|
mobilenetベースSSD | なし | 36.3 |
mobilenetベースSSD | あり | 26.8 |
VGGベースSSD | なし | 4.9 |
VGGベースSSD | あり | 3.4 |
以下は、この追加処理を施したSSDモデルでナンバープレートぼかし処理を入れて作成した動画です。ぼかし処理は、11:45~のシーンに施しました。
※BGM、音声付き動画なので、youtubeで再生する際はお気をつけください。埋め込みのまま再生する分には、mute設定を入れておいたので大丈夫です。
おわりに
今回、mobilenetベースSSDに初挑戦しました。といっても、ソフトの移植がメインで、アノテーション等はVGGベースSSDの転移学習時と全く同じものを使う等したので、mobilenet固有の学習ノウハウ等は特にありませんでした。
ただ、最初学習したときにナンバープレートが全く検知できなかったことをきっかけに、ソースを調査して、mobilenetの6箇所ある出力層の特徴マップの解像度や、DefaultBoxのサイズ定義が、小さい物体には不向きな設定になっていることを知ることができました。
DefaultBoxサイズには上述の応急措置を施しましたが、特徴マップの解像度については全く手が出せませんでした。今後は、もう少し理解を深めて、そのあたりの改良も施せたらと思いました。