0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

第8回では、モデル保存、checkpoint、ファインチューニング、GPU、AMP、DDP、よくあるエラーの切り分けを整理しました。

今回扱うのは、コンピュータビジョンでよく出てくる3つのタスクです。具体的には、画像分類、画像セグメンテーション、物体検出を扱います。

この3つは、どれも画像を入力してモデルの出力を得る点では似ています。しかし、出力の形、ラベルの作り方、損失関数、評価指標、後処理はかなり違います。分類では画像全体に1つまたは複数のラベルを付けます。セグメンテーションではピクセルごとにラベルを付けます。物体検出では、対象のカテゴリだけでなく位置も予測します。

目的は、モデル名を覚えることではありません。logits が何を表すのか、maskのshapeがどうなるのか、boxの座標形式をどう扱うのか、NMSが何をしているのか、Faster R-CNNの訓練時と推論時で戻り値がどう変わるのかを、PyTorchとtorchvisionのコードに結びつけて理解することです。

目次

この記事で扱うこと

この記事では、次の内容を扱います。

  • 画像分類、セマンティックセグメンテーション、インスタンスセグメンテーション、物体検出の違い
  • 画像分類モデルの入力shapeと出力logits
  • argmaxsoftmax、top-k、クラス名への変換
  • ResNetの残差接続とResNet-18の使い方
  • 2クラス分類用ResNet-18 checkpointの推論コード
  • CrossEntropyLoss と分類指標
  • セグメンテーションlogits、mask、ignore_index
  • IoU、Dice、mIoUの考え方
  • FCN、U-Net、DeepLabの構造
  • torchvisionのDeepLabV3推論
  • DeepLabV3のmaskをpaletteで可視化する流れ
  • U-Netによる人物mask訓練・推論の骨組み
  • 物体検出のbox形式、IoU、NMS
  • One-stage検出器とTwo-stage検出器の違い
  • Faster R-CNN、FPN、RPN、RoI Headsの流れ
  • torchvisionのFaster R-CNN訓練インターフェース
  • COCO事前学習済みFaster R-CNNの推論コード
  • Penn-Fudan Pedestriansを使う検出訓練の骨組み
  • transforms v2で画像、box、maskを同期して変換する考え方
  • 分類、セグメンテーション、検出の評価指標
  • よくあるエラーと確認ポイント

コードは、小さなTensorで確認できるものを中心にします。実画像や大きなデータセットが必要な箇所では、どのようなデータを用意すればよいかを説明します。

第8回から第9回へのつながり

第8回では、学習済みモデルを保存し、読み込み、GPU上で動かすための基本を見ました。今回の話は、そのモデルが画像タスクでどのような出力を返し、その出力をどう解釈するかに進みます。

第8回:モデルを扱える状態にする
  -> state_dictを保存する
  -> checkpointから再開する
  -> 事前学習済みモデルを読み込む
  -> deviceへ移して安全に実行する

第9回:画像タスクの出力を理解する
  -> 分類logitsをクラス名へ変換する
  -> セグメンテーションlogitsをmaskへ変換する
  -> 検出結果のbox、label、scoreを読む
  -> IoU、NMS、mAPなどの評価と後処理を理解する

同じResNet系のbackboneを使っていても、分類、セグメンテーション、検出では最後のheadと戻り値が変わります。その違いを押さえると、torchvisionのモデルを使うときに「何を入力し、何が返ってくるか」を読みやすくなります。

コンピュータビジョンタスクの全体像

代表的な画像タスクを並べると、次のようになります。

タスク 主な問い 典型的な出力
画像分類 画像全体に何が写っているか 画像ごとのクラスlogits
マルチラベル分類 画像にどのカテゴリが含まれるか クラスごとの独立logits
セマンティックセグメンテーション 各ピクセルがどのクラスか ピクセルごとのクラスlogits
インスタンスセグメンテーション 各物体の領域はどこか box、label、score、mask
パノプティックセグメンテーション 背景領域と物体インスタンスを統一して説明する ピクセルのクラスIDとインスタンスID
物体検出 何が、どこにあるか boxes、labels、scores

どのタスクでも、モデルが直接「犬」「車」「道路」といった文字列を返すわけではありません。モデルが返すのはTensorです。そのTensorを、クラスID、mask、box、scoreへ変換し、人間が読める名前や可視化に結びつけます。

まずは一番単純な画像分類を例に、このTensorの形と、名前への変換の流れを具体的に見ていきます。

画像分類の入力と出力

RGB画像は、PyTorchでは一般にチャンネルが先に来るTensorとして扱います。

単一画像: (C, H, W)
batch  : (N, C, H, W)

分類モデルの出力は、多くの場合次の形です。

logits shape = (N, num_classes)

各logitは、Softmax前の未正規化スコアです。クラス名はモデル内部から突然出てくるのではなく、別に用意した対応表で解釈します。

class_to_idx = {"ants": 0, "bees": 1}  # 訓練時に決めたクラス名 -> クラスIDの対応表
idx_to_class = {value: key for key, value in class_to_idx.items()}  # 逆引き用の対応表(クラスID -> クラス名)

print(idx_to_class[1])
# bees

訓練、検証、推論では、必ず同じクラス順を使います。state_dict だけを保存して class_to_idx を記録していないと、モデルの数値出力は正しくても、名前の解釈を間違えることがあります。

logitsからクラス名へ変換する

単一ラベル分類では、最大logitを持つクラスを予測クラスとして扱えます。

import torch


categories = ["cat", "dog", "bird"]
logits = torch.tensor(
    [[1.0, 3.0, 0.2],
     [2.5, 0.1, 0.0]]
)

predicted = logits.argmax(dim=1)  # クラス次元(dim=1)で最大のindexを取る -> 予測クラスID
probabilities = logits.softmax(dim=1)  # クラス次元を確率らしい値に正規化する(各サンプルの合計が1になる)
confidence = probabilities.max(dim=1).values  # サンプルごとに最大確率だけを取り出す
names = [categories[index] for index in predicted.tolist()]  # クラスIDを文字列名に変換する

print("predicted:", predicted.tolist())
# predicted: [1, 0]
print("names:", names)
# names: ['dog', 'cat']
print("confidence:", [round(value.item(), 4) for value in confidence])
# confidence: [0.836, 0.8527]

softmax の最大値は、クラス集合が固定されているという前提で正規化したスコアです。必ずしも「現実世界で正しい確率」や「校正済みの信頼度」ではありません。実運用では、温度スケーリング、Expected Calibration Error、棄却しきい値(confidenceがこの値を下回ったら「分からない」として扱う境界線)などを別途確認することがあります。

マルチラベル分類では、各クラスが互いに排他的とは限らず、複数のクラスが同時に成り立つことがあります。たとえば1枚の画像に「空」と「木」と「建物」が同時に写っていてもおかしくありません。その場合はSoftmaxのargmaxではなく、クラスごとのSigmoidとしきい値を使います。

単一ラベル分類: softmax -> argmax
マルチラベル分類: sigmoid -> classごとのthreshold

