Pipeline d'Annotation Automatique SAM3 + Export YOLO : Guide Complet

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 :

  1. Stage 1 : Generation d’images (hors scope de cet article)
  2. Stage 2 : Annotation SAM3 text-prompted + export YOLO
  3. Stage 3 : Entrainement YOLO

Le stage 2 repose sur deux modules principaux :

  • src/annotator.pyBDAnnotator : annotation SAM3 text-prompted
  • src/dataset_manager.pyDatasetManager : 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 :

IDClasseMethodeNb promptsMin areaExemples de prompts
0characterpositive92%person, man, woman, robot
1facepositive70.5%head with hair, portrait
2mouthpositive40.1%mouth, lips, open mouth
3text_in_imagepositive100.3%text, sign, speech bubble
4skypositive75%sky, clouds, sunset
5groundpositive103%ground, floor, road, grass
6wallpositive73%wall, brick wall, fence
7vegetationpositive112%tree, bushes, flowers
8waterpositive112%water, river, lake, sea
9proppositive860.5%sword, guitar, beret, castle

Deux observations :

  1. La classe prop a 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.
  2. Le min_area_ratio filtre 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 prop avec 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 &gt; 0 and intersection / union &gt; 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 &lt; 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) &gt; max_points:
        epsilon = 2.0
        contour = cv2.approxPolyDP(contour, epsilon, True)
        while len(contour) &gt; max_points and epsilon &lt; 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 :

MetriqueValeur
Images train1328
Images val920
Labels train1084 fichiers (avec au moins 1 detection)
Labels total9797 annotations

Distribution par Classe

ClasseAnnotations% du total
face313532.0%
character230723.5%
empty_bg138814.2%
wall6136.3%
important_object6046.2%
sky5805.9%
mouth5525.6%
ground4464.6%
text_in_image1721.8%

Observations :

  1. Desequilibre fort : face represente 32% des annotations, text_in_image seulement 1.8%. C’est un probleme classique qu’il faudra gerer a l’entrainement (augmentation, poids de classe).
  2. 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.
  3. 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