🤖 Data Engineering for AI
Module : Data Engineering for AI
3. Transformer les données
Durée estimée : 3-4 heures
Prérequis : Module 2 (Stockage) + Docker installé
📍 Où en es-tu dans le parcours ?
DONNÉES BRUTES DATASET PRÊT
(chaos) (training-ready)
│ ▲
│ Module 2 Module 3 Module 4 Module 5 │
│ ────────── ────────── ────────── ────────── │
│ │
▼ │
┌─────────┐ ┌─────────────┐ ┌──────────┐ ┌────────┴───────┐
│ │ │ │ │ │ │ │
│ STOCKER │─────▶│ TRANSFORMER │─────▶│ ENRICHIR │─────▶│ AUTOMATISER & │
│ ✅ │ │ ▲▲▲▲ │ │ │ │ VERSIONNER │
│ │ │ │ │ │ │ │
└─────────┘ └─────────────┘ └──────────┘ └────────────────┘
TU ES ICI
🎯 Question de ce module
Comment transformer des données à grande échelle de manière uniforme et efficace ?
📦 Ce que tu vas livrer
À la fin de ce module, tu auras :
- Des transformations fondamentales maîtrisées (resize, normalize, convert)
- Un pipeline Spark pour traiter à grande échelle
- Du Schema Enforcement avec Pydantic pour valider les données
- Des contrôles qualité (corruption, doublons)
- De la data augmentation pour enrichir ton dataset
🛠️ Ce que tu vas apprendre
| Compétence | Pourquoi c'est important | Outil |
|---|---|---|
| Transformations | Uniformiser les données pour le training | Pillow, OpenCV |
| Schema Enforcement | Valider AVANT de transformer | Pydantic |
| Traitement distribué | Traiter des millions de fichiers | Spark |
| Quality checks | Filtrer les données corrompues | Hashing, validation |
| Data augmentation | Augmenter la diversité du dataset | Transforms aléatoires |
3.0 Qu'est-ce qu'une image numériquement ?
Avant de transformer des images, il faut comprendre ce qu'elles sont.
Une image = une matrice de pixels
┌─────────────────────────────────────────────────────────────────────────────┐
│ ANATOMIE D'UNE IMAGE NUMÉRIQUE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Une image de 4×3 pixels : │
│ │
│ ┌─────┬─────┬─────┬─────┐ │
│ │ P00 │ P01 │ P02 │ P03 │ ← Ligne 0 │
│ ├─────┼─────┼─────┼─────┤ │
│ │ P10 │ P11 │ P12 │ P13 │ ← Ligne 1 │
│ ├─────┼─────┼─────┼─────┤ │
│ │ P20 │ P21 │ P22 │ P23 │ ← Ligne 2 │
│ └─────┴─────┴─────┴─────┘ │
│ ↑ ↑ ↑ ↑ │
│ Col0 Col1 Col2 Col3 │
│ │
│ Résolution = Largeur × Hauteur = 4 × 3 = 12 pixels │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Un pixel = des valeurs de couleur
Chaque pixel contient des valeurs numériques représentant sa couleur :
┌─────────────────────────────────────────────────────────────────────────────┐
│ MODES COULEUR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ GRAYSCALE (L) RGB RGBA │
│ 1 canal 3 canaux 4 canaux │
│ │
│ ┌─────┐ ┌─────┬─────┬─────┐ ┌─────┬─────┬─────┬─────┐│
│ │ 128 │ │ 255 │ 0 │ 0 │ │ 255 │ 0 │ 0 │ 128 ││
│ └─────┘ └─────┴─────┴─────┘ └─────┴─────┴─────┴─────┘│
│ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ │
│ Intensité Rouge Vert Bleu R G B Alpha │
│ (0=noir, (ce pixel est (ce pixel est rouge │
│ 255=blanc) rouge pur) semi-transparent) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Mode | Canaux | Valeurs par pixel | Usage |
|---|---|---|---|
| L (Grayscale) | 1 | 0-255 | Photos N&B, masques |
| RGB | 3 | (R, G, B) chacun 0-255 | Photos couleur standard |
| RGBA | 4 | (R, G, B, A) chacun 0-255 | Images avec transparence |
| P (Palette) | 1 | Index dans une palette de couleurs | GIF, images optimisées |
En mémoire : un tableau NumPy
En Python, une image est représentée comme un tableau NumPy :
import numpy as np
from PIL import Image
img = Image.open('photo.jpg')
arr = np.array(img)
print(arr.shape) # (hauteur, largeur, canaux)
# Exemple: (1080, 1920, 3) = image 1920×1080 en RGB
print(arr.dtype) # uint8 (entiers 0-255)
# Accéder à un pixel
pixel = arr[100, 200] # Ligne 100, Colonne 200
# Retourne [R, G, B] ex: [255, 128, 64]
┌─────────────────────────────────────────────────────────────────────────────┐
│ FORME DU TABLEAU (shape) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Image RGB 1920×1080 : │
│ │
│ arr.shape = (1080, 1920, 3) │
│ ↑ ↑ ↑ │
│ Hauteur Largeur Canaux │
│ (H) (W) (C) │
│ │
│ ⚠️ Attention : NumPy utilise (H, W, C), pas (W, H, C) ! │
│ PIL utilise (W, H) pour img.size │
│ │
│ Taille mémoire = H × W × C × sizeof(dtype) │
│ = 1080 × 1920 × 3 × 1 byte │
│ = 6,220,800 bytes ≈ 6 MB (non compressé) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Formats de fichiers : compression et caractéristiques
| Format | Compression | Transparence | Usage typique | Taille |
|---|---|---|---|---|
| JPEG | Avec perte (lossy) | ❌ Non | Photos | Petit |
| PNG | Sans perte (lossless) | ✅ Oui (RGBA) | Screenshots, logos | Moyen |
| WebP | Les deux | ✅ Oui | Web moderne | Très petit |
| BMP | Aucune | ❌ Non | Legacy Windows | Très gros |
| GIF | Sans perte (palette) | ✅ Oui (1 bit) | Animations simples | Variable |
| TIFF | Variable | ✅ Oui | Archivage, impression | Gros |
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMPRESSION : AVEC vs SANS PERTE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SANS PERTE (PNG) AVEC PERTE (JPEG) │
│ ──────────────── ───────────────── │
│ │
│ Original → Compressé → Décompressé Original → Compressé → Décompressé │
│ A B A A B A' │
│ │
│ A == A (identique) A ≠ A' (légèrement différent) │
│ Fichier plus gros Fichier plus petit │
│ Parfait pour le traitement Artefacts possibles │
│ │
│ 💡 Pour le ML : JPEG quality=85-95 est un bon compromis │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
💡 Pour l'IA : On utilise souvent JPEG (photos) ou WebP (web) pour le stockage, et on convertit en tenseurs NumPy/PyTorch pour le training.
pythonVoir le code
# ============================================
# Explorer une image numériquement
# ============================================
from PIL import Image
import numpy as np
# Créer une petite image de test (8x6 pixels, RGB)
arr = np.zeros((6, 8, 3), dtype=np.uint8)
# Remplir avec des couleurs
arr[0:2, :, 0] = 255 # Lignes 0-1 : Rouge
arr[2:4, :, 1] = 255 # Lignes 2-3 : Vert
arr[4:6, :, 2] = 255 # Lignes 4-5 : Bleu
# Convertir en image PIL
img = Image.fromarray(arr)
print("📷 Propriétés de l'image :")
print(f" Taille (W×H) : {img.size}") # PIL: (largeur, hauteur)
print(f" Mode : {img.mode}") # RGB, RGBA, L, etc.
print(f" Format : {img.format}") # JPEG, PNG, etc. (None si créé en mémoire)
print(f"\n📊 Tableau NumPy :")
print(f" Shape (H, W, C) : {arr.shape}") # NumPy: (hauteur, largeur, canaux)
print(f" Dtype : {arr.dtype}") # uint8 = entiers 0-255
print(f" Taille mémoire : {arr.nbytes} bytes")
print(f"\n🎨 Valeur d'un pixel :")
print(f" Pixel [0, 0] (rouge) : {arr[0, 0]}")
print(f" Pixel [2, 0] (vert) : {arr[2, 0]}")
print(f" Pixel [4, 0] (bleu) : {arr[4, 0]}")3.1 Connexion au Module 2 : récupérer les données
Continuité du parcours
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONTINUITÉ MODULE 2 → MODULE 3 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ MODULE 2 (Stocker) MODULE 3 (Transformer) │
│ ────────────────── ─────────────────────── │
│ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ MinIO │ │ Lire depuis MinIO │ │
│ │ raw-images/ │ ────────────────▶│ Transformer │ │
│ │ catalog.parquet│ │ Écrire résultats │ │
│ └─────────────────┘ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ MinIO │ │
│ │ processed-images/ │ │
│ │ catalog.parquet │ │
│ └─────────────────────┘ │
│ │
│ 💡 On travaille avec les VRAIES données du Module 2 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Prérequis
Assure-toi que :
- Docker est lancé avec MinIO (
docker-compose up -d) - Tu as complété le Module 2 (bucket
raw-imagesouproject-imagescréé) - Des images sont présentes dans le bucket
pythonVoir le code
# ============================================
# Connexion à MinIO (depuis Module 2)
# ============================================
import boto3
from botocore.client import Config
import pandas as pd
import io
# Configuration MinIO (identique au Module 2)
MINIO_ENDPOINT = "http://localhost:9000"
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin123"
# Buckets
SOURCE_BUCKET = "raw-images" # Créé au Module 2
DEST_BUCKET = "processed-images" # Nouveau bucket pour les images transformées
# Créer le client S3
s3_client = boto3.client(
's3',
endpoint_url=MINIO_ENDPOINT,
aws_access_key_id=MINIO_ACCESS_KEY,
aws_secret_access_key=MINIO_SECRET_KEY,
config=Config(signature_version='s3v4'),
region_name='us-east-1'
)
print("✅ Client S3 connecté à MinIO")pythonVoir le code
# ============================================
# Vérifier les buckets existants
# ============================================
def list_buckets():
"""Liste tous les buckets."""
response = s3_client.list_buckets()
return [b['Name'] for b in response['Buckets']]
def create_bucket_if_not_exists(bucket_name: str):
"""Crée un bucket s'il n'existe pas."""
try:
s3_client.head_bucket(Bucket=bucket_name)
print(f"✅ Bucket '{bucket_name}' existe")
except:
s3_client.create_bucket(Bucket=bucket_name)
print(f"✅ Bucket '{bucket_name}' créé")
# Lister les buckets
buckets = list_buckets()
print(f"📦 Buckets disponibles : {buckets}")
# Vérifier que le bucket source existe
if SOURCE_BUCKET not in buckets:
print(f"\n⚠️ Le bucket '{SOURCE_BUCKET}' n'existe pas !")
print(f" As-tu complété le Module 2 ?")
print(f" Ou utilise 'project-images' si c'est le nom de ton bucket.")
else:
print(f"\n✅ Bucket source '{SOURCE_BUCKET}' trouvé")
# Créer le bucket destination
create_bucket_if_not_exists(DEST_BUCKET)pythonVoir le code
# ============================================
# Lire le catalogue du Module 2
# ============================================
def load_catalog_from_minio(bucket: str, key: str = "metadata/catalog.parquet") -> pd.DataFrame:
"""
Charge le catalogue Parquet depuis MinIO.
"""
try:
response = s3_client.get_object(Bucket=bucket, Key=key)
parquet_bytes = response['Body'].read()
df = pd.read_parquet(io.BytesIO(parquet_bytes))
print(f"✅ Catalogue chargé : {len(df)} entrées")
return df
except Exception as e:
print(f"⚠️ Impossible de charger le catalogue : {e}")
return None
# Charger le catalogue
catalog = load_catalog_from_minio(SOURCE_BUCKET)
if catalog is not None:
print(f"\n📊 Aperçu du catalogue :")
print(catalog.head())pythonVoir le code
# ============================================
# Lister les images dans le bucket source
# ============================================
def list_images(bucket: str, prefix: str = "", extensions: list = None) -> list:
"""
Liste toutes les images dans un bucket.
Args:
bucket: Nom du bucket
prefix: Préfixe (dossier) optionnel
extensions: Extensions à filtrer (ex: ['.jpg', '.png'])
Returns:
Liste des clés (chemins) des images
"""
if extensions is None:
extensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']
images = []
paginator = s3_client.get_paginator('list_objects_v2')
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
if 'Contents' in page:
for obj in page['Contents']:
key = obj['Key']
if any(key.lower().endswith(ext) for ext in extensions):
images.append({
'key': key,
'size': obj['Size'],
'last_modified': obj['LastModified']
})
return images
# Lister les images
images = list_images(SOURCE_BUCKET)
print(f"📷 {len(images)} images trouvées dans '{SOURCE_BUCKET}'")
if images:
print(f"\n📋 Premières images :")
for img in images[:5]:
print(f" {img['key']} ({img['size']} bytes)")
if len(images) > 5:
print(f" ... et {len(images) - 5} autres")pythonVoir le code
# ============================================
# Fonction pour lire une image depuis MinIO
# ============================================
from PIL import Image
def read_image_from_minio(bucket: str, key: str) -> tuple:
"""
Lit une image depuis MinIO.
Returns:
(image_bytes, PIL.Image) ou (None, None) si erreur
"""
try:
response = s3_client.get_object(Bucket=bucket, Key=key)
image_bytes = response['Body'].read()
img = Image.open(io.BytesIO(image_bytes))
return image_bytes, img
except Exception as e:
print(f"❌ Erreur lecture {key}: {e}")
return None, None
def write_image_to_minio(bucket: str, key: str, image_bytes: bytes, metadata: dict = None):
"""
Écrit une image dans MinIO.
"""
extra_args = {'ContentType': 'image/jpeg'}
if metadata:
extra_args['Metadata'] = {k: str(v) for k, v in metadata.items()}
s3_client.put_object(
Bucket=bucket,
Key=key,
Body=image_bytes,
**extra_args
)
# Test : lire la première image
if images:
test_key = images[0]['key']
img_bytes, img = read_image_from_minio(SOURCE_BUCKET, test_key)
if img:
print(f"✅ Image lue : {test_key}")
print(f" Taille : {img.size}")
print(f" Mode : {img.mode}")
print(f" Bytes : {len(img_bytes)}")
else:
print("⚠️ Aucune image à lire. Vérifie le Module 2.")🔄 Alternative : Générer des images de test
Si tu n'as pas complété le Module 2 ou si tu veux tester rapidement, exécute la cellule suivante pour créer des images de test :
pythonVoir le code
# ============================================
# OPTIONNEL : Générer des images de test
# (Si tu n'as pas fait le Module 2)
# ============================================
import numpy as np
def generate_test_images_to_minio(bucket: str, n: int = 20):
"""
Génère des images de test et les uploade dans MinIO.
"""
create_bucket_if_not_exists(bucket)
print(f"📤 Génération de {n} images de test...")
for i in range(n):
# Créer une image aléatoire avec taille variable
w = np.random.randint(128, 512)
h = np.random.randint(128, 512)
arr = np.random.randint(0, 255, (h, w, 3), dtype=np.uint8)
img = Image.fromarray(arr)
# Convertir en bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=85)
img_bytes = buffer.getvalue()
# Upload
key = f"test/image_{i:04d}.jpg"
write_image_to_minio(bucket, key, img_bytes, {'width': str(w), 'height': str(h)})
print(f"✅ {n} images générées dans '{bucket}/test/'")
# Décommente pour générer des images de test
# generate_test_images_to_minio(SOURCE_BUCKET, n=20)3.2 Pourquoi les images comme exemple ?
Dans ce module, on utilise les images comme fil conducteur. Pourquoi ?
| Raison | Explication |
|---|---|
| Concret | Tu vois directement le résultat de tes transformations |
| Représentatif | Les concepts s'appliquent à tous les types de données |
| Demandé | Vision par ordinateur = cas d'usage majeur de l'IA |
Les mêmes concepts s'appliquent à tous les types de données
| Concept | Images | Audio | Texte | Vidéo |
|---|---|---|---|---|
| Uniformiser | Resize, crop | Resample, trim | Tokenize, pad | Extract frames |
| Normaliser | [0,1] ou [-1,1] | Amplitude norm | Lowercase, clean | Frame norm |
| Augmenter | Flip, rotate | Speed, pitch | Synonym, back-translate | Temporal augment |
| Valider | Corruption check | Format check | Encoding check | Frame integrity |
💡 Ce que tu apprends ici sur les images, tu pourras l'adapter à l'audio (speech-to-text), au texte (LLMs), ou à la vidéo.
3.2.1 Ce que ce module couvre (et ne couvre pas)
Ce module te donne les fondamentaux — pas une liste exhaustive de toutes les transformations possibles.
| ✅ Couvert | ❌ Non couvert (à explorer plus tard) |
|---|---|
| Resize, crop, padding | Transformations géométriques avancées |
| Normalisation standard | Normalisation spécifique par modèle |
| Augmentation de base | Mixup, CutMix, AutoAugment |
| Détection de doublons | Clustering avancé |
| Pipeline Spark | Optimisations GPU (DALI, etc.) |
🎯 L'objectif : Comprendre les principes et savoir construire un pipeline. Les techniques avancées viendront avec l'expérience.
3.2.2 Pourquoi Spark ?
Pour le traitement distribué, plusieurs options existent :
| Outil | Forces | Faiblesses | Quand l'utiliser |
|---|---|---|---|
| Python multiprocessing | Simple, natif | Limité à 1 machine | < 100K fichiers |
| Dask | Pythonic, flexible | Moins mature pour le ML | Datasets moyens |
| Ray | Excellent pour ML | Plus complexe à setup | Training distribué |
| Spark | Standard industrie, scalable | Overhead pour petits datasets | > 1M fichiers |
On choisit Spark parce que :
- Standard industrie — Tu le retrouveras dans la plupart des entreprises
- Vraiment distribué — Fonctionne sur 1 machine ou 1000 nœuds
- Écosystème riche — Intégration S3, Parquet, Delta Lake
- Compétence valorisée — Recherchée sur le marché du travail
┌─────────────────────────────────────────────────────────────────┐
│ QUAND UTILISER QUOI ? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ < 10K fichiers → Python simple (boucle + threads) │
│ 10K - 100K → Multiprocessing ou Dask │
│ 100K - 1M → Spark (local) ou Dask │
│ > 1M fichiers → Spark (cluster) │
│ │
│ 💡 Dans ce module, on utilise Spark en mode local. │
│ Le même code fonctionnera sur un cluster de 100 machines. │
│ │
└─────────────────────────────────────────────────────────────────┘
Setup de l'environnement
On étend l'environnement du Module 2 avec Spark :
# docker-compose.yml (ajout à celui du Module 2)
version: '3.8'
services:
minio:
image: minio/minio:latest
container_name: minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
spark-master:
image: bitnami/spark:3.5
container_name: spark-master
environment:
- SPARK_MODE=master
ports:
- "8080:8080" # Spark UI
- "7077:7077" # Spark Master
spark-worker:
image: bitnami/spark:3.5
container_name: spark-worker
environment:
- SPARK_MODE=worker
- SPARK_MASTER_URL=spark://spark-master:7077
depends_on:
- spark-master
volumes:
minio_data:
# Lancer l'environnement
docker-compose up -d
# Vérifier Spark UI : http://localhost:8080
3.3 Transformations fondamentales
Pourquoi transformer les images ?
Chaque transformation répond à un problème concret du ML :
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROBLÈME → TRANSFORMATION → BÉNÉFICE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PROBLÈME TRANSFORMATION BÉNÉFICE │
│ ──────── ────────────── ──────── │
│ │
│ Tailles variées RESIZE Batching possible │
│ (64px à 4000px) (256×256) GPU efficace │
│ │
│ Valeurs [0-255] NORMALIZE Training stable │
│ (échelle arbitraire) ([0,1] ou [-1,1]) Convergence rapide │
│ │
│ Modes mixtes CONVERT Format uniforme │
│ (RGB, RGBA, L, P) (→ RGB) Pas d'erreurs │
│ │
│ Formats variés ENCODE Stockage optimisé │
│ (PNG, BMP, TIFF) (→ JPEG/WebP) I/O rapide │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3.3.1 RESIZE — Uniformiser les dimensions
Le problème :
┌─────────────────────────────────────────────────────────────────────────────┐
│ Ton dataset contient : │
│ │
│ ┌────┐ ┌──────────┐ ┌───┐ ┌────────────────┐ │
│ │64px│ │ 800px │ │256│ │ 2000px │ │
│ │ │ │ │ │ │ │ │ │
│ └────┘ └──────────┘ └───┘ └────────────────┘ │
│ │
│ ❌ Impossible de faire un batch (le GPU attend des tenseurs identiques) │
│ ❌ Une image 4000×4000 = 48 MB en mémoire (explose le GPU) │
│ ❌ Temps de chargement très variable │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
La solution : tout ramener à une taille fixe
| Taille cible | Usage typique | Mémoire/image |
|---|---|---|
| 224×224 | Classification (ResNet, VGG) | ~150 KB |
| 256×256 | Usage général, embeddings | ~200 KB |
| 512×512 | Génération d'images (SD) | ~800 KB |
| 1024×1024 | Haute qualité (SDXL) | ~3 MB |
Les modes de resize et quand les utiliser :
| Mode | Comportement | Quand l'utiliser |
|---|---|---|
| EXACT | Force la taille, déforme | ❌ Rarement (déforme l'image) |
| FIT | Garde le ratio, taille variable | ⚠️ Si tu gères le padding après |
| COVER | Garde le ratio, crop le surplus | ✅ Classification (sujet centré) |
| PAD | Garde le ratio, ajoute du noir | ✅ Détection d'objets, génération |
3.3.2 NORMALIZE — Mettre à l'échelle les valeurs
Le problème :
┌─────────────────────────────────────────────────────────────────────────────┐
│ Les pixels sont en [0, 255] (entiers) │
│ │
│ Neurone : output = activation(W × pixel + b) │
│ │
│ Si pixel = 255 et W = 0.01 : │
│ W × pixel = 2.55 (valeur raisonnable ✅) │
│ │
│ Si pixel = 255 et W = 1.0 : │
│ W × pixel = 255 (valeur ÉNORME ❌) │
│ → Gradients explosent │
│ → Training instable │
│ → Le modèle ne converge pas │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
La solution : normaliser les valeurs
| Normalisation | Formule | Range | Usage |
|---|---|---|---|
| [0, 1] | pixel / 255 |
0 → 1 | Standard, simple |
| [-1, 1] | (pixel / 127.5) - 1 |
-1 → 1 | GANs, Stable Diffusion |
| ImageNet | (pixel/255 - mean) / std |
~[-2, 2] | Classification pré-entraînée |
Pourquoi [-1, 1] pour les GANs ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ La fonction tanh() (souvent utilisée en sortie de générateur) │
│ produit des valeurs dans [-1, 1] │
│ │
│ Si tes images cibles sont en [0, 1] mais le générateur produit [-1, 1] │
│ → Le discriminateur voit une différence systématique │
│ → Training biaisé │
│ │
│ 💡 Aligne toujours tes données avec la sortie attendue du modèle │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3.3.3 CONVERT — Uniformiser le mode couleur et le format
Le problème des modes couleur :
┌─────────────────────────────────────────────────────────────────────────────┐
│ Ton dataset contient : │
│ │
│ image1.jpg → RGB (3 canaux) → shape: (256, 256, 3) │
│ image2.png → RGBA (4 canaux) → shape: (256, 256, 4) ← DIFFÉRENT ! │
│ image3.gif → P (palette) → shape: (256, 256) ← CRASH ! │
│ image4.bmp → L (grayscale) → shape: (256, 256) ← CRASH ! │
│ │
│ Le modèle attend (batch, 3, 256, 256) pour TOUTES les images │
│ → Erreur de dimension si les canaux varient │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
La solution : tout convertir en RGB
| Mode source | Conversion vers RGB | Attention |
|---|---|---|
| RGBA | Composite sur fond blanc | Perte de la transparence |
| L (grayscale) | Dupliquer sur 3 canaux | R=G=B |
| P (palette) | Convertir via palette | Peut avoir transparence |
| CMYK | Conversion colorimétrique | Rarement rencontré |
Le problème des formats de fichier :
| Format source | Problème | Solution |
|---|---|---|
| BMP | Énorme (pas de compression) | → JPEG |
| TIFF | Complexe, variable | → JPEG ou PNG |
| GIF | Palette 256 couleurs max | → PNG ou JPEG |
| PNG | Gros pour les photos | → JPEG (si pas de transparence) |
Recommandation pour le ML :
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ Photos / Images naturelles → JPEG (quality=85-95) │
│ Screenshots / Graphiques → PNG (lossless) │
│ Web / Mobile → WebP (meilleur ratio qualité/taille) │
│ │
│ 💡 JPEG quality=85 est un bon compromis : │
│ - Compression ~10x vs BMP │
│ - Artefacts invisibles à l'œil │
│ - Acceptable pour le ML (le modèle apprend à ignorer le bruit) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
pythonVoir le code
# ============================================
# Imports et configuration
# ============================================
# !pip install pillow numpy matplotlib pydantic
from PIL import Image, ImageOps, ImageFilter, ImageEnhance
import numpy as np
import io
import hashlib
from typing import Tuple, Optional, List, Union
from dataclasses import dataclass
from enum import Enum
import matplotlib.pyplot as plt
# Configuration matplotlib
plt.rcParams['figure.figsize'] = (12, 4)
print("✅ Imports chargés")pythonVoir le code
# ============================================
# Créer une image de test
# ============================================
def create_test_image(width: int = 400, height: int = 300) -> Image.Image:
"""Crée une image de test avec un gradient."""
arr = np.zeros((height, width, 3), dtype=np.uint8)
# Gradient horizontal (rouge → bleu)
for x in range(width):
arr[:, x, 0] = int(255 * (1 - x / width)) # Rouge décroissant
arr[:, x, 2] = int(255 * x / width) # Bleu croissant
# Gradient vertical (vert)
for y in range(height):
arr[y, :, 1] = int(255 * y / height)
return Image.fromarray(arr)
# Créer et afficher
test_img = create_test_image(400, 300)
print(f"📷 Image créée: {test_img.size} - Mode: {test_img.mode}")
plt.imshow(test_img)
plt.title(f"Image originale: {test_img.size}")
plt.axis('off')
plt.show()Pourquoi resize ? Le problème du batching
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI UNIFORMISER LES TAILLES ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PROBLÈME : Le GPU traite des BATCHES (lots d'images simultanées) │
│ │
│ Batch = Tensor de forme [N, C, H, W] │
│ │ │ │ └── Largeur (doit être identique) │
│ │ │ └───── Hauteur (doit être identique) │
│ │ └──────── Canaux (3 pour RGB) │
│ └─────────── Nombre d'images dans le batch │
│ │
│ ❌ IMPOSSIBLE : ✅ POSSIBLE : │
│ │
│ Image 1: 1920×1080 Image 1: 256×256 │
│ Image 2: 640×480 ──────────▶ Image 2: 256×256 │
│ Image 3: 800×600 Image 3: 256×256 │
│ │
│ Tailles différentes Tailles identiques │
│ = pas de batching = batching efficace │
│ = training 10-100x plus lent = utilisation GPU optimale │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Situation | Conséquence |
|---|---|
| Tailles variées | Batch size = 1, GPU sous-utilisé, training très lent |
| Tailles uniformes | Batch size = 32/64/128, GPU à 100%, training rapide |
💡 Règle : Toutes les images d'un batch doivent avoir exactement les mêmes dimensions.
Quel mode de resize choisir ?
| Mode | Ce qu'il fait | Avantage | Inconvénient | Quand l'utiliser |
|---|---|---|---|---|
| EXACT | Force W×H | Taille garantie | Déforme l'image | ❌ Rarement (perte d'info) |
| FIT | Garde ratio, tient dans la boîte | Pas de déformation | Taille variable | Si taille variable OK |
| COVER | Garde ratio, remplit + crop | Taille fixe, pas de padding | Perd des bords | Quand les bords sont peu importants |
| PAD | Garde ratio, ajoute padding | Taille fixe, rien perdu | Pixels "vides" | ✅ Recommandé pour ML |
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI PAD EST RECOMMANDÉ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Image originale EXACT (déforme) PAD (préserve) │
│ ┌──────────────┐ ┌────────┐ ┌────────┐ │
│ │ │ │████████│ │▒▒████▒▒│ │
│ │ Chat │ ──▶ │██Chat██│ ou │▒▒Chat▒▒│ │
│ │ allongé │ │████████│ │▒▒████▒▒│ │
│ │ │ └────────┘ └────────┘ │
│ └──────────────┘ Chat écrasé Chat intact + padding │
│ ❌ Perte d'info ✅ Info préservée │
│ │
│ Le modèle apprend à ignorer le padding (pixels noirs/gris uniformes) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Resize : uniformiser les dimensions
Pourquoi c'est indispensable ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI RESIZE ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SANS RESIZE AVEC RESIZE │
│ ─────────── ─────────── │
│ │
│ Image 1: 1920×1080 Image 1: 256×256 │
│ Image 2: 640×480 Image 2: 256×256 │
│ Image 3: 3000×2000 Image 3: 256×256 │
│ │
│ ❌ Impossible de créer un batch ✅ Batch de 3 images │
│ (tailles différentes) (même tensor 3×256×256×3) │
│ │
│ ❌ Training image par image ✅ Training par batch de 32/64 │
│ (très lent, GPU sous-utilisé) (GPU utilisé à 100%) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Problème sans resize | Conséquence | Impact |
|---|---|---|
| Tailles variables | Pas de batching | Training 10-100x plus lent |
| Images trop grandes | Out of Memory (OOM) | Crash GPU |
| Images trop petites | Perte de détails après resize up | Qualité dégradée |
💡 Tailles courantes : 224×224 (ImageNet), 256×256, 512×512 (Stable Diffusion), 1024×1024 (haute qualité)
pythonVoir le code
# ============================================
# Resize : redimensionner les images
# ============================================
class ResizeMode(Enum):
"""Modes de redimensionnement."""
EXACT = "exact" # Force la taille exacte (peut déformer)
FIT = "fit" # Garde le ratio, tient dans la boîte
COVER = "cover" # Garde le ratio, couvre la boîte (avec crop)
PAD = "pad" # Garde le ratio, ajoute du padding
def resize_image(
img: Image.Image,
target_size: Tuple[int, int],
mode: ResizeMode = ResizeMode.FIT,
resample: int = Image.Resampling.LANCZOS,
pad_color: Tuple[int, int, int] = (0, 0, 0)
) -> Image.Image:
"""
Redimensionne une image selon le mode choisi.
Args:
img: Image PIL
target_size: (width, height) cible
mode: Mode de redimensionnement
resample: Algorithme de resampling
pad_color: Couleur du padding (pour mode PAD)
"""
target_w, target_h = target_size
orig_w, orig_h = img.size
if mode == ResizeMode.EXACT:
return img.resize(target_size, resample)
# Calculer le ratio
ratio_w = target_w / orig_w
ratio_h = target_h / orig_h
if mode == ResizeMode.FIT:
ratio = min(ratio_w, ratio_h)
new_size = (int(orig_w * ratio), int(orig_h * ratio))
return img.resize(new_size, resample)
elif mode == ResizeMode.COVER:
ratio = max(ratio_w, ratio_h)
new_size = (int(orig_w * ratio), int(orig_h * ratio))
resized = img.resize(new_size, resample)
# Centrer et cropper
left = (resized.width - target_w) // 2
top = (resized.height - target_h) // 2
return resized.crop((left, top, left + target_w, top + target_h))
elif mode == ResizeMode.PAD:
ratio = min(ratio_w, ratio_h)
new_size = (int(orig_w * ratio), int(orig_h * ratio))
resized = img.resize(new_size, resample)
# Créer un canvas et centrer
canvas = Image.new('RGB', target_size, pad_color)
paste_x = (target_w - resized.width) // 2
paste_y = (target_h - resized.height) // 2
canvas.paste(resized, (paste_x, paste_y))
return canvas
# Démonstration
target = (256, 256)
fig, axes = plt.subplots(1, 5, figsize=(15, 3))
axes[0].imshow(test_img)
axes[0].set_title(f"Original\n{test_img.size}")
for i, mode in enumerate(ResizeMode):
resized = resize_image(test_img, target, mode)
axes[i+1].imshow(resized)
axes[i+1].set_title(f"{mode.value}\n{resized.size}")
for ax in axes:
ax.axis('off')
plt.tight_layout()
plt.show()Pourquoi normaliser ? La stabilité du training
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI NORMALISER LES PIXELS ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PROBLÈME : Les réseaux de neurones sont sensibles à l'échelle des valeurs│
│ │
│ Pixels bruts [0, 255] Pixels normalisés [-1, 1] │
│ ───────────────────── ──────────────────────── │
│ │
│ • Valeurs grandes (255) • Valeurs petites, centrées │
│ • Gradients instables • Gradients stables │
│ • Learning rate difficile • Learning rate standard │
│ • Convergence lente/impossible • Convergence rapide │
│ │
│ ❌ Training chaotique ✅ Training stable │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Normalisation | Formule | Plage | Usage typique |
|---|---|---|---|
| [0, 1] | pixel / 255 | 0 à 1 | Modèles simples, autoencoders |
| [-1, 1] | (pixel / 127.5) - 1 | -1 à 1 | GANs, Stable Diffusion |
| ImageNet | (pixel - mean) / std | Variable | Classification (ResNet, ViT) |
💡 Règle : Utilise la même normalisation au training ET à l'inférence. Sinon, le modèle verra des données complètement différentes !
Normalisation : stabiliser le training
Pourquoi c'est crucial ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI NORMALISER ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SANS NORMALISATION AVEC NORMALISATION │
│ ────────────────── ─────────────────── │
│ │
│ Pixels: [0, 255] Pixels: [-1, 1] ou [0, 1] │
│ │
│ Problèmes: Avantages: │
│ • Gradients énormes (×255) • Gradients stables │
│ • Poids du réseau explosent • Convergence rapide │
│ • Learning rate très petit • Learning rate standard │
│ • Training instable • Training stable │
│ │
│ Loss: 📈📉📈📉📈📉 (oscille) Loss: 📉📉📉📉 (descend) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Mode | Formule | Quand l'utiliser |
|---|---|---|
| [0, 1] | pixel / 255 |
La plupart des cas |
| [-1, 1] | (pixel / 127.5) - 1 |
GANs, Stable Diffusion |
| ImageNet | (pixel - mean) / std |
Modèles pré-entraînés sur ImageNet |
⚠️ Important : Utilise la MÊME normalisation pour le training ET l'inférence !
pythonVoir le code
# ============================================
# Normalisation : convertir les valeurs de pixels
# ============================================
def normalize_image(
img: Image.Image,
mode: str = "0_1"
) -> np.ndarray:
"""
Normalise les pixels d'une image.
Args:
img: Image PIL
mode:
"0_1" → [0, 1]
"-1_1" → [-1, 1]
"imagenet" → Normalisation ImageNet (mean, std)
Returns:
numpy array normalisé (H, W, C) ou (C, H, W)
"""
arr = np.array(img).astype(np.float32)
if mode == "0_1":
return arr / 255.0
elif mode == "-1_1":
return (arr / 127.5) - 1.0
elif mode == "imagenet":
# Normalisation ImageNet standard
mean = np.array([0.485, 0.456, 0.406])
std = np.array([0.229, 0.224, 0.225])
arr = arr / 255.0
return (arr - mean) / std
else:
raise ValueError(f"Mode inconnu: {mode}")
# Démonstration
small_img = create_test_image(4, 4)
arr_original = np.array(small_img)
print("📊 Normalisation des pixels:\n")
print(f"Original [0-255]:")
print(f" Min: {arr_original.min()}, Max: {arr_original.max()}")
for mode in ["0_1", "-1_1", "imagenet"]:
normalized = normalize_image(small_img, mode)
print(f"\n{mode}:")
print(f" Min: {normalized.min():.3f}, Max: {normalized.max():.3f}")Pourquoi convertir le mode couleur ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI UNIFORMISER LE MODE COULEUR ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PROBLÈME : Les images ont des modes différents │
│ │
│ Image 1: RGB (3 canaux) ┐ │
│ Image 2: RGBA (4 canaux) ├──▶ Shapes différentes = crash ! │
│ Image 3: L (1 canal) ┘ │
│ │
│ SOLUTION : Tout convertir en RGB (3 canaux) │
│ │
│ RGBA → RGB : Fusionner la transparence avec un fond blanc │
│ L → RGB : Dupliquer le canal gris 3 fois │
│ P → RGB : Convertir la palette en vraies couleurs │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Pourquoi convertir le format de fichier ?
| Situation | Problème | Solution |
|---|---|---|
| PNG 10 MB | Fichiers énormes, I/O lent | Convertir en JPEG (500 KB) |
| BMP legacy | Pas de compression | Convertir en JPEG/WebP |
| Formats mixtes | Décodeurs différents | Uniformiser en JPEG |
| GIF animé | Plusieurs frames | Extraire la première frame |
💡 Pour le ML : JPEG quality 85-95 est un bon compromis taille/qualité. WebP est encore mieux si ton pipeline le supporte.
Conversion de format : uniformiser le pipeline
Pourquoi c'est nécessaire ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI CONVERTIR ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ DONNÉES BRUTES APRÈS CONVERSION │
│ ────────────── ───────────────── │
│ │
│ image1.png (RGBA, 4 canaux) image1.jpg (RGB, 3 canaux) │
│ image2.gif (P, palette) image2.jpg (RGB, 3 canaux) │
│ image3.bmp (RGB, 50 MB) image3.jpg (RGB, 500 KB) │
│ image4.webp (RGBA) image4.jpg (RGB, 3 canaux) │
│ │
│ ❌ 4 formats différents ✅ 1 format unique │
│ ❌ Canaux variables (3 ou 4) ✅ Toujours 3 canaux │
│ ❌ Taille de stockage variable ✅ Compression optimisée │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Problème | Sans conversion | Avec conversion |
|---|---|---|
| RGBA → RGB | 4 canaux au lieu de 3 → Erreur de shape | Transparence gérée (fond blanc) |
| Palette (P) | Index au lieu de couleurs → Couleurs fausses | Converti en vraies couleurs RGB |
| Grayscale (L) | 1 canal → Shape incompatible | Converti en RGB (3 canaux identiques) |
| BMP non compressé | 50 MB par image → Stockage x100 | JPEG 500 KB → Économie massive |
💡 Conseil : JPEG quality=85-90 est un bon compromis qualité/taille pour le ML
pythonVoir le code
# ============================================
# Conversion de format et mode couleur
# ============================================
def convert_image(
img: Image.Image,
target_mode: str = "RGB",
target_format: str = "JPEG",
quality: int = 85
) -> bytes:
"""
Convertit une image vers un mode couleur et format cible.
Args:
img: Image PIL
target_mode: Mode couleur cible (RGB, L, RGBA)
target_format: Format de sortie (JPEG, PNG, WEBP)
quality: Qualité de compression (pour JPEG/WEBP)
Returns:
bytes de l'image convertie
"""
# Convertir le mode couleur
if img.mode != target_mode:
# Gérer les cas spéciaux
if img.mode == 'RGBA' and target_mode == 'RGB':
# Créer un fond blanc et composite
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3]) # Alpha channel
img = background
else:
img = img.convert(target_mode)
# Sauvegarder dans le format cible
buffer = io.BytesIO()
save_kwargs = {}
if target_format.upper() in ['JPEG', 'WEBP']:
save_kwargs['quality'] = quality
img.save(buffer, format=target_format, **save_kwargs)
return buffer.getvalue()
# Démonstration
print("🔄 Conversion de formats:\n")
# Créer une image RGBA (avec transparence)
rgba_img = Image.new('RGBA', (256, 256), (255, 0, 0, 128)) # Rouge semi-transparent
print(f"Image originale: mode={rgba_img.mode}")
for fmt in ['JPEG', 'PNG', 'WEBP']:
converted = convert_image(rgba_img, target_mode='RGB', target_format=fmt)
print(f" → {fmt}: {len(converted)} bytes")3.4 Traitement distribué avec Spark
Architecture Spark pour le traitement d'images
┌─────────────────────────────────────────────────────────────────────────────┐
│ SPARK POUR LES IMAGES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ MinIO (source) Spark Cluster MinIO (destination) │
│ ──────────── ─────────────── ─────────────────── │
│ │
│ ┌──────────┐ ┌─────────────┐ ┌──────────────────┐ │
│ │ raw/ │ │ Driver │ │ processed/ │ │
│ │ img1.jpg │─────────▶│ Program │────────────▶│ img1.jpg │ │
│ │ img2.jpg │ └──────┬──────┘ │ img2.jpg │ │
│ │ ... │ │ │ ... │ │
│ │ imgN.jpg │ ┌──────┴──────┐ │ imgN.jpg │ │
│ └──────────┘ │ │ └──────────────────┘ │
│ ┌─────┴─────┐ ┌─────┴─────┐ │
│ │ Worker 1 │ │ Worker 2 │ │
│ │ (1000 │ │ (1000 │ │
│ │ images) │ │ images) │ │
│ └───────────┘ └───────────┘ │
│ │
│ 💡 Chaque worker traite un sous-ensemble en parallèle │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
pythonVoir le code
# ============================================
# Setup PySpark (mode local pour la démo)
# ============================================
# !pip install pyspark
from pyspark.sql import SparkSession
from pyspark.sql.functions import udf, col, lit
from pyspark.sql.types import (
StructType, StructField, StringType, IntegerType, BinaryType, BooleanType
)
# Créer une session Spark locale
spark = SparkSession.builder \
.appName("ImageProcessing") \
.master("local[*]") \
.config("spark.driver.memory", "4g") \
.getOrCreate()
# Réduire les logs
spark.sparkContext.setLogLevel("WARN")
print(f"✅ Spark initialisé")
print(f" Version: {spark.version}")
print(f" UI: http://localhost:4040")pythonVoir le code
# ============================================
# Transformation d'images avec Spark
# ============================================
def process_image_bytes(image_bytes: bytes, target_size: int = 256) -> dict:
"""
Transforme une image (fonction exécutée sur chaque worker).
Returns:
Dict avec les infos de l'image transformée
"""
try:
# Ouvrir l'image
img = Image.open(io.BytesIO(image_bytes))
# Convertir en RGB si nécessaire
if img.mode != 'RGB':
if img.mode == 'RGBA':
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
else:
img = img.convert('RGB')
# Resize (mode PAD pour garder le ratio)
img = resize_image(img, (target_size, target_size), ResizeMode.PAD)
# Convertir en JPEG
output_bytes = convert_image(img, 'RGB', 'JPEG', quality=85)
# Calculer le hash
md5_hash = hashlib.md5(output_bytes).hexdigest()
return {
'success': True,
'output_bytes': output_bytes,
'width': target_size,
'height': target_size,
'size_bytes': len(output_bytes),
'md5_hash': md5_hash,
'error': None
}
except Exception as e:
return {
'success': False,
'output_bytes': None,
'width': 0,
'height': 0,
'size_bytes': 0,
'md5_hash': None,
'error': str(e)
}
# Test de la fonction
test_bytes = convert_image(create_test_image(400, 300), 'RGB', 'JPEG')
result = process_image_bytes(test_bytes, 256)
print(f"✅ Test transformation:")
print(f" Success: {result['success']}")
print(f" Size: {result['width']}x{result['height']}")
print(f" Bytes: {result['size_bytes']}")pythonVoir le code
# ============================================
# Pipeline Spark complet (simulation)
# ============================================
# Simuler des données d'entrée
def generate_test_data(n: int = 100) -> list:
"""Génère des images de test avec différentes tailles."""
data = []
for i in range(n):
# Tailles variées
w = np.random.randint(64, 512)
h = np.random.randint(64, 512)
img = create_test_image(w, h)
img_bytes = convert_image(img, 'RGB', 'JPEG')
data.append({
'id': f'img_{i:05d}',
'original_width': w,
'original_height': h,
'image_bytes': img_bytes
})
return data
# Générer les données
print("📷 Génération de 100 images de test...")
test_data = generate_test_data(100)
print(f" ✅ {len(test_data)} images générées")
# Créer un DataFrame Spark
schema = StructType([
StructField("id", StringType(), False),
StructField("original_width", IntegerType(), False),
StructField("original_height", IntegerType(), False),
StructField("image_bytes", BinaryType(), False)
])
df = spark.createDataFrame(test_data, schema)
print(f"\n📊 DataFrame Spark créé: {df.count()} lignes")
df.printSchema()pythonVoir le code
# ============================================
# Appliquer la transformation avec Spark UDF
# ============================================
from pyspark.sql.functions import pandas_udf
import pandas as pd
# Définir le schéma de sortie
output_schema = StructType([
StructField("success", BooleanType(), False),
StructField("output_bytes", BinaryType(), True),
StructField("width", IntegerType(), False),
StructField("height", IntegerType(), False),
StructField("size_bytes", IntegerType(), False),
StructField("md5_hash", StringType(), True),
StructField("error", StringType(), True)
])
# UDF pour la transformation
@udf(output_schema)
def transform_image_udf(image_bytes: bytes) -> dict:
return process_image_bytes(image_bytes, target_size=256)
# Appliquer la transformation
print("🔄 Transformation avec Spark...")
df_transformed = df.withColumn("result", transform_image_udf(col("image_bytes")))
# Extraire les champs du résultat
df_final = df_transformed.select(
col("id"),
col("original_width"),
col("original_height"),
col("result.success").alias("success"),
col("result.width").alias("new_width"),
col("result.height").alias("new_height"),
col("result.size_bytes").alias("new_size_bytes"),
col("result.md5_hash").alias("md5_hash"),
col("result.error").alias("error")
)
# Afficher les résultats
print("\n📊 Résultats:")
df_final.show(5, truncate=False)pythonVoir le code
# ============================================
# Statistiques de la transformation
# ============================================
from pyspark.sql.functions import sum as spark_sum, count, avg
# Calculer les stats
stats = df_final.agg(
count("*").alias("total"),
spark_sum(col("success").cast("int")).alias("success_count"),
avg("new_size_bytes").alias("avg_size")
).collect()[0]
print("📊 Statistiques de transformation:\n")
print(f" Total images: {stats['total']}")
print(f" Succès: {stats['success_count']}")
print(f" Échecs: {stats['total'] - stats['success_count']}")
print(f" Taille moyenne: {stats['avg_size']:.0f} bytes")
print(f" Taux de succès: {stats['success_count']/stats['total']*100:.1f}%")3.5 Schema Enforcement avec Pydantic
Pourquoi valider AVANT de transformer ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ SCHEMA ENFORCEMENT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SANS VALIDATION AVEC VALIDATION │
│ ─────────────── ─────────────── │
│ │
│ Image arrive → Transformation Image arrive → Validation │
│ (format inconnu) (peut crasher) (format inconnu) (Pydantic) │
│ │ │
│ ❌ Erreur en plein traitement ┌───────┴───────┐ │
│ ❌ Difficile à debugger ▼ ▼ │
│ ❌ Pipeline corrompu Valide Invalide │
│ │ │ │
│ ▼ ▼ │
│ Transformation Rejeté │
│ (sûre) (loggé) │
│ │
│ 💡 Valider tôt = échouer tôt = debugger facilement │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
🔧 Intérêt concret du Schema Enforcement
Scénario réel :
┌─────────────────────────────────────────────────────────────────────────────┐
│ SANS VALIDATION vs AVEC VALIDATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SANS VALIDATION │
│ ─────────────── │
│ │
│ Image 1 ────▶ Transform ────▶ ✅ OK │
│ Image 2 ────▶ Transform ────▶ ✅ OK │
│ ... │
│ Image 9999 ──▶ Transform ────▶ ✅ OK │
│ Image 10000 ─▶ Transform ────▶ 💥 CRASH (image corrompue) │
│ │
│ → 3 heures de calcul perdues │
│ → Debug difficile (quelle image ?) │
│ → Pipeline à relancer │
│ │
│ AVEC VALIDATION (Pydantic) │
│ ────────────────────────── │
│ │
│ Image 1 ────▶ Validate ─▶ ✅ ─▶ Transform │
│ Image 2 ────▶ Validate ─▶ ❌ ─▶ Log + Skip (grayscale inattendu) │
│ Image 3 ────▶ Validate ─▶ ❌ ─▶ Log + Skip (trop petite) │
│ Image 4 ────▶ Validate ─▶ ✅ ─▶ Transform │
│ │
│ → Erreurs détectées IMMÉDIATEMENT │
│ → Pipeline continue sans crash │
│ → Rapport clair des problèmes │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Exemples de problèmes détectés par la validation :
| Problème | Conséquence sans validation | Détection Pydantic |
|---|---|---|
| Image grayscale (1 canal) au lieu de RGB | IndexError: index 2 out of bounds |
mode not in ['RGB', 'RGBA'] |
| Image 10×10 pixels | Resize produit des artefacts | width < 32 |
| Image 50000×50000 | OOM, crash mémoire | width > 10000 |
| Fichier PDF renommé .jpg | UnidentifiedImageError |
format not in ['JPEG', 'PNG'] |
| Ratio 1:100 (bandeau) | Resize déforme complètement | aspect_ratio > 10 |
Schema Enforcement : pourquoi valider en amont ?
Le coût des erreurs tardives
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI VALIDER TÔT ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SANS VALIDATION AVEC VALIDATION (Pydantic) │
│ ─────────────── ─────────────────────── │
│ │
│ Image corrompue Image corrompue │
│ ↓ ↓ │
│ Stockage (OK) ❌ REJETÉE ICI │
│ ↓ │ │
│ Resize (OK) │ (économise tout le reste) │
│ ↓ │ │
│ Normalize (OK) │ │
│ ↓ │ │
│ WebDataset (OK) │ │
│ ↓ │ │
│ Training epoch 1... 2... 3... │ │
│ ↓ │ │
│ ❌ CRASH epoch 47 │ │
│ (après 3 jours de GPU) │ │
│ │
│ Coût: $$$$ + 3 jours perdus Coût: 0.001 seconde │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Quand valider | Coût de l'erreur | Difficulté de debug |
|---|---|---|
| À l'ingestion | ~0 | Trivial (on sait quelle image) |
| À la transformation | Minutes perdues | Facile |
| Au training | Heures/jours perdus | Difficile |
| En production | Modèle défaillant | Très difficile |
💡 Règle d'or : "Fail fast, fail early" — Échouer tôt coûte moins cher
pythonVoir le code
# ============================================
# Schema Enforcement avec Pydantic
# ============================================
# !pip install pydantic
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, Literal
class ImageSchema(BaseModel):
"""
Schéma de validation pour une image.
Définit les contraintes que chaque image doit respecter
AVANT d'être transformée.
"""
# Dimensions
width: int = Field(ge=32, le=10000, description="Largeur en pixels")
height: int = Field(ge=32, le=10000, description="Hauteur en pixels")
# Format et mode
format: Literal['JPEG', 'PNG', 'WEBP', 'GIF', 'BMP'] = Field(
description="Format de l'image"
)
mode: Literal['RGB', 'RGBA', 'L', 'P'] = Field(
description="Mode couleur"
)
# Taille du fichier
size_bytes: int = Field(ge=100, le=50_000_000, description="Taille en bytes")
# Ratio d'aspect (optionnel)
aspect_ratio: Optional[float] = None
@field_validator('aspect_ratio', mode='before')
@classmethod
def compute_aspect_ratio(cls, v, info):
if v is None and 'width' in info.data and 'height' in info.data:
return info.data['width'] / info.data['height']
return v
@model_validator(mode='after')
def validate_aspect_ratio(self):
"""Vérifie que le ratio d'aspect est raisonnable."""
ratio = self.width / self.height
if ratio < 0.1 or ratio > 10:
raise ValueError(f"Ratio d'aspect extrême: {ratio:.2f}")
return self
print("✅ Schéma Pydantic défini")pythonVoir le code
# ============================================
# Utiliser le schéma pour valider des images
# ============================================
def validate_image(img: Image.Image, img_bytes: bytes) -> tuple:
"""
Valide une image contre le schéma.
Returns:
(is_valid, schema_or_error)
"""
try:
schema = ImageSchema(
width=img.width,
height=img.height,
format=img.format or 'JPEG',
mode=img.mode,
size_bytes=len(img_bytes)
)
return True, schema
except Exception as e:
return False, str(e)
# Test avec différentes images
print("🔍 Tests de validation:\n")
test_cases = [
("Image normale", create_test_image(256, 256)),
("Image trop petite", create_test_image(16, 16)),
("Ratio extrême", create_test_image(500, 20)),
]
for name, img in test_cases:
img_bytes = convert_image(img, 'RGB', 'JPEG')
is_valid, result = validate_image(img, img_bytes)
status = "✅" if is_valid else "❌"
print(f"{status} {name} ({img.size}):")
if is_valid:
print(f" Validé: {result.width}x{result.height}, {result.format}")
else:
print(f" Erreur: {result[:80]}...")3.6 Data Quality : Validation et détection de corruption
Types de problèmes à détecter
| Problème | Impact | Détection |
|---|---|---|
| Fichier corrompu | Crash du training | Tenter d'ouvrir avec PIL |
| Format incorrect | Erreur de décodage | Vérifier les magic bytes |
| Image tronquée | Données manquantes | img.verify() |
| Taille anormale | Comportement erratique | Vérifier dimensions |
| Fichier vide | Crash | Vérifier size > 0 |
Pourquoi valider la qualité ? Les crashs silencieux
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI CONTRÔLER LA QUALITÉ ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PROBLÈME : Une seule image corrompue peut ruiner ton pipeline │
│ │
│ Scénario : │
│ • Tu lances un training sur 1 million d'images │
│ • Après 12 heures (image #847,293)... CRASH ! │
│ • Une image tronquée que PIL ne peut pas décoder │
│ • 12 heures perdues, 0 résultat │
│ │
│ SOLUTION : Valider AVANT, pas pendant │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Images │────▶│ Validate │────▶│ Train │────▶│ Model │ │
│ │ brutes │ │ (1h) │ │ (12h) │ │ final │ │
│ └──────────┘ └────┬─────┘ └──────────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ Rejetées │ ← Identifiées AVANT le training │
│ │ (logged) │ │
│ └──────────┘ │
│ │
│ ✅ 1h de validation = 0 crash pendant 12h de training │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Check | Ce qu'on détecte | Pourquoi c'est critique |
|---|---|---|
| Fichier vide | 0 bytes | Crash immédiat |
| Format invalide | Pas une image | PIL crash |
| Image tronquée | Téléchargement interrompu | Crash ou artefacts |
| Trop petite | < 32px | Pas assez d'info pour apprendre |
| Trop grande | > 10000px | OOM (Out of Memory) |
| Mode inattendu | CMYK, LAB | Crash si le modèle attend RGB |
pythonVoir le code
# ============================================
# Quality Checker complet
# ============================================
@dataclass
class QualityReport:
"""Rapport de qualité d'une image."""
is_valid: bool
errors: List[str]
warnings: List[str]
width: Optional[int] = None
height: Optional[int] = None
format: Optional[str] = None
mode: Optional[str] = None
size_bytes: Optional[int] = None
class ImageQualityChecker:
"""
Vérifie la qualité d'une image selon plusieurs critères.
"""
# Magic bytes pour détecter le format
MAGIC_BYTES = {
b'\xff\xd8\xff': 'JPEG',
b'\x89PNG': 'PNG',
b'GIF87a': 'GIF',
b'GIF89a': 'GIF',
b'RIFF': 'WEBP', # Simplifié
b'BM': 'BMP'
}
def __init__(
self,
min_size: int = 32,
max_size: int = 10000,
min_bytes: int = 100,
max_bytes: int = 50_000_000,
allowed_formats: List[str] = None
):
self.min_size = min_size
self.max_size = max_size
self.min_bytes = min_bytes
self.max_bytes = max_bytes
self.allowed_formats = allowed_formats or ['JPEG', 'PNG', 'WEBP', 'GIF', 'BMP']
def detect_format(self, data: bytes) -> Optional[str]:
"""Détecte le format à partir des magic bytes."""
for magic, fmt in self.MAGIC_BYTES.items():
if data[:len(magic)] == magic:
return fmt
return None
def check(self, image_bytes: bytes) -> QualityReport:
"""
Vérifie la qualité d'une image.
Returns:
QualityReport avec les résultats
"""
errors = []
warnings = []
# 1. Vérifier la taille du fichier
size_bytes = len(image_bytes)
if size_bytes < self.min_bytes:
errors.append(f"Fichier trop petit: {size_bytes} bytes")
if size_bytes > self.max_bytes:
errors.append(f"Fichier trop grand: {size_bytes} bytes")
# 2. Vérifier le format (magic bytes)
detected_format = self.detect_format(image_bytes)
if not detected_format:
errors.append("Format non reconnu (magic bytes)")
elif detected_format not in self.allowed_formats:
errors.append(f"Format non autorisé: {detected_format}")
# 3. Tenter d'ouvrir l'image
try:
img = Image.open(io.BytesIO(image_bytes))
# 4. Vérifier l'intégrité
try:
img.verify()
except Exception as e:
errors.append(f"Image corrompue: {str(e)}")
# Ré-ouvrir après verify()
img = Image.open(io.BytesIO(image_bytes))
# 5. Vérifier les dimensions
if img.width < self.min_size or img.height < self.min_size:
errors.append(f"Image trop petite: {img.width}x{img.height}")
if img.width > self.max_size or img.height > self.max_size:
errors.append(f"Image trop grande: {img.width}x{img.height}")
# 6. Warnings (non bloquants)
aspect_ratio = img.width / img.height
if aspect_ratio < 0.25 or aspect_ratio > 4:
warnings.append(f"Ratio d'aspect inhabituel: {aspect_ratio:.2f}")
if img.mode not in ['RGB', 'RGBA', 'L']:
warnings.append(f"Mode couleur peu commun: {img.mode}")
return QualityReport(
is_valid=len(errors) == 0,
errors=errors,
warnings=warnings,
width=img.width,
height=img.height,
format=img.format,
mode=img.mode,
size_bytes=size_bytes
)
except Exception as e:
errors.append(f"Impossible d'ouvrir: {str(e)}")
return QualityReport(
is_valid=False,
errors=errors,
warnings=warnings,
size_bytes=size_bytes
)
print("✅ Quality Checker défini")pythonVoir le code
# ============================================
# Tester le Quality Checker
# ============================================
checker = ImageQualityChecker(min_size=64, max_size=4096)
# Créer différents cas de test
test_cases = [
("Image valide", convert_image(create_test_image(256, 256), 'RGB', 'JPEG')),
("Image trop petite", convert_image(create_test_image(32, 32), 'RGB', 'JPEG')),
("Ratio extrême", convert_image(create_test_image(800, 50), 'RGB', 'JPEG')),
("Données corrompues", b'not an image at all'),
("Fichier vide", b''),
]
print("🔍 Tests de qualité:\n")
for name, data in test_cases:
report = checker.check(data)
status = "✅" if report.is_valid else "❌"
print(f"{status} {name}:")
if report.is_valid:
print(f" {report.width}x{report.height}, {report.format}, {report.size_bytes} bytes")
else:
for error in report.errors:
print(f" ⚠️ {error}")
if report.warnings:
for warning in report.warnings:
print(f" 💡 {warning}")
print()3.7 Déduplication
Pourquoi dédupliquer ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ IMPACT DES DUPLICATAS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Dans le dataset LAION-5B, certaines images apparaissaient │
│ des MILLIERS de fois. Conséquences : │
│ │
│ ❌ Overfitting : Le modèle mémorise au lieu d'apprendre │
│ ❌ Biais : Certains contenus sont surreprésentés │
│ ❌ Gaspillage : Stockage et compute inutiles │
│ ❌ Évaluation faussée : Fuites entre train/test │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Types de déduplication
| Type | Méthode | Détecte |
|---|---|---|
| Exact | Hash MD5/SHA256 | Copies identiques bit à bit |
| Perceptuel | pHash, dHash | Images visuellement similaires |
| Sémantique | Embeddings (CLIP) | Même sujet/contenu (Module 4) |
🔧 Intérêt concret de la déduplication
Scénario réel — Le désastre LAION-5B :
┌─────────────────────────────────────────────────────────────────────────────┐
│ IMPACT DES DUPLICATAS SUR LE TRAINING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Dataset avec duplicatas : │
│ │
│ 🐱 Chat1.jpg ×1 │
│ 🐕 Chien1.jpg ×1 │
│ 🌅 Sunset.jpg ×500 ← Très populaire sur le web │
│ 🐱 Chat2.jpg ×1 │
│ 🌅 Sunset.jpg ×500 ← Encore le même ! │
│ │
│ Résultat du training : │
│ ───────────────────── │
│ • Le modèle MÉMORISE sunset.jpg au lieu de généraliser │
│ • Génère toujours le même coucher de soleil │
│ • Mauvais sur les autres images (sous-représentées) │
│ │
│ C'est exactement ce qui est arrivé à Stable Diffusion v1 ! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Deux types de duplicatas à détecter :
| Type | Exemple | Méthode | Seuil |
|---|---|---|---|
| Exact | Même fichier copié | Hash MD5 | Identique = duplicata |
| Perceptuel | Même image recompressée, légèrement croppée | dHash/pHash | Distance < 5 = duplicata |
Exemple concret :
Image A : photo.jpg (original)
Image B : photo_resized.jpg (même photo, resize)
Image C : photo_compressed.jpg (même photo, JPEG q=50)
Hash MD5 : A ≠ B ≠ C → Pas détecté comme duplicata ❌
Hash dHash : A ≈ B ≈ C → Détecté ! Distance < 3 ✅
Pourquoi dédupliquer ? L'overfitting silencieux
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI SUPPRIMER LES DUPLICATAS ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PROBLÈME : Les duplicatas faussent l'apprentissage │
│ │
│ Dataset avec duplicatas : │
│ ┌───────────────────────────────────────────┐ │
│ │ 🐱 🐱 🐱 🐱 🐱 🐕 🐕 🐦 🐦 🐦 │ │
│ │ ↑──────────┘ │ │
│ │ Même image 5 fois ! │ │
│ └───────────────────────────────────────────┘ │
│ │
│ Conséquences : │
│ • Le modèle voit ce chat 5x plus souvent │
│ • Il MÉMORISE ce chat spécifique │
│ • Il généralise mal aux autres chats │
│ • Metrics faussées (si duplicatas entre train/test) │
│ │
│ PIRE : Duplicatas entre train et test = fuites de données ! │
│ Le modèle "triche" en reconnaissant des images déjà vues │
│ → Accuracy artificiellement haute, mauvaise en production │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Type de duplicata | Exemple | Détection |
|---|---|---|
| Exact | Même fichier copié 2 fois | Hash MD5 identique |
| Near-exact | JPEG re-compressé | Hash MD5 différent, mais dHash identique |
| Perceptuel | Même photo, légèrement croppée | dHash très proche (distance < 5) |
| Sémantique | Même scène, angle différent | Embeddings proches (Module 4) |
Déduplication : pourquoi supprimer les doublons ?
L'impact des duplicatas
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI DÉDUPLIQUER ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ AVEC DUPLICATAS SANS DUPLICATAS │
│ ─────────────── ──────────────── │
│ │
│ Dataset: Dataset: │
│ • Image A (×500 fois) • Image A (×1) │
│ • Image B (×300 fois) • Image B (×1) │
│ • Image C (×1 fois) • Image C (×1) │
│ │
│ Problèmes: Avantages: │
│ ❌ Le modèle mémorise A et B ✅ Apprentissage équilibré │
│ ❌ Ignore C (sous-représenté) ✅ Chaque image compte │
│ ❌ Test set contaminé ✅ Évaluation fiable │
│ (si A est dans train ET test) │
│ │
│ Métriques faussement bonnes ! Métriques réalistes │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Type de duplicata | Exemple | Risque |
|---|---|---|
| Exact | Même fichier copié 2 fois | Biais, surentraînement |
| Near-duplicate | JPEG à différentes qualités | Fausse diversité |
| Sémantique | 2 photos du même objet | Fuite train→test |
📊 Exemple réel : Dans LAION-5B, certaines images apparaissaient des MILLIERS de fois. Les modèles entraînés dessus les "connaissaient par cœur" au lieu d'apprendre.
pythonVoir le code
# ============================================
# Déduplication par hash
# ============================================
def compute_exact_hash(image_bytes: bytes) -> str:
"""Hash exact (MD5) - détecte les copies identiques."""
return hashlib.md5(image_bytes).hexdigest()
def compute_perceptual_hash(img: Image.Image, hash_size: int = 8) -> str:
"""
Hash perceptuel (dHash) - détecte les images visuellement similaires.
Principe:
1. Réduire à une petite taille (9x8)
2. Convertir en niveaux de gris
3. Comparer chaque pixel avec son voisin
4. Encoder les différences en binaire
"""
# Resize à hash_size+1 x hash_size (pour comparer horizontalement)
img = img.convert('L').resize((hash_size + 1, hash_size), Image.Resampling.LANCZOS)
pixels = np.array(img)
# Comparer chaque pixel avec son voisin de droite
diff = pixels[:, 1:] > pixels[:, :-1]
# Convertir en hash hexadécimal
hash_bits = diff.flatten()
hash_int = int(''.join(['1' if b else '0' for b in hash_bits]), 2)
return format(hash_int, f'0{hash_size * hash_size // 4}x')
def hamming_distance(hash1: str, hash2: str) -> int:
"""Calcule la distance de Hamming entre deux hashs."""
if len(hash1) != len(hash2):
raise ValueError("Les hashs doivent avoir la même longueur")
# Convertir en entiers et XOR
int1 = int(hash1, 16)
int2 = int(hash2, 16)
xor = int1 ^ int2
# Compter les bits différents
return bin(xor).count('1')
print("✅ Fonctions de déduplication définies")pythonVoir le code
# ============================================
# Test de déduplication
# ============================================
# Créer des images pour tester
original = create_test_image(256, 256)
identical = original.copy() # Copie exacte
resized = original.resize((128, 128)).resize((256, 256)) # Légèrement différent
rotated = original.rotate(5) # Rotation légère
different = create_test_image(256, 256) # Complètement différent
images = [
("Original", original),
("Identique", identical),
("Resized", resized),
("Rotated 5°", rotated),
("Différent", different)
]
print("🔍 Comparaison des hashs:\n")
# Calculer les hashs
results = []
for name, img in images:
img_bytes = convert_image(img, 'RGB', 'JPEG')
exact = compute_exact_hash(img_bytes)
perceptual = compute_perceptual_hash(img)
results.append((name, exact[:12], perceptual))
# Afficher
print(f"{'Image':<15} {'Hash exact (MD5)':<15} {'Hash perceptuel (dHash)'}")
print("-" * 50)
for name, exact, perceptual in results:
print(f"{name:<15} {exact:<15} {perceptual}")
# Comparer les distances
print("\n📊 Distances par rapport à l'original:")
original_phash = results[0][2]
for name, _, phash in results[1:]:
dist = hamming_distance(original_phash, phash)
similarity = (1 - dist / 64) * 100 # 64 bits pour dHash 8x8
print(f" {name}: distance={dist}, similarité={similarity:.1f}%")3.8 Data Augmentation
Pourquoi augmenter les données ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATA AUGMENTATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1 image originale → Plusieurs variantes → Dataset enrichi │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 🐱 │ │ 🐱 │ Original │
│ │ Chat │ → │ 🐱 │ Flippé horizontalement │
│ └─────────┘ │ 🐱 │ Rotation 10° │
│ │ 🐱 │ Luminosité +20% │
│ │ 🐱 │ Crop aléatoire │
│ └─────────┘ │
│ │
│ ✅ Plus de diversité │
│ ✅ Meilleure généralisation │
│ ✅ Robustesse aux variations │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
🔧 Intérêt concret de la Data Augmentation
Scénario réel — Overfitting :
┌─────────────────────────────────────────────────────────────────────────────┐
│ SANS vs AVEC AUGMENTATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SANS AUGMENTATION (1000 images) │
│ ─────────────────────────────── │
│ │
│ Training : [🐱] [🐱] [🐱] ... (toujours les mêmes) │
│ │
│ Le modèle apprend : │
│ • "Les chats sont toujours de face" │
│ • "Les chats sont toujours bien éclairés" │
│ • "Les chats sont toujours centrés" │
│ │
│ Test sur nouvelle image : │
│ [🐱 de profil] → ❌ "Pas un chat" (jamais vu de profil) │
│ │
│ ─────────────────────────────────────────────────────────────────────── │
│ │
│ AVEC AUGMENTATION (1000 → 5000 images virtuelles) │
│ ───────────────────────────────────────────────── │
│ │
│ Training : [🐱] [🐱↔️] [🐱🔄] [🐱🌓] [🐱✂️] ... │
│ orig flip rotate dark crop │
│ │
│ Le modèle apprend : │
│ • "Un chat peut être orienté différemment" │
│ • "Un chat peut être dans l'ombre" │
│ • "Un chat peut être partiellement visible" │
│ │
│ Test sur nouvelle image : │
│ [🐱 de profil] → ✅ "C'est un chat !" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Chaque augmentation enseigne quelque chose :
| Augmentation | Ce que le modèle apprend |
|---|---|
| Flip horizontal | L'objet peut être à gauche ou à droite |
| Rotation (±15°) | L'objet n'est pas toujours droit |
| Brightness | L'éclairage varie |
| Crop | L'objet peut être partiellement visible |
| Color jitter | Les couleurs peuvent varier |
⚠️ Attention : Augmentations à éviter selon le contexte
| Contexte | À éviter | Pourquoi |
|---|---|---|
| Imagerie médicale | Flip horizontal | Cœur à droite = pathologie ! |
| OCR / Texte | Rotation forte | Texte devient illisible |
| Satellite / Cartes | Flip vertical | Nord devient Sud |
| Détection de défauts | Color jitter | La couleur EST l'information |
Pourquoi l'augmentation améliore le modèle ?
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI DATA AUGMENTATION ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PROBLÈME : Le modèle mémorise au lieu d'apprendre │
│ │
│ Dataset : 1000 photos de chats │
│ - Tous les chats regardent à droite │
│ - Tous bien éclairés │
│ - Tous centrés │
│ │
│ Le modèle apprend : "chat = animal regardant à droite, bien éclairé" │
│ ❌ Il échoue sur un chat regardant à gauche ! │
│ │
│ SOLUTION : Augmenter artificiellement la diversité │
│ │
│ ┌─────────┐ Flip ┌─────────┐ │
│ │ ▶ 🐱 │ ─────────▶ │ 🐱 ◀ │ Chat regardant à gauche │
│ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ Rotate ┌─────────┐ │
│ │ 🐱 │ ─────────▶ │ ↺ 🐱 │ Chat penché │
│ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ Brightness ┌─────────┐ │
│ │ 🐱 │ ─────────▶ │ 🐱 │ Chat dans l'ombre │
│ │ (clair) │ │ (sombre)│ │
│ └─────────┘ └─────────┘ │
│ │
│ 1000 images × 5 augmentations = 5000 images "virtuelles" │
│ ✅ Le modèle généralise mieux │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Quelles augmentations pour quel cas ?
| Augmentation | Effet | ✅ Bon pour | ❌ Éviter si |
|---|---|---|---|
| Flip horizontal | Miroir gauche/droite | Objets symétriques | Texte, chiffres |
| Flip vertical | Miroir haut/bas | Vues aériennes | Visages, objets avec gravité |
| Rotation | Tourne ±15° | Objets à orientation variable | Documents, cartes |
| Brightness | Plus clair/sombre | Robustesse à l'éclairage | Toujours OK |
| Contrast | Plus/moins de contraste | Robustesse aux conditions | Toujours OK |
| Crop | Zoom aléatoire | Robustesse à la position | Si bords importants |
| Color jitter | Variation de couleurs | Photos réelles | Couleurs significatives (médical) |
Data Augmentation : pourquoi augmenter ?
Le problème : overfitting
┌─────────────────────────────────────────────────────────────────────────────┐
│ POURQUOI AUGMENTER ? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SANS AUGMENTATION AVEC AUGMENTATION │
│ ───────────────── ────────────────── │
│ │
│ Dataset: 1000 images Dataset effectif: 10000+ images │
│ │
│ Le modèle voit: Le modèle voit: │
│ • Toujours le chat à droite • Chat à droite, à gauche │
│ • Toujours bien éclairé • Éclairage variable │
│ • Toujours même angle • Angles différents │
│ │
│ Résultat: Résultat: │
│ ❌ "Chat à gauche ? Connais pas" ✅ "Chat à gauche ? Oui, c'est un chat" │
│ ❌ Mémorise les exemples ✅ Apprend le concept "chat" │
│ ❌ Échoue sur nouvelles images ✅ Généralise bien │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Augmentation | Ce qu'elle apporte | Exemple concret |
|---|---|---|
| Flip horizontal | Invariance gauche/droite | Un chat reste un chat, miroir ou pas |
| Rotation | Invariance à l'orientation | Photo penchée = toujours valide |
| Brightness | Robustesse à l'éclairage | Jour, nuit, intérieur, extérieur |
| Crop aléatoire | Invariance à la position | Objet pas toujours au centre |
| Color jitter | Robustesse aux couleurs | Différents appareils photo |
⚠️ Attention : Certaines augmentations peuvent DÉTRUIRE l'information !
- Flip vertical sur des chiffres : 6 devient 9
- Rotation sur du texte : illisible
- Flip sur des radios médicales : gauche/droite inversés (dangereux !)
pythonVoir le code
# ============================================
# Data Augmentation
# ============================================
import random
class ImageAugmenter:
"""Augmentation d'images pour le training."""
@staticmethod
def horizontal_flip(img: Image.Image, p: float = 0.5) -> Image.Image:
"""Flip horizontal avec probabilité p."""
if random.random() < p:
return ImageOps.mirror(img)
return img
@staticmethod
def vertical_flip(img: Image.Image, p: float = 0.5) -> Image.Image:
"""Flip vertical avec probabilité p."""
if random.random() < p:
return ImageOps.flip(img)
return img
@staticmethod
def rotate(img: Image.Image, max_angle: float = 15) -> Image.Image:
"""Rotation aléatoire."""
angle = random.uniform(-max_angle, max_angle)
return img.rotate(angle, resample=Image.Resampling.BILINEAR, fillcolor=(128, 128, 128))
@staticmethod
def adjust_brightness(img: Image.Image, factor_range: tuple = (0.8, 1.2)) -> Image.Image:
"""Ajuste la luminosité."""
factor = random.uniform(*factor_range)
enhancer = ImageEnhance.Brightness(img)
return enhancer.enhance(factor)
@staticmethod
def adjust_contrast(img: Image.Image, factor_range: tuple = (0.8, 1.2)) -> Image.Image:
"""Ajuste le contraste."""
factor = random.uniform(*factor_range)
enhancer = ImageEnhance.Contrast(img)
return enhancer.enhance(factor)
@staticmethod
def adjust_saturation(img: Image.Image, factor_range: tuple = (0.8, 1.2)) -> Image.Image:
"""Ajuste la saturation."""
factor = random.uniform(*factor_range)
enhancer = ImageEnhance.Color(img)
return enhancer.enhance(factor)
@staticmethod
def random_crop(img: Image.Image, crop_ratio: float = 0.9) -> Image.Image:
"""Crop aléatoire puis resize à la taille originale."""
w, h = img.size
new_w = int(w * crop_ratio)
new_h = int(h * crop_ratio)
left = random.randint(0, w - new_w)
top = random.randint(0, h - new_h)
cropped = img.crop((left, top, left + new_w, top + new_h))
return cropped.resize((w, h), Image.Resampling.LANCZOS)
def augment(self, img: Image.Image, n: int = 1) -> List[Image.Image]:
"""
Applique des augmentations aléatoires.
Args:
img: Image originale
n: Nombre de variantes à générer
Returns:
Liste d'images augmentées
"""
results = []
for _ in range(n):
augmented = img.copy()
# Appliquer les augmentations aléatoirement
augmented = self.horizontal_flip(augmented, p=0.5)
augmented = self.rotate(augmented, max_angle=10)
augmented = self.adjust_brightness(augmented)
augmented = self.adjust_contrast(augmented)
augmented = self.random_crop(augmented, crop_ratio=0.95)
results.append(augmented)
return results
print("✅ Augmenter défini")pythonVoir le code
# ============================================
# Démonstration de l'augmentation
# ============================================
# Créer une image de test
original = create_test_image(256, 256)
# Augmenter
augmenter = ImageAugmenter()
augmented = augmenter.augment(original, n=5)
# Visualiser
fig, axes = plt.subplots(1, 6, figsize=(15, 3))
axes[0].imshow(original)
axes[0].set_title("Original")
axes[0].axis('off')
for i, aug_img in enumerate(augmented):
axes[i + 1].imshow(aug_img)
axes[i + 1].set_title(f"Augmenté {i + 1}")
axes[i + 1].axis('off')
plt.tight_layout()
plt.show()
print(f"📷 1 image → {len(augmented) + 1} variantes (original + {len(augmented)} augmentées)")3.9 Projet pratique : Pipeline de preprocessing complet
Architecture du pipeline
┌─────────────────────────────────────────────────────────────────────────────┐
│ PIPELINE DE PREPROCESSING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ENTRÉE VALIDATION TRANSFORM SORTIE │
│ ────── ────────── ───────── ────── │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
│ │ Images │────▶│ Schema │─────▶│ Resize │─────▶│ Images │ │
│ │ brutes │ │ Check │ │ + Norm │ │ uniformes │ │
│ └─────────┘ └────┬────┘ └────┬────┘ └─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Rejetés │ │ Dedup │ │
│ │ (logs) │ │ Check │ │
│ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
pythonVoir le code
# ============================================
# Pipeline de preprocessing complet
# ============================================
class PreprocessingPipeline:
"""
Pipeline complet de preprocessing d'images.
Étapes:
1. Validation (qualité + schéma)
2. Transformation (resize, normalize)
3. Déduplication
"""
def __init__(
self,
target_size: int = 256,
min_size: int = 64,
max_size: int = 4096,
quality: int = 85
):
self.target_size = target_size
self.quality = quality
self.checker = ImageQualityChecker(min_size=min_size, max_size=max_size)
# Stats
self.processed = 0
self.rejected = 0
self.duplicates = 0
# Hash pour déduplication
self.seen_hashes = set()
def process(self, image_bytes: bytes, image_id: str) -> dict:
"""
Traite une image à travers le pipeline.
Returns:
Dict avec les résultats
"""
result = {
'id': image_id,
'status': 'unknown',
'output_bytes': None,
'metadata': {},
'errors': []
}
# 1. Quality check
report = self.checker.check(image_bytes)
if not report.is_valid:
result['status'] = 'rejected'
result['errors'] = report.errors
self.rejected += 1
return result
# 2. Ouvrir l'image
try:
img = Image.open(io.BytesIO(image_bytes))
# 3. Déduplication (hash perceptuel)
phash = compute_perceptual_hash(img)
if phash in self.seen_hashes:
result['status'] = 'duplicate'
self.duplicates += 1
return result
self.seen_hashes.add(phash)
# 4. Transformer
# Convertir en RGB
if img.mode != 'RGB':
if img.mode == 'RGBA':
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
else:
img = img.convert('RGB')
# Resize
img = resize_image(img, (self.target_size, self.target_size), ResizeMode.PAD)
# Convertir en JPEG
output_bytes = convert_image(img, 'RGB', 'JPEG', self.quality)
# 5. Métadonnées
result['status'] = 'success'
result['output_bytes'] = output_bytes
result['metadata'] = {
'width': self.target_size,
'height': self.target_size,
'original_width': report.width,
'original_height': report.height,
'size_bytes': len(output_bytes),
'phash': phash,
'md5': compute_exact_hash(output_bytes)
}
self.processed += 1
return result
except Exception as e:
result['status'] = 'error'
result['errors'] = [str(e)]
self.rejected += 1
return result
def get_stats(self) -> dict:
"""Retourne les statistiques."""
total = self.processed + self.rejected + self.duplicates
return {
'total': total,
'processed': self.processed,
'rejected': self.rejected,
'duplicates': self.duplicates,
'success_rate': self.processed / max(1, total) * 100
}
print("✅ Pipeline de preprocessing défini")pythonVoir le code
# ============================================
# Exécuter le pipeline
# ============================================
# Créer le pipeline
pipeline = PreprocessingPipeline(target_size=256, min_size=64)
# Générer des images de test (avec quelques duplicatas et erreurs)
print("📷 Génération des images de test...\n")
test_images = []
for i in range(50):
if i % 10 == 0: # Créer un duplicata
img = create_test_image(256, 256) # Même image que précédemment
elif i % 7 == 0: # Image trop petite
img = create_test_image(32, 32)
else:
w = np.random.randint(128, 512)
h = np.random.randint(128, 512)
img = create_test_image(w, h)
img_bytes = convert_image(img, 'RGB', 'JPEG')
test_images.append((f'img_{i:04d}', img_bytes))
# Traiter
print("🔄 Traitement en cours...\n")
results = []
for img_id, img_bytes in test_images:
result = pipeline.process(img_bytes, img_id)
results.append(result)
# Afficher les stats
stats = pipeline.get_stats()
print(f"📊 Résultats du pipeline:")
print(f" Total: {stats['total']}")
print(f" ✅ Traités: {stats['processed']}")
print(f" ❌ Rejetés: {stats['rejected']}")
print(f" 🔄 Duplicatas: {stats['duplicates']}")
print(f" 📈 Taux de succès: {stats['success_rate']:.1f}%")
# Afficher quelques résultats
print(f"\n📋 Exemples de résultats:")
for status in ['success', 'rejected', 'duplicate']:
examples = [r for r in results if r['status'] == status][:2]
for r in examples:
print(f" {r['status']}: {r['id']}")
if r['errors']:
print(f" Erreur: {r['errors'][0]}")Pipeline complet avec MinIO
Maintenant, exécutons le pipeline complet qui :
- Lit les images depuis
raw-images(Module 2) - Transforme : valide, resize, normalise, déduplique
- Écrit les résultats dans
processed-images - Génère un nouveau catalogue Parquet
pythonVoir le code
# ============================================
# Pipeline complet : MinIO → Transform → MinIO
# ============================================
def run_full_pipeline(
source_bucket: str,
dest_bucket: str,
target_size: int = 256,
max_images: int = None
):
"""
Pipeline complet de transformation :
1. Lit les images depuis MinIO (source)
2. Valide et transforme
3. Écrit les résultats dans MinIO (destination)
4. Génère un nouveau catalogue
Args:
source_bucket: Bucket source (ex: raw-images)
dest_bucket: Bucket destination (ex: processed-images)
target_size: Taille cible des images
max_images: Limite le nombre d'images (None = toutes)
"""
# Créer le bucket destination
create_bucket_if_not_exists(dest_bucket)
# Lister les images source
source_images = list_images(source_bucket)
if max_images:
source_images = source_images[:max_images]
print(f"🔄 Pipeline: {source_bucket} → {dest_bucket}")
print(f" Images à traiter: {len(source_images)}")
print(f" Taille cible: {target_size}x{target_size}")
print()
# Initialiser le pipeline
pipeline = PreprocessingPipeline(target_size=target_size)
# Catalogue de sortie
output_records = []
# Traiter chaque image
for i, img_info in enumerate(source_images):
source_key = img_info['key']
# Lire depuis MinIO
img_bytes, _ = read_image_from_minio(source_bucket, source_key)
if img_bytes is None:
continue
# Transformer
result = pipeline.process(img_bytes, source_key)
# Si succès, écrire dans le bucket destination
if result['status'] == 'success':
# Nouveau chemin dans le bucket destination
dest_key = f"transformed/{source_key.split('/')[-1]}"
# Écrire
write_image_to_minio(
dest_bucket,
dest_key,
result['output_bytes'],
result['metadata']
)
# Ajouter au catalogue
output_records.append({
'source_key': source_key,
'dest_key': dest_key,
'source_bucket': source_bucket,
'dest_bucket': dest_bucket,
**result['metadata']
})
# Afficher progression
if (i + 1) % 10 == 0 or (i + 1) == len(source_images):
stats = pipeline.get_stats()
print(f" [{i+1}/{len(source_images)}] ✅ {stats['processed']} | ❌ {stats['rejected']} | 🔄 {stats['duplicates']}")
# Sauvegarder le catalogue
if output_records:
catalog_df = pd.DataFrame(output_records)
# Sauvegarder en Parquet dans MinIO
parquet_buffer = io.BytesIO()
catalog_df.to_parquet(parquet_buffer, index=False)
parquet_buffer.seek(0)
s3_client.put_object(
Bucket=dest_bucket,
Key='metadata/catalog.parquet',
Body=parquet_buffer.getvalue(),
ContentType='application/octet-stream'
)
print(f"\n✅ Catalogue sauvegardé: s3://{dest_bucket}/metadata/catalog.parquet")
# Stats finales
stats = pipeline.get_stats()
print(f"\n📊 Résultats finaux:")
print(f" ✅ Transformées: {stats['processed']}")
print(f" ❌ Rejetées: {stats['rejected']}")
print(f" 🔄 Duplicatas: {stats['duplicates']}")
print(f" 📈 Taux de succès: {stats['success_rate']:.1f}%")
return catalog_df if output_records else None
print("✅ Pipeline MinIO → Transform → MinIO défini")pythonVoir le code
# ============================================
# Exécuter le pipeline complet
# ============================================
# Lancer le pipeline
output_catalog = run_full_pipeline(
source_bucket=SOURCE_BUCKET, # raw-images (Module 2)
dest_bucket=DEST_BUCKET, # processed-images
target_size=256,
max_images=50 # Limiter pour la démo
)
# Afficher le catalogue
if output_catalog is not None:
print(f"\n📋 Aperçu du catalogue de sortie:")
print(output_catalog[['dest_key', 'width', 'height', 'size_bytes']].head())pythonVoir le code
# ============================================
# Vérifier les résultats dans MinIO
# ============================================
# Lister les images transformées
transformed_images = list_images(DEST_BUCKET, prefix="transformed/")
print(f"📦 Images dans '{DEST_BUCKET}':")
print(f" Total: {len(transformed_images)} images transformées")
# Charger et vérifier le catalogue
output_catalog = load_catalog_from_minio(DEST_BUCKET)
if output_catalog is not None:
print(f"\n📊 Statistiques du catalogue:")
print(f" Taille moyenne: {output_catalog['size_bytes'].mean():.0f} bytes")
print(f" Dimensions: {output_catalog['width'].iloc[0]}x{output_catalog['height'].iloc[0]}")
# Visualiser une image transformée
if transformed_images:
import matplotlib.pyplot as plt
test_key = transformed_images[0]['key']
_, img = read_image_from_minio(DEST_BUCKET, test_key)
if img:
plt.figure(figsize=(4, 4))
plt.imshow(img)
plt.title(f"Image transformée: {img.size}")
plt.axis('off')
plt.show()3.10 Code source et ressources
Structure du projet
module3-transform/
├── docker-compose.yml # Infrastructure (MinIO + Spark)
├── requirements.txt # Dépendances Python
├── src/
│ ├── __init__.py
│ ├── transforms.py # Fonctions de transformation
│ ├── quality.py # Quality Checker
│ ├── dedup.py # Déduplication
│ ├── augmentation.py # Data Augmentation
│ ├── schema.py # Schémas Pydantic
│ └── pipeline.py # Pipeline complet
└── notebooks/
└── module3.ipynb # Ce notebook
Télécharger le code
Exécute la cellule suivante pour générer et télécharger tout le code du module.
pythonVoir le code
# ============================================
# Générer le package téléchargeable
# ============================================
import os
import zipfile
import base64
from IPython.display import HTML, display
# Créer la structure
os.makedirs('module3-transform/src', exist_ok=True)
# requirements.txt
requirements = '''pillow>=9.0.0
numpy>=1.24.0
pydantic>=2.0.0
pyspark>=3.5.0
matplotlib>=3.7.0
boto3>=1.28.0
'''
with open('module3-transform/requirements.txt', 'w') as f:
f.write(requirements)
# docker-compose.yml
docker_compose = '''version: '3.8'
services:
minio:
image: minio/minio:latest
container_name: minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
spark-master:
image: bitnami/spark:3.5
container_name: spark-master
environment:
- SPARK_MODE=master
ports:
- "8080:8080"
- "7077:7077"
spark-worker:
image: bitnami/spark:3.5
container_name: spark-worker
environment:
- SPARK_MODE=worker
- SPARK_MASTER_URL=spark://spark-master:7077
depends_on:
- spark-master
volumes:
minio_data:
'''
with open('module3-transform/docker-compose.yml', 'w') as f:
f.write(docker_compose)
# src/__init__.py
init_py = '''from .transforms import resize_image, normalize_image, convert_image, ResizeMode
from .quality import ImageQualityChecker, QualityReport
from .dedup import compute_exact_hash, compute_perceptual_hash, hamming_distance
from .augmentation import ImageAugmenter
from .schema import ImageSchema
from .pipeline import PreprocessingPipeline
'''
with open('module3-transform/src/__init__.py', 'w') as f:
f.write(init_py)
print("✅ Structure du projet créée")
print("\n📦 Exécute la cellule suivante pour télécharger le ZIP")pythonVoir le code
# ============================================
# Créer le ZIP téléchargeable
# ============================================
# Créer le ZIP
zip_path = 'module3-transform.zip'
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk('module3-transform'):
for file in files:
file_path = os.path.join(root, file)
arcname = file_path
zipf.write(file_path, arcname)
# Créer le lien de téléchargement
with open(zip_path, 'rb') as f:
zip_data = f.read()
b64 = base64.b64encode(zip_data).decode()
# Afficher le bouton de téléchargement
html = f'''
<a download="module3-transform.zip"
href="data:application/zip;base64,{b64}"
style="display: inline-block; padding: 10px 20px;
background-color: #4CAF50; color: white;
text-decoration: none; border-radius: 5px;
font-weight: bold;">
📦 Télécharger module3-transform.zip
</a>
'''
display(HTML(html))
print(f"\n✅ Archive créée: {zip_path} ({os.path.getsize(zip_path)} bytes)")🎯 Checkpoint — Quiz
Teste tes connaissances ! Réponds aux questions, puis déroule pour voir les réponses.
Question 1 : Pourquoi faut-il resize toutes les images à la même taille ?
A) Pour que ce soit plus joli
B) Pour pouvoir créer des batches (le GPU traite des tensors de taille fixe)
C) Pour réduire la taille du dataset
D) Ce n'est pas nécessaire
📖 Voir la réponse
Réponse : B
Le GPU traite les images par batch (ex: 32 images à la fois). Pour créer un batch, toutes les images doivent avoir la même taille.
| Sans resize | Avec resize |
|---|---|
| Tailles variables | Toutes en 256×256 |
| Pas de batching possible | Batch de 32 images |
| Training 100x plus lent | GPU utilisé à 100% |
Question 2 : Quel mode de resize est recommandé pour le ML ?
A) EXACT (force la taille, déforme l'image)
B) FIT (garde le ratio, taille variable)
C) PAD (garde le ratio, ajoute du padding)
D) Aucun, on garde les tailles originales
📖 Voir la réponse
Réponse : C
| Mode | Comportement | Problème |
|---|---|---|
| EXACT | Déforme l'image | Le modèle apprend des formes déformées |
| FIT | Taille variable | Pas de batching |
| COVER | Crop une partie | Perte d'information |
| PAD | Ajoute du noir/blanc | ✅ Pas de déformation, taille fixe |
Question 3 : Pourquoi normaliser les pixels de [0, 255] à [-1, 1] ?
A) Pour économiser de la mémoire
B) Pour que les gradients soient stables pendant le training
C) Pour améliorer la qualité visuelle
D) C'est obligatoire pour ouvrir l'image
📖 Voir la réponse
Réponse : B
Les réseaux de neurones fonctionnent mieux avec des valeurs centrées autour de 0.
| Sans normalisation | Avec normalisation |
|---|---|
| Pixels [0, 255] | Pixels [-1, 1] |
| Gradients énormes | Gradients stables |
| Learning rate très petit | Learning rate standard |
| Loss oscille | Loss descend régulièrement |
Question 4 : Quand doit-on valider les données avec Pydantic ?
A) Après le training
B) Pendant le training
C) Le plus tôt possible, avant toute transformation
D) Jamais, ce n'est pas utile
📖 Voir la réponse
Réponse : C
"Fail fast, fail early" — Une erreur détectée à l'ingestion coûte 0. Une erreur détectée à l'epoch 47 du training coûte 3 jours de GPU.
| Quand | Coût de l'erreur | Difficulté debug |
|---|---|---|
| À l'ingestion | ~0 | Trivial |
| À la transformation | Minutes | Facile |
| Au training | Heures/jours | Difficile |
Question 5 : Quelle est la différence entre hash MD5 et hash perceptuel (dHash) ?
A) MD5 est plus rapide
B) MD5 détecte les copies exactes, dHash détecte les images visuellement similaires
C) C'est la même chose
D) dHash est pour le texte, MD5 pour les images
📖 Voir la réponse
Réponse : B
| Type | Détecte | Exemple |
|---|---|---|
| MD5 (exact) | Copies bit-à-bit identiques | Même fichier copié |
| dHash (perceptuel) | Images visuellement similaires | JPEG qualité 80 vs 90 |
Une image redimensionnée ou recompressée aura un MD5 différent mais un dHash similaire.
Question 6 : Quand NE PAS utiliser la data augmentation ?
A) Jamais, il faut toujours augmenter
B) Quand la transformation détruit le sens (ex: flip vertical sur des radios médicales)
C) Quand on a assez de données
D) Quand on utilise un GPU
📖 Voir la réponse
Réponse : B
L'augmentation doit préserver le sens de l'image.
| Cas | Problème |
|---|---|
| Flip vertical sur chiffres | 6 devient 9 |
| Rotation sur du texte | Texte illisible |
| Flip sur radios médicales | Gauche/droite inversés (dangereux !) |
| Saturation sur détection de couleur | Fausse les couleurs |
Toujours se demander : "Cette transformation préserve-t-elle le sens ?"
Question 7 : Pourquoi utiliser Spark plutôt que Python simple ?
A) Spark est gratuit, Python est payant
B) Spark distribue le traitement sur plusieurs machines/cores
C) Spark est plus facile à apprendre
D) Il n'y a pas de différence
📖 Voir la réponse
Réponse : B
| Volume | Outil recommandé |
|---|---|
| < 10K images | Python simple (boucle + threads) |
| 10K - 100K | Multiprocessing ou Dask |
| 100K - 1M | Spark local |
| > 1M | Spark cluster |
Spark divise le travail entre un Driver (coordonne) et des Workers (exécutent). Ça scale horizontalement.
➡️ Prochaine étape
Module 4 : Enrichir les données
Tu vas :
- Comprendre ce que sont les embeddings
- Utiliser CLIP pour vectoriser tes images
- Stocker et rechercher avec Chroma (vector database)
- Détecter les doublons sémantiques
Module Data Engineering for AI — From Zero to Hero Bootcamp