画像認識(YOLOを含む)

画像認識のタスクは、画像全体に 1 ラベルを付ける分類だけではありません。どこにあるかを出す物体検出、画素ごとに塗り分けるセグメンテーションまで含めると、出力の形そのものが変わります。

このノートでは、物体検出を中心に、ボックス表現、IoU、NMS、YOLO の出力設計、評価指標までを順に見ます。

「何が写っているか」だけでは足りない場面がある

画像分類は 1 枚に 1 ラベルを付けますが、検出はその物体がどこにあるかまで答えなければいけません。猫が写っている、で終わらず、どの矩形が猫なのかを返す必要がある時点で、出力設計も評価指標も一段複雑になります。

このノートでは、まずボックス表現をそろえ、IoU と NMS で検出器の基本語彙を固めます。そのあとで YOLO の出力テンソルを読み、最後に AP / mAP で「検出の良し悪し」をどう測るかまでつなげます。

分類と検出の差は、モデルより先に出力形式に現れる

分類ならクラス確率だけ見れば済みますが、検出では座標、存在確率、クラスを同時に扱います。この notebook は YOLO を 1 つのアルゴリズムとして暗記するより、「検出タスクが何を要求するか」を分解して理解するためのものです。

最初の関門は、箱の表現と重なり方を読むこと

xyxyxywh を行き来できないと、IoU も NMS も読みづらくなります。まずは箱の持ち方をそろえて、検出器の土台にある幾何を先に片付けます。

YOLO の難しさは、1 回で全部出す設計にある

どのセルがどの物体を担当するか、重複予測をどう間引くか、位置と分類をどう同時学習するか。YOLO は高速さの代わりに、出力の読み方に独特の約束があります。ここではその約束を順にほどきます。

実装より先に、検出器の見方を身につける

この notebook で目指すのは、大規模検出モデルを訓練できるようになることではなく、YOLO の出力テンソルや評価表を読んで意味が取れるようになることです。論文やライブラリの説明を読む前提をここで整えます。

まずはボックス表現を往復する

最初に xyxyxywh を相互変換して、どちらの表現が何に向くかを確認します。後の IoU や NMS を読む基盤になります。

import numpy as np
import matplotlib.pyplot as plt

try:
    import torch
    import torch.nn as nn
    import torch.optim as optim
    TORCH_AVAILABLE = True
except ModuleNotFoundError:
    torch = None
    nn = None
    optim = None
    TORCH_AVAILABLE = False

np.random.seed(42)

まずは、バウンディングボックスの表現を統一します。

このノートでは画像座標を使います。原点は左上 (0, 0)x は右向き、y は下向きに増えます。
xyxy(x1, y1, x2, y2)xywh(cx, cy, w, h) です。

ここでは xyxy(左上x, 左上y, 右下x, 右下y)と xywh(中心x, 中心y, 幅, 高さ)を相互変換する関数を用意します。

def xywh_to_xyxy(box_xywh):
    cx, cy, w, h = box_xywh
    x1 = cx - w / 2
    y1 = cy - h / 2
    x2 = cx + w / 2
    y2 = cy + h / 2
    return np.array([x1, y1, x2, y2], dtype=np.float64)


def xyxy_to_xywh(box_xyxy):
    x1, y1, x2, y2 = box_xyxy
    cx = (x1 + x2) / 2
    cy = (y1 + y2) / 2
    w = max(0.0, x2 - x1)
    h = max(0.0, y2 - y1)
    return np.array([cx, cy, w, h], dtype=np.float64)

次に、IoU で「どれくらい合っているか」を測る

物体検出では、クラスだけでなく位置も合っていなければ成功とは言えません。IoU はその位置の一致度を測る最も基本的な指標です。

sample_xywh = np.array([40.0, 30.0, 28.0, 18.0])
converted = xywh_to_xyxy(sample_xywh)
back = xyxy_to_xywh(converted)

print('xywh -> xyxy:', np.round(converted, 3))
print('xyxy -> xywh:', np.round(back, 3))

IoU(Intersection over Union)は、予測ボックスと正解ボックスの重なり具合を測る指標です。

IoU = 重なり面積 / (予測面積 + 正解面積 - 重なり面積) で定義します。
IoU=1 に近いほど一致、IoU=0 に近いほど不一致です。
物体検出では「当たったかどうか」の判定や AP/mAP 計算で中心的に使います。