ここまでは1回の推論でlogitsをどう解釈するかを見てきました。次に、画像の読み込みから最終的なクラス名までの一連の流れ全体を整理します。

分類推論の流れ

分類推論の流れは、次のように整理できます。

画像を読み込む
 -> RGB/BGRを確認する
 -> Resize / Crop
 -> Tensorへ変換し、値域を整える
 -> Normalize
 -> batch次元を追加する
 -> deviceへ移す
 -> model.eval()
 -> torch.inference_mode()
 -> logits
 -> argmax / top-k
 -> idx_to_class

特に重要なのは、前処理を訓練時または事前学習済み重みの想定と合わせることです。OpenCVで読んだ画像はBGR、PILやtorchvisionで扱う画像は通常RGBです。ここを間違えると、コードは動いても精度が大きく崩れることがあります。

model.eval()torch.inference_mode() は役割が違います。eval() はDropoutやBatchNormなどのモジュールの挙動を切り替えます。inference_mode() はautogradの記録を止め、推論を軽くします。推論では両方を使うのが基本です。

この流れを支えるモデルの中身を具体的に見るために、代表的な分類モデルであるResNetの仕組みを確認します。

ResNetと残差学習

深いネットワークでは、単純に層を増やしても最適化が難しくなることがあります。ResNetは、各blockで変換後の出力そのものではなく、入力に足し込む差分を学習する形を使います。

通常の深いblock:
  output = H(x)

残差block:
  F(x) = H(x) - x
  output = F(x) + x

基本的な構造は次のように見られます。

x ------------------------------+
 |                               |
 +-> Conv -> Norm -> ReLU -> Conv+
                                 |
                         Add -> ReLU

入力と出力のチャンネル数や空間サイズが変わる場合は、shortcut側にも1x1畳み込みなどを置いて形を合わせます。

x -> 1x1 Conv / Norm -> shortcut

残差接続は、勾配が流れる短い経路を作り、深いネットワークの最適化を助けます。ただし、初期化、Normalization、学習率、データ前処理が不要になるわけではありません。ResNet-18は、stem、4つの残差stage、global average pooling、全結合分類headから構成される代表的な分類モデルです。

ResNet-18で分類推論を行う例

torchvisionの事前学習済み重みを使う場合は、重みオブジェクトから前処理とクラス名も取得できます。

from PIL import Image
import torch
from torchvision.models import resnet18, ResNet18_Weights


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

weights = ResNet18_Weights.DEFAULT  # 推奨される既定の事前学習済み重みを選ぶ
model = resnet18(weights=weights).eval().to(device)
preprocess = weights.transforms()  # その重みが学習時に使った前処理(Resize/Normalizeなど)を取得する
categories = weights.meta["categories"]  # 重みに紐づくImageNet 1000クラスの名前一覧

image = Image.open("your_image.jpg").convert("RGB")  # 自分の画像ファイルに置き換える
input_tensor = preprocess(image).unsqueeze(0).to(device)

with torch.inference_mode():
    logits = model(input_tensor)
    probabilities = logits.softmax(dim=1)
    top_prob, top_index = probabilities.topk(5, dim=1)  # 確率が高い上位5件を取り出す

for probability, index in zip(top_prob[0], top_index[0]):
    print(categories[index], float(probability))  # クラス名と確率をスコアの高い順に表示する

このコードは指定した画像ファイルに依存するため、出力されるクラス名とスコアは画像によって変わります。初回実行時にはResNet-18の重みファイルがPyTorchの公式サーバーからダウンロードされ、通常は ~/.cache/torch/hub/checkpoints/ にキャッシュされます。たとえば golden retriever 0.87 のように、クラス名とSoftmax確率が上位5件分、確率の高い順に表示されます。実際にどのクラス名が出るかは、指定した画像の内容によって変わります。

アリとハチの2クラス分類のように、ResNet-18の最後の全結合層を差し替えて訓練済みcheckpointを読む場合は、次のような形になります。画像データには、PyTorch公式チュートリアルで使われる hymenoptera_datahttps://download.pytorch.org/tutorial/hymenoptera_data.zip)のような train/antstrain/beesval/antsval/bees 構成のフォルダを使えます。ants/beesそれぞれ訓練用約120枚、検証用75枚というImageNetのごく小さな部分集合なので、転移学習の練習にちょうど良い規模です。

from pathlib import Path
import time
import torch
from torch import nn
from PIL import Image
from torchvision import transforms
from torchvision.models import resnet18


classes = ["ants", "bees"]
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

inference_transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.485, 0.456, 0.406],
        std=[0.229, 0.224, 0.225],
    ),
])


def build_resnet18_for_two_classes(checkpoint_path):
    model = resnet18(weights=None)  # checkpoint側の重みを読み込むので、ここでは構造だけ作る
    model.fc = nn.Linear(model.fc.in_features, len(classes))  # 1000クラス用headを2クラス用に差し替える

    checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
    state_dict = checkpoint.get("model_state_dict", checkpoint)  # 辞書に包まれたcheckpointと、state_dict直接の両方に対応する
    model.load_state_dict(state_dict)
    return model.to(device).eval()  # 推論用にdeviceへ移し、eval()でDropout/BatchNormを推論モードにする


def predict_image(image_path, checkpoint_path):
    image = Image.open(image_path).convert("RGB")
    image_tensor = inference_transform(image).unsqueeze(0).to(device)  # (C, H, W) -> (1, C, H, W)
    model = build_resnet18_for_two_classes(checkpoint_path)

    with torch.inference_mode():
        start = time.perf_counter()
        logits = model(image_tensor)
        elapsed = time.perf_counter() - start

    predicted_index = logits.argmax(dim=1).item()  # バッチ内に1枚だけなので.item()でPythonの数値に変換する
    return classes[predicted_index], elapsed


predicted_name, elapsed = predict_image(
    Path("your_image.jpg"),
    Path("checkpoint_14_epoch.pkl"),
)
print(f"predict: {predicted_name} elapsed: {elapsed:.3f}s")

このコードの出力は、指定した画像とcheckpointによって変わります。重要なのは、推論時にも訓練時と同じResize、CenterCrop、Normalizeを使い、outputs.data ではなく logits.argmax(dim=1) で予測クラスを取り出す点です。

重みを使わず、出力shapeだけを確認するなら次のようにできます。

import torch
from torchvision.models import resnet18


model = resnet18(weights=None).eval()
example = torch.randn(2, 3, 224, 224)

with torch.inference_mode():
    logits = model(example)

print("logits shape:", tuple(logits.shape))
# logits shape: (2, 1000)

weights=None ではランダム初期化なので、分類結果に意味はありません。ここでは、ResNet-18の既定分類headが1000クラス分のlogitsを返すことだけを確認しています。

分類の訓練、損失、指標

単一ラベル多クラス分類では、CrossEntropyLoss を使うことが多いです。

import torch
from torch import nn


logits = torch.tensor(
    [[1.0, 3.0, 0.2],
     [2.5, 0.1, 0.0]]
)
targets = torch.tensor([1, 0])  # 各サンプルの正解クラスID(shape (N,) のtorch.long)

