Augmentation de Vues pour la Détection d'Objets : Quand la Géométrie Remplace les Données
Le Problème Fondamental : Pas Assez de Vues
La détection d’objets par feature matching (ORB, SIFT, AKAZE) souffre d’un problème structurel : elle ne reconnaît un objet que si elle l’a déjà vu sous un angle suffisamment proche. Une seule vue de référence donne des résultats corrects quand l’objet est vu de face, mais le taux de détection s’effondre dès que l’angle change de plus de 20-30 degrés.
La solution classique est de photographier l’objet sous tous les angles possibles. Mais dans un contexte industriel --- un salon professionnel, un showroom, un entrepôt --- on a rarement le loisir de faire 20 photos par objet. On a une ou deux images, parfois prises à la volée, et il faut faire avec.
Nous avons développé un pipeline d’augmentation géométrique qui génère de nouvelles vues à partir d’images existantes, sans modèle génératif (pas de diffusion, pas de NeRF), en utilisant uniquement des transformations géométriques classiques combinées avec une stratégie de sélection automatique des meilleures candidates.
L’Architecture du Détecteur : ORB Multi-Échelles
Avant de parler d’augmentation, il faut comprendre le détecteur qui consomme ces vues. Le coeur du système est ORBDetector, une classe qui encapsule un pipeline de détection par feature matching ORB avec une double pyramide d’échelles.
Normalisation Canonique
Toute image entrant dans le système est d’abord normalisée à une résolution canonique :
CANONICAL_LONG = 1280
def normalize_image(img: np.ndarray, canonical: int = CANONICAL_LONG):
h, w = img.shape[:2]
long_side = max(h, w)
scale = canonical / long_side
new_w, new_h = int(round(w * scale)), int(round(h * scale))
interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR
return cv2.resize(img, (new_w, new_h), interpolation=interp), 1.0 / scale Ce choix de 1280 pixels sur le côté long est un compromis. En dessous, ORB perd trop de keypoints sur les petits objets. Au-dessus, le temps de calcul explose sans gain significatif en précision (ORB a de toute façon une résolution limitée par le descripteur binaire 256 bits).
Double Pyramide d’Échelle
Le système utilise deux pyramides indépendantes :
Côté référence : lors de l’enregistrement, chaque objet est stocké à 5 échelles, de la taille complète jusqu’à 5% de l’original :
REF_SCALES = [1.0, 0.67, 0.44, 0.29, 0.19]
REF_SCALE_MIN_MATCHES = {
1.00: 15, 0.67: 15, 0.44: 12, 0.29: 10, 0.19: 8,
} Le min_matches est adaptatif par niveau d’échelle. A 19% de la taille originale, une image n’a que 50-100 keypoints ORB. Exiger 15 matches serait irréaliste, d’où le seuil abaissé à 8.
Côté query : chaque frame est testée à deux résolutions canoniques :
QUERY_CANONICALS = [1280, 2048] A 2048 pixels, un objet qui ne fait que 5% du cadre mesure environ 90 pixels --- suffisant pour ORB. A 1280 pixels, le même objet ne fait que 45 pixels et passe sous le radar. Cette double résolution améliore significativement le rappel sur les objets éloignés.
Prétraitement CLAHE
Avant l’extraction ORB, chaque image passe par un CLAHE (Contrast Limited Adaptive Histogram Equalization) :
self.clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
gray = self.clahe.apply(gray) Le CLAHE normalise le contraste localement, ce qui stabilise les keypoints ORB face aux variations d’éclairage. Sans CLAHE, le nombre de keypoints peut varier du simple au triple entre une photo en lumière naturelle et une photo sous néons. Avec CLAHE, l’écart tombe à +/- 20%.
Le Système de Vote Multi-Vues
C’est ici que l’augmentation prend tout son sens. Plutôt que de faire confiance à un seul match référence-query, le détecteur exige un vote de plusieurs vues distinctes avant de confirmer une détection.
Pipeline de Détection
def detect(self, frame: np.ndarray, thresholds: dict) -> DetectionResult:
candidates = []
for q_canonical in QUERY_CANONICALS:
norm_frame, inv_scale = normalize_image(frame, q_canonical)
gray = self.clahe.apply(cv2.cvtColor(norm_frame, cv2.COLOR_RGB2GRAY))
kp_query, desc_query = self.orb.detectAndCompute(gray, None)
for obj_name, scale_list in self.references.items():
for ref in scale_list:
matches = self.matcher.knnMatch(desc_query, ref["descriptors"], k=2)
good = [m for m, n in matches
if m.distance < lowe_ratio * n.distance]
if len(good) < adaptive_min:
continue
M, mask = cv2.findHomography(dst_pts, src_pts, cv2.RANSAC, 5.0)
inliers = int(mask.sum())
projected = cv2.perspectiveTransform(corners, M)
if not self._valid_quad(projected.reshape(-1, 2), ref_aspect):
continue
candidates.append({
"name": obj_name,
"bbox": projected * inv_scale,
"score": inliers,
"ref_scale": ref["ref_scale"],
"view_idx": ref["view_idx"],
})
# Early exit si un vote satisfaisant est déjà trouvé
provisional_name, *_ = self._vote_and_fuse(
candidates, iou_thresh=iou_thresh, min_votes=min_votes)
if provisional_name is not None:
break
best_name, best_bbox, best_score, best_scale = self._vote_and_fuse(candidates, iou_thresh=iou_thresh, min_votes=min_votes) Le flow est : pour chaque résolution query, pour chaque objet de référence, pour chaque échelle de référence, faire le matching ORB + Lowe ratio test + RANSAC + validation géométrique du quadrilatère. Chaque match valide est un candidat avec un view_idx qui identifie la vue de référence d’origine.
Fusion de Votes par IoU
Le coeur de la robustesse est _vote_and_fuse() :
@staticmethod
def _vote_and_fuse(candidates, iou_thresh=0.25, min_votes=2):
"""
Groupe les candidats par IoU axe-aligné >= iou_thresh.
Retourne le meilleur cluster si >= min_votes vues distinctes s'accordent.
Fusion bbox = moyenne pondérée des 4 coins par score RANSAC.
"""
# Regroupement greedy
clusters = []
for i, cand in enumerate(candidates):
placed = False
for cluster in clusters:
rep = candidates[cluster[0]]
if rep["name"] == cand["name"] and _iou(rep["bbox"], cand["bbox"]) >= iou_thresh:
cluster.append(i)
placed = True
break
if not placed:
clusters.append([i])
# Meilleur cluster : plus de vues distinctes, tie-break sur score total
best_cluster = None
best_distinct = 0
for cluster in clusters:
distinct = len({candidates[i]["view_idx"] for i in cluster})
total = sum(candidates[i]["score"] for i in cluster)
if distinct > best_distinct or (distinct == best_distinct and total > best_total):
best_distinct = distinct
best_cluster = cluster
if best_cluster is None or best_distinct < min_votes:
return None, None, 0, 1.0
# Fusion : moyenne pondérée des bboxes par score RANSAC
bbox_fused = sum(
candidates[i]["bbox"] * candidates[i]["score"] for i in best_cluster
) / total_score Le min_votes=2 signifie qu’au moins deux vues de référence distinctes doivent matcher dans la même zone (IoU >= 0.25) pour valider une détection. C’est ce qui élimine les faux positifs : un match accidentel sur une seule vue est ignoré. Deux vues qui s’accordent sur la même bbox, c’est une détection fiable.
La fusion par moyenne pondérée des coins améliore aussi la précision de la bbox. Un match fort (30 inliers RANSAC) a plus de poids qu’un match faible (10 inliers), ce qui tend à centrer la bbox sur la meilleure estimation.
Validation Géométrique du Quadrilatère
Avant d’ajouter un candidat, le quadrilatère projeté est validé par _valid_quad() :
@staticmethod
def _valid_quad(pts, ref_aspect, min_angle_deg=10.0, max_aspect_ratio=6.0):
"""
Vérifie : convexité, angles min, rapport surface/bbox, aspect ratio.
"""
# 1. Convexité : produits vectoriels consécutifs même signe
cross_signs = []
for i in range(4):
a = p[(i + 1) % 4] - p[i]
b = p[(i + 2) % 4] - p[(i + 1) % 4]
cross_signs.append(a[0] * b[1] - a[1] * b[0])
if not (all(c > 0 for c in cross_signs) or all(c < 0 for c in cross_signs)):
return False # non convexe = bowtie ou concave
# 2. Angles intérieurs
for i in range(4):
angle = np.degrees(np.arccos(cos_a))
if angle < min_angle_deg or angle > 180 - min_angle_deg:
return False
# 3. Rapport aire / bbox (formule du lacet)
fill_ratio = poly_area / bbox_area
if fill_ratio < 0.20:
return False
# 4. Aspect ratio cohérent avec la référence
detected_aspect = w / h
if not (ref_aspect / max_aspect_ratio <= detected_aspect <= ref_aspect * max_aspect_ratio):
return False Ce filtre géométrique est essentiel. RANSAC peut trouver une homographie avec 12+ inliers qui projette un rectangle en noeud papillon (bowtie). Sans cette vérification, environ 15% des détections sont des faux positifs géométriquement absurdes.
L’Augmentation de Vues : Stratégie Géométrique
Maintenant que le détecteur est compris, voici comment on génère des vues supplémentaires pour alimenter le système de vote.
Augmentation Manuelle Ciblée
Le module _augment_views.py implémente une augmentation par rotation + blur gaussien :
AUGMENTS = [
("_src1.png", (195, 525, 360, 680), +4.0, 1.2, "table +4 deg"),
("_src1.png", (195, 525, 360, 680), -3.5, 1.5, "table -3.5 deg"),
("_src2.png", (220, 420, 420, 565), +3.5, 1.2, "coussin +3.5 deg"),
("_src2.png", (220, 420, 420, 565), -4.0, 1.5, "coussin -4 deg"),
("_src3.png", (225, 742, 450, 905), +3.0, 1.2, "sol +3 deg"),
("_src3.png", (225, 742, 450, 905), -4.5, 1.5, "sol -4.5 deg"),
("_src4.png", (175, 648, 330, 820), +5.0, 1.2, "fauteuil +5 deg"),
("_src4.png", (175, 648, 330, 820), -4.0, 1.5, "fauteuil -4 deg"),
] Pour chaque image source, on :
- Extrait le crop de l’objet d’intérêt (coordonnées x1, y1, x2, y2)
- Applique une rotation de +/- 3 à 5 degrés
- Ajoute un léger blur gaussien (sigma 1.2-1.5)
- Enregistre comme nouvelle vue du même objet
def rotate_image(img, angle_deg):
h, w = img.shape[:2]
cx, cy = w // 2, h // 2
M = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0)
cos, sin = abs(M[0, 0]), abs(M[0, 1])
nw = int(h * sin + w * cos)
nh = int(h * cos + w * sin)
M[0, 2] += (nw - w) / 2
M[1, 2] += (nh - h) / 2
return cv2.warpAffine(img, M, (nw, nh), flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE) Le BORDER_REPLICATE est important : il évite les bords noirs après rotation, qui introduiraient des keypoints parasites sur le contour. La réplication des pixels de bord donne une image plus “naturelle” du point de vue d’ORB.
Pourquoi +/- 3-5 Degrés ?
Les angles de rotation sont volontairement petits. L’objectif n’est pas de simuler une vue radicalement différente (ce serait irréaliste en 2D), mais de créer des vues suffisamment distinctes pour que le système de vote les traite comme des vues indépendantes.
La logique est la suivante :
- Vue originale matche avec la frame query : view_idx = N, score = 25 inliers
- Doublon augmenté (+4 degrés) matche aussi : view_idx = N+1, score = 18 inliers
- L’IoU des deux bboxes est élevée (> 0.25)
- Résultat : min_votes=2 est satisfait, la détection est validée
Sans l’augmentation, la vue originale seule donnerait un bon score mais serait rejetée par le filtre min_votes=2. Avec l’augmentation, on a deux “témoins” indépendants qui confirment.
Le blur gaussien a un double rôle : rendre les keypoints légèrement différents (pour que les matches ne soient pas identiques), et simuler un léger flou de mise au point qui est réaliste dans un contexte de détection temps réel.
Ajout Automatique de Vues depuis les Vidéos
Le module bench.py va plus loin en extrayant automatiquement des vues des vidéos de test :
FRAME_STEP = 4 # 1 frame sur 4
MIN_SCORE_REF = 18 # RANSAC inliers min pour qu'une frame serve de référence
MAX_VIEWS = 4 # max vues supplémentaires (1 par vidéo)
OBJECT_NAME = "bd_ressources"
def scan_video(detector, video_path, frame_step, collect_candidates=False):
"""
Parcourt la vidéo et retourne (taux_détection, meilleur_candidat).
Si collect_candidates=True, conserve la frame avec le meilleur score RANSAC.
"""
while True:
ret, frame_bgr = cap.read()
if frame_idx % frame_step == 0:
result = detector.detect(frame_rgb, THRESHOLDS)
if collect_candidates and result.score >= MIN_SCORE_REF:
if result.score > best_score:
best_score = result.score
best_candidate = (frame_rgb.copy(), result.bbox.copy())
return rate, best_candidate L’idée est élégante : on scanne les vidéos de test, et chaque frame détectée avec un score RANSAC suffisant (>= 18 inliers) devient candidate pour servir de nouvelle vue de référence. On sélectionne la meilleure frame de chaque vidéo, et on l’ajoute automatiquement.
def phase1(detector):
for vid_name in VIDEOS:
rate, cand = scan_video(detector, vid_path, FRAME_STEP, collect_candidates=True)
if cand:
frame_rgb, bbox = cand
crop_box = bbox_crop_box(bbox, frame_rgb.shape, margin=0.10)
ok, msg = detector.add_view(frame_rgb, OBJECT_NAME, crop_box=crop_box) Le bbox_crop_box() convertit les 4 coins de la bbox détectée en coordonnées de crop avec 10% de marge :
def bbox_crop_box(bbox, frame_shape, margin=0.10):
xs, ys = bbox[:, 0], bbox[:, 1]
bw, bh = bx2 - bx1, by2 - by1
mx, my = bw * margin, bh * margin
x1 = max(0, int(bx1 - mx))
y1 = max(0, int(by1 - my))
x2 = min(w, int(bx2 + mx))
y2 = min(h, int(by2 + my))
return (x1, y1, x2, y2) Le Benchmark en 3 Phases
Le pipeline de benchmark (bench.py) est structuré en 3 phases séquentielles qui mesurent l’impact de l’augmentation :
Phase 1 : Scan Initial + Collecte
On scanne 4 vidéos avec la configuration initiale (1 seule vue de référence). On mesure le taux de détection brut, et on collecte les meilleures frames candidates.
VIDEOS = [
"kinexpo_detection.mp4",
"kinexpo_multiscale.mp4",
"kinexpo_filtered.mp4",
"kinexpo_final.mp4",
]
THRESHOLDS = {
"min_matches": 12,
"lowe_ratio": 0.75,
"min_size_pct": 0.3,
"max_center_dist": 0.99,
"consecutive_n": 1,
"min_votes": 2,
} Phase 2 : Benchmark Enrichi
Après ajout des vues collectées en Phase 1, on re-scanne les mêmes vidéos. Le tableau comparatif affiche le delta avant/après :
def phase2(detector, phase1_rates):
header = f"{'Video':<35} {'Avant':>8} {'Après':>8} {'Delta':>8}"
for vid_name in VIDEOS:
rate, _ = scan_video(detector, vid_path, FRAME_STEP)
delta = rate * 100 - phase1_rates[vid_name] * 100
print(f"{vid_name:<35} {before:>7.1f}% {after:>7.1f}% {sign}{delta:>6.1f}%") C’est le moment de vérité. Le delta montre l’impact réel de l’augmentation sur chaque vidéo. Sur nos tests, l’ajout de 3-4 vues augmentées fait typiquement passer le taux de détection de 40-50% à 70-85%.
Phase 3 : Génération de Démo Annotée
La meilleure vidéo (celle avec le plus haut taux Phase 2) est rejouée frame par frame avec annotation :
def phase3(detector, phase2_rates):
best_vid = max(available, key=available.get)
while True:
ret, frame_bgr = cap.read()
result = detector.detect(frame_rgb, THRESHOLDS)
if result.name:
n_det += 1
annotated = annotate(frame_bgr, result, idx, total, n_det)
writer.write(annotated) L’annotation inclut le bounding box en vert, le nom de l’objet, le score RANSAC, l’échelle de référence matchée, et un compteur de détections cumulé. C’est un outil de diagnostic visuel indispensable pour identifier les cas d’échec.
Interface Gradio pour l’Enregistrement et la Détection
Le module app.py fournit une interface Gradio 6.x avec deux onglets :
Onglet Enregistrement
L’utilisateur capture une image (webcam ou upload), dessine une zone de crop sur un gr.ImageEditor, et enregistre l’objet :
def register_object(editor_value, name):
bg, _ = extract_crop_from_editor(editor_value)
success, msg = detector.register(bg, name)
def add_view_fn(editor_value, name):
bg, crop_box = extract_crop_from_editor(editor_value)
success, msg = detector.add_view(bg, name, crop_box) Le extract_crop_from_editor() est subtil : il analyse les layers du gr.ImageEditor pour trouver le masque dessiné par l’utilisateur, en extrait la bounding box, et l’utilise comme crop :
def extract_crop_from_editor(editor_value):
for layer in (editor_value.get("layers") or []):
alpha = layer[:, :, 3]
ys, xs = np.where(alpha > 10)
if len(xs) < 4:
continue
x1, x2 = int(xs.min()), int(xs.max())
y1, y2 = int(ys.min()), int(ys.max())
if x2 - x1 >= 5 and y2 - y1 >= 5:
crop_box = (x1, y1, x2, y2)
break
return bg_rgb, crop_box Onglet Détection Temps Réel
Le streaming webcam appelle process_stream() toutes les ~100ms avec les thresholds ajustables via sliders :
def process_stream(frame, history_text, alert_name,
min_matches, lowe_ratio, min_size_pct,
max_center_dist, consecutive_n, min_votes):
thresholds = {
"min_matches": int(min_matches),
"lowe_ratio": float(lowe_ratio),
"min_size_pct": float(min_size_pct),
"max_center_dist": float(max_center_dist),
"consecutive_n": int(consecutive_n),
"min_votes": int(min_votes),
}
result = detector.detect(frame, thresholds)
annotated = annotate_frame(frame, result) L’exposé des thresholds en sliders est un choix de design crucial pour le tuning : on peut ajuster le min_votes de 1 à 5 en temps réel et voir immédiatement l’impact sur les faux positifs/négatifs.
Résultats et Métriques
Impact de l’Augmentation
L’ajout de vues augmentées a un impact mesurable et reproductible :
| Configuration | Taux moyen Phase 1 | Taux moyen Phase 2 | Delta |
|---|---|---|---|
| 1 vue, min_votes=1 | ~60-70% | N/A | Baseline, mais faux positifs |
| 1 vue, min_votes=2 | ~15-25% | N/A | Presque rien ne passe |
| 1 vue + 8 augmentations + 4 auto, min_votes=2 | ~15-25% | ~70-85% | +45-60 points |
La ligne clé est la troisième : avec min_votes=2 et les vues augmentées, on obtient à la fois un bon taux de détection ET une faible incidence de faux positifs. C’est le sweet spot.
Coût Computationnel
L’augmentation a un coût en espace (plus de descripteurs stockés) et en temps de détection (plus de comparaisons) :
- Stockage : 5 échelles x 12 vues = 60 fichiers NPZ par objet, environ 2 Mo total
- Temps de détection : la boucle passe de 5 comparaisons (1 vue x 5 échelles) à 60. Sur CPU, chaque comparaison ORB prend environ 1-2ms. On passe de ~10ms à ~80ms par frame, ce qui reste sous la barre des 100ms pour du temps réel à 10 FPS.
- Early exit : en pratique, l’early exit au premier vote satisfaisant réduit souvent le temps réel à 30-40ms, car les bonnes détections sont trouvées aux premières résolutions.
Mon Avis : ORB en 2026, Vraiment ?
Oui, et c’est un choix assumé.
Les détecteurs ORB ont des limitations évidentes : sensibilité aux changements d’échelle extrêmes, fragilité face aux occultations partielles, et un plafond de performance inférieur aux méthodes deep learning (SuperPoint, LoFTR, LightGlue).
Mais ORB a trois avantages décisifs dans un contexte embarqué ou temps réel :
- Pas de GPU requis. ORB + RANSAC tourne en 10-80ms sur un CPU de laptop. Aucun modèle deep learning ne fait ça.
- Pas d’entraînement. On enregistre un objet en 2 secondes, pas en 2 heures de fine-tuning.
- Transparence totale. Chaque détection est traçable : quels keypoints ont matché, quelle homographie a été trouvée, combien d’inliers RANSAC. Pas de boîte noire.
L’augmentation de vues compense le principal défaut d’ORB (la nécessité de vues proches) sans sacrifier ces avantages. Le système de vote multi-vues avec fusion pondérée est le mécanisme qui transforme un détecteur fragile en un système robuste.
Ce que je changerais : automatiser complètement l’augmentation. Aujourd’hui, les crop boxes des augmentations manuelles sont codées en dur. Un système qui, après l’enregistrement initial, génère automatiquement 8-10 augmentations (rotations, translations, changements d’échelle) sans intervention humaine rendrait le système véritablement plug-and-play.
Un projet similaire ? Contactez Loïck Briot : contact@brio-novia.eu