🤖 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 2MODULE 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 :

  1. Docker est lancé avec MinIO (docker-compose up -d)
  2. Tu as complété le Module 2 (bucket raw-images ou project-images créé)
  3. 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 :

  1. Standard industrie — Tu le retrouveras dans la plupart des entreprises
  2. Vraiment distribué — Fonctionne sur 1 machine ou 1000 nœuds
  3. Écosystème riche — Intégration S3, Parquet, Delta Lake
  4. 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ÈMETRANSFORMATIONBÉ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'infoInfo 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 batchBatch de 3 images                │
│      (tailles différentes)                (même tensor 3×256×256×3)        │
│                                                                             │
│   ❌ Training image par imageTraining 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 :     ABCPas détecté comme duplicataHash dHash :   ABCDé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 A500 fois)                • Image A1)                      │
│   • Image B300 fois)                • Image B1)                      │
│   • Image C1 fois)                  • Image C1)                      │
│                                                                             │
│   Problèmes:                           Avantages:                          │
│   ❌ Le modèle mémorise A et BApprentissage é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 (10005000 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 angleAngles différents                 │
│                                                                             │
│   Résultat:                            Résultat:                           │
│   ❌ "Chat à gauche ? Connais pas""Chat à gauche ? Oui, c'est un chat" │
│   ❌ Mémorise les exemplesApprend le concept "chat"         │
│   ❌ Échoue sur nouvelles imagesGé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 :

  1. Lit les images depuis raw-images (Module 2)
  2. Transforme : valide, resize, normalise, déduplique
  3. Écrit les résultats dans processed-images
  4. 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