criterion = nn.CrossEntropyLoss()
loss = criterion(logits, targets)

print("loss:", round(loss.item(), 4))
# loss: 0.1693

CrossEntropyLoss へ渡す logits には、事前にSoftmaxをかけません。内部でlog-softmax相当の計算が行われます。targetはshape (N,)torch.long Tensorで、値は 0 <= target < num_classes に入っている必要があります。

よく使う分類指標には、次のようなものがあります。

  • Top-1 Accuracy
  • Top-k Accuracy
  • Precision、Recall、F1
  • confusion matrix
  • ROC-AUC、PR-AUC
  • calibration指標

Top-k Accuracyは、正解クラスが上位k個の予測に含まれるかを見ます。

import torch


logits = torch.tensor(
    [[0.1, 2.0, 1.0],
     [3.0, 1.0, 0.0]]
)
targets = torch.tensor([1, 1])

top1 = (logits.argmax(dim=1) == targets).float().mean()  # 予測の1位が正解と一致する割合
top2_indices = logits.topk(2, dim=1).indices  # 各サンプルの上位2クラスのindexを取る
top2 = top2_indices.eq(targets.unsqueeze(1)).any(dim=1).float().mean()  # 正解が上位2位以内に入っている割合

print("top1:", float(top1), "top2:", float(top2))
# top1: 0.5 top2: 1.0

データが不均衡な場合、Accuracyだけでは少数クラスの失敗を見落とすことがあります。macro平均、micro平均、weighted平均のどれを使うかも、評価の意味に直結します。

分類についての基本はここまでです。ここからは、画像全体に1つのラベルを付ける分類とは違い、ピクセル単位でラベルを予測するセグメンテーションに進みます。出力の形が大きく変わる点に注目してください。

画像セグメンテーションの定義と種類

画像セグメンテーションは、画像をピクセルまたは領域単位で分けるタスクです。

スーパーピクセル分割

色やテクスチャが近い隣接ピクセルをまとめ、少数の領域に分けます。これは前処理や構造解析で使われることがありますが、必ずしも意味カテゴリを予測するわけではありません。

セマンティックセグメンテーション

各ピクセルにカテゴリIDを割り当てます。同じカテゴリの別個体は区別しません。たとえば、2人の人物が写っていても、どちらも person として同じIDになります。

インスタンスセグメンテーション

カテゴリだけでなく、個体ごとの領域も区別します。典型的には、各物体についてbox、label、score、maskを出力します。

パノプティックセグメンテーション

空や道路のように個体数を数えられない背景領域(stuff)と、人物や車のように個体を数えられる物体(things)を同時に扱います。

種類 同じカテゴリの別個体を区別するか 主な出力
セマンティックセグメンテーション しない ピクセルごとのクラスID
インスタンスセグメンテーション する インスタンスごとのmask
パノプティックセグメンテーション する クラスIDとインスタンスID

この記事では、まず一番基本的なセマンティックセグメンテーションを例に、入力と出力のTensor形状を具体的に見ていきます。

セマンティックセグメンテーションのTensor

セマンティックセグメンテーションでは、入力は分類と同じく画像Tensorです。

input: (N, 3, H, W)

出力logitsは、各ピクセル位置にクラス数ぶんのスコアを持ちます。

logits: (N, C, H_out, W_out)

クラス次元で argmax を取ると、ピクセルごとの予測maskになります。

import torch


logits = torch.tensor(
    [[[[0.1, 2.0],
       [0.2, 0.3]],
      [[1.2, 0.5],
       [0.1, 1.5]],
      [[0.0, 0.1],
       [2.2, 0.4]]]]
)

pred_mask = logits.argmax(dim=1)  # (N, C, H, W) -> (N, H, W)

print("mask shape:", tuple(pred_mask.shape))
# mask shape: (1, 2, 2)
print(pred_mask)
# tensor([[[1, 0],
#          [2, 1]]])

ここで出てくる C はクラス次元です。C枚の完成画像が返ってくるという意味ではありません。argmax 後の (N, H, W) が、各ピクセルのクラスIDを持つmaskです。

モデルによっては、出力サイズが入力サイズと完全には一致しません。その場合は、デコーダや補間で入力解像度へ戻します。ただし、ラベルmaskをリサイズするときは、クラスIDが混ざらないように最近傍補間を使います。

セグメンテーションのラベル、損失、指標

セマンティックセグメンテーションのラベルは、通常次の形です。

target shape: (N, H, W)
target dtype: torch.long
target value: 0..C-1 または ignore_index

多クラスのセマンティックセグメンテーションでは、CrossEntropyLoss がよく使われます。

import torch
from torch import nn


logits = torch.randn(2, 3, 4, 4)  # 2枚、3クラス、4x4のピクセルlogits
target = torch.randint(0, 3, (2, 4, 4), dtype=torch.long)

criterion = nn.CrossEntropyLoss()
loss = criterion(logits, target)

print("loss is scalar:", loss.ndim == 0)
# loss is scalar: True

二値セグメンテーションでは、設計が2通りあります。

設計 出力 損失 後処理
1チャンネルlogits (N, 1, H, W) BCEWithLogitsLoss sigmoid + threshold
2クラスlogits (N, 2, H, W) CrossEntropyLoss argmax(dim=1)

どちらを使う場合も、モデル出力、targetのshape、targetのdtype、後処理を一貫させます。

IoUは、予測領域と正解領域がどれくらい重なるかを見る指標です。

IoU_c = TP_c / (TP_c + FP_c + FN_c)
Dice_c = 2 * TP_c / (2 * TP_c + FP_c + FN_c)

小さなmaskでクラスごとのIoUを計算してみます。

import torch


pred = torch.tensor(
    [[0, 1, 1],
     [0, 1, 2]]
)
target = torch.tensor(
    [[0, 1, 0],
     [0, 2, 2]]
)

ious = []
for class_id in range(3):
    pred_c = pred == class_id  # このクラスだと予測した位置のbool mask
    target_c = target == class_id  # このクラスが正解の位置のbool mask
    intersection = (pred_c & target_c).sum()  # 両方でTrueの画素数 = TP
    union = (pred_c | target_c).sum()  # どちらかでTrueの画素数
    ious.append((intersection / union).item())

print([round(value, 4) for value in ious])
# [0.6667, 0.3333, 0.5]

mIoUを報告するときは、背景クラスを含めるか、ignore_index をどう扱うか、クラスが存在しない画像をどう扱うかを明示します。

指標の意味が分かったところで、実際にどのようなネットワーク構造でセグメンテーションを実現するのかを見ていきます。まずは、この分野の基礎となったFCNからです。

FCN

FCN、Fully Convolutional Networkは、分類ネットワークの全結合分類headを空間的な予測へ置き換え、ピクセル単位の出力を可能にした考え方です。

Encoderで空間サイズを下げながら特徴を取る
 -> coarse score mapを作る
 -> upsamplingで空間サイズを戻す
 -> skip fusionで浅い特徴と深い特徴を混ぜる
 -> dense pixel prediction