def iou_xyxy(box_a, box_b):
    ax1, ay1, ax2, ay2 = box_a
    bx1, by1, bx2, by2 = box_b

    inter_x1 = max(ax1, bx1)
    inter_y1 = max(ay1, by1)
    inter_x2 = min(ax2, bx2)
    inter_y2 = min(ay2, by2)

    inter_w = max(0.0, inter_x2 - inter_x1)
    inter_h = max(0.0, inter_y2 - inter_y1)
    inter_area = inter_w * inter_h

    area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
    area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
    union = area_a + area_b - inter_area

    return 0.0 if union <= 0 else inter_area / union


gt = np.array([20.0, 20.0, 60.0, 52.0])
p1 = np.array([18.0, 24.0, 62.0, 54.0])
p2 = np.array([45.0, 20.0, 80.0, 48.0])

print('IoU(pred1, gt):', round(iou_xyxy(p1, gt), 4))
print('IoU(pred2, gt):', round(iou_xyxy(p2, gt), 4))

NMS は何を残して何を捨てるのか

検出器は近い位置に似たボックスを複数出しがちです。NMS はそれをスコア順で整理し、「同じ物体を何回も数えない」ために使います。

canvas = np.zeros((80, 100), dtype=np.float64)

fig, ax = plt.subplots(figsize=(6.6, 4.0))
ax.imshow(canvas, cmap='gray', vmin=0, vmax=1)


def draw_box(ax, b, color, label):
    x1, y1, x2, y2 = b
    rect = plt.Rectangle((x1, y1), x2 - x1, y2 - y1, fill=False, edgecolor=color, linewidth=2)
    ax.add_patch(rect)
    ax.text(x1, y1 - 2, label, color=color, fontsize=9)


draw_box(ax, gt, 'lime', 'gt')
draw_box(ax, p1, 'cyan', 'pred1')
draw_box(ax, p2, 'orange', 'pred2')
ax.set_title('Bounding Boxes and Overlap')
ax.axis('off')
plt.tight_layout()
plt.show()

物体検出では近い位置に重複予測が出やすいため、NMS(Non-Maximum Suppression)で重複を間引きます。
スコア順に見て、IoUが高いボックスを抑制するのが基本です。

下は単一クラスを想定した最小実装です。マルチクラスではクラスごとにNMSを行います。

def nms(boxes, scores, iou_thresh=0.5):
    boxes = np.asarray(boxes, dtype=np.float64)
    scores = np.asarray(scores, dtype=np.float64)

    order = np.argsort(scores)[::-1]
    keep = []

    while len(order) > 0:
        i = order[0]
        keep.append(i)

        rest = order[1:]
        remaining = []
        for j in rest:
            if iou_xyxy(boxes[i], boxes[j]) < iou_thresh:
                remaining.append(j)
        order = np.array(remaining, dtype=int)

    return keep


boxes = np.array([
    [16, 18, 58, 50],
    [18, 20, 60, 52],
    [44, 18, 79, 47],
    [10, 40, 36, 70],
], dtype=np.float64)

scores = np.array([0.86, 0.91, 0.63, 0.58])
keep_idx = nms(boxes, scores, iou_thresh=0.45)

print('keep index:', keep_idx)
print('kept boxes:')
print(boxes[keep_idx])

YOLO は 1 枚の画像をどう出力へ変えるか

ここでは S x S グリッドごとに、座標、objectness、クラスを出す考え方を見ます。検出は分類より出力がずっと構造的です。

def iou_on_wh(box_wh, anchor_wh):
    # 中心を一致させ、幅と高さだけで形状の近さを測るIoU(アンカー選択用)
    bw, bh = box_wh
    aw, ah = anchor_wh
    inter = min(bw, aw) * min(bh, ah)
    union = bw * bh + aw * ah - inter
    return inter / (union + 1e-12)


