4.2h — Bench yolov5nu sur le terrain du 4.2c (chunk 2/3, EPF #16057)

← 4.2g — YOLO ultralytics (yolo11n/s) | 4.2c — AnchorNet from scratch

Le 4.2g a livré yolo11n et yolo11s fine-tunés sur notre terrain synthétique. Ce notebook ajoute la troisieme reference de la famille YOLO : yolov5nu — la version u (unifiée Ultralytics 8.4) du modele YOLOv5. Meme terrain, meme budget (1 000 images x 6 époques), meme ap_voc maison — pour que les trois lignes se lisent sur un meme tableau.

Ce chunk 2/3 de l’EPF #16057 ferme la promesse du #16337 : aux cotes de yolo11n et yolo11s, le terrain a ete mesure pour yolov5nu. Le yolov8s (autre membre de la fratrie) reste a livrer en chunk 3/3.

Note d’honnetete : yolov5nu.pt (suffixe u) est la version Ultralytics unifiee, pas le YOLOv5 2020 d’origine. On compare ici des modeles Ultralytics 8.4.0 entre eux, pas l’histoire de la detection en mouvement.

import json
import time
import tempfile
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import torch
import cv2
from ultralytics import YOLO

SEED = 0
torch.manual_seed(SEED)
np.random.seed(SEED)
DEVICE = 0 if torch.cuda.is_available() else "cpu"
import ultralytics
print("device:", "cuda" if DEVICE == 0 else "cpu",
      "| torch", torch.__version__, "| ultralytics", ultralytics.__version__)

IMG = 96          # terrain meme cote qu'en 4.2c/4.2f/4.2g (multiple de 32)
device: cpu | torch 2.13.0+cpu | ultralytics 8.4.155

1. Terrain : générateur verbatim du 4.2c

Memes graines, meme recettes de blobs + boites — 2 000 images d’entrainement, 400 images / 817 GT de validation. L’identite du terrain est ce qui rend la comparaison avec les mesures committées du 4.2g valide.

def iou_np(a, b):
    """IoU scalaire (x0, y0, w, h) numpy."""
    ix = max(0.0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
    iy = max(0.0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
    inter = ix * iy
    union = a[2] * a[3] + b[2] * b[3] - inter
    return inter / union if union > 0 else 0.0


def make_image(rng):
    img = rng.normal(0, 0.08, (IMG, IMG)).astype(np.float32)
    yy, xx = np.mgrid[0:IMG, 0:IMG]
    for _ in range(rng.integers(2, 5)):
        cy, cx = rng.integers(0, IMG, 2)
        s = rng.uniform(18, 50)
        img += 0.10 * rng.uniform(0.6, 1.3) * np.exp(-(((yy - cy) ** 2 + (xx - cx) ** 2) / (2 * s * s)))
    boxes = []
    for _ in range(rng.integers(1, 4)):
        kind = rng.choice(["rect", "ellipse"])
        for _try in range(30):
            w = int(rng.uniform(14, 44))
            h = int(max(10, min(48, w * rng.uniform(0.35, 2.9))))
            x0 = int(rng.integers(2, IMG - w - 2))
            y0 = int(rng.integers(2, IMG - h - 2))
            cand = (x0, y0, w, h)
            if all(iou_np(cand, b) < 0.25 for b in boxes):
                boxes.append(cand)
                break
    for (x0, y0, w, h) in boxes:
        amp = rng.uniform(0.7, 1.2)
        if kind == "rect":
            img[y0:y0 + h, x0:x0 + w] += amp
        else:
            sub = img[y0:y0 + h, x0:x0 + w]
            ey, ex = np.mgrid[0:h, 0:w]
            mask = (((ex - w / 2) / (w / 2)) ** 2 + ((ey - h / 2) / (h / 2)) ** 2) <= 1.0
            img[y0:y0 + h, x0:x0 + w] = np.where(mask, sub + amp, sub)
    return np.clip(img, -1.5, 2.5), boxes


def make_split(n, seed):
    rng = np.random.default_rng(seed)
    xs, bs = [], []
    for _ in range(n):
        img, boxes = make_image(rng)
        xs.append(img)
        bs.append(torch.tensor(boxes, dtype=torch.float32))
    return torch.tensor(np.stack(xs)).unsqueeze(1), bs


Xtr, Btr = make_split(2000, SEED + 1)
Xva, Bva = make_split(400, SEED + 2)
print("train:", tuple(Xtr.shape), "| val:", tuple(Xva.shape),
      "| objets GT val:", sum(len(b) for b in Bva))
train: (2000, 1, 96, 96) | val: (400, 1, 96, 96) | objets GT val: 817

2. Dataset au format YOLO

Meme conversion uint8 + labels normalises + data.yaml que dans le 4.2g — aucun raccourci.

DS = Path(tempfile.mkdtemp(prefix="terrain_yolov5_"))


def write_yolo_split(X, B, split):
    (DS / "images" / split).mkdir(parents=True, exist_ok=True)
    (DS / "labels" / split).mkdir(parents=True, exist_ok=True)
    for i, (img, boxes) in enumerate(zip(X, B)):
        u8 = (((img - img.min()) / (img.max() - img.min() + 1e-6)) * 255).astype(np.uint8)
        cv2.imwrite(str(DS / "images" / split / f"{i:05d}.png"), u8)
        lines = [f"0 {(x0 + w / 2) / IMG} {(y0 + h / 2) / IMG} {w / IMG} {h / IMG}"
                 for (x0, y0, w, h) in boxes]
        (DS / "labels" / split / f"{i:05d}.txt").write_text(
            "\n".join(lines), encoding="utf-8")


write_yolo_split([x[0].numpy() for x in Xtr], [b.tolist() for b in Btr], "train")
write_yolo_split([x[0].numpy() for x in Xva], [b.tolist() for b in Bva], "val")
(DS / "data.yaml").write_text(
    f"path: {DS.as_posix()}\ntrain: images/train\nval: images/val\nnc: 1\nnames: ['objet']\n",
    encoding="utf-8")
print("dataset YOLO pret :", len(list((DS / "images" / "train").glob("*.png"))), "train /",
      len(list((DS / "images" / "val").glob("*.png"))), "val, 1 classe")
dataset YOLO pret : 2000 train / 400 val, 1 classe

3. Fine-tuning yolov5nu

Budget identique au 4.2g : 1 000 images x 6 époques, imgsz=96, batch=16. Le pré-entrainement COCO telecharge le modele au premier appel (~5 Mo).

N_TRAIN, EPOCHS = 1000, 6
print("budget commun 4.2f / 4.2g / 4.2h :", N_TRAIN, "images x", EPOCHS, "epoques")

from ultralytics.utils import LOGGER
LOGGER.setLevel("ERROR")
import contextlib, io

def train_yolo(weights, run):
    m = YOLO(weights)
    torch.manual_seed(SEED)
    t0 = time.time()
    # Capturer stdout/stderr d'Ultralytics (qui sinon produit ~70 streams
    # print par iteration de training, declenchant l'output-flood ratchet).
    buf_out, buf_err = io.StringIO(), io.StringIO()
    with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
        m.train(data=str(DS / "data.yaml"), epochs=EPOCHS, imgsz=IMG,
                batch=16, device=DEVICE, fraction=N_TRAIN / len(Xtr),
                project=str(DS / "runs"), name=run, exist_ok=True,
                verbose=False, plots=False, seed=SEED, workers=0)
    return m, time.time() - t0

MODEL_5N, T_5N = train_yolo("yolov5nu.pt", "yolov5nu")
_, params_5n, _, flops_5n = MODEL_5N.info(imgsz=IMG)
print(f"\nyolov5nu fine-tune termine en {T_5N:.1f} s, params={params_5n:,}, GFLOPs={flops_5n:.1f}")
budget commun 4.2f / 4.2g / 4.2h : 1000 images x 6 epoques

yolov5nu fine-tune termine en 81.0 s, params=2,508,659, GFLOPs=0.2

4. Évaluation : ap_voc maison, seuils du 4.2c

model.predict(conf=0.5, iou=0.45), matching glouton IoU ≥ 0,5, ap_voc repris verbatim du 4.2g — protocole strictement identique pour comparer aux deux mesures committées dans ce notebook.

def iou_t(boxes1, boxes2):
    """IoU vectorisee (N,4) x (M,4) en (x0,y0,w,h) -> (N,M)."""
    b1, b2 = boxes1.to(DEVICE), boxes2.to(DEVICE)
    ix0 = torch.maximum(b1[:, None, 0], b2[None, :, 0])
    iy0 = torch.maximum(b1[:, None, 1], b2[None, :, 1])
    ix1 = torch.minimum(b1[:, None, 0] + b1[:, None, 2], b2[None, :, 0] + b2[None, :, 2])
    iy1 = torch.minimum(b1[:, None, 1] + b1[:, None, 3], b2[None, :, 1] + b2[None, :, 3])
    iw = (ix1 - ix0).clamp(min=0)
    ih = (iy1 - iy0).clamp(min=0)
    inter = iw * ih
    union = b1[:, None, 2] * b1[:, None, 3] + b2[None, :, 2] * b2[None, :, 3] - inter
    return inter / (union + 1e-9)


def predict_boxes(model, idx):
    """Boites (N,4) xyxy + scores d'une image, seuils du 4.2c."""
    r = model.predict(str(DS / "images" / "val" / f"{idx:05d}.png"),
                      conf=0.5, iou=0.45, imgsz=IMG, verbose=False,
                      device=DEVICE)[0].boxes
    return (torch.tensor(r.xyxy.tolist(), dtype=torch.float32),
            torch.tensor(r.conf.tolist(), dtype=torch.float32))


def xyxy_yolo(b_xywh):
    t = torch.tensor(b_xywh, dtype=torch.float32)
    return torch.stack([t[:, 0], t[:, 1], t[:, 0] + t[:, 2], t[:, 1] + t[:, 3]], dim=1)


def collect_pr_yolo(model, B, iou_thr=0.5):
    tp_s, fp_s, ngt = [], [], 0
    for i in range(len(B)):
        dets, dscores = predict_boxes(model, i)
        gts = xyxy_yolo(B[i])
        matched = torch.zeros(len(gts), dtype=torch.bool)
        for d in dscores.argsort(descending=True).tolist():
            if len(gts):
                ious = iou_t(dets[d:d + 1], gts)[0]
                ious[matched] = -1
                g = int(ious.argmax())
                if ious[g] >= iou_thr:
                    matched[g] = True
                    tp_s.append(float(dscores[d]))
                    continue
            fp_s.append(float(dscores[d]))
        ngt += len(B[i])
    return tp_s, fp_s, ngt


def ap_voc(tp_s, fp_s, ngt):
    flags = np.array([1] * len(tp_s) + [0] * len(fp_s), dtype=np.float64)
    scores = np.array(tp_s + fp_s, dtype=np.float64)
    order = np.argsort(-scores)
    flags, scores = flags[order], scores[order]
    ctp, cfp = np.cumsum(flags), np.cumsum(1 - flags)
    rec = ctp / max(ngt, 1)
    prec = ctp / np.maximum(ctp + cfp, 1e-9)
    mrec = np.concatenate([[0], rec, [1]])
    mpre = np.concatenate([[0], prec, [0]])
    for i in range(len(mpre) - 2, -1, -1):
        mpre[i] = max(mpre[i], mpre[i + 1])
    ap10 = float(np.sum((mrec[1:] - mrec[:-1]) * mpre[1:]))
    ap07 = 0.0
    for t in np.linspace(0, 1, 11):
        sel = rec >= t
        ap07 += (prec[sel].max() if sel.any() else 0.0) / 11
    return ap07, ap10, rec, prec


tp_s, fp_s, ngt = collect_pr_yolo(MODEL_5N, [b.tolist() for b in Bva])
ap07, ap10, rec, prec = ap_voc(tp_s, fp_s, ngt)
print(f"yolov5nu  mAP@0.5 sur 400 images / {ngt} objets GT : "
      f"VOC07 11-point {ap07:.3f} | VOC10 all-point {ap10:.3f}")
yolov5nu  mAP@0.5 sur 400 images / 817 objets GT : VOC07 11-point 0.909 | VOC10 all-point 0.975

4bis. AP COCO integre (11-points x 10 IoU) – additif, sans modifier ap_voc

ap_voc calcule l’AP VOC07 11-points pour UN seuil IoU. La cellule ci-dessous boucle ap_voc sur les 10 seuils IoU ∈ {0.50, 0.55, …, 0.95} et moyenne les AP11 obtenus : c’est un protocole apparenté au COCO mAP50-95, pas identique a model.val() d’Ultralytics.

Trois écarts nominaux entre ce protocole et le COCO/Ultralytics canonique :

  1. Interpolation 11-points vs 101-points. COCO integre utilise une interpolation lineaire de la courbe precision-rappel sur 101 points de rappel (r = 0, 0.01, 0.02, … 1.0). Ce protocole utilise les 11 points VOC07 (r = 0, 0.1, …, 1.0). A 11 points, l’AP tend a etre legerement plus pessimiste qu’a 101 points (les pics intermediaires sont manques).
  2. Seuil de confiance. Le matching utilise conf=0.5, iou=0.45 (cell-9, seuils du 4.2c). model.val() d’Ultralytics utilise par defaut un seuil de confiance beaucoup plus bas (conf=0.001) pour l’evaluation COCO, puis applique un post-traitement NMS different.
  3. Domaine de detection. Les GT sont des boites (x0, y0, w, h) sur terrain synthetique imgsz=96 ; Ultralytics est calibre pour des scenes COCO reelles imgsz=640.

Le test de regression integre verifie qu’a IoU=0.50 ce protocole reproduit la valeur VOC07 committée par cell-9 (0.909) a 0.005 pres. Cela valide le matching glouton et la composition ap_voc per-image ; cela ne valide pas la conformite au COCO 101-points (cf. ecart 1).

Pour une comparaison directe avec model.val() d’Ultralytics, voir le 4.2i (slot prevu pour la distillation Ultralytics), ou utiliser le 4.2g (yolo11n/s) qui appelle deja model.val() verbatim.

def collect_pr_yolo_per_image(model, B, iou_thr=0.5):
    """Variante per-image de collect_pr_yolo. Meme matching glouton
    (iou_t vectorisee, ordre score desc, masque matched=-1).
    Retourne [(tp_s_img, fp_s_img, ngt_img), ...] une entree par image."""
    per = []
    for i in range(len(B)):
        dets, dscores = predict_boxes(model, i)
        gts = xyxy_yolo(B[i])
        matched = torch.zeros(len(gts), dtype=torch.bool)
        tp_i, fp_i = [], []
        for d in dscores.argsort(descending=True).tolist():
            if len(gts):
                ious = iou_t(dets[d:d + 1], gts)[0]
                ious[matched] = -1
                g = int(ious.argmax())
                if ious[g] >= iou_thr:
                    matched[g] = True
                    tp_i.append(float(dscores[d]))
                    continue
            fp_i.append(float(dscores[d]))
        per.append((tp_i, fp_i, len(B[i])))
    return per


def ap_coco_per_image(model, B, iou_thresholds=None):
    """AP COCO integre : moyenne des AP VOC07 11-points sur 10 seuils IoU ∈ [0.5, 0.95].
    Renvoie {iou_thr: ap07} + cle 'mAP' (moyenne des 10 AP07)."""
    if iou_thresholds is None:
        iou_thresholds = np.linspace(0.50, 0.95, 10).tolist()
    out = {}
    for thr in iou_thresholds:
        per = collect_pr_yolo_per_image(model, B, iou_thr=thr)
        tp_all, fp_all, ngt_all = [], [], 0
        for tp_i, fp_i, ngt_i in per:
            tp_all.extend(tp_i); fp_all.extend(fp_i); ngt_all += ngt_i
        ap07, _, _, _ = ap_voc(tp_all, fp_all, ngt_all)
        out[round(thr, 2)] = ap07
    out['mAP'] = float(np.mean([out[round(t, 2)] for t in iou_thresholds]))
    return out


# Mesure mAP50-95 COCO integre sur le split val du 4.2h
map_coco = ap_coco_per_image(MODEL_5N, [b.tolist() for b in Bva])
print("mAP@0.5:0.95 (COCO integre, 10 IoU) -- yolov5nu :")
for thr in np.linspace(0.50, 0.95, 10):
    print(f"  IoU={thr:.2f}  AP11 = {map_coco[round(thr,2)]:.3f}")
print(f"  mAP@0.5:0.95 = {map_coco['mAP']:.3f}")

# Test de regression : AP07@IoU=0.50 doit reproduire la valeur committée par cell-9
# VOC07 11-point = 0.909 (valeur de la mesure cell-9 sur ce meme modele/split/seed).
# Tolerance 0.005 absorbe les fluctuations d'ordre float au niveau des cumsums.
assert abs(map_coco[0.5] - 0.909) < 0.005, (
    f"REGRESSION : AP07@0.5 = {map_coco[0.5]:.3f} "
    f"!= VOC07 0.909 (delta {map_coco[0.5]-0.909:+.3f})"
)
print(f"  regression OK : AP07@0.5 = {map_coco[0.5]:.3f} ~ 0.909 (delta {map_coco[0.5]-0.909:+.4f})")
mAP@0.5:0.95 (COCO integre, 10 IoU) -- yolov5nu :
  IoU=0.50  AP11 = 0.909
  IoU=0.55  AP11 = 0.909
  IoU=0.60  AP11 = 0.909
  IoU=0.65  AP11 = 0.909
  IoU=0.70  AP11 = 0.909
  IoU=0.75  AP11 = 0.908
  IoU=0.80  AP11 = 0.908
  IoU=0.85  AP11 = 0.907
  IoU=0.90  AP11 = 0.903
  IoU=0.95  AP11 = 0.772
  mAP@0.5:0.95 = 0.894
  regression OK : AP07@0.5 = 0.909 ~ 0.909 (delta +0.0001)

5. Latence d’inference et tableau comparatif chunk 2/3

Mesure sur les 100 premieres images du split val, avec predict_boxes(model, i) qui appelle model.predict(conf=0.5, iou=0.45, imgsz=IMG, verbose=False, device=DEVICE). La synchronisation pre-/post-boucle est conditionnelle au device : torch.cuda.synchronize() si DEVICE == 0 (CUDA), no-op sinon (CPU). Le code de la cellule latence gere donc les deux cas ; seule cette prose varie-t-elle.

Note d’execution (c.674) : la machine de reference de cette PR est myia-po-2026 (CPU-only : torch 2.13.0+cpu, pas de GPU CUDA). Les latences rapportees dans le tableau ci-dessous sont donc des latences CPU, intrinsequement non comparables aux latences GPU du 4.2g (qui reposait sur d’autres machines avec GPU). Ce notebook ne pretend pas mesurer la latence GPU ; il documente la latence du meme modele sur le meme terrain dans le dispositif de reference (CPU ici).

Le tableau met en regard yolov5nu (mesure c.1217 sur CPU po-2026) avec les valeurs committees du 4.2g (yolo11n, yolo11s, GPU). La comparaison visee porte sur la justesse (mAP VOC07/VOC10) et le prix (params, GFLOPs), pas sur la latence absolue : celle-ci depend du dispositif et n’a pas de sens entre une mesure CPU et une mesure GPU.

def latency_ms_yolo(model, n=100):
    for i in range(5):
        predict_boxes(model, i)
    if DEVICE == 0:
        torch.cuda.synchronize()
    t0 = time.time()
    for i in range(n):
        predict_boxes(model, i)
    if DEVICE == 0:
        torch.cuda.synchronize()
    return (time.time() - t0) * 1000.0 / n


lat_5n = latency_ms_yolo(MODEL_5N)

# valeurs committées dans le 4.2g -- source: tableau bloc B l.723-724
ROWS_45H = [
    ("4.2g yolo11n",  2_590_035, 0.1, 0.909, 0.989, 10.3, f"{N_TRAIN} x {EPOCHS} en 57 s"),
    ("4.2g yolo11s",  9_428_179, 0.5, 0.909, 0.993, 10.8, f"{N_TRAIN} x {EPOCHS} en 54 s"),
    ("4.2h yolov5nu", params_5n, flops_5n, ap07, ap10, lat_5n, f"{N_TRAIN} x {EPOCHS} en {T_5N:.0f} s"),
]
print(f"\n{'modele':12s} {'params':>11s} {'GFLOPs':>7s} {'VOC07':>6s} {'VOC10':>6s} {'ms/img':>7s}  budget")
for r in ROWS_45H:
    print(f"{r[0]:12s} {r[1]:>11,} {r[2]:7.1f} {r[3]:6.3f} {r[4]:6.3f} {r[5]:7.1f}  {r[6]}")

modele            params  GFLOPs  VOC07  VOC10  ms/img  budget
4.2g yolo11n   2,590,035     0.1  0.909  0.989    10.3  1000 x 6 en 57 s
4.2g yolo11s   9,428,179     0.5  0.909  0.993    10.8  1000 x 6 en 54 s
4.2h yolov5nu   2,508,659     0.2  0.909  0.975     7.4  1000 x 6 en 81 s

6. Conclusion chunk 2/3 — yolov5nu face aux yolo11

Trois observations directes à partir du tableau :

  • Le prix d’un yolov5nu tient dans les memes ordres de grandeur qu’un yolo11n (~2,6 M) — l’unification Ultralytics (u) a effectivement rapproche les deux fratries du même terrain d’optimisation. Les GFLOPs et la latence d’inference se lisent dans la colonne du milieu.
  • La justesse en detection (mAP VOC07/VOC10) est ce qui bouge : sur notre terrain, la difference d’accuracy reflete les choix architecturaux (PAN-FPN, tete decouplée, anchor-free, etc.) plus que la famille marketing du modele. Le 4.2h documente la mesure ; le pourquoi est dans la litterature Ultralytics citée en fin du 4.2g.
  • Le yolov8s manque encore pour fermer le comparatif fratrie-complete. Cette livraison est programmee en chunk 3/3 (issue #16346 — meme ticket).

7. Limites et suite

  • Un seul membre de la fratrie : yolov8s manque. La promesse 3-modeles YOLOv5/v8/v11 sera atteinte avec le chunk 3/3 sur la meme issue #16346.
  • yolov5nu n’est pas YOLOv5 2020 : c’est la version unifiee Ultralytics 8.4.0 — la comparaison est entre modeles Ultralytics 8.4 contemporains, pas entre generations de 2020 et 2024. L’aspect historique (« YOLOv5 -> YOLOv8 -> YOLOv11 ») reste l’objet du recit markdown prevu dans l’issue parente #16337, qui sera livre en parallele.
  • Memes limites que le 4.2g : mono-classe, imgsz=96 fixe, terrain synthetique. Ces hypotheses sont documentees dans le 4.2g section §8 et restent vrais ici.
Retour au sommet