深い層は意味情報に強く、浅い層は位置や境界情報を保ちやすいです。FCNは、これらを組み合わせてセグメンテーションを行う流れを作りました。ただし、境界の細かさや小さな構造の復元には限界があり、その後のU-NetやDeepLabなどへ発展していきます。

U-Net

U-Netは、encoder-decoder構造とskip connectionを組み合わせたセグメンテーションモデルです。

Input
 -> Encoder 1 ----skip----------------> Decoder 1
 -> Encoder 2 ------skip-----------> Decoder 2
 -> Bottleneck
 -> Upsample + concat encoder feature
 -> segmentation logits

Encoderは空間サイズを下げながら意味情報を抽出します。Decoderはupsamplingで空間サイズを戻します。skip connectionは、encoder側の高解像度特徴をdecoder側へ渡し、境界や位置情報の復元を助けます。

U-Netは医用画像でよく知られていますが、考え方自体はさまざまなセグメンテーションタスクで使われます。実践では、encoderにImageNet事前学習済みbackboneを使う、attentionを入れる、3D画像向けに拡張する、といった派生も多くあります。

U-Netはskip connectionで解像度を直接保ちながら受容野を広げました。これとは別のアプローチで受容野を広げようとしたのが、次にDeepLab系のモデルです。

DeepLab

DeepLab系のモデルは、受容野を広げながら空間解像度を保つ工夫と、複数スケールの文脈情報を取り込むことを重視したセグメンテーションモデルです。

DeepLab v1

Atrous convolution、またはdilated convolutionを使い、畳み込みカーネルのサンプル点の間隔を広げます。たとえば、3x3カーネルにdilation=2を適用すると、実際に値を読む点は3x3の9点のままでも、点と点の間隔が2pxに広がるため、パラメータ数を増やさずに見かけ上5x5相当の範囲を見られるようになります。これにより、空間サイズを過度に下げずに受容野を広げられます。境界の改善にCRFを組み合わせる構成も使われました。

DeepLab v2

ASPP、Atrous Spatial Pyramid Poolingを導入します。複数のdilation rateを並列に使い、異なるスケールの文脈を同時に取り込みます。

DeepLab v3

ASPPを改良し、より安定して複数スケールの文脈情報を抽出します。CRFは必須の中心部品ではなくなります。

DeepLab v3+

DeepLab v3にencoder-decoder構造を加えます。高レベル特徴から意味情報を取り、低レベル特徴で境界や位置情報を補います。depthwise separable convolutionを使って計算を軽くする構成もあります。

Backbone -> atrous features -> ASPP
                             |
low-level feature -> projection -> concat -> decoder -> logits

DeepLabを理解するときは、dilated convolution、ASPP、低レベル特徴との融合、出力strideの4点を押さえると整理しやすいです。

torchvision DeepLabV3で推論する例

torchvisionには、セマンティックセグメンテーション用のモデルも用意されています。DeepLabV3 ResNet50を使う基本形は次の通りです。

from PIL import Image
import torch
from torchvision.models.segmentation import (
    deeplabv3_resnet50,
    DeepLabV3_ResNet50_Weights,
)


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

weights = DeepLabV3_ResNet50_Weights.DEFAULT
model = deeplabv3_resnet50(weights=weights).eval().to(device)
preprocess = weights.transforms()

image = Image.open("your_image.jpg").convert("RGB")  # 自分の画像ファイルに置き換える
x = preprocess(image).unsqueeze(0).to(device)

with torch.inference_mode():
    output = model(x)
    logits = output["out"]
    mask = logits.argmax(dim=1)

print("logits shape:", tuple(logits.shape))
print("mask shape:", tuple(mask.shape))

このコードは画像ファイルと事前学習済み重みに依存するため、shapeや予測maskは入力画像によって変わります。DeepLabV3の戻り値は辞書で、通常は output["out"] にセグメンテーションlogitsが入ります。

前処理を担う weights.transforms() は、入力画像の短辺を520pxにリサイズしてから正規化します(長辺は縦横比を保ったまま決まります)。そのため logits のshapeは (1, 21, H, W)mask のshapeは (1, H, W) となり、21はPascal VOCの20物体クラス+背景の合計です。HWは入力画像の縦横比によって変わります。クラス名の一覧は、ResNet-18やFaster R-CNNと同じように weights.meta["categories"] で取得できます。

予測maskを画像として確認するには、クラスIDを色に変換します。次の例は、Pascal VOC系の21クラスを想定した小さなpalette変換です。実際の画像では、このRGB TensorをPIL画像やMatplotlibで表示します。

import torch


def voc_palette(num_classes=21):
    base = torch.tensor([2 ** 25 - 1, 2 ** 15 - 1, 2 ** 21 - 1])
    class_ids = torch.arange(num_classes)[:, None]
    return (class_ids * base % 255).to(torch.uint8)


mask = torch.tensor(
    [[0, 15],
     [8, 2]],
    dtype=torch.long,
)
palette = voc_palette()
rgb_mask = palette[mask]  # (H, W) のクラスIDを (H, W, 3) の色へ変換する

print("rgb mask:", tuple(rgb_mask.shape), rgb_mask.dtype)
# rgb mask: (2, 2, 3) torch.uint8

色付きmaskはあくまで表示用です。訓練や評価では、色の付いたRGB画像ではなく、各ピクセルがクラスIDを持つmaskを使うほうが扱いやすいです。

torchvisionのセグメンテーションモデルを使うときも、前処理は重みオブジェクトから取得します。重みが想定するResize、値域、Normalizeとずれると、推論結果は不安定になります。

ここまでは画像全体に対するセグメンテーションを見てきました。この応用として、人物の背景だけを消す処理を具体的に見ていきます。

人物セグメンテーション、切り抜き、Matting

人物の背景を消す処理は、広い意味では「人物領域を取り出す」処理ですが、技術的にはいくつかの段階があります。

処理 出力 向いていること
二値セグメンテーション 前景/背景の0-1 mask 人物領域の大まかな抽出
インスタンスセグメンテーション 人物ごとのmask 複数人物の区別
Matting 連続値のalpha matte 髪、半透明、境界の自然な合成

Mattingでは、画像は次のような合成として考えます。

I = alpha * F + (1 - alpha) * B

ここで、alpha は前景の透明度、F は前景、B は背景です。二値maskでは alpha が0または1だけですが、Mattingでは0.0から1.0の連続値を扱います。

import torch


alpha = torch.tensor([0.0, 0.5, 1.0]).view(3, 1)
foreground = torch.ones(3, 3)
background = torch.zeros(3, 3)
composite = alpha * foreground + (1 - alpha) * background

print(composite)
# tensor([[0.0000, 0.0000, 0.0000],
#         [0.5000, 0.5000, 0.5000],
#         [1.0000, 1.0000, 1.0000]])

二値の人物maskでは、予測maskと正解maskの重なりをDiceで見ることがあります。小さなTensorで計算すると、式の意味が分かりやすくなります。

import torch


