L’histoire de la boîte de 10 000 pièces rejetées
Usine d’injection plastique. Cadence : 4 pièces par seconde. Contrôle qualité : deux opérateurs en bout de ligne, lampe UV, loupe. Résultat : 3% de faux positifs (bonnes pièces jetées), 0,8% de faux négatifs (pièces défectueuses expédiées).
Le client a ramené une boîte. 10 000 pièces retournées. Motif : “aspect de surface non conforme.” L’équipe qualité a re-contrôlé : 7 200 pièces conformes. Le coût du retour ? 34 000€. Le coût de la reprise ? 12 000€. Le coût du temps passé à argumenter avec le client ? Incalculable.
Ce jour-là, le responsable qualité m’a dit : “On a besoin d’une machine, pas d’un humain fatigué à 22h.”
C’est exactement ce que ce pipeline résout.
Pourquoi pas un CNN end-to-end ?
La question revient systématiquement. “On a déjà TensorFlow, ResNet est open source, pourquoi s’embêter avec XGBoost ?”
Trois raisons terrain, pas des arguments théoriques.
Raison 1 : les données sont rares et déséquilibrées.
En contrôle qualité industriel, les défauts représentent 1 à 5% de la production. Pour entraîner un CNN end-to-end correctement, vous avez besoin de 500 à 5000 images par classe. En pratique, vous avez 200 images de “rayures”, 80 de “bulles”, 1200 de “OK”. Fine-tuner tout un ResNet sur ce ratio, c’est chercher des ennuis.
Raison 2 : l’explicabilité est non-négociable.
Le responsable qualité doit justifier chaque rejet devant le client. “Le modèle dit NOK” n’est pas une réponse acceptable. Avec XGBoost, vous sortez une feature importance, un SHAP plot, une explication sur quelle zone de l’image a déclenché le rejet. Le CNN extrait, XGBoost décide — et il peut expliquer pourquoi.
Raison 3 : la vitesse d’inférence sur edge.
Un CNN ResNet-50 complet sur CPU industriel : 200-400ms par image. Sur une ligne à 4 pièces/seconde, vous êtes déjà hors budget. CNN tronqué comme extracteur + XGBoost : 12-25ms total, ONNX compilé.
Le CNN end-to-end n’est pas la bonne solution quand vous avez peu de données, besoin d’explicabilité, et une contrainte temps réel < 50ms.
Architecture du pipeline hybride
flowchart LR
A[Caméra industrielle\n4K, 60fps] --> B[Prétraitement\nCrop ROI\nNormalisation\nAugmentation]
B --> C[CNN Tronqué\nEfficientNet-B2\nCouches gelées\nGlobal Avg Pool]
C --> D[Vecteur features\n1408 dims → 512 dims\nPCA optionnel]
D --> E[XGBoost Classifier\nn_estimators=200\nmax_depth=6]
E --> F{Score > 0.85?}
F -->|OUI| G[✅ OK\nConvoyeur principal]
F -->|NON| H[❌ NOK\nBac de rebut]
F -->|0.70-0.85| I[⚠️ Borderline\nContrôle humain]
style A fill:#1a1a2e,color:#fff
style C fill:#16213e,color:#fff
style E fill:#0f3460,color:#fff
style G fill:#1a472a,color:#fff
style H fill:#7f1d1d,color:#fff
style I fill:#78350f,color:#fff
Trois zones de décision. Pas deux. Le “borderline” entre 0.70 et 0.85 est crucial — c’est là que vous capturez les cas ambigus sans polluer votre bac de rebut avec des faux positifs.
Transfer Learning : ResNet ou EfficientNet ?
On travaille sur EfficientNet-B2 en production. Voilà pourquoi.
| Modèle | Features dims | Params | Inférence CPU (ms) | ImageNet top-1 |
|---|---|---|---|---|
| ResNet-50 | 2048 | 25M | 45ms | 76.1% |
| ResNet-18 | 512 | 11M | 18ms | 69.8% |
| EfficientNet-B0 | 1280 | 5.3M | 12ms | 77.1% |
| EfficientNet-B2 | 1408 | 9.1M | 20ms | 80.1% |
| MobileNetV3-L | 960 | 5.4M | 8ms | 75.2% |
EfficientNet-B2 offre le meilleur ratio qualité/vitesse pour des défauts cosmétiques. Les features à 1408 dimensions capturent suffisamment de granularité pour distinguer une rayure superficielle d’une marque d’éjection.
Règle de gel des couches :
import torchvision.models as models
import torch.nn as nn
# Charger EfficientNet-B2 pré-entraîné ImageNet
backbone = models.efficientnet_b2(weights='IMAGENET1K_V1')
# Geler TOUTES les couches — on n'entraîne pas le CNN
for param in backbone.parameters():
param.requires_grad = False
# Supprimer le classifier final — on veut les features brutes
backbone.classifier = nn.Identity()
# Résultat : extracteur de features pur, aucun gradient
On gèle tout. On ne fine-tune rien. Raison : avec 2000 images, fine-tuner même les dernières couches crée du surapprentissage. ImageNet a déjà appris les textures, les gradients, les formes — c’est exactement ce dont on a besoin pour des pièces plastiques.
Pipeline complet : extraction + classification
import torch
import torchvision.models as models
import torchvision.transforms as transforms
from PIL import Image
import numpy as np
import xgboost as xgb
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import joblib
from pathlib import Path
# === EXTRACTEUR DE FEATURES ===
class CNNFeatureExtractor:
def __init__(self, device='cpu'):
self.device = device
backbone = models.efficientnet_b2(weights='IMAGENET1K_V1')
backbone.classifier = torch.nn.Identity()
backbone.eval()
for p in backbone.parameters():
p.requires_grad = False
self.model = backbone.to(device)
self.transform = transforms.Compose([
transforms.Resize((260, 260)),
transforms.CenterCrop(260),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]
)
])
def extract(self, image_path: str) -> np.ndarray:
img = Image.open(image_path).convert('RGB')
tensor = self.transform(img).unsqueeze(0).to(self.device)
with torch.no_grad():
features = self.model(tensor)
return features.squeeze().cpu().numpy() # shape: (1408,)
def extract_batch(self, image_paths: list) -> np.ndarray:
return np.stack([self.extract(p) for p in image_paths])
# === ENTRAÎNEMENT XGBOOST ===
def train_pipeline(data_dir: str, output_dir: str):
extractor = CNNFeatureExtractor()
# Charger les images et labels
image_paths, labels = [], []
for class_dir in Path(data_dir).iterdir():
if class_dir.is_dir():
for img_path in class_dir.glob('*.png'):
image_paths.append(str(img_path))
labels.append(class_dir.name)
print(f"Dataset : {len(image_paths)} images, {len(set(labels))} classes")
# Extraction features
print("Extraction features CNN...")
X = extractor.extract_batch(image_paths) # (N, 1408)
# Encodage labels
le = LabelEncoder()
y = le.fit_transform(labels)
# Split train/test stratifié
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
# XGBoost avec scale_pos_weight pour déséquilibre de classes
n_ok = np.sum(y_train == le.transform(['OK'])[0])
n_nok = len(y_train) - n_ok
scale_pw = n_ok / n_nok
clf = xgb.XGBClassifier(
n_estimators=200,
max_depth=6,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
scale_pos_weight=scale_pw,
use_label_encoder=False,
eval_metric='logloss',
tree_method='hist',
random_state=42
)
clf.fit(
X_train, y_train,
eval_set=[(X_test, y_test)],
verbose=50
)
# Évaluation
y_pred = clf.predict(X_test)
print(classification_report(y_test, y_pred, target_names=le.classes_))
# Sauvegarde
Path(output_dir).mkdir(parents=True, exist_ok=True)
joblib.dump(clf, f"{output_dir}/xgboost_classifier.joblib")
joblib.dump(le, f"{output_dir}/label_encoder.joblib")
return clf, le
# === INFÉRENCE PRODUCTION ===
def predict_single(image_path: str, extractor, clf, le, threshold=0.85):
features = extractor.extract(image_path)
proba = clf.predict_proba([features])[0]
max_proba = np.max(proba)
pred_class = le.inverse_transform([np.argmax(proba)])[0]
if max_proba >= threshold:
decision = pred_class
confidence = "HIGH"
elif max_proba >= 0.70:
decision = "BORDERLINE"
confidence = "LOW"
else:
decision = "NOK"
confidence = "HIGH"
return {
"decision": decision,
"confidence": confidence,
"score": float(max_proba),
"class": pred_class,
"probas": dict(zip(le.classes_, proba.tolist()))
}
Pas de magie. 150 lignes, pipeline complet de l’image à la décision.
Benchmark : CNN seul vs XGBoost features brutes vs CNN+XGBoost
Testé sur notre dataset de pièces injectées : 2847 images, 6 classes de défauts (OK, rayure, bulle, marque éjection, flash, contamination).
| Approche | Accuracy | F1 NOK | Inférence (ms) | Données requises |
|---|---|---|---|---|
| XGBoost sur pixels bruts (224×224×3=150K) | 71.2% | 0.58 | 8ms | Beaucoup |
| CNN ResNet-50 end-to-end fine-tuné | 88.4% | 0.81 | 180ms | Beaucoup |
| CNN EfficientNet-B2 end-to-end | 89.1% | 0.83 | 95ms | Beaucoup |
| CNN EfficientNet-B2 features + XGBoost | 91.3% | 0.89 | 22ms | Peu |
| CNN features + SVM RBF | 87.6% | 0.79 | 19ms | Peu |
| CNN features + Random Forest | 88.9% | 0.82 | 24ms | Peu |
CNN+XGBoost bat le CNN end-to-end de 2 points d’accuracy, tout en étant 8× plus rapide sur CPU. Le gain vient de la capacité de XGBoost à traiter les features non-linéaires et de gérer le déséquilibre de classes via scale_pos_weight.
Le cas “XGBoost sur pixels bruts” sert de baseline naïve — c’est ce que certains essaient en premier. L’espace de 150 000 dimensions est trop bruité, XGBoost s’y noie.
Cas concret : tri cosmétique pièces injectées plastique
Contexte client : fabricant de boîtiers plastiques pour l’électronique grand public. 3 lignes d’injection, cadence 180 pièces/minute par ligne. Défauts acceptables : aucun sur la face A (visible). Défauts tolérés face B : rayures < 2mm, pas de bulle, pas de flash.
Dataset constitué :
- 1840 images OK
- 312 images “rayure face A”
- 178 images “bulle”
- 267 images “flash”
- 144 images “marque éjection”
- 89 images “contamination” (poussière, graisse)
Protocole d’acquisition : caméra Cognex IS5605 (5MP), éclairage dôme diffus + éclairage rasant commutable, déclenchement sur capteur inductif. Résolution effective : 0.04mm/pixel.
Résultats après 3 semaines de production :
Rapport de performance — Semaine 11 à 13
Pièces inspectées : 847 200
Décisions HIGH confidence (>85%) : 94.2%
Décisions BORDERLINE (<85%) → contrôle humain : 5.8%
Sur les BORDERLINE contrôlés :
- 71% confirmés OK par opérateur
- 29% confirmés NOK par opérateur
Performance globale :
Taux de faux positifs (OK rejeté) : 0.31% [vs 3.0% avant]
Taux de faux négatifs (NOK livré) : 0.12% [vs 0.8% avant]
Réduction des retours client : -89% (3 incidents en 3 semaines vs 11 avant)
Le résultat clé : 5.8% de BORDERLINE qui vont en contrôle humain. Ce n’est pas un échec — c’est un design intentionnel. On ne cherche pas à forcer une décision sur les cas ambigus. La machine gère ce qu’elle maîtrise, l’humain intervient sur l’incertitude résiduelle.
Gestion du déséquilibre de classes
Point critique souvent sous-estimé. En contrôle qualité, votre dataset est structurellement déséquilibré. 95% OK, 5% NOK dans le meilleur des cas.
Trois leviers cumulatifs :
# Levier 1 : scale_pos_weight dans XGBoost
n_negative = np.sum(y == 0) # OK
n_positive = np.sum(y == 1) # NOK
clf = xgb.XGBClassifier(scale_pos_weight=n_negative/n_positive)
# Levier 2 : Augmentation sur les classes minoritaires AVANT extraction
from torchvision.transforms import v2
augment = v2.Compose([
v2.RandomHorizontalFlip(p=0.5),
v2.RandomVerticalFlip(p=0.5),
v2.RandomRotation(degrees=90),
v2.ColorJitter(brightness=0.2, contrast=0.3),
v2.GaussianBlur(kernel_size=3, sigma=(0.1, 1.0))
])
# Appliquer x5 sur les classes < 200 images
# Levier 3 : Seuil de décision asymétrique
threshold_nok = 0.40 # si proba NOK > 40% → rejeter
Le seuil de décision est un paramètre métier, pas un paramètre technique. C’est le responsable qualité qui doit fixer le coût relatif d’un faux positif vs un faux négatif.
Export ONNX et déploiement edge
Le CNN en PyTorch + XGBoost en joblib, c’est votre stack de développement. En production, vous voulez une seule chose : inférence < 50ms sur le PLC ou la caméra intelligente.
import torch
import torch.onnx
# Export EfficientNet-B2 tronqué → ONNX
dummy_input = torch.randn(1, 3, 260, 260)
backbone.eval()
torch.onnx.export(
backbone,
dummy_input,
"efficientnet_b2_features.onnx",
export_params=True,
opset_version=17,
input_names=['image'],
output_names=['features'],
dynamic_axes={
'image': {0: 'batch_size'},
'features': {0: 'batch_size'}
}
)
# Benchmark inférence ONNX
import onnxruntime as ort
import numpy as np
import time
session = ort.InferenceSession(
"efficientnet_b2_features.onnx",
providers=['CPUExecutionProvider']
)
dummy = np.random.randn(1, 3, 260, 260).astype(np.float32)
for _ in range(3):
features = session.run(['features'], {'image': dummy})[0]
times = []
for _ in range(100):
t0 = time.perf_counter()
features = session.run(['features'], {'image': dummy})[0]
times.append(time.perf_counter() - t0)
print(f"Inférence CNN ONNX : {np.mean(times)*1000:.1f}ms ± {np.std(times)*1000:.1f}ms")
# Résultat typique : 14.3ms ± 1.2ms
Latence mesurée sur Intel i5-8500T (CPU industriel compact) : 18-22ms en régime stabilisé. Bien sous les 50ms réglementaires pour cette application.
Mise en production : les pièges à éviter
Piège 1 : entraîner sur un dataset propre, déployer sur flux réel. Les images de production ont du bruit : vibrations, variations d’éclairage, pièces mal positionnées. Solution : capturer les premières 5000 images de production en mode logging, les annoter, re-entraîner.
Piège 2 : oublier la dérive de distribution. La matière première change de fournisseur, la couleur du pigment varie légèrement. Solution : monitorer le score moyen hebdomadaire. Si ça descend de > 5 points, re-annoter et re-entraîner.
Piège 3 : no human-in-the-loop. La zone BORDERLINE n’est pas optionnelle. Chaque décision humaine sur un BORDERLINE est un label gratuit.
Piège 4 : un seul éclairage. Les rayures sont visibles en éclairage rasant. Les bulles en éclairage transmis. Solution : double déclenchement avec deux éclairages, concaténation des features (2816 dims → +3.2% accuracy sur les rayures fines).
Roadmap : vers l’auto-amélioration
Le pipeline statique est un début. La version 2 se ré-entraîne automatiquement :
- Chaque image BORDERLINE validée par l’opérateur → stockée avec label
- Chaque semaine : si > 200 nouveaux labels → re-extraction features + re-fit XGBoost
- A/B test automatique : nouveau modèle vs ancien sur les 500 derniers cas connus
- Si nouveau modèle > ancien de > 1% F1 → déploiement automatique
Le CNN reste gelé (stable, pas de dérive). Seul XGBoost évolue. Coût de re-fit : 30 secondes sur CPU. Vous pouvez faire évoluer le classifieur sans toucher à l’extracteur.
Besoin d’adapter ce pipeline à votre process ? Contactez BCUB3 — on fait un diagnostic data en 2h.