1. Au-delà des rectangles horizontaux : pourquoi adopter la détection orientée
La détection classique repose sur des boîtes englobantes alignées avec les axes de l'image. Cette approche suffit pour des sujets bien positionnés, mais montre ses limites dès que les objets présentent une inclinaison significative. Prenons l'exemple de l'imagerie aérienne : une voiture garée en biais sera entourée d'un rectangle horizontal englobant massivement d'autres véhicules ou portions de route. Ce phénomène d'inclusion de fond dégrade la qualité des caractéristiques extraites et complique la séparation des instances proches.
La détection d'objets orientés (OBB) répond à cette problématique en paramétrant chaque cible via quatre points définissant un parallélogramme arbitraire. YOLOv8 OBB, extension officielle d'Ultralytics, génère des prédictions sous la forme classe x1 y1 x2 y2 x3 y3 x4 y4 où les coordonnées sont normalisées. Cette représentation s'adapte naturellement aux navires, aux infrastructures routières, aux panneaux inclinés ou au texte manuscrit.
La principale difficulté réside dans la préparation des données. Les tutoriels existants survolent généralement l'étape de conversion, poutrant critique. Cet article détaille l'ensemble du processus : analyse des formats sources, transformation vers la structure YOLO OBB, et mise en place d'un pipeline d'entraînement opérationnel.
2. Installation de l'environnement de travail
La configuration repose sur Python 3.9 et Conda pour l'isolation des dépendences.
# Création de l'environnement dédié
conda create -n yolo_obb python=3.9 -y
conda activate yolo_obb
# Récupération des sources
git clone https://github.com/ultralytics/ultralytics.git
cd ultralytics
# Installation avec miroir optimisé pour la zone géographique
pip install ultralytics --index-url https://pypi.tuna.tsinghua.edu.cn/simple
La vérification s'effectue via :
python -c "import ultralytics; print(ultralytics.__version__)"
Les bibliothèques complémentaires (Pillow, opencv-python) sont générlaement résolues automatiquement.
3. Spécifications du format de sortie YOLO OBB
Chaque image annotée requiert un fichier texte homonyme (extension .txt). Chaque ligne encode un objet selon :
identifiant_classe p1_x p1_y p2_x p2_y p3_x p3_y p4_x p4_y
Contraintes fondamentales :
- Normalisation obligatoire : toutes les coordonnées divisées par la largeur (axe X) ou la hauteur (axe Y) de l'image
- Ordre des sommets : cohérence conservée (sens horaire recommandé) sans obligation stricte côté modèle
- Origine des indices : la première classe porte l'index 0
4. Conversion depuis les formats sources courants
4.1 Format DOTA (télédétection)
Structure typique d'une annotation :
125.0 340.5 290.0 340.5 290.0 410.0 125.0 410.0 vehicle 0
Les huit premières valeurs correspondent aux coordonnées absolues des sommets. Le script de transformation :
import os
import json
from pathlib import Path
from PIL import Image
def dota_vers_yolo_obb(chemin_source, repertoire_images, repertoire_sortie, mapping_classes):
"""
Convertit les annotations DOTA vers le format YOLO OBB normalisé.
Paramètres:
chemin_source: fichier .txt DOTA ou répertoire contenant les annotations
repertoire_images: localisation des images correspondantes
repertoire_sortie: destination des fichiers .txt convertis
mapping_classes: dictionnaire {nom_classe: index_entier}
"""
repertoire_sortie = Path(repertoire_sortie)
repertoire_sortie.mkdir(parents=True, exist_ok=True)
fichiers_annotation = Path(chemin_source).glob('*.txt') if Path(chemin_source).is_dir() else [Path(chemin_source)]
for fichier in fichiers_annotation:
nom_base = fichier.stem
chemin_image = Path(repertoire_images) / f"{nom_base}.png"
if not chemin_image.exists():
chemin_image = chemin_image.with_suffix('.jpg')
with Image.open(chemin_image) as img:
largeur, hauteur = img.size
entrees_yolo = []
with open(fichier, 'r', encoding='utf-8') as f:
for ligne in f:
ligne = ligne.strip()
if not ligne or ligne.startswith('#'):
continue
elements = ligne.split()
if len(elements) < 9:
continue
# Extraction des coordonnées absolues
coords_abs = [float(v) for v in elements[:8]]
nom_categorie = elements[8]
if nom_categorie not in mapping_classes:
continue
id_classe = mapping_classes[nom_categorie]
# Normalisation par les dimensions de l'image
coords_norm = []
for idx, val in enumerate(coords_abs):
ref = largeur if idx % 2 == 0 else hauteur
coords_norm.append(f"{val / ref:.6f}")
entrees_yolo.append(f"{id_classe} {' '.join(coords_norm)}")
if entrees_yolo:
fichier_sortie = repertoire_sortie / f"{nom_base}.txt"
with open(fichier_sortie, 'w') as f:
f.write('\n'.join(entrees_yolo))
4.2 Format personnalisé à champs variables
Pour des exports où la catégorie apparaît en position arbitraire :
def parser_annotation_arbitraire(ligne_brute, position_coords=(0, 8), position_classe=-2):
"""
Extrait les informations d'une ligne selon un schéma configurable.
Paramètres:
position_coords: tuple (début, fin) des valeurs numériques
position_classe: index de la cellule contenant le nom de classe
"""
champs = ligne_brute.strip().split()
valeurs_numeriques = champs[position_coords[0]:position_coords[1]]
points_flottants = [float(v) for v in valeurs_numeriques]
etiquette = champs[position_classe]
return points_flottants, etiquette
4.3 Conversion depuis COCO (polygones orientés)
Certains jeux COCO contiennent des masques de segmentation décrivant des rectangles orientés. La transformation requiert l'extraction du rectangle minimal englobant :
import numpy as np
import cv2
def rectangle_minimal_oriente(points_segmentation):
"""
Calcule le rectangle orienté optimal à partir d'un nuage de points.
Retourne les 4 sommets dans l'ordre cohérent.
"""
points = np.array(points_segmentation, dtype=np.float32).reshape(-1, 2)
# Obtention du rectangle d'aire minimale
rect = cv2.minAreaRect(points)
sommets = cv2.boxPoints(rect) # 4 points dans l'ordre arbitraire
# Réordonnancement pour cohérence (sens horaire depuis le point haut-gauche)
centre = np.mean(sommets, axis=0)
angles = np.arctan2(sommets[:, 1] - centre[1], sommets[:, 0] - centre[0])
ordre = np.argsort(angles)
sommets_ordonnes = sommets[ordre]
return sommets_ordonnes.flatten().tolist()
5. Organisation du répertoire d'entraînement
YOLOv8 attend une arborescence stricte :
projet_detection/
├── data.yaml # Configuration du jeu de données
├── images/
│ ├── entrainement/
│ └── validation/
└── labels/
├── entrainement/
└── validation/
Fichier data.yaml minimal :
chemin_racine: .
ensemble_entrainement: images/entrainement
ensemble_validation: images/validation
noms_classes:
0: navire
1: avion
2: vehicule
3: batiment
6. Validation visuelle des annotations
Avant tout entraînement, la vérification graphique évite les erreurs silencieuses :
import matplotlib.pyplot as plt
import matplotlib.patches as patches
def visualiser_obb(chemin_image, chemin_annotation, couleurs_classes=None):
"""
Affiche l'image avec les rectangles orientés superposés.
"""
fig, axe = plt.subplots(1, figsize=(12, 10))
img = plt.imread(chemin_image)
axe.imshow(img)
hauteur_img, largeur_img = img.shape[:2]
if couleurs_classes is None:
couleurs_classes = plt.cm.tab10.colors
with open(chemin_annotation, 'r') as f:
for ligne in f:
elements = ligne.strip().split()
id_classe = int(elements[0])
coords_norm = [float(v) for v in elements[1:9]]
# Reconversion en pixels pour affichage
coords_px = []
for i in range(0, 8, 2):
x = coords_norm[i] * largeur_img
y = coords_norm[i+1] * hauteur_img
coords_px.extend([x, y])
# Création du polygone
polygone = patches.Polygon(
[(coords_px[i], coords_px[i+1]) for i in range(0, 8, 2)],
closed=True,
edgecolor=couleurs_classes[id_classe % len(couleurs_classes)],
facecolor='none',
linewidth=2
)
axe.add_patch(polygone)
# Annotation de la classe
centre_x = sum(coords_px[0::2]) / 4
centre_y = sum(coords_px[1::2]) / 4
axe.text(centre_x, centre_y, str(id_classe),
color='white', fontsize=10, weight='bold',
bbox=dict(boxstyle='round', facecolor=couleurs_classes[id_classe % len(couleurs_classes)]))
axe.set_axis_off()
plt.tight_layout()
plt.show()
7. Lancement de l'entraînement
Avec les données préparées :
from ultralytics import YOLO
# Chargement du modèle pré-entraîné en détection orientée
modele = YOLO('yolov8n-obb.pt') # Version nano, alternatives: s, m, l, x
# Entraînement avec les hyperparamètres par défaut optimisés
resultats = modele.train(
data='chemin/vers/data.yaml',
epochs=150,
imgsz=1024,
batch=16,
patience=20,
save_period=10,
device=0 # ou 'cpu', ou liste pour multi-GPU [0,1,2,3]
)
Les métriques spécifiques à l'OBB incluent l'IoU orientée (calculée entre polygones convexes plutôt que rectangles alignés) et l'angle moyen de prédiction.