def compute_dice(pred_mask, true_mask, eps=1e-7):
    pred_mask = pred_mask.bool()
    true_mask = true_mask.bool()
    intersection = (pred_mask & true_mask).sum()  # 両方でTrueの画素数
    return (2 * intersection + eps) / (pred_mask.sum() + true_mask.sum() + eps)  # epsはゼロ除算を防ぐための微小値


pred_mask = torch.tensor(
    [[1, 0, 1],
     [0, 1, 0]]
)
true_mask = torch.tensor(
    [[1, 0, 0],
     [0, 1, 1]]
)

dice = compute_dice(pred_mask, true_mask)
print("dice:", round(float(dice), 4))
# dice: 0.6667

U-Netで人物maskを訓練する場合、Datasetは画像とmaskのペアを返すように作ります。PortraitDataset が読み込むデータの例としては、公開されている人物切り抜きデータセットが使えます。たとえば、Kaggleで公開されている「AISegment.com - Matting Human Datasets」(https://www.kaggle.com/datasets/laurentmih/aisegmentcom-matting-human-datasets、元データはhttps://github.com/aisegmentcn/matting_human_datasets)は、600x800の半身ポートレート画像34,427枚を収録しており、clip_img/ ディレクトリにJPG原画像、matting/ ディレクトリにアルファチャンネル付きPNGマスクが入っています。PortraitDataset は、この clip_imgmatting のペアを読み込み、JPGをRGB画像Tensorへ、PNGのアルファチャンネルを0/1maskへ変換するものだとイメージすると分かりやすいです。次の骨組みでは、PortraitDatasetUNet はプロジェクト側で定義済みだとします。maskが0/1の二値ラベルなら、出力を1チャンネルlogitsにして BCEWithLogitsLoss を使うと整理しやすいです。

import torch
from torch import nn
from torch.utils.data import DataLoader


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
mask_threshold = 0.5

train_dataset = PortraitDataset("data/PortraitDataset/train")
valid_dataset = PortraitDataset("data/PortraitDataset/valid")
train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, drop_last=True)
valid_loader = DataLoader(valid_dataset, batch_size=1, shuffle=False)

model = UNet(in_channels=3, out_channels=1, init_features=32).to(device)  # 前景/背景の2値なので出力チャンネルは1つ
criterion = nn.BCEWithLogitsLoss()  # 1チャンネルlogits + 0/1ラベルの組み合わせに合う損失
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=150, gamma=0.1)

for epoch in range(num_epochs):
    model.train()
    for images, masks in train_loader:
        images = images.to(device)
        masks = masks.to(device).float()

        logits = model(images)
        loss = criterion(logits, masks)

        optimizer.zero_grad(set_to_none=True)
        loss.backward()
        optimizer.step()

        pred_masks = torch.sigmoid(logits).ge(mask_threshold)  # logits -> 確率 -> しきい値で二値maskへ
        dice = compute_dice(pred_masks.cpu(), masks.cpu().ge(mask_threshold))  # 直近バッチのDiceを確認する例

    scheduler.step()  # optimizer.step()の後、エポック単位でschedulerを進める

実際の訓練では、loss.item()dice.item()をエポックごとにprintやログに残し、値が改善しているかを確認しながら進めます。

推論では、checkpointから重みを読み、sigmoid としきい値でmaskへ変換します。表示するときは、入力画像の横に白黒maskを並べると、どの領域が前景として残ったかを確認できます。

from collections import OrderedDict
from pathlib import Path
import torch
from PIL import Image
from torchvision import transforms


def load_unet(checkpoint_path):
    model = UNet(in_channels=3, out_channels=1, init_features=32)
    checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=True)

    cleaned_state = OrderedDict(
        (key.removeprefix("module."), value)
        for key, value in checkpoint["model_state_dict"].items()
    )
    model.load_state_dict(cleaned_state)
    return model.to(device).eval()


preprocess = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
])

model = load_unet(Path("checkpoint_399_epoch.pkl"))
image = Image.open("your_portrait.png").convert("RGB")
image_tensor = preprocess(image).unsqueeze(0).to(device)

with torch.inference_mode():
    logits = model(image_tensor)
    mask = torch.sigmoid(logits).ge(0.5)

人物を「切り抜く」だけならセグメンテーションmaskで足りることがあります。ただし、髪の毛や半透明な物体を自然に合成したい場合は、Mattingとして設計する必要があります。

ここまでは、ピクセル単位でラベルを予測するセグメンテーションを見てきました。ここからは視点を変え、画像の中に「物体がどこにあるか」を矩形boxで示す物体検出に入ります。同じ畳み込みネットワークの特徴を使いながらも、予測する対象がピクセルgridからbox座標へと変わる点が大きな違いです。

物体検出の定義

物体検出は、画像内の物体について「何が」「どこに」あるかを予測します。

典型的な推論結果は、画像ごとに次のような辞書になります。

{
    "boxes":  FloatTensor[num_objects, 4],
    "labels": Int64Tensor[num_objects],
    "scores": Tensor[num_objects],
}

よく使われるbox形式はXYXYです。

[x1, y1, x2, y2]
0 <= x1 < x2 <= W
0 <= y1 < y2 <= H

x1, y1 は左上、x2, y2 は右下を表します。データセットやライブラリによって、XYWH、CXCYWH、正規化座標などを使う場合もあります。どの形式なのかを曖昧にしたまま変換すると、学習も評価も壊れます。

import torch


boxes = torch.tensor(
    [[10.0, 20.0, 50.0, 80.0],
     [15.0, 30.0, 45.0, 70.0]]
)
widths = boxes[:, 2] - boxes[:, 0]
heights = boxes[:, 3] - boxes[:, 1]
areas = widths * heights

print("areas:", areas.tolist())
# areas: [2400.0, 1200.0]

boxの幅や高さが0以下になる場合、そのboxは無効です。Faster R-CNNなどの検出モデルでは、targetのboxが正しいXYXY形式であることが前提になります。

COCOで事前学習済みのFaster R-CNNを使うと、画像ごとにbox、label、scoreの辞書が返ります。次のコードは、scoreが高い検出だけを取り出してクラス名とboxを表示する例です。

from PIL import Image
import torch
from torchvision.models.detection import (
    fasterrcnn_resnet50_fpn_v2,
    FasterRCNN_ResNet50_FPN_V2_Weights,
)


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
weights = FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT
model = fasterrcnn_resnet50_fpn_v2(weights=weights).eval().to(device)
preprocess = weights.transforms()
categories = weights.meta["categories"]

image = Image.open("your_detection_image.jpg").convert("RGB")
image_tensor = preprocess(image).to(device)

with torch.inference_mode():
    prediction = model([image_tensor])[0]

keep = prediction["scores"] >= 0.5
for box, label, score in zip(
    prediction["boxes"][keep],
    prediction["labels"][keep],
    prediction["scores"][keep],
):
    class_name = categories[int(label)]
    print(class_name, round(float(score), 3), [round(float(value), 1) for value in box])

このコードの出力は、指定した画像と検出しきい値によって変わります。たとえば person 0.987 [34.0, 12.0, 220.0, 400.0] のように、クラス名、score、[x1, y1, x2, y2] のboxが検出ごとに1行ずつ表示されるイメージです。Matplotlibや torchvision.utils.draw_bounding_boxes を使えば、boxとクラス名を画像上に重ねて表示できます。

Bounding Box、IoU、NMS

IoU、Intersection over Unionは、2つのboxがどれくらい重なるかを見る指標です。

IoU(A, B) = area(A ∩ B) / area(A ∪ B)

IoUは、anchorやproposalと正解boxの対応付け、NMS、AP/mAP評価などで使われます。

NMS、Non-Maximum Suppressionは、同じ物体を指している重複boxを抑制する後処理です。

scoreが高い順に並べる
 -> 最高scoreのboxを残す
 -> そのboxとIoUが高すぎるboxを消す
 -> 残りで繰り返す

小さな例で確認します。

import torch
from torchvision.ops import box_iou, nms


boxes = torch.tensor(
    [[0.0, 0.0, 10.0, 10.0],
     [1.0, 1.0, 11.0, 11.0],
     [20.0, 20.0, 30.0, 30.0]]
)
scores = torch.tensor([0.9, 0.8, 0.7])

ious = box_iou(boxes[:1], boxes)
keep = nms(boxes, scores, iou_threshold=0.5)

print(torch.round(ious, decimals=4))
# tensor([[1.0000, 0.6807, 0.0000]])
print("keep:", keep.tolist())
# keep: [0, 2]

1番目と2番目のboxは大きく重なっているため、scoreが高い1番目だけが残ります。3番目のboxは離れているので残ります。NMSは「一定数のproposalを選ぶ処理」ではなく、重なりすぎる候補をscoreに基づいて整理する処理です。

スライディングウィンドウ、畳み込み、受容野

昔ながらのスライディングウィンドウでは、画像中のさまざまな位置とスケールに小さな窓を当て、各窓を分類器に通します。

window 1 -> classifier
window 2 -> classifier
window 3 -> classifier
...

この方法は直感的ですが、重複する計算が多く、スケールや縦横比の組み合わせも増えます。

畳み込みネットワークでは、中間特徴を共有しながら密な位置の予測を行えます。特徴マップ上の1点は、元画像のある範囲から計算されています。この範囲を受容野と呼びます。

元画像の局所領域
    |
    v receptive field
特徴マップ上の1点

受容野は、カーネルサイズ、stride、padding、dilation、pooling、upsamplingによって変わります。たとえば、stride1の3x3畳み込みを1回使うと受容野は3x3ですが、同じ3x3畳み込みを2回重ねると受容野は5x5相当に、3回重ねると7x7相当に広がります(1回重ねるごとに1辺が2pxずつ広がるイメージです)。ただし、特徴マップの1点が元画像の「ただ1つの厳密な矩形」だけを見ている、と単純化しすぎると誤解しやすいです。実際には有効受容野や境界条件も関係します。

受容野の考え方を踏まえたうえで、検出器の設計にはいくつかの方向性があります。次に、代表的な2つの流派であるOne-stageとTwo-stageを見ていきます。

One-stageとTwo-stage

物体検出器は、大きくOne-stageとTwo-stageに分けて説明されることがあります。

Two-stage

Two-stage検出器は、まず候補領域を作り、その候補に対して分類とbox回帰を行います。

候補領域を生成する
 -> proposalごとに分類とbox調整を行う

代表例はFaster R-CNNやMask R-CNNです。小さな物体や高精度が必要な場面で強いことが多い一方、構造は複雑になりやすいです。

One-stage

One-stage検出器は、特徴マップ上の密な位置から直接クラスとboxを予測します。

画像 -> dense detector -> boxes / classes

代表例はYOLO、SSD、RetinaNet、FCOSなどです。構造が直接的で速いものが多いですが、前景と背景の不均衡や小物体の扱いには工夫が必要です。

ただし、One-stageなら必ず速い、Two-stageなら必ず高精度、というほど単純ではありません。backbone、入力解像度、後処理、実装、ハードウェアによって結果は変わります。

ここからは、Two-stage検出器の代表例であるFaster R-CNNを具体的に見ていきます。

Faster R-CNNの全体像

Faster R-CNNは、代表的なTwo-stage検出器です。全体の流れは次のようになります。

Images
 -> GeneralizedRCNNTransform
 -> Backbone + FPN feature maps
 -> Region Proposal Network
 -> proposals + proposal losses
 -> RoI Align
 -> box head
 -> class logits + box regression
 -> decode + score threshold + NMS
 -> detections

GeneralizedRCNNTransform は、入力画像ごとに違うサイズをモデルが扱いやすい範囲へResizeし、Normalizeを適用する前処理stepです。バッチ内の画像サイズが揃っていなくても、内部で画像ごとに処理してからバッチにまとめてくれるため、呼び出す側でサイズを揃える必要はありません。

訓練時と推論時で戻り値が変わる点が重要です。

モード 入力 戻り値
model.train() images + targets loss辞書
model.eval() images prediction辞書のlist

Faster R-CNNの主な部品は、次の通りです。

  1. Backbone
  2. FPN
  3. Anchor Generator
  4. RPN Head
  5. Proposal filter / NMS
  6. RoI Align
  7. Box Head
  8. Classifier / Regressor

この流れを分けて見ると、Faster R-CNNは「画像からいきなり最終boxを出す1つの黒箱」ではなく、候補生成と候補分類を段階的に行うモデルだと分かります。

BackboneとFPN

Backboneは画像から特徴マップを抽出します。FPN、Feature Pyramid Networkは、複数スケールの特徴を組み合わせる仕組みです。

C5 -> P5
      upsample + C4 -> P4
                 upsample + C3 -> P3
                            ...

深い層は意味情報が強く、浅い層は高解像度です。FPNはそれらを組み合わせ、大小さまざまな物体を扱いやすくします。

torchvisionのResNet50-FPN系モデルでは、通常、複数のピラミッドレベルが使われます。説明のために単一特徴マップだけを描くことはありますが、実装では複数スケールをまとめて扱う点に注意します。

RPN、Anchor、Proposal

RPN、Region Proposal Networkは、物体がありそうな領域を候補として作ります。

feature maps
 -> anchor generator
 -> RPN head
 -> objectness + bbox deltas
 -> decode
 -> clip / min-size filter
 -> pre-NMS top-k
 -> NMS
 -> post-NMS top-k proposals

ここでの「top-k」は、分類の指標として説明したTop-k Accuracyとは別の意味です。こちらは、objectnessスコアが高い候補を上位k件だけ残す、proposal数を絞るための処理です。

Anchorは、特徴マップ上の各位置に置かれる基準boxです。サイズや縦横比を複数用意し、それぞれに対して「物体らしさ」と「boxのずれ」を予測します。

Anchor数は、概念的には次のように決まります。

sum_over_levels(H_l * W_l * anchors_per_location_l)

つまり、入力サイズ、特徴マップの段数、各段の空間サイズ、anchor設定によって変わります。特定の数字をモデルの固定定義として覚えるより、「密な候補を作り、NMSやtop-kで絞る」と理解するほうが安全です。

RoI Headsと最終予測