def encode_yolo_target(gt_box_xyxy, class_id, image_size=96, S=6, B=2, C=3, anchors_wh=None):
    # target shape: (S, S, B*5 + C)
    # per anchor: (tx, ty, tw, th, obj)
    target = np.zeros((S, S, B * 5 + C), dtype=np.float64)

    # anchors_wh are normalized by image size (w,h in 0~1)
    if anchors_wh is None:
        if B == 2:
            anchors_wh = np.array([[0.25, 0.25], [0.45, 0.45]], dtype=np.float64)
        else:
            sizes = np.linspace(0.2, 0.5, B)
            anchors_wh = np.stack([sizes, sizes], axis=1)

    gt_xywh = xyxy_to_xywh(gt_box_xyxy)
    cx, cy, w, h = gt_xywh

    cell_size = image_size / S
    col = min(S - 1, int(cx // cell_size))
    row = min(S - 1, int(cy // cell_size))

    # cell_x, cell_y: relative position inside the cell (0~1)
    # cell_w, cell_h: normalized by image_size (0~1)
    # NOTE: この教材では tw/th の anchor-relative 変換(log比)を省略した簡略形を使う
    cell_x = (cx / cell_size) - col
    cell_y = (cy / cell_size) - row
    cell_w = w / image_size
    cell_h = h / image_size

    ious = np.array([iou_on_wh((cell_w, cell_h), a) for a in anchors_wh], dtype=np.float64)
    anchor_idx = int(np.argmax(ious))

    start = anchor_idx * 5
    target[row, col, start:start + 5] = np.array([cell_x, cell_y, cell_w, cell_h, 1.0])
    target[row, col, B * 5 + class_id] = 1.0

    return target, (row, col, anchor_idx), anchors_wh


gt_box = np.array([24.0, 30.0, 58.0, 66.0])
target, responsible, anchors = encode_yolo_target(gt_box, class_id=1, image_size=96, S=6, B=2, C=3)

row, col, anchor_idx = responsible
print('responsible (row, col, anchor):', responsible)
print('anchors (normalized w,h):', np.round(anchors, 3))
print('cell vector:', np.round(target[row, col], 4))

座標・存在確率・クラスを同時に学習する

YOLO の損失は 1 種類ではなく、位置、objectness、クラスの誤差を合わせたものです。どの項が何を担当しているかを分けて読むと混乱しにくくなります。

def yolo_loss_components(pred, target, S=6, B=2, C=3, lambda_coord=5.0, lambda_noobj=0.5):
    pred = pred.copy()
    target = target.copy()

    # pred_boxes/tgt_boxes: (S,S,B,5)  where 5=(tx,ty,tw,th,obj)
    pred_boxes = pred[..., :B * 5].reshape(S, S, B, 5)
    tgt_boxes = target[..., :B * 5].reshape(S, S, B, 5)

    # class logits/targets: (S,S,C)
    pred_cls = pred[..., B * 5:]
    tgt_cls = target[..., B * 5:]

    # Per-anchor masks
    obj_mask = tgt_boxes[..., 4]                 # (S,S,B)
    noobj_mask = 1.0 - obj_mask                  # (S,S,B)

    # Coordinate and objectness losses apply on responsible anchors
    coord_loss = np.sum(obj_mask[..., None] * (pred_boxes[..., :4] - tgt_boxes[..., :4]) ** 2)
    obj_loss = np.sum(obj_mask * (pred_boxes[..., 4] - tgt_boxes[..., 4]) ** 2)

    # No-object penalty applies to non-responsible anchors too
    noobj_loss = np.sum(noobj_mask * (pred_boxes[..., 4] - tgt_boxes[..., 4]) ** 2)

    # Class loss is cell-level (if any anchor in that cell has object)
    cell_obj_mask = np.max(obj_mask, axis=2, keepdims=True)  # (S,S,1)
    cls_loss = np.sum(cell_obj_mask * (pred_cls - tgt_cls) ** 2)

    total = lambda_coord * coord_loss + obj_loss + lambda_noobj * noobj_loss + cls_loss

    return {
        'coord_loss': float(coord_loss),
        'obj_loss': float(obj_loss),
        'noobj_loss': float(noobj_loss),
        'cls_loss': float(cls_loss),
        'total': float(total),
    }


S, B, C = 6, 2, 3
# これは学習ループではなく、損失の性質を比較するためのダミー予測
pred_far = np.random.uniform(low=0.0, high=1.0, size=(S, S, B * 5 + C))
pred_near = np.clip(target + np.random.normal(0.0, 0.05, size=(S, S, B * 5 + C)), 0.0, 1.0)

loss_far = yolo_loss_components(pred_far, target, S=S, B=B, C=C)
loss_near = yolo_loss_components(pred_near, target, S=S, B=B, C=C)

print('--- far prediction ---')
for k, v in loss_far.items():
    print(k, round(v, 4))

print('--- near-to-target prediction ---')
for k, v in loss_near.items():
    print(k, round(v, 4))

AP と mAP は何を順位付きで見ているか

検出では「高スコアの予測から順に見たとき、どれだけ正しく当たっているか」が重要です。AP は 1 クラスの精度と再現率の曲線を要約し、mAP はそれをクラス平均したものです。

def average_precision_from_pr(precision, recall):
    # VOC2007 11-point interpolation (educational)
    ap = 0.0
    for t in np.linspace(0, 1, 11):
        p = np.max(precision[recall >= t]) if np.any(recall >= t) else 0.0
        ap += p / 11.0
    return float(ap)


def evaluate_ap_single_class(pred_boxes, pred_scores, gt_boxes, iou_thresh=0.5):
    order = np.argsort(pred_scores)[::-1]
    matched = np.zeros(len(gt_boxes), dtype=bool)

    tp = []
    fp = []

    for idx in order:
        pb = pred_boxes[idx]
        best_iou = 0.0
        best_gt = -1
        for g_i, gb in enumerate(gt_boxes):
            iou = iou_xyxy(pb, gb)
            if iou > best_iou:
                best_iou = iou
                best_gt = g_i

        if best_iou >= iou_thresh and best_gt >= 0 and not matched[best_gt]:
            tp.append(1)
            fp.append(0)
            matched[best_gt] = True
        else:
            tp.append(0)
            fp.append(1)

    tp = np.cumsum(tp)
    fp = np.cumsum(fp)
    precision = tp / np.maximum(tp + fp, 1e-12)
    recall = tp / max(len(gt_boxes), 1)

    ap = average_precision_from_pr(precision, recall)
    return precision, recall, ap


gt_boxes_eval = np.array([
    [20, 18, 54, 50],
    [58, 26, 86, 58],
], dtype=np.float64)

pred_boxes_eval = np.array([
    [19, 19, 52, 49],  # TP
    [60, 24, 88, 60],  # TP
    [15, 12, 30, 26],  # FP
    [57, 24, 85, 57],  # duplicate near second GT -> FP
], dtype=np.float64)

pred_scores_eval = np.array([0.93, 0.87, 0.44, 0.73])

precision, recall, ap = evaluate_ap_single_class(pred_boxes_eval, pred_scores_eval, gt_boxes_eval, iou_thresh=0.5)
print('precision:', np.round(precision, 4))
print('recall   :', np.round(recall, 4))
print('VOC07 [email protected] (11-point):', round(ap, 4))

YOLO の外側にある設計も整理する

最後に、CNN ベースのバックボーンと Transformer / ViT 系バックボーンの違いを軽く整理します。ここでは YOLO 本体というより、検出器全体の設計の見取り図を持つのが目的です。

if TORCH_AVAILABLE:
    torch.manual_seed(42)

    class TinyYoloHeadDemo(nn.Module):
        def __init__(self, S=6, B=2, C=3):
            super().__init__()
            self.S, self.B, self.C = S, B, C
            self.backbone = nn.Sequential(
                nn.Conv2d(1, 8, 3, padding=1),
                nn.ReLU(),
                nn.MaxPool2d(2),
                nn.Conv2d(8, 16, 3, padding=1),
                nn.ReLU(),
                nn.AdaptiveAvgPool2d((1, 1)),
            )
            self.head = nn.Linear(16, S * S * (B * 5 + C))

        def forward(self, x):
            feat = self.backbone(x).flatten(1)
            out = self.head(feat)
            return out.view(-1, self.S, self.S, self.B * 5 + self.C)

    model = TinyYoloHeadDemo(S=6, B=2, C=3)
    n_params = sum(p.numel() for p in model.parameters())
    print('TinyYoloHeadDemo params:', n_params)

    x_dummy = torch.randn(4, 1, 64, 64)
    out_dummy = model(x_dummy)
    print('output shape:', tuple(out_dummy.shape))
    print('NOTE: 形状理解用デモのため、実YOLOの空間ヘッド実装とは異なります。')
else:
    print('PyTorch未導入のため、TinyYoloHeadDemo例はスキップしました。')

YOLO を読むときは、ボックス表現、IoU、NMS、担当セル、損失の内訳、評価指標の順で追うと迷いにくくなります。順番に分解すれば、検出器の複雑さもだいぶ整理できます。