Pipeline d'Annotation Automatique SAM3 + Export YOLO : Guide Complet
Annoter 2000 Images Sans Cliquer une Seule Fois
L’annotation manuelle de datasets est le goulot d’etranglement de tout projet de vision par ordinateur. Un annotateur humain traite environ 50-100 images par heure pour de la detection d’objets simple. Pour de la segmentation, c’est 10-20. Multipliez par 2000 images avec 10 classes, et vous obtenez des semaines de travail fastidieux.
Nous avons construit un pipeline d’annotation entierement automatique qui utilise SAM3 en mode text-prompted pour detecter et segmenter 10 classes dans des images de bande dessinee, puis exporter le tout au format YOLO pour l’entrainement. Le resultat : 2248 images annotees avec 9797 labels sans intervention humaine.
Cet article decrit l’architecture reelle du pipeline, les vrais fichiers de configuration, et les problemes rencontres.
Architecture du Pipeline
Le pipeline se decompose en trois stages :
- Stage 1 : Generation d’images (hors scope de cet article)
- Stage 2 : Annotation SAM3 text-prompted + export YOLO
- Stage 3 : Entrainement YOLO
Le stage 2 repose sur deux modules principaux :
src/annotator.py—BDAnnotator: annotation SAM3 text-promptedsrc/dataset_manager.py—DatasetManager: export YOLO + statistiques
Et un fichier de configuration central :
config/classes.yaml— definition des classes, prompts, seuils
Les 10 Classes de Detection
Le fichier classes.yaml definit chaque classe avec ses prompts SAM3 et ses seuils :
classes:
character:
id: 0
color: [255, 0, 0]
sam3_prompts:
- "person"
- "man"
- "woman"
- "child"
- "creature"
- "robot"
- "humanoid"
- "hero"
- "villain"
vlm_prompt: "Is there a character in this region?"
min_area_ratio: 0.02
face:
id: 1
color: [0, 255, 0]
sam3_prompts:
- "head with hair"
- "full head including hair"
- "face and hair"
- "head"
- "portrait with hair"
- "head with hat"
- "head with helmet"
min_area_ratio: 0.005 Voici le tableau complet des 10 classes :
| ID | Classe | Methode | Nb prompts | Min area | Exemples de prompts |
|---|---|---|---|---|---|
| 0 | character | positive | 9 | 2% | person, man, woman, robot |
| 1 | face | positive | 7 | 0.5% | head with hair, portrait |
| 2 | mouth | positive | 4 | 0.1% | mouth, lips, open mouth |
| 3 | text_in_image | positive | 10 | 0.3% | text, sign, speech bubble |
| 4 | sky | positive | 7 | 5% | sky, clouds, sunset |
| 5 | ground | positive | 10 | 3% | ground, floor, road, grass |
| 6 | wall | positive | 7 | 3% | wall, brick wall, fence |
| 7 | vegetation | positive | 11 | 2% | tree, bushes, flowers |
| 8 | water | positive | 11 | 2% | water, river, lake, sea |
| 9 | prop | positive | 86 | 0.5% | sword, guitar, beret, castle |
Deux observations :
- La classe
propa 86 prompts. C’est le catalogue le plus large — de “sword” a “accordion”, de “beret” a “crystal orb”. Chaque prompt est envoye individuellement a SAM3. - Le
min_area_ratiofiltre les detections trop petites. Un “mouth” peut etre 0.1% de l’image, mais un “sky” doit faire au moins 5%.
Chaque classe possede aussi un vlm_prompt pour une validation optionnelle par VLM (Vision Language Model), mais cette fonctionnalite n’etait pas activee lors de notre benchmark.
BDAnnotator : Le Coeur du Pipeline
Initialisation
class BDAnnotator:
def __init__(
self,
device=None,
confidence_threshold=0.3,
iou_threshold=0.5,
vlm_client=None,
vlm_validation=False,
):
self.device = device or (
"cuda" if torch.cuda.is_available() else "cpu"
)
self.confidence_threshold = confidence_threshold
self.iou_threshold = iou_threshold
self.vlm_client = vlm_client
self.vlm_validation = vlm_validation and vlm_client is not None
self.model = None
self.processor = None
self.classes = load_classes() Le chargement du modele SAM3 est paresseux — il ne se fait qu’au premier appel :
def load_models(self):
if self.model is not None:
return
from sam3.model_builder import build_sam3_image_model
from sam3.model.sam3_image_processor import Sam3Processor
self.model = build_sam3_image_model(
device=self.device,
eval_mode=True,
checkpoint_path=None,
load_from_HF=True,
enable_inst_interactivity=False,
)
self.processor = Sam3Processor(self.model, device=self.device) Le parametre load_from_HF=True telecharge le checkpoint depuis HuggingFace. enable_inst_interactivity=False desactive le mode interactif — on n’en a pas besoin pour du batch.
Detection par Classe
Pour chaque classe positive, on envoie chaque prompt individuellement a SAM3 :
def _detect_class(self, image, state, prompts):
detections = []
for prompt in prompts:
result = self.processor.set_text_prompt(prompt, state)
if not result or "masks" not in result:
continue
raw_masks = result["masks"]
raw_scores = result.get("scores")
for i in range(num_masks):
score = float(raw_scores[i].item())
if score < self.confidence_threshold:
continue
mask = raw_masks[i]
if isinstance(mask, torch.Tensor):
mask = mask.cpu().numpy()
while mask.ndim > 2:
mask = mask.squeeze(0)
if mask.sum() < 100:
continue
detections.append({
"mask": mask,
"score": score,
"prompt": prompt
})
return detections Plusieurs points importants :
- Un prompt = un appel SAM3. Pour la classe
propavec 86 prompts, c’est 86 inferences par image. C’est lent mais exhaustif. - Le seuil de confiance (
confidence_threshold=0.3) est volontairement bas pour maximiser le rappel. Le NMS se charge ensuite d’eliminer les doublons. - La taille minimale (
mask.sum() < 100) filtre les masques parasites de quelques pixels.
NMS sur les Masques
Les detections multiples pour une meme zone (ex: “person” et “man” detectent le meme personnage) sont fusionnees par NMS :
def nms_masks(detections, iou_threshold=0.5):
sorted_dets = sorted(
detections, key=lambda x: x["score"], reverse=True
)
keep = []
suppressed = set()
for i, det_i in enumerate(sorted_dets):
if i in suppressed:
continue
keep.append(det_i)
mask_i = det_i["mask"]
for j in range(i + 1, len(sorted_dets)):
if j in suppressed:
continue
mask_j = sorted_dets[j]["mask"]
intersection = np.logical_and(mask_i, mask_j).sum()
union = np.logical_or(mask_i, mask_j).sum()
if union > 0 and intersection / union > iou_threshold:
suppressed.add(j)
return keep Le NMS opere directement sur les masques binaires — pas sur les bounding boxes. C’est plus precis pour des formes non-rectangulaires.
Detection du Fond Vide
La classe “empty_bg” (id 7 dans le pipeline original) utilise une approche inverse : au lieu de detecter, on soustrait toutes les autres detections et on cherche les composantes connexes restantes :
def _detect_empty_bg(self, image, all_masks, min_area_ratio):
h, w = np.array(image).shape[:2]
occupied = np.zeros((h, w), dtype=bool)
for mask in all_masks:
occupied |= mask.astype(bool)
empty = ~occupied
min_area = int(h * w * min_area_ratio)
empty_uint8 = empty.astype(np.uint8) * 255
contours, _ = cv2.findContours(
empty_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
detections = []
for contour in contours:
area = cv2.contourArea(contour)
if area < min_area:
continue
region_mask = np.zeros((h, w), dtype=np.uint8)
cv2.drawContours(region_mask, [contour], -1, 1, -1)
detections.append({
"mask": region_mask.astype(bool),
"score": 1.0,
"prompt": "empty_background",
})
return detections C’est elegant : au lieu de demander a SAM3 “ou est le vide ?” (prompt qui n’a pas de sens pour un modele de segmentation), on deduit le vide par soustraction.
Annotation Complete d’une Image
La methode annotate_image orchestre tout :
def annotate_image(self, image_path):
self.load_models()
image = Image.open(image_path).convert("RGB")
w, h = image.size
all_annotations = []
for cls_name, cls_def in self.classes.items():
if cls_def.detection_method == "negative":
continue
state = self.processor.set_image(image)
detections = self._detect_class(
image, state, cls_def.sam3_prompts
)
# ... NMS, filtrage, conversion bbox/polygone Le resultat est un dictionnaire avec bbox normalisees et polygones :
# Sortie type
{
"image_path": "/path/to/image.jpg",
"width": 1024, "height": 768,
"annotations": [
{
"class_id": 0,
"class_name": "character",
"bbox": [0.45, 0.32, 0.20, 0.55], # cx, cy, w, h
"score": 0.92,
"polygon": [[0.35, 0.10], [0.55, 0.10], ...]
},
]
} Conversion Masque vers Format YOLO
Masque vers Bounding Box
def mask_to_bbox_normalized(mask, img_w, img_h):
rows = np.any(mask, axis=1)
cols = np.any(mask, axis=0)
y_min, y_max = np.where(rows)[0][[0, -1]]
x_min, x_max = np.where(cols)[0][[0, -1]]
w = (x_max - x_min + 1) / img_w
h = (y_max - y_min + 1) / img_h
x_center = (x_min + (x_max - x_min + 1) / 2) / img_w
y_center = (y_min + (y_max - y_min + 1) / 2) / img_h
return [x_center, y_center, w, h] Masque vers Polygone
Pour le mode segmentation, on convertit le masque en polygone normalise avec simplification :
def mask_to_polygon_normalized(mask, img_w, img_h, max_points=80):
mask_uint8 = mask.astype(np.uint8)
contours, _ = cv2.findContours(
mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_TC89_L1
)
contour = max(contours, key=cv2.contourArea)
# Simplifier si trop de points
if len(contour) > max_points:
epsilon = 2.0
contour = cv2.approxPolyDP(contour, epsilon, True)
while len(contour) > max_points and epsilon < 20:
epsilon *= 1.5
contour = cv2.approxPolyDP(contour, epsilon, True)
# Normaliser
polygon = []
for pt in contour:
x = max(0.0, min(1.0, pt[0][0] / img_w))
y = max(0.0, min(1.0, pt[0][1] / img_h))
polygon.append([round(x, 6), round(y, 6)])
return polygon L’algorithme CHAIN_APPROX_TC89_L1 (Teh-Chin chain approximation) produit des contours deja simplifies. La boucle approxPolyDP reduit davantage si necessaire, en augmentant progressivement epsilon jusqu’a passer sous 80 points.
DatasetManager : Export YOLO
Structure du Dataset
dataset/yolo/
images/
train/ # 80% des images (seed=42)
val/ # 20% des images
labels/
train/ # Labels YOLO correspondants
val/
dataset.yaml # Config Ultralytics Le Code d’Export
class DatasetManager:
def __init__(self, dataset_dir=None):
self.dataset_dir = dataset_dir or DATASET_DIR
self.classes = load_classes()
self.class_names = {c.id: c.name for c in self.classes.values()}
def export_yolo(
self,
annotations,
output_dir=None,
train_ratio=0.80,
seed=42,
mode="detect",
):
output_dir = output_dir or self.dataset_dir / "yolo"
# Creation des repertoires
for split in ["train", "val"]:
for subdir in ["images", "labels"]:
(output_dir / subdir / split).mkdir(
parents=True, exist_ok=True
)
# Nettoyage des anciens labels
# (evite les formats mixtes si on change de mode)
for d in [train_lbl_dir, val_lbl_dir]:
for old_lbl in d.glob("*.txt"):
old_lbl.unlink()
# Shuffle et split
random.seed(seed)
shuffled = list(annotations)
random.shuffle(shuffled)
split_idx = int(len(shuffled) * train_ratio) Les Deux Modes d’Export
Mode detect — 1 ligne par detection :
class_id x_center y_center width height Mode segment — 1 ligne par detection avec polygone :
class_id x1 y1 x2 y2 ... xn yn Le mode segment est plus riche mais produit des fichiers de labels plus gros.
Le dataset.yaml Genere
path: /opt/workspace/bd_dataset_agent/dataset/yolo
train: images/train
val: images/val
names:
0: character
1: face
2: mouth
3: text_in_image
4: sky
5: ground
6: wall
7: vegetation
8: water
9: prop Ce fichier est directement consommable par ultralytics pour lancer un entrainement YOLO.
Statistiques Reelles du Dataset Produit
Apres annotation de 2248 images :
| Metrique | Valeur |
|---|---|
| Images train | 1328 |
| Images val | 920 |
| Labels train | 1084 fichiers (avec au moins 1 detection) |
| Labels total | 9797 annotations |
Distribution par Classe
| Classe | Annotations | % du total |
|---|---|---|
| face | 3135 | 32.0% |
| character | 2307 | 23.5% |
| empty_bg | 1388 | 14.2% |
| wall | 613 | 6.3% |
| important_object | 604 | 6.2% |
| sky | 580 | 5.9% |
| mouth | 552 | 5.6% |
| ground | 446 | 4.6% |
| text_in_image | 172 | 1.8% |
Observations :
- Desequilibre fort :
facerepresente 32% des annotations,text_in_imageseulement 1.8%. C’est un probleme classique qu’il faudra gerer a l’entrainement (augmentation, poids de classe). - Taux de couverture : 1084 fichiers avec labels sur 1328 images train = 81.6%. Les 18.4% restants n’ont aucune detection au-dessus du seuil.
- Ratio annotations/image : 9797 / 2248 = 4.36 annotations par image en moyenne. C’est raisonnable pour des images de BD.
Ce Qui Marche et Ce Qui Ne Marche Pas
Ce qui marche remarquablement bien
- Les personnages et visages : SAM3 avec les prompts “person”, “man”, “woman”, “head with hair” est tres fiable. Le rappel est eleve.
- Le ciel et les murs : les zones uniformes sont bien detectees.
- Le fond vide par soustraction : simple et efficace.
Ce qui pose probleme
- Les props : avec 86 prompts, c’est la classe la plus lente ET la plus bruyante. “door”, “window”, “lamp” produisent beaucoup de faux positifs. Il faudrait une curation des prompts.
- Le texte dans l’image : seulement 172 annotations sur 2248 images, c’est sous-detecte. Le prompt “speech bubble” ne matche pas toujours les phylacteres de BD.
- Le temps de traitement : avec ~130 prompts par image (somme sur toutes les classes), et chaque prompt declenchant une inference SAM3, le debit est d’environ 2-3 images par minute sur un GPU A100. Pour 2248 images, ca fait ~15 heures.
Piste d’amelioration : validation VLM
Le pipeline prevoit une validation optionnelle par VLM :
class BDAnnotator:
def __init__(self, vlm_client=None, vlm_validation=False):
self.vlm_validation = vlm_validation and vlm_client is not None L’idee : apres detection SAM3, on envoie le crop a un VLM (GPT-4V, Qwen-VL) avec le vlm_prompt de la classe. Si le VLM repond “non”, on supprime la detection. C’est un filtre de precision au prix d’un cout API supplementaire.
Reproduire le Pipeline
Installation
# SAM3
conda create -n sam3 python=3.12
pip install torch==2.7.0 torchvision --index-url
https://download.pytorch.org/whl/cu126
pip install sam3
# YOLO (pour l'entrainement stage 3)
pip install ultralytics Usage
from src.annotator import BDAnnotator
from src.dataset_manager import DatasetManager
# Annotation
ann = BDAnnotator(confidence_threshold=0.3, iou_threshold=0.5)
results = ann.annotate_batch([
"/path/img1.jpg", "/path/img2.jpg"
])
ann.unload() # Liberer le GPU
# Export YOLO
dm = DatasetManager()
stats = dm.export_yolo(
annotations=results,
train_ratio=0.80,
seed=42,
mode="segment" # ou "detect"
)
print(stats)
# {"train": 1328, "val": 920, "labels": 9797, "per_class": Counter(...)} Conclusion : L’Annotation Automatique est Viable
Le pipeline SAM3 text-prompted + export YOLO produit un dataset exploitable avec un effort minimal. La qualite n’est pas celle d’une annotation humaine experte, mais elle est suffisante pour bootstrapper un modele YOLO qui sera ensuite affine par active learning.
Le vrai gain est dans le ratio temps/resultat : 15 heures GPU contre plusieurs semaines d’annotation humaine pour un resultat comparable a 80-85% de precision.
Les limites sont claires : le desequilibre de classes, le bruit sur les props, et la lenteur du traitement prompt-par-prompt. Mais pour un premier dataset de travail, c’est un point de depart solide.
Un projet similaire ? Contactez Loick Briot : contact@brio-novia.eu