RPNで作ったproposalは、RoI Headsへ渡されます。

訓練時は、proposalと正解boxをIoUで対応付け、正例と負例をsamplingします。その後、RoI Alignで固定サイズの特徴に変換し、分類lossとbox回帰lossを計算します。

proposals + ground truth
 -> IoU matching
 -> positive / negative sampling
 -> RoI Align
 -> box head
 -> classification loss + box regression loss

推論時は、proposalごとにクラスlogitsとbox回帰量を出し、boxをdecodeし、背景を除き、score thresholdとNMSを通して最終検出を作ります。

proposals
 -> RoI Align
 -> class logits / box regression
 -> decode boxes
 -> remove background
 -> score threshold
 -> per-class NMS
 -> top detections

RoI Poolingは、proposalの座標を特徴マップのグリッドに合わせて整数値に丸めてから領域を切り出すため、小数点以下の位置情報が失われ、小さな物体ほどずれが目立ちやすくなります。RoI Alignは、この丸め処理を行わず、双線形補間でproposal内の任意の位置の特徴値を計算してから固定サイズへプーリングします。座標を丸めない分、境界や小物体の位置合わせで有利です。

ここまででFaster R-CNNの各部品を個別に見てきました。次は、これらを実際にtorchvisionでどう呼び出すのかを見ていきます。

torchvision Faster R-CNNの訓練インターフェース

torchvisionの検出モデルは、分類モデルと入力形式が違います。画像Tensorのlistを渡します。

images: list[Tensor[C, H, W]]

画像ごとにサイズが違っていても構いません。訓練時は、画像listとtarget listを一緒に渡します。

model.train()
loss_dict = model(images, targets)
loss = sum(loss_dict.values())

targetには、少なくとも boxeslabels が必要です。

target = {
    "boxes": boxes,    # FloatTensor[num_objects, 4], XYXY形式
    "labels": labels,  # Int64Tensor[num_objects]
}

推論時はtargetを渡しません。

model.eval()
with torch.inference_mode():
    predictions = model(images)

predictionは画像ごとの辞書listです。

{
    "boxes": FloatTensor[num_detections, 4],
    "labels": Int64Tensor[num_detections],
    "scores": Tensor[num_detections],
}

小さなtargetを作り、dtypeとshapeを確認します。

import torch


target = {
    "boxes": torch.tensor([[10.0, 20.0, 50.0, 80.0]], dtype=torch.float32),
    "labels": torch.tensor([1], dtype=torch.int64),
}

print("boxes:", target["boxes"].shape, target["boxes"].dtype)
# boxes: torch.Size([1, 4]) torch.float32
print("labels:", target["labels"].shape, target["labels"].dtype)
# labels: torch.Size([1]) torch.int64

検出モデルでは、ラベル0は背景として扱われます。自作データセットの物体クラスは、通常1から始めます。これは通常の分類タスクでtargetを0から始める感覚と違うため、混同しやすい点です。

Faster R-CNNを微調整する例

Faster R-CNNを自分のクラス数に合わせるには、box predictorを置き換えます。

import torch
from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

model = fasterrcnn_resnet50_fpn_v2(
    weights=None,
    weights_backbone=None,
)

num_classes = 2  # background + pedestrian
in_features = model.roi_heads.box_predictor.cls_score.in_features  # 既存box_predictorの入力次元をそのまま流用する
model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)  # 出力側だけ自分のクラス数に差し替える
model.to(device)

print("num_classes:", model.roi_heads.box_predictor.cls_score.out_features)
# num_classes: 2

上のコードでは、ダウンロードなしで構造だけ確認できるように weights=Noneweights_backbone=None にしています。実データで事前学習済み重みから微調整する場合は、FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT などの重みを指定します。重みを指定した場合、初回実行時に公式の重みファイルがダウンロードされます。

num_classes には背景を含めます。たとえば「歩行者」1クラスだけを検出する場合でも、background + pedestrian で2クラスです。targetの labels は物体クラスを1以上にします。

歩行者検出の練習には、Penn-Fudan Pedestriansデータセットを使えます。公式チュートリアルでは、https://www.cis.upenn.edu/~jshi/ped_html/PennFudanPed.zip を取得し、展開後の PNGImagesPedMasks から画像、mask、boxを作っています。画像170枚、歩行者インスタンス345個の小さなデータセットなので、検出やインスタンスセグメンテーションの流れを確認する用途に向いています。

訓練ループの中心は、画像listとtarget辞書listをdeviceへ移し、model(images, targets) が返すloss辞書を合計してbackwardする部分です。

import torch
from torch.utils.data import DataLoader
from torchvision.transforms import v2


def get_transform(train):
    steps = []
    if train:
        steps.append(v2.RandomHorizontalFlip(p=0.5))  # 画像とboxes/masksを同期して反転する
    steps.extend([
        v2.ToDtype(torch.float32, scale=True),
        v2.ToPureTensor(),
    ])
    return v2.Compose(steps)


def collate_fn(batch):
    return tuple(zip(*batch))  # 画像サイズや物体数が違うので、stackせずlist相当で返す


train_dataset = PennFudanDataset(
    "data/PennFudanPed",
    transforms=get_transform(train=True),
)
train_loader = DataLoader(
    train_dataset,
    batch_size=1,
    shuffle=True,
    collate_fn=collate_fn,
)

params = [parameter for parameter in model.parameters() if parameter.requires_grad]
optimizer = torch.optim.SGD(params, lr=0.001, momentum=0.9, weight_decay=0.0005)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)

for epoch in range(num_epochs):
    model.train()
    for images, targets in train_loader:
        images = [image.to(device) for image in images]
        targets = [
            {key: value.to(device) for key, value in target.items()}
            for target in targets
        ]

        loss_dict = model(images, targets)  # 訓練モードなのでloss辞書が返る(loss_classifier、loss_box_regなど複数キー)
        losses = sum(loss for loss in loss_dict.values())  # 複数のlossを合計して1つのスカラーにする

        optimizer.zero_grad(set_to_none=True)
        losses.backward()
        optimizer.step()

    scheduler.step()

実際に訓練するときは、losses.item() をエポックやイテレーションごとにprintし、値が下がっているかを確認します。loss_dict には loss_classifierloss_box_regloss_objectnessloss_rpn_box_reg などのキーがあり、個別に監視すると、分類とbox回帰のどちらが学習の妨げになっているかを切り分けやすくなります。

PennFudanDataset 側では、各サンプルが image, target を返すようにします。targetには、boxeslabels、必要に応じて masksimage_idareaiscrowd を入れます。COCO形式の評価まで行う場合は、これらの追加フィールドが役立ちます。

ここまで訓練ループの流れを見てきましたが、検出やセグメンテーションではaugmentationの適用方法にも注意が必要です。

検出、セグメンテーションデータとtransforms v2

検出やセグメンテーションでは、画像だけをランダム変換すればよいわけではありません。画像を左右反転したら、boxやmaskも同じように変換する必要があります。

torchvision transforms v2では、画像、box、mask、keypointsなどをまとめて扱うための仕組みがあります。

import torch
from torchvision.transforms import v2


transforms = v2.Compose([
    v2.RandomHorizontalFlip(p=0.5),
    v2.ToDtype(torch.float32, scale=True),
])

実際の検出データでは、tv_tensors.BoundingBoxestv_tensors.Mask を使うと、座標形式や画像サイズの情報を持たせたまま変換しやすくなります。

Datasetは、よく次の形で返します。

image, target

検出では画像サイズや物体数が画像ごとに違うため、既定のcollateではstackできないことがあります。その場合はlistのまま返すcollate関数を使います。

def collate_fn(batch):
    return tuple(zip(*batch))


batch = [
    ("image0", {"labels": [1]}),
    ("image1", {"labels": [2]}),
]
images, targets = collate_fn(batch)  # (image, target)のペア列を、image列とtarget列に分解する

print(images)
# ('image0', 'image1')
print(targets[0]["labels"])
# [1]

COCO形式のデータを使う場合は、torchvisionの参照実装やtransforms v2のサンプルが役立ちます。自作データセットでは、box形式、ラベルの開始番号(0始まりか1始まりか)、maskのdtype、画像の値域を必ず確認します。

分類、セグメンテーション、検出の評価指標

タスクごとに、見るべき指標は変わります。

分類

  • Top-1 Accuracy
  • Top-k Accuracy
  • Precision、Recall、F1
  • confusion matrix
  • ROC-AUC、PR-AUC

分類では、クラス不均衡があるとAccuracyだけでは不十分です。少数クラスのRecallやmacro F1を確認します。

セグメンテーション

  • Pixel Accuracy
  • per-class IoU
  • mIoU
  • Dice
  • Boundary F-score

セグメンテーションでは、背景が大きい画像ほどPixel Accuracyが高く見えやすいです。小さな対象を扱う場合は、IoUやDiceをクラスごとに見ることが重要です。

物体検出

  • AP at a specific IoU
  • COCO AP
  • AP50、AP75
  • small / medium / large AP
  • Average Recall

検出APでは、score順に予測を並べ、IoUしきい値で正解boxと対応付けます。同じ正解boxに複数の予測が重なっても、true positiveとして数えられるのは通常1つだけです。単一画像のprecisionやAP50だけで、検出器全体の性能を言い切らないようにします。

ここまで説明してきた内容を、図として整理しておきます。

図として整理する

分類

RGB image
 -> Tensor (3, H, W)
 -> model
 -> logits (C,)
 -> argmax / top-k
 -> class name

残差接続

x -> conv path -> F(x) --+
|                        +-> F(x) + x
+-------- identity ------+

セグメンテーション出力

input (3, H, W)
 -> network
 -> logits (C, H, W)
 -> argmax over C
 -> class-id mask (H, W)
 -> paletteで可視化

色付き画像は表示用です。訓練ラベルとしては、色が混ざったRGB画像ではなく、クラスIDを持つmaskを使うほうが扱いやすいです。

U-Net

high resolution -> encoder -> bottleneck -> decoder -> high resolution
                    |                         ^
                    +------ skip concat ------+

DeepLab

Atrous conv: カーネルのサンプル点の間隔を広げる
ASPP       : 複数のdilation rateを並列に使う
v3+        : ASPP特徴と低レベル特徴をdecoderで融合する

One-stageとTwo-stage

One-stage: image -> dense detector -> boxes / classes
Two-stage: image -> proposals -> RoI classifier / regressor

Faster R-CNN

image
 -> backbone / FPN
 -> RPN anchors / objectness / deltas
 -> proposal NMS
 -> RoI Align
 -> class logits + refined boxes
 -> final NMS

よくあるつまずきどころ

つまずき 原因 対処
RGB/BGRを間違える OpenCVとPIL/torchvisionで色順が違う 入力直後に色順を確認する
事前学習済み重みとNormalizeが合わない 重みが想定した前処理と違う weights.transforms() を使う
logitsを文字列だと思う モデル出力は数値Tensor idx_to_class で名前へ変換する
CrossEntropyLoss 前にSoftmaxする loss内部でlog-softmax相当を計算する 生のlogitsを渡す
マルチラベル分類でargmaxする クラスが排他的とは限らない Sigmoidとクラスごとのしきい値を使う
maskを双線形補間する クラスIDが小数や混色になる ラベルmaskには最近傍補間を使う
セグメンテーションlogitsを完成画像だと思う Cはクラス次元 argmax(dim=1) 後に可視化する
二値segmentationの出力とtargetが合わない 1チャンネル設計と2クラス設計が混ざる loss、shape、dtypeをそろえる
背景込みmIoUか分からない 評価ルールが曖昧 背景とignore_indexの扱いを明記する
segmentationをMattingと同じだと思う 二値maskと連続alphaは別物 境界品質が必要ならMattingとして設計する
box形式を混同する XYXY、XYWH、CXCYWHが混ざる 入出力ごとに形式を明記する
Faster R-CNNのlabelsをfloatにする labelsはクラスID torch.int64 にする
検出クラス数に背景を入れ忘れる Faster R-CNNは背景を含む num_classes = 実クラス数 + 1 にする
画像だけaugmentationする boxやmaskが追従しない transforms v2や同期変換を使う
サイズの違う検出画像をstackしようとする 既定collateは同shapeを期待する listを返す collate_fn を使う
proposal数を固定定義だと思う 入力や設定で変わる RPN、NMS、top-kの流れで理解する
NMSを全クラスまとめて雑にかける 別クラスのboxまで消えることがある class-awareまたはbatched NMSを使う
訓練時にFaster R-CNNへtargetsを渡さない train時はloss計算にtargetが必要 model(images, targets) にする
推論時にloss辞書を期待する eval時はprediction listを返す train/evalの戻り値を分けて扱う
AP50だけで性能を断定する IoUしきい値が1つだけ COCO APやクラス別APも確認する

参考リンク

おわりに

今回は、画像分類、画像セグメンテーション、物体検出をまとめて整理しました。

画像分類では、モデル出力はクラス名ではなくlogitsです。argmax やtop-kでクラスIDを取り、保存しておいたクラス対応表で名前へ変換します。事前学習済み重みを使うときは、重みが想定する前処理も一緒に扱います。

セグメンテーションでは、出力Tensorのクラス次元と空間次元を分けて理解します。logitsは (N, C, H, W)、予測maskは argmax(dim=1) 後の (N, H, W) です。ラベルmaskの補間、ignore_index、背景クラス、mIoUやDiceの定義も、結果の解釈に大きく関わります。

物体検出では、クラス分類だけでなくbox座標とscoreを扱います。Faster R-CNNでは、backbone/FPN、RPN、proposal、RoI Align、box head、NMSという段階を経て最終予測が作られます。訓練時はloss辞書、推論時はprediction listが返る点も重要です。

3つのタスクは、どれも画像をTensorにしてモデルへ渡すところから始まります。しかし、出力の意味と評価方法はまったく同じではありません。shape、dtype、前処理、ラベル定義、後処理を1つずつ確認できるようになると、torchvisionの公式モデルも自作モデルもずっと扱いやすくなります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?