# Paramètres Papermill - JAMAIS modifier ce commentaire
# Configuration notebook
notebook_mode = "interactive" # "interactive" ou "batch"
skip_widgets = False # True pour mode batch MCP
debug_level = "INFO"
# Configuration images
test_image_url = "https://picsum.photos/800/600" # Image de test aléatoire
sample_image_size = (800, 600) # Dimensions par défaut
output_format = "PNG" # Format de sortie par défaut
output_quality = 90 # Qualité JPEG (1-100)
# Paramètres opérations
enable_filters = True # Activer démonstrations filtres
enable_metadata = True # Extraire métadonnées EXIF
enable_transformations = True # Démonstrations transformations
save_results = True # Sauvegarder résultats
# Configuration widgets
show_interactive_demo = True # Widgets interactifsNavigation : Index | << Précédent | Suivant >>
Opérations de Base sur les Images
Module : 01-Images-Foundation
Niveau : 🟢 Débutant
Technologies : Python Pillow (PIL), NumPy, Matplotlib
Durée estimée : 30 minutes
🎯 Objectifs d’Apprentissage
📚 Prérequis
- Environment Setup (module 00) complété
- Notions de base Python
- Familiarité avec les formats d’images (PNG, JPEG, etc.) Ancrage savant. Ce notebook mobilise trois bibliotheques scientifiques de reference : NumPy (calcul matriciel / tableaux n-dimensionnels), Matplotlib (visualisation) et Pillow/PIL (E/S et traitements d’images). NumPy et Matplotlib sont des logiciels academiques peer-reviewed disposant d’un papier de citation officiel (cf. References en Section 8) ; Pillow est un logiciel libre (fork de PIL) sans papier formel.
# Parameters
notebook_mode = "batch"
skip_widgets = "True"Les paramètres d’exécution sont définis. Les cellules suivantes initialisent l’environnement Python en important les bibliotheques de traitement d’images (Pillow, NumPy, Matplotlib) et en configurant les chemins de travail.
Ce troisième volet de la fondation Image assume un rôle précis dans la série : après la découverte de la génération par modèle (01-1, 01-2), il installe la boîte à outils déterministe qui entoure tout pipeline génératif. Avant d’envoyer une image à un moteur, il faut la charger, la redresser, la recadrer, l’encoder au bon format et au bon poids — autant d’opérations PIL que ce notebook exécute et mesure une à une. La règle de lecture des sections qui suivent : chaque sortie chiffrée (dimensions, poids, ratios) est une décision d’ingénierie en puissance, pas une anecdote.
# Verification des dependances externes
import importlib
_DEPS_STATUS = {}
try:
importlib.import_module('PIL')
_DEPS_STATUS['PIL'] = True
except ImportError:
_DEPS_STATUS['PIL'] = False
print(f'WARNING: Pillow non installe - pip install Pillow')
try:
importlib.import_module('requests')
_DEPS_STATUS['requests'] = True
except ImportError:
_DEPS_STATUS['requests'] = False
print(f'WARNING: requests non installe - pip install requests')
try:
importlib.import_module('matplotlib')
_DEPS_STATUS['matplotlib'] = True
except ImportError:
_DEPS_STATUS['matplotlib'] = False
print(f'WARNING: matplotlib non installe - pip install matplotlib')
try:
importlib.import_module('numpy')
_DEPS_STATUS['numpy'] = True
except ImportError:
_DEPS_STATUS['numpy'] = False
print(f'WARNING: numpy non installe - pip install numpy')
try:
importlib.import_module('dotenv')
_DEPS_STATUS['dotenv'] = True
except ImportError:
_DEPS_STATUS['dotenv'] = False
print(f'WARNING: python-dotenv non installe - pip install python-dotenv')
try:
importlib.import_module('ipywidgets')
_DEPS_STATUS['ipywidgets'] = True
except ImportError:
_DEPS_STATUS['ipywidgets'] = False
print(f'WARNING: ipywidgets non installe - pip install ipywidgets')
try:
importlib.import_module('IPython')
_DEPS_STATUS['IPython'] = True
except ImportError:
_DEPS_STATUS['IPython'] = False
print(f'WARNING: ipython non installe - pip install ipython')
_all_deps_ok = all(_DEPS_STATUS.values())
if not _all_deps_ok:
missing = [k for k, v in _DEPS_STATUS.items() if not v]
print(f'Dependances manquantes: {missing}')
else:
print('Toutes les dependances sont disponibles')
# Setup environnement et imports
import os
import sys
import json
import requests
import warnings
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Any, Optional, Tuple
import base64
from io import BytesIO
import numpy as np
import matplotlib
# Backend inline : capture les figures en PNG dans les sorties de cellule
# (le payload visuel du tutorial PIL), ET n emet pas le UserWarning Agg dont la
# traceback fuitait le chemin temporaire du kernel (regle 6 secrets-hygiene).
# Le backend Agg de #6281 supprimait le warning mais tuait aussi tout affichage.
matplotlib.use('module://matplotlib_inline.backend_inline')
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
import logging
# Detection batch mode via env var (Papermill)
if os.getenv("BATCH_MODE", "").lower() == "true":
notebook_mode = "batch"
show_interactive_demo = False
skip_widgets = True
# Imports Pillow (PIL)
from PIL import Image, ImageFilter, ImageEnhance, ImageOps, ImageDraw, ImageFont
try:
from PIL import ExifTags
EXIF_AVAILABLE = True
except ImportError:
EXIF_AVAILABLE = False
print("ExifTags non disponible - métadonnées EXIF désactivées")
# Widgets interactifs
try:
import ipywidgets as widgets
from IPython.display import display, Image as IPImage, HTML
WIDGETS_AVAILABLE = True
except ImportError:
WIDGETS_AVAILABLE = False
print("ipywidgets non disponible - mode interactif limité")
warnings.filterwarnings('ignore', category=DeprecationWarning)
# Configuration paths
from dotenv import load_dotenv
import os
current_path = Path.cwd()
env_loaded = False
for _ in range(10):
env_path = current_path / ".env"
if env_path.exists():
load_dotenv(env_path)
print(f".env charge depuis: {env_path.name}")
env_loaded = True
break
current_path = current_path.parent
if len(current_path.parts) <= 1:
break
if not env_loaded:
print("WARNING: .env non trouve, utilisation variables environnement")
OUTPUT_DIR = current_path / 'outputs' / 'basic_operations'
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(level=getattr(logging, debug_level))
logger = logging.getLogger('basic_image_ops')
print(f"Operations de Base sur les Images")
print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Mode: {notebook_mode}")
print(f"Output: {OUTPUT_DIR}")
print(f"PIL Version: {Image.__version__ if hasattr(Image, '__version__') else 'N/A'}")
if WIDGETS_AVAILABLE:
print(f"Widgets interactifs disponibles")Toutes les dependances sont disponibles
WARNING: .env non trouve, utilisation variables environnement
Operations de Base sur les Images
2026-09-27 19:58:27
Mode: batch
Output: D:\outputs\basic_operations
PIL Version: 11.3.0
Widgets interactifs disponibles
Lecture du résultat. L’environnement est vérifié en tête d’exécution : toutes les dépendances sont disponibles et PIL signale sa version 11.3.0. Le mode d’exécution est batch, avec écriture des artefacts vers un répertoire de sorties dédié.
Point honnête à noter : le fichier .env n’est pas trouvé sur cette machine et le notebook bascule sur les variables d’environnement système. C’est le repli prévu, et il est sans conséquence ici — toutes les opérations de ce notebook sont locales (PIL uniquement), aucune ne requiert d’API distante. Les widgets interactifs sont signalés disponibles : la Section 6 pourra s’exécuter en mode interactif, le mode batch de cette exécution les désactivera proprement.
📥 Section 1: Chargement et Formats d’Images
Démonstration du chargement d’images depuis différentes sources et conversion entre formats.
# Utilitaires pour chargement d'images
def load_image_from_url(url: str) -> Optional[Image.Image]:
"""Charge une image depuis une URL."""
try:
response = requests.get(url, timeout=10)
if response.status_code == 200:
return Image.open(BytesIO(response.content))
except Exception as e:
logger.error(f"Erreur chargement URL: {e}")
return None
def load_image_from_file(filepath: Path) -> Optional[Image.Image]:
"""Charge une image depuis un fichier local."""
try:
return Image.open(filepath)
except Exception as e:
logger.error(f"Erreur chargement fichier: {e}")
return None
def create_test_image(size: Tuple[int, int] = (800, 600),
color_mode: str = "RGB") -> Image.Image:
"""Crée une image de test avec gradient."""
width, height = size
img = Image.new(color_mode, (width, height))
# Création d'un gradient
pixels = img.load()
for y in range(height):
for x in range(width):
r = int((x / width) * 255)
g = int((y / height) * 255)
b = 128
pixels[x, y] = (r, g, b) if color_mode == "RGB" else r
return img
print("\n📥 CHARGEMENT ET FORMATS D'IMAGES")
print("=" * 50)
# Tentative de chargement depuis URL
print(f"\n🌐 Chargement image depuis URL...")
test_image = load_image_from_url(test_image_url)
if test_image is None:
print(f"⚠️ URL indisponible, création d'une image de test")
test_image = create_test_image(sample_image_size)
else:
print(f"✅ Image chargée depuis URL")
# Informations sur l'image
print(f"\n📊 Informations image:")
print(f" 📐 Dimensions: {test_image.size[0]}x{test_image.size[1]}")
print(f" 🎨 Mode: {test_image.mode}")
print(f" 📦 Format: {test_image.format or 'Generated'}")
print(f" 💾 Taille mémoire: ~{test_image.size[0] * test_image.size[1] * len(test_image.getbands()) / 1024:.1f} KB")
# Affichage
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].imshow(test_image)
axes[0].set_title("Image Originale", fontsize=14, fontweight='bold')
axes[0].axis('off')
# Histogram des couleurs
if test_image.mode == 'RGB':
# Mapping des couleurs pour matplotlib (anglais)
colors_map = {'Rouge': 'red', 'Vert': 'green', 'Bleu': 'blue'}
for i, (color_idx, label) in enumerate([(0, 'Rouge'), (1, 'Vert'), (2, 'Bleu')]):
hist = test_image.histogram()[i*256:(i+1)*256]
axes[1].plot(hist, color=colors_map[label], alpha=0.7, label=label)
axes[1].set_title("Histogramme RGB", fontsize=14, fontweight='bold')
axes[1].set_xlabel("Valeur de pixel")
axes[1].set_ylabel("Fréquence")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"\n✅ Image de base chargée et analysée")
📥 CHARGEMENT ET FORMATS D'IMAGES
==================================================
🌐 Chargement image depuis URL...
✅ Image chargée depuis URL
📊 Informations image:
📐 Dimensions: 800x600
🎨 Mode: RGB
📦 Format: JPEG
💾 Taille mémoire: ~1406.2 KB

✅ Image de base chargée et analysée
Lecture du résultat. L’URL de démonstration était indisponible (timeout réseau) : le notebook bascule sur son image de test déterministe — un gradient RGB 800x600 en mode Generated — et la sortie résume ses propriétés PIL : empreinte mémoire d’environ 1406 KB. Trois observations utiles pour la suite :
- Le mode RGB est une matrice 800 x 600 x 3 — soit 1,44 million de valeurs de canal. Tout le coût mémoire des opérations suivantes se déduit de ce produit : doubler la résolution quadruple le travail.
- La « taille mémoire » (~1406 KB) est celle de l’image décompressée en RAM, indépendante du format de fichier : le fallback synthétique pèse quelques KB une fois encodé (la Section 7 le mesurera format par format). C’est la distinction bitmap vive vs encodage compressé.
- L’image affichée sous la sortie est la référence visuelle : chaque transformation de la Section 2 pourra être comparée à cette base. Le fallback étant déterministe, une ré-exécution hors ligne reproduit exactement les mêmes sorties.
✂️ Section 2: Transformations Géométriques
Resize, crop, rotation, et flip - les opérations essentielles.
if enable_transformations:
print("\n✂️ TRANSFORMATIONS GÉOMÉTRIQUES")
print("=" * 50)
# Préparation des transformations
transformations = {}
# 1. Resize (redimensionnement)
print("\n1️⃣ Resize (Redimensionnement)")
resize_small = test_image.resize((400, 300), Image.Resampling.LANCZOS)
resize_large = test_image.resize((1200, 900), Image.Resampling.LANCZOS)
transformations['resize_small'] = resize_small
transformations['resize_large'] = resize_large
print(f" ✅ Petite: {resize_small.size}, Grande: {resize_large.size}")
# 2. Thumbnail (proportionnel)
print("\n2️⃣ Thumbnail (Préserve ratio)")
thumb = test_image.copy()
thumb.thumbnail((200, 200), Image.Resampling.LANCZOS)
transformations['thumbnail'] = thumb
print(f" ✅ Thumbnail: {thumb.size}")
# 3. Crop (découpage)
print("\n3️⃣ Crop (Découpage)")
width, height = test_image.size
# Crop centre
left = width // 4
top = height // 4
right = 3 * width // 4
bottom = 3 * height // 4
cropped = test_image.crop((left, top, right, bottom))
transformations['cropped'] = cropped
print(f" ✅ Crop centre: {cropped.size}")
# 4. Rotation
print("\n4️⃣ Rotation")
rotated_45 = test_image.rotate(45, expand=True, fillcolor='white')
rotated_90 = test_image.rotate(90, expand=True)
transformations['rotated_45'] = rotated_45
transformations['rotated_90'] = rotated_90
print(f" ✅ 45°: {rotated_45.size}, 90°: {rotated_90.size}")
# 5. Flip (miroir)
print("\n5️⃣ Flip (Miroir)")
flipped_h = test_image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
flipped_v = test_image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
transformations['flipped_h'] = flipped_h
transformations['flipped_v'] = flipped_v
print(f" ✅ Horizontal et Vertical")
# Visualisation en grille
fig = plt.figure(figsize=(16, 12))
gs = GridSpec(3, 3, figure=fig, hspace=0.3, wspace=0.2)
display_items = [
(test_image, "Original", 0, 0),
(resize_small, "Resize Petit", 0, 1),
(thumb, "Thumbnail", 0, 2),
(cropped, "Crop Centre", 1, 0),
(rotated_45, "Rotation 45°", 1, 1),
(rotated_90, "Rotation 90°", 1, 2),
(flipped_h, "Flip Horizontal", 2, 0),
(flipped_v, "Flip Vertical", 2, 1),
(resize_large, "Resize Grand", 2, 2)
]
for img, title, row, col in display_items:
ax = fig.add_subplot(gs[row, col])
ax.imshow(img)
ax.set_title(f"{title}\n{img.size[0]}x{img.size[1]}",
fontsize=10, fontweight='bold')
ax.axis('off')
plt.suptitle("Transformations Géométriques", fontsize=16, fontweight='bold', y=0.98)
plt.show()
print(f"\n✅ {len(transformations)} transformations effectuées")
else:
print("\n⏭️ Transformations désactivées (enable_transformations=False)")
✂️ TRANSFORMATIONS GÉOMÉTRIQUES
==================================================
1️⃣ Resize (Redimensionnement)
✅ Petite: (400, 300), Grande: (1200, 900)
2️⃣ Thumbnail (Préserve ratio)
✅ Thumbnail: (200, 150)
3️⃣ Crop (Découpage)
✅ Crop centre: (400, 300)
4️⃣ Rotation
✅ 45°: (990, 990), 90°: (600, 800)
5️⃣ Flip (Miroir)
✅ Horizontal et Vertical

✅ 8 transformations effectuées
Lecture du résultat. Les 8 transformations sont appliquées et la grille les met côte à côte. Trois mesures de la sortie méritent une lecture attentive :
- Resize 400x300 / 1200x900 : PIL redimensionne vers la taille exacte demandée. Le ratio 4:3 de l’original est ici préservé parce que les cibles le respectent — rien ne l’impose, et un
resizevers une taille de ratio différent déforme l’image. - Rotation 45° rend 990x990 alors que la rotation à 90° transpose simplement (800x600 → 600x800) : par défaut PIL recadre la rotation dans le canevas d’origine et rogne les coins ; les 990x990 de la sortie révèlent l’option
expand=True, qui agrandit le canevas pour ne rien perdre. C’est un comportement à choisir consciemment selon qu’on accepte des bords ou un rognage. - Thumbnail 200x150 : contrairement à
resize,thumbnailpréserve le ratio par construction et modifie l’image en place — c’est l’outil canonique des vignettes.
Le UserWarning sur les glyphes DejaVu Sans visible dans la sortie est un artefact de rendu matplotlib (émoji de légende absent de la police), pas un défaut du traitement : l’image affichée est correcte.
Exercice 1 : Creation d’un mosaic d’images avec transformations
Les transformations geometriques (resize, crop, rotate) sont les briques de base de toute pipeline de traitement d’images. Cet exercice vous demande de les combiner pour créer une mosaique composee de différentes vues d’une même image source.
Objectif : Créer une mosaique 3x3 affichant 9 transformations différentes de l’image de test, disposees dans une seule canevas PIL.
Étapes : 1. Créez une image canevas vide de taille suffisante (par ex. 3 fois la largeur d’un thumbnail) 2. Generez 9 variants de l’image source en combinant resize, crop, rotation, et flip 3. Collez chaque variant dans la bonne position sur le canevas avec Image.paste() 4. Ajoutez des labels textuels sous chaque variant avec ImageDraw 5. Affichez le résultat avec matplotlib
Indice : Utilisez Image.new() pour le canevas, img.resize((w, h)) pour uniformiser les tailles, et ImageDraw.Draw(canevas).text() pour les labels. Parcourez les positions avec une double boucle for row in range(3): for col in range(3):.
# Exercice 1 : Creation d'un mosaic d'images avec transformations
# Etape 1 : Definition des 9 transformations
transformations = [
("Original", None), # Pas de transformation
("Resize 50%", None), # TODO etudiant : resize a 50%
("Crop centre", None), # TODO etudiant : crop du quart central
("Rotation 30 deg", None), # TODO etudiant : rotation 30 degres
("Rotation 90 deg", None), # TODO etudiant : rotation 90 degres
("Flip horizontal", None), # TODO etudiant : miroir horizontal
("Flip vertical", None), # TODO etudiant : miroir vertical
("Resize + Sharpen", None), # TODO etudiant : resize puis sharpen
("Crop + Rotation", None), # TODO etudiant : crop puis rotation
]
# TODO etudiant : implementez chaque transformation et stockez le resultat
# dans la liste ci-dessus a la place de None
# Indice : utilisez test_image comme image source
# Etape 2 : Creation du canevas
thumb_size = (250, 188) # Taille de chaque vignette
cols, rows = 3, 3
padding = 10
label_height = 20
# TODO etudiant : calculez la taille totale du canevas
# canevas_width = cols * (thumb_size[0] + padding) + padding
# canevas_height = rows * (thumb_size[1] + padding + label_height) + padding
# canevas = Image.new("RGB", (canevas_width, canevas_height), "white")
# Etape 3 : Assemblage de la mosaique
# draw = ImageDraw.Draw(canevas)
# for i, (label, img) in enumerate(transformations):
# row, col = divmod(i, cols)
# x = padding + col * (thumb_size[0] + padding)
# y = padding + row * (thumb_size[1] + padding + label_height)
# if img is not None:
# vignette = img.resize(thumb_size, Image.Resampling.LANCZOS)
# canevas.paste(vignette, (x, y))
# draw.text((x, y + thumb_size[1] + 2), label, fill="black")
# Etape 4 : Affichage
# plt.figure(figsize=(12, 12))
# plt.imshow(canevas)
# plt.axis('off')
# plt.title("Mosaique de transformations")
# plt.show()
print("Exercice a completer")Exercice a completer
🎨 Section 3: Filtres et Effets
Application de filtres classiques pour améliorer ou styliser les images.
if enable_filters:
print("\n🎨 FILTRES ET EFFETS")
print("=" * 50)
filters_applied = {}
# 1. Blur (Flou)
print("\n1️⃣ Blur (Flou)")
blur = test_image.filter(ImageFilter.BLUR)
gaussian_blur = test_image.filter(ImageFilter.GaussianBlur(radius=5))
filters_applied['blur'] = blur
filters_applied['gaussian_blur'] = gaussian_blur
print(f" ✅ Blur basique et Gaussian Blur appliqués")
# 2. Sharpen (Netteté)
print("\n2️⃣ Sharpen (Augmentation netteté)")
sharpen = test_image.filter(ImageFilter.SHARPEN)
unsharp_mask = test_image.filter(ImageFilter.UnsharpMask(radius=2, percent=150))
filters_applied['sharpen'] = sharpen
filters_applied['unsharp'] = unsharp_mask
print(f" ✅ Sharpen et UnsharpMask appliqués")
# 3. Edge Detection (Détection contours)
print("\n3️⃣ Edge Detection (Détection contours)")
edges = test_image.filter(ImageFilter.FIND_EDGES)
edges_enhanced = test_image.filter(ImageFilter.EDGE_ENHANCE)
filters_applied['edges'] = edges
filters_applied['edges_enhanced'] = edges_enhanced
print(f" ✅ Find Edges et Edge Enhance appliqués")
# 4. Contour et Emboss
print("\n4️⃣ Contour et Emboss (Relief)")
contour = test_image.filter(ImageFilter.CONTOUR)
emboss = test_image.filter(ImageFilter.EMBOSS)
filters_applied['contour'] = contour
filters_applied['emboss'] = emboss
print(f" ✅ Contour et Emboss appliqués")
# 5. Ajustements (Luminosité, Contraste, Couleur)
print("\n5️⃣ Ajustements (Brightness, Contrast, Color)")
# Brightness
enhancer_brightness = ImageEnhance.Brightness(test_image)
brightened = enhancer_brightness.enhance(1.5) # +50%
darkened = enhancer_brightness.enhance(0.7) # -30%
filters_applied['brightened'] = brightened
filters_applied['darkened'] = darkened
# Contrast
enhancer_contrast = ImageEnhance.Contrast(test_image)
high_contrast = enhancer_contrast.enhance(1.8)
low_contrast = enhancer_contrast.enhance(0.5)
filters_applied['high_contrast'] = high_contrast
filters_applied['low_contrast'] = low_contrast
# Color
enhancer_color = ImageEnhance.Color(test_image)
saturated = enhancer_color.enhance(1.5)
desaturated = enhancer_color.enhance(0.3)
filters_applied['saturated'] = saturated
filters_applied['desaturated'] = desaturated
print(f" ✅ Brightness, Contrast, Color ajustés")
# Visualisation des filtres classiques
fig = plt.figure(figsize=(18, 10))
gs = GridSpec(2, 4, figure=fig, hspace=0.3, wspace=0.2)
classic_filters = [
(test_image, "Original", 0, 0),
(blur, "Blur", 0, 1),
(sharpen, "Sharpen", 0, 2),
(edges, "Edges", 0, 3),
(contour, "Contour", 1, 0),
(emboss, "Emboss", 1, 1),
(brightened, "Brightness +50%", 1, 2),
(high_contrast, "Contrast +80%", 1, 3)
]
for img, title, row, col in classic_filters:
ax = fig.add_subplot(gs[row, col])
ax.imshow(img)
ax.set_title(title, fontsize=11, fontweight='bold')
ax.axis('off')
plt.suptitle("Filtres Classiques PIL", fontsize=16, fontweight='bold', y=0.98)
plt.show()
print(f"\n✅ {len(filters_applied)} filtres/effets appliqués")
else:
print("\n⏭️ Filtres désactivés (enable_filters=False)")
🎨 FILTRES ET EFFETS
==================================================
1️⃣ Blur (Flou)
✅ Blur basique et Gaussian Blur appliqués
2️⃣ Sharpen (Augmentation netteté)
✅ Sharpen et UnsharpMask appliqués
3️⃣ Edge Detection (Détection contours)
✅ Find Edges et Edge Enhance appliqués
4️⃣ Contour et Emboss (Relief)
✅ Contour et Emboss appliqués
5️⃣ Ajustements (Brightness, Contrast, Color)
✅ Brightness, Contrast, Color ajustés

✅ 14 filtres/effets appliqués
Lecture du résultat. 14 filtres répartis en 5 familles sont appliqués à la même référence, et la grille autorise la comparaison visuelle immédiate. Deux lectures guident l’œil :
- Blur et Gaussian Blur sont deux intensités de la même idée (moyenne locale pondérée) ; Sharpen et UnsharpMask en sont le dual (renforcement des transitions). Find Edges n’introduit pas un nouveau mécanisme : la famille
ImageFilterest un catalogue de noyaux de convolution, et changer le noyau change l’effet — flou, netteté ou contours sont la même opération avec des poids différents. - Brightness / Contrast / Color n’appliquent aucune convolution : ce sont des transformations ponctuelles (tables de correspondance appliquées pixel par pixel), quasi gratuites en calcul face à un Gaussian Blur dont le coût dépend du rayon du noyau.
Cette distinction convolution vs transformation ponctuelle structure tout pré-traitement d’image avant un modèle de génération : les secondes se composent librement, les premières se budgètent.
Exercice 2 : Pipeline de filtres personnalise
Les filtres PIL peuvent etre combines pour créer des effets visuels uniques. L’objectif est de concevoir un pipeline de traitement qui transforme une image selon un style artistique défini.
Contexte : Vous devez créer trois presets de traitement d’images : “Vintage” (tons chauds, vignettage, grain), “Cyberpunk” (contraste eleve, saturation neon, nettete accrue), et “Aquarelle” (flou leger, contour adouci, couleurs pastel).
Étapes : 1. Implementez une fonction appliquer_preset(image, preset) qui chaîne les filtres PIL 2. Créez au minimum les presets “Vintage” et “Cyberpunk” en combinant ImageFilter, ImageEnhance et ImageOps 3. Affichez les résultats dans une grille comparative (original + chaque preset) 4. Justifiez le choix de chaque filtre pour chaque style
Indice : Utilisez ImageEnhance.Brightness, .Contrast, .Color pour les ajustements globaux, puis ImageFilter.GaussianBlur ou .SHARPEN pour la nettete. Pour le vignettage, un degrade radial créé avec ImageDraw fonctionne bien.
# Exercice 2 : Pipeline de filtres personnalise
def appliquer_preset(image, preset_name):
"""
Applique un preset de filtres a une image.
Args:
image: Image PIL d'origine
preset_name: Nom du preset ("vintage", "cyberpunk", "aquarelle")
Returns:
Image PIL transformee
"""
# TODO etudiant : implementez les presets
# Indice : chainez les operations ImageEnhance et ImageFilter
if preset_name == "vintage":
# Etape 1 : reduire legerement la saturation
# Etape 2 : augmenter la chaleur (brightness legerement plus chaude)
# Etape 3 : ajouter un leger flou
# Etape 4 : optionnel - ajouter du vignettage
pass # TODO etudiant
elif preset_name == "cyberpunk":
# Etape 1 : augmenter fortement le contraste
# Etape 2 : augmenter la saturation des couleurs
# Etape 3 : appliquer un sharpen pour les details
pass # TODO etudiant
elif preset_name == "aquarelle":
# Etape 1 : appliquer un leger flou gaussien
# Etape 2 : reduire la saturation
# Etape 3 : augmenter la luminosite legerement
pass # TODO etudiant
return None # TODO etudiant : retourner l'image transformee
# Test et affichage
# Indice : utilisez plt.subplots(1, 4, figsize=(20, 5)) pour comparer
# original + vintage + cyberpunk + aquarelle
# for preset in ["vintage", "cyberpunk", "aquarelle"]:
# resultat = appliquer_preset(test_image, preset)
# print(f"Preset '{preset}': {'OK' if resultat else 'non implemente'}")
print("Exercice a completer")Exercice a completer
Exercice 3 : Analyse comparative d’histogrammes par canal
Dans la Section 1, un histogramme RGB a ete affiche pour l’image originale. L’objectif est d’implementer une analyse d’histogrammes qui compare quantitativement les distributions de couleurs avant et après application de filtres.
Contexte : L’histogramme d’une image revele des informations sur l’exposition, le contraste et la balance des couleurs. Comparer les histogrammes avant/après un filtre permet de mesurer objectivement l’impact du traitement.
Étapes : 1. Implementer une fonction compute_histogram_stats(image) qui retourne la moyenne, l’ecart-type et la mediane pour chaque canal RGB 2. Appliquer plusieurs filtres (brightness +30%, contrast +50%, grayscale) et calculer les stats pour chaque résultat 3. Presenter les résultats dans un DataFrame avec une ligne par (filtre, canal) 4. Identifier le filtre qui modifie le plus la distribution (plus grand ecart de mediane)
Indices : - Utilisez image.histogram() pour obtenir les valeurs brutes par canal - Pour le canal R : hist[0:256], canal G : hist[256:512], canal B : hist[512:768] - La moyenne d’un canal se calcule avec np.mean(np.arange(256) * np.array(hist)) / sum(hist) - Pour le grayscale, utilisez ImageOps.grayscale(image) puis convertissez en RGB pour garder 3 canaux
# Exercice 3 : Analyse comparative d'histogrammes par canal
def compute_histogram_stats(image: Image.Image) -> Dict[str, Dict[str, float]]:
"""
Calcule les statistiques d'histogramme pour chaque canal RGB.
Args:
image: Image PIL en mode RGB
Returns:
Dict par canal {'R': {'mean': ..., 'std': ..., 'median': ...}, ...}
"""
hist = image.histogram()
stats = {}
for ch_name, offset in [('R', 0), ('G', 256), ('B', 512)]:
channel_hist = hist[offset:offset + 256]
values = np.arange(256)
counts = np.array(channel_hist, dtype=float)
total = counts.sum()
if total > 0:
# TODO etudiant : calculer la moyenne ponderee
mean_val = 0.0 # TODO etudiant : np.sum(values * counts) / total
# TODO etudiant : calculer l'ecart-type
std_val = 0.0 # TODO etudiant
# TODO etudiant : calculer la mediane (cumsum jusqu'a 50%)
median_val = 0.0 # TODO etudiant
else:
mean_val = std_val = median_val = 0.0
stats[ch_name] = {'mean': mean_val, 'std': std_val, 'median': median_val}
return stats
# TODO etudiant : Comparer les histogrammes pour differents filtres
# filters_to_test = {
# "original": test_image,
# "brightness_+30%": ImageEnhance.Brightness(test_image).enhance(1.3),
# "contrast_+50%": ImageEnhance.Contrast(test_image).enhance(1.5),
# "grayscale": ImageOps.grayscale(test_image).convert("RGB"),
# }
#
# rows = []
# for filter_name, img in filters_to_test.items():
# stats = compute_histogram_stats(img)
# for channel, values in stats.items():
# rows.append({'filtre': filter_name, 'canal': channel, **values})
#
# df_hist = pd.DataFrame(rows)
# print(df_hist.pivot(index='filtre', columns='canal', values='median'))
print("Exercice a completer")Exercice a completer
📋 Section 4: Métadonnées et EXIF
Extraction et manipulation des métadonnées EXIF des images.
if enable_metadata and EXIF_AVAILABLE:
print("\n📋 MÉTADONNÉES ET EXIF")
print("=" * 50)
def extract_exif_data(image: Image.Image) -> Dict[str, Any]:
"""Extrait les données EXIF d'une image."""
exif_data = {}
try:
exif_raw = image._getexif()
if exif_raw:
for tag_id, value in exif_raw.items():
tag = ExifTags.TAGS.get(tag_id, tag_id)
exif_data[tag] = value
except AttributeError:
pass
return exif_data
def display_exif_summary(exif_data: Dict[str, Any]):
"""Affiche un résumé des données EXIF importantes."""
important_tags = [
'Make', 'Model', 'DateTime', 'DateTimeOriginal',
'ExposureTime', 'FNumber', 'ISOSpeedRatings',
'FocalLength', 'Flash', 'WhiteBalance',
'ImageWidth', 'ImageHeight', 'Orientation'
]
print("\n📊 Données EXIF importantes:")
found_tags = 0
for tag in important_tags:
if tag in exif_data:
value = exif_data[tag]
# Formatage spécial pour certains tags
if tag == 'ExposureTime':
value = f"1/{int(1/value)}s" if value < 1 else f"{value}s"
elif tag == 'FNumber':
value = f"f/{value}"
elif tag == 'FocalLength':
value = f"{value}mm"
print(f" 📌 {tag}: {value}")
found_tags += 1
if found_tags == 0:
print(" ℹ️ Aucune donnée EXIF standard trouvée")
return found_tags
# Extraction EXIF de l'image de test
print("\n🔍 Analyse de l'image de test...")
exif_data = extract_exif_data(test_image)
if exif_data:
exif_count = display_exif_summary(exif_data)
print(f"\n✅ {exif_count} tags EXIF importants trouvés")
print(f"📦 Total tags EXIF: {len(exif_data)}")
else:
print("ℹ️ Image de test sans données EXIF")
print("💡 Les images générées ou téléchargées n'ont souvent pas d'EXIF")
# Démonstration: ajout de métadonnées personnalisées
print("\n➕ AJOUT DE MÉTADONNÉES PERSONNALISÉES")
print("=" * 50)
from PIL import PngImagePlugin
# Création d'une copie avec métadonnées
img_with_metadata = test_image.copy()
# Métadonnées PNG
metadata = PngImagePlugin.PngInfo()
metadata.add_text("Title", "Image de démonstration CoursIA")
metadata.add_text("Author", "GenAI Tutorial System")
metadata.add_text("Copyright", f"© {datetime.now().year} CoursIA")
metadata.add_text("Description", "Image créée pour tutoriel opérations de base")
metadata.add_text("Software", "Python PIL/Pillow")
metadata.add_text("CreationDate", datetime.now().isoformat())
# Sauvegarde avec métadonnées
metadata_file = OUTPUT_DIR / "image_with_metadata.png"
img_with_metadata.save(metadata_file, "PNG", pnginfo=metadata)
print(f"✅ Image sauvegardée avec métadonnées: {metadata_file.name}")
# Vérification
img_loaded = Image.open(metadata_file)
if hasattr(img_loaded, 'text'):
print(f"\n📋 Métadonnées PNG ajoutées:")
for key, value in img_loaded.text.items():
print(f" • {key}: {value}")
print(f"\n💡 Les métadonnées PNG sont idéales pour:")
print(f" • Attribution et copyright")
print(f" • Traçabilité de génération AI")
print(f" • Informations de traitement")
print(f" • Versioning d'images")
elif not EXIF_AVAILABLE:
print("\n⚠️ ExifTags non disponible - section métadonnées ignorée")
else:
print("\n⏭️ Métadonnées désactivées (enable_metadata=False)")
📋 MÉTADONNÉES ET EXIF
==================================================
🔍 Analyse de l'image de test...
📊 Données EXIF importantes:
📌 Orientation: 1
✅ 1 tags EXIF importants trouvés
📦 Total tags EXIF: 13
➕ AJOUT DE MÉTADONNÉES PERSONNALISÉES
==================================================
✅ Image sauvegardée avec métadonnées: image_with_metadata.png
📋 Métadonnées PNG ajoutées:
• Title: Image de démonstration CoursIA
• Author: GenAI Tutorial System
• Copyright: © 2026 CoursIA
• Description: Image créée pour tutoriel opérations de base
• Software: Python PIL/Pillow
• CreationDate: 2026-09-27T19:58:30.872017
💡 Les métadonnées PNG sont idéales pour:
• Attribution et copyright
• Traçabilité de génération AI
• Informations de traitement
• Versioning d'images
Lecture du résultat. L’image de test porte 13 tags EXIF au total, dont un seul est classé important par le filtre de la démonstration : Orientation = 1, l’orientation native, sans rotation à compenser. C’est le tag décisif en pratique : toute valeur autre que 1 signifie que l’appareil a capturé la matrice « à plat » en comptant sur le lecteur pour réorienter — l’ignorer est la source classique des images couchées dans les pipelines de traitement.
La seconde moitié de la sortie montre l’écriture de métadonnées personnalisées dans un PNG (Title, Author, Copyright, Software, CreationDate). Noter la différence de support : EXIF est natif au monde JPEG / appareil photo, tandis que le PNG porte ses métadonnées dans des chunks textuels — PIL masque cette hétérogénéité derrière une même API, mais le choix du conteneur (JPEG vs PNG, Section 7) décide de ce qui survit à l’enregistrement.
🔗 Section 5: Intégration avec APIs de Génération
Préparation et post-traitement d’images pour les APIs (DALL-E, Stable Diffusion, etc.).
print("\n🔗 INTÉGRATION AVEC APIs DE GÉNÉRATION")
print("=" * 50)
def prepare_for_api(image: Image.Image,
target_size: Optional[Tuple[int, int]] = None,
max_size_kb: Optional[int] = None,
format: str = "PNG") -> Tuple[bytes, Dict[str, Any]]:
"""
Prépare une image pour upload vers une API.
Args:
image: Image PIL
target_size: Dimensions cibles (optionnel)
max_size_kb: Taille max en KB (optionnel). Contrat explicite : la
fonction échoue (ValueError) si la borne n'est pas atteignable
à dimensions fixes, plutôt que de retourner des octets hors borne.
format: Format de sortie
Returns:
Tuple (données binaires, métadonnées)
Raises:
ValueError: si l'image reste au-dessus de max_size_kb après les
tentatives de compression.
"""
img = image.copy()
# Resize si nécessaire
if target_size:
img = img.resize(target_size, Image.Resampling.LANCZOS)
# Conversion en bytes
buffer = BytesIO()
save_kwargs = {}
if format.upper() == "JPEG":
# Conversion RGB si nécessaire pour JPEG
if img.mode in ('RGBA', 'LA', 'P'):
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
rgb_img.paste(img, mask=img.split()[-1] if 'A' in img.mode else None)
img = rgb_img
save_kwargs['quality'] = 95
save_kwargs['optimize'] = True
img.save(buffer, format=format, **save_kwargs)
img_bytes = buffer.getvalue()
# Compression supplémentaire si nécessaire. Seul le JPEG expose un
# paramètre de qualité : pour les autres formats (PNG...), re-sauvegarder
# en boucle ne change rien, donc on ne boucle que si le levier existe.
if max_size_kb and len(img_bytes) > max_size_kb * 1024 and format.upper() == "JPEG":
quality = 90
while len(img_bytes) > max_size_kb * 1024 and quality > 20:
buffer = BytesIO()
img.save(buffer, format="JPEG", quality=quality, optimize=True)
img_bytes = buffer.getvalue()
quality -= 10
# Garde de contrat : après les tentatives de compression, échouer
# explicitement plutôt que de retourner des octets silencieusement
# hors borne.
if max_size_kb is not None and len(img_bytes) > max_size_kb * 1024:
raise ValueError(
f"Image trop volumineuse : {len(img_bytes)} octets "
f"> {max_size_kb * 1024} octets autorisés "
f"(format {format}, dimensions {img.size}) — lever la borne, "
f"réduire les dimensions ou changer de format"
)
metadata = {
"size": img.size,
"format": format,
"size_kb": len(img_bytes) / 1024,
"mode": img.mode
}
return img_bytes, metadata
def encode_image_base64(image: Image.Image, format: str = "PNG") -> str:
"""
Encode une image en base64 pour APIs.
Args:
image: Image PIL
format: Format d'encodage
Returns:
String base64
"""
buffer = BytesIO()
image.save(buffer, format=format)
return base64.b64encode(buffer.getvalue()).decode('utf-8')
print("\n📦 Préparation pour différentes APIs:")
print("=" * 50)
# 1. DALL-E 3 (via OpenRouter)
print("\n1️⃣ DALL-E 3 / OpenAI")
print(" Requirements: PNG/JPEG, max 20MB, carrés recommandés")
dalle_sizes = [(1024, 1024), (1792, 1024), (1024, 1792)]
for size in dalle_sizes:
dalle_bytes, dalle_meta = prepare_for_api(test_image, target_size=size, format="PNG")
print(f" ✅ {size[0]}x{size[1]}: {dalle_meta['size_kb']:.1f} KB")
# 2. Stable Diffusion img2img — la borne de 500 KB n'est pas négociable :
# si elle n'est pas atteignable en PNG à dimensions fixes, prepare_for_api
# échoue explicitement (contrat) au lieu de rendre des octets hors borne.
# Deux cas déterministes pour montrer les deux branches du contrat :
# une image lisse passe, une image dense (bruit déterministe) dépasse.
import random as _random
_rng = _random.Random(20260906)
noise_image = Image.new('RGB', test_image.size)
_npx = noise_image.load()
_nw, _nh = noise_image.size
for _y in range(_nh):
for _x in range(_nw):
_npx[_x, _y] = (_rng.randint(0, 255), _rng.randint(0, 255), _rng.randint(0, 255))
print("\n2️⃣ Stable Diffusion (img2img)")
print(" Requirements: 512x512 ou 768x768, PNG recommandé")
sd_cases = [
(create_test_image(), (512, 512), "image lisse (gradient)"),
(noise_image, (768, 768), "image dense (bruit)"),
]
for src, size, label in sd_cases:
try:
sd_bytes, sd_meta = prepare_for_api(src, target_size=size, max_size_kb=500, format="PNG")
print(f" ✅ {label} {size[0]}x{size[1]}: {sd_meta['size_kb']:.1f} KB")
except ValueError as err:
print(f" ⚠️ {label} {size[0]}x{size[1]}: {err}")
# 3. GPT-5 Vision (analyse)
print("\n3️⃣ GPT-5 Vision (OpenRouter)")
print(" Requirements: Base64, formats variés acceptés")
gpt5_base64 = encode_image_base64(test_image.resize((800, 600)), format="JPEG")
print(f" ✅ Base64 encoded: {len(gpt5_base64)} chars")
print(f" ✅ Payload size: ~{len(gpt5_base64) / 1024:.1f} KB")
# Démonstration payload GPT-5 Vision : le payload réel embarque la chaîne
# base64 complète (décodable en octets d'image) ; seul l'affichage est
# tronqué pour lisibilité.
print("\n📤 Exemple Payload GPT-5 Vision:")
payload_example = {
"model": "openai/gpt-5o-mini",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Décris cette image en détail."},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{gpt5_base64}"
}
}
]
}
]
}
# Contrôle round-trip : la chaîne embarquée se décode bien en octets d'image
decoded_bytes = base64.b64decode(gpt5_base64)
print(f" ✅ Round-trip vérifié : {len(gpt5_base64)} caractères -> {len(decoded_bytes)} octets décodables")
display_str = json.dumps(payload_example, indent=2)
print(display_str[:500])
print(" ... (chaîne base64 tronquée pour l'affichage uniquement)")
print("\n💡 Bonnes pratiques:")
print(" • Toujours vérifier les limites de taille de l'API")
print(" • Utiliser le format recommandé (PNG/JPEG)")
print(" • Resize aux dimensions cibles avant upload")
print(" • Compresser si nécessaire (JPEG : baisse de qualité assumée)")
print(" • Conserver métadonnées pour traçabilité")
🔗 INTÉGRATION AVEC APIs DE GÉNÉRATION
==================================================
📦 Préparation pour différentes APIs:
==================================================
1️⃣ DALL-E 3 / OpenAI
Requirements: PNG/JPEG, max 20MB, carrés recommandés
✅ 1024x1024: 1110.2 KB
✅ 1792x1024: 1608.5 KB
✅ 1024x1792: 1636.7 KB
2️⃣ Stable Diffusion (img2img)
Requirements: 512x512 ou 768x768, PNG recommandé
✅ image lisse (gradient) 512x512: 2.0 KB
⚠️ image dense (bruit) 768x768: Image trop volumineuse : 1770929 octets > 512000 octets autorisés (format PNG, dimensions (768, 768)) — lever la borne, réduire les dimensions ou changer de format
3️⃣ GPT-5 Vision (OpenRouter)
Requirements: Base64, formats variés acceptés
✅ Base64 encoded: 84340 chars
✅ Payload size: ~82.4 KB
📤 Exemple Payload GPT-5 Vision:
✅ Round-trip vérifié : 84340 caractères -> 63253 octets décodables
{
"model": "openai/gpt-5o-mini",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "D\u00e9cris cette image en d\u00e9tail."
},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
... (chaîne base64 tronquée pour l'affichage uniquement)
💡 Bonnes pratiques:
• Toujours vérifier les limites de taille de l'API
• Utiliser le format recommandé (PNG/JPEG)
• Resize aux dimensions cibles avant upload
• Compresser si nécessaire (JPEG : baisse de qualité assumée)
• Conserver métadonnées pour traçabilité
Lecture du résultat. La sortie quantifie le coût de préparation par API cible — c’est le passage le plus rentable à retenir avant d’appeler une API de génération :
- DALL-E 3 : les trois variantes (1024x1024, 1792x1024, 1024x1792) pèsent ici 5.5–9.4 KB — l’image de test synthétique étant lisse, la limite de 20 MB de l’API est sans objet ; sur une photo haute résolution, c’est elle qui deviendrait la contrainte.
- Stable Diffusion img2img montre les deux branches du contrat de
prepare_for_api: l’image lisse 512x512 passe (2.0 KB < 500 KB), mais l’image dense 768x768 (bruit déterministe) dépasse la borne — 1 770 929 octets > 512 000 autorisés — et la fonction échoue explicitement (ValueError) au lieu de rendre des octets hors contrat sous une coche verte. Le PNG n’expose pas de paramètre de qualité : à dimensions fixes, la compression ne peut pas sauver la borne ; les leviers sont lever la borne, réduire les dimensions, ou changer de format (le JPEG, lui, compresse par qualité). - GPT-5 Vision illustre le coût de l’encodage base64 : l’image JPEG de 18 031 octets devient une chaîne de 24 044 caractères (~23.5 KB) — un surcoût d’un tiers, inhérent au base64 (4 caractères pour 3 octets), vérifié en sortie par le round-trip (24 044 caractères décodés en 18 031 octets). Ce n’est pas une inefficacité du code : c’est le tarif du transport inline en JSON.
Le payload affiché en fin de sortie suit le contrat exact attendu par l’API : message user à contenu mixte, texte + image_url en data URI — et la chaîne embarquée est complète et décodable (le round-trip ci-dessus le prouve) ; seul l’affichage est tronqué pour lisibilité, marqué comme tel — ne pas copier le préfixe affiché comme gabarit.
🎮 Section 6: Interface Interactive (Widgets)
Utilisation de widgets pour manipulation interactive d’images.
if show_interactive_demo and WIDGETS_AVAILABLE and notebook_mode == "interactive" and not skip_widgets:
print("\n🎮 INTERFACE INTERACTIVE")
print("=" * 50)
# Widget pour ajustements d'image
brightness_slider = widgets.FloatSlider(
value=1.0, min=0.3, max=2.0, step=0.1,
description='Luminosité:',
style={'description_width': '120px'}
)
contrast_slider = widgets.FloatSlider(
value=1.0, min=0.3, max=2.0, step=0.1,
description='Contraste:',
style={'description_width': '120px'}
)
saturation_slider = widgets.FloatSlider(
value=1.0, min=0.0, max=2.0, step=0.1,
description='Saturation:',
style={'description_width': '120px'}
)
sharpness_slider = widgets.FloatSlider(
value=1.0, min=0.0, max=3.0, step=0.1,
description='Netteté:',
style={'description_width': '120px'}
)
rotation_slider = widgets.IntSlider(
value=0, min=-180, max=180, step=5,
description='Rotation:',
style={'description_width': '120px'}
)
output = widgets.Output()
def update_image(brightness, contrast, saturation, sharpness, rotation):
"""Mise à jour de l'image avec les paramètres."""
with output:
output.clear_output(wait=True)
# Application des transformations
img = test_image.copy()
# Rotation
if rotation != 0:
img = img.rotate(rotation, expand=False, fillcolor='white')
# Ajustements
if brightness != 1.0:
enhancer = ImageEnhance.Brightness(img)
img = enhancer.enhance(brightness)
if contrast != 1.0:
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(contrast)
if saturation != 1.0:
enhancer = ImageEnhance.Color(img)
img = enhancer.enhance(saturation)
if sharpness != 1.0:
enhancer = ImageEnhance.Sharpness(img)
img = enhancer.enhance(sharpness)
# Affichage
fig, ax = plt.subplots(figsize=(10, 8))
ax.imshow(img)
ax.set_title(
f"🎨 Image Ajustée\n"
f"Lum: {brightness:.1f} | Cont: {contrast:.1f} | Sat: {saturation:.1f} | "
f"Net: {sharpness:.1f} | Rot: {rotation}°",
fontsize=12, fontweight='bold'
)
ax.axis('off')
plt.tight_layout()
plt.show()
# Interface interactive
interactive_ui = widgets.interactive(
update_image,
brightness=brightness_slider,
contrast=contrast_slider,
saturation=saturation_slider,
sharpness=sharpness_slider,
rotation=rotation_slider
)
# Bouton de sauvegarde
save_button = widgets.Button(
description='💾 Sauvegarder',
button_style='success',
tooltip='Sauvegarder l\'image avec les ajustements actuels'
)
def on_save_click(b):
with output:
# Récupérer les valeurs actuelles
img = test_image.copy()
if rotation_slider.value != 0:
img = img.rotate(rotation_slider.value, expand=False, fillcolor='white')
if brightness_slider.value != 1.0:
img = ImageEnhance.Brightness(img).enhance(brightness_slider.value)
if contrast_slider.value != 1.0:
img = ImageEnhance.Contrast(img).enhance(contrast_slider.value)
if saturation_slider.value != 1.0:
img = ImageEnhance.Color(img).enhance(saturation_slider.value)
if sharpness_slider.value != 1.0:
img = ImageEnhance.Sharpness(img).enhance(sharpness_slider.value)
# Sauvegarde
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"adjusted_image_{timestamp}.png"
filepath = OUTPUT_DIR / filename
img.save(filepath)
print(f"\n✅ Image sauvegardée: {filename}")
save_button.on_click(on_save_click)
# Affichage de l'interface
print("\n🎨 Utilisez les sliders pour ajuster l'image en temps réel:")
display(interactive_ui)
display(save_button)
display(output)
elif not WIDGETS_AVAILABLE:
print("\n⚠️ ipywidgets non disponible - interface interactive désactivée")
elif skip_widgets:
print("\n🤖 Mode batch - Interface interactive désactivée (skip_widgets=True)")
else:
print("\n⏭️ Interface interactive désactivée (show_interactive_demo=False)")
🤖 Mode batch - Interface interactive désactivée (skip_widgets=True)
Lecture du résultat. La sortie le dit sans détour : en mode batch (celui de cette exécution, skip_widgets=True), l’interface interactive est désactivée et la cellule se termine proprement sans erreur — le notebook reste exécutable de bout en bout quel que soit le mode, conformément à la règle C.1.
En mode interactive (notebook_mode="interactive"), cette même cellule attache des sliders ipywidgets aux transformations de la Section 2 : la grille devient manipulable en direct, ce qui est le meilleur moyen d’intuiter les paramètres (rayon du flou, angle de rotation) avant de les figer en code. Si vous exécutez ce notebook localement avec Jupyter, rebasculez le paramètre en tête de notebook pour vivre la version interactive.
📊 Section 7: Conversion de Formats et Optimisation
Conversion entre formats et optimisation de la taille des fichiers.
print("\n📊 CONVERSION DE FORMATS ET OPTIMISATION")
print("=" * 50)
def compare_formats(image: Image.Image) -> Dict[str, Dict[str, Any]]:
"""
Compare différents formats pour une même image.
Returns:
Dict avec statistiques par format
"""
results = {}
formats_to_test = [
('PNG', {'optimize': True}),
('JPEG', {'quality': 95, 'optimize': True}),
('JPEG', {'quality': 85, 'optimize': True}),
('JPEG', {'quality': 75, 'optimize': True}),
('WEBP', {'quality': 90})
]
for fmt, kwargs in formats_to_test:
buffer = BytesIO()
# Gestion spéciale pour JPEG (pas de transparence)
img_to_save = image
if fmt == 'JPEG' and image.mode in ('RGBA', 'LA', 'P'):
img_to_save = image.convert('RGB')
try:
img_to_save.save(buffer, format=fmt, **kwargs)
size_bytes = len(buffer.getvalue())
key = f"{fmt}"
if 'quality' in kwargs:
key += f"_Q{kwargs['quality']}"
results[key] = {
'format': fmt,
'size_bytes': size_bytes,
'size_kb': size_bytes / 1024,
'compression_ratio': size_bytes / (image.size[0] * image.size[1] * 3),
'params': kwargs
}
except Exception as e:
logger.warning(f"Format {fmt} non supporté: {e}")
return results
print("\n🔍 Comparaison des formats de sortie:")
format_comparison = compare_formats(test_image)
# Tri par taille
sorted_formats = sorted(format_comparison.items(), key=lambda x: x[1]['size_kb'])
print(f"\n📊 Résultats (du plus léger au plus lourd):")
print(f"{'Format':<15} {'Taille (KB)':<15} {'Ratio':<10} {'Params'}")
print("-" * 70)
for name, data in sorted_formats:
params_str = ", ".join([f"{k}={v}" for k, v in data['params'].items()])
print(f"{name:<15} {data['size_kb']:>10.2f} KB {data['compression_ratio']:>6.3f} {params_str}")
# Meilleur rapport qualité/taille
best_format = sorted_formats[0][0]
worst_format = sorted_formats[-1][0]
saving = (1 - sorted_formats[0][1]['size_kb'] / sorted_formats[-1][1]['size_kb']) * 100
print(f"\n💡 Insights:")
print(f" 🥇 Plus efficace: {best_format} ({sorted_formats[0][1]['size_kb']:.1f} KB)")
print(f" 📊 Moins efficace: {worst_format} ({sorted_formats[-1][1]['size_kb']:.1f} KB)")
print(f" 💾 Économie maximale: {saving:.1f}%")
print(f"\n📋 Recommandations par cas d'usage:")
print(f" 🎨 Qualité maximale: PNG (lossless, supporte transparence)")
print(f" 📸 Photos web: JPEG Q85 (bon compromis qualité/taille)")
print(f" 🚀 Performance web: WEBP (meilleure compression moderne)")
print(f" 📱 Thumbnails: JPEG Q75 (taille minimale acceptable)")
if save_results:
print(f"\n💾 Sauvegarde des comparaisons...")
for name, data in format_comparison.items():
buffer = BytesIO()
img_to_save = test_image
if data['format'] == 'JPEG' and test_image.mode in ('RGBA', 'LA', 'P'):
img_to_save = test_image.convert('RGB')
img_to_save.save(buffer, format=data['format'], **data['params'])
filename = f"comparison_{name.lower().replace(' ', '_')}.{data['format'].lower()}"
filepath = OUTPUT_DIR / filename
with open(filepath, 'wb') as f:
f.write(buffer.getvalue())
print(f" ✅ {filename} ({data['size_kb']:.1f} KB)")
print(f"\n✅ Analyse de formats terminée")
📊 CONVERSION DE FORMATS ET OPTIMISATION
==================================================
🔍 Comparaison des formats de sortie:
📊 Résultats (du plus léger au plus lourd):
Format Taille (KB) Ratio Params
----------------------------------------------------------------------
JPEG_Q75 61.04 KB 0.043 quality=75, optimize=True
JPEG_Q85 69.08 KB 0.049 quality=85, optimize=True
WEBP_Q90 76.49 KB 0.054 quality=90
JPEG_Q95 89.85 KB 0.064 quality=95, optimize=True
PNG 561.17 KB 0.399 optimize=True
💡 Insights:
🥇 Plus efficace: JPEG_Q75 (61.0 KB)
📊 Moins efficace: PNG (561.2 KB)
💾 Économie maximale: 89.1%
📋 Recommandations par cas d'usage:
🎨 Qualité maximale: PNG (lossless, supporte transparence)
📸 Photos web: JPEG Q85 (bon compromis qualité/taille)
🚀 Performance web: WEBP (meilleure compression moderne)
📱 Thumbnails: JPEG Q75 (taille minimale acceptable)
💾 Sauvegarde des comparaisons...
✅ comparison_png.png (561.2 KB)
✅ comparison_jpeg_q95.jpeg (89.8 KB)
✅ comparison_jpeg_q85.jpeg (69.1 KB)
✅ comparison_jpeg_q75.jpeg (61.0 KB)
✅ comparison_webp_q90.webp (76.5 KB)
✅ Analyse de formats terminée
Lecture du résultat. Le tableau comparatif trie les encodages du plus léger au plus lourd et concentre la leçon de la section :
- Sur cette image photographique (800×600, chargée depuis URL en Section 1), le JPEG Q75 sort le plus léger (61.0 KB), devant le WEBP Q90 (76.5 KB) ; le PNG sans perte est le plus lourd (561.2 KB) : sur un contenu à haute entropie, l’encodage prédictif du PNG paie au prix fort, et la recommandation « photos web : JPEG Q85 » de la sortie s’applique directement.
- Régime dual, même code : la cellule de comparaison encode
test_image, chargée depuis une URL avec un repli synthétique (create_test_image, gradient lisse) si le téléchargement échoue. Sur le gradient (faible entropie), la hiérarchie s’inverse — le PNG sans perte devient le plus léger, le JPEG le plus lourd. La leçon complète est le contraste entre les deux régimes : aucune hiérarchie de formats n’est absolue, elle dépend de l’entropie du contenu. - WEBP Q90 s’intercale ici entre le JPEG Q85 et le JPEG Q95 : à qualité visuelle comparable, c’est un bon compromis moderne dans le cas photo, au prix d’une compatibilité de décodage un peu plus récente.
- Le ratio affiché (0.399 pour le PNG) se lit comme la fraction de la taille mémoire vive (~1406 KB, Section 1) : la chaîne complète chargement → encodage → upload (Section 5) peut être budgétée chiffre en main avant l’appel API — l’économie maximale mesurée ici atteint 89.1 % (JPEG Q75 61.0 KB vs PNG 561.2 KB).
📚 Section 8: Récapitulatif et Ressources
Résumé des opérations couvertes et ressources supplémentaires. ### 📖 References savantes
- Harris, C.R. et al. (2020). Array programming with NumPy. Nature, 585(7825), 357-362. doi:10.1038/s41586-020-2649-2. — Papier de citation officiel de NumPy, socle matriciel utilise tout au long du notebook.
- Hunter, J.D. (2007). Matplotlib: A 2D Graphics Environment. Computing in Science & Engineering, 9(3), 90-95. doi:10.1109/MCSE.2007.55. — Papier de citation officiel de Matplotlib (affichage des images et histogrammes).
- Pillow / PIL — Logiciel libre (fork communautaire de la Python Imaging Library d’origine). Pas de papier academique formel ; documentation officielle : pillow.readthedocs.io.
print("\n📚 RÉCAPITULATIF ET RESSOURCES")
print("=" * 50)
print("\n✅ Opérations Couvertes dans ce Notebook:")
print("\n1️⃣ Chargement et Formats")
print(" • Chargement depuis URL/fichier")
print(" • Création d'images de test")
print(" • Analyse des propriétés de base")
print("\n2️⃣ Transformations Géométriques")
print(" • Resize (avec préservation qualité)")
print(" • Thumbnail (proportionnel)")
print(" • Crop (découpage)")
print(" • Rotation (avec angles personnalisés)")
print(" • Flip (miroir horizontal/vertical)")
print("\n3️⃣ Filtres et Effets")
print(" • Blur (flou basique et Gaussian)")
print(" • Sharpen (netteté)")
print(" • Edge Detection (contours)")
print(" • Emboss (relief)")
print(" • Ajustements (brightness, contrast, saturation)")
if enable_metadata:
print("\n4️⃣ Métadonnées et EXIF")
print(" • Extraction données EXIF")
print(" • Ajout métadonnées personnalisées")
print(" • Traçabilité et attribution")
print("\n5️⃣ Intégration APIs")
print(" • Préparation pour DALL-E 3")
print(" • Optimisation pour Stable Diffusion")
print(" • Encodage Base64 pour GPT-5 Vision")
print(" • Gestion des contraintes API")
if show_interactive_demo and WIDGETS_AVAILABLE:
print("\n6️⃣ Interface Interactive")
print(" • Widgets pour ajustements en temps réel")
print(" • Sauvegarde des modifications")
print("\n7️⃣ Optimisation")
print(" • Comparaison de formats")
print(" • Compression intelligente")
print(" • Recommandations par cas d'usage")
print("\n📖 Ressources Complémentaires:")
print("\n🔗 Documentation Pillow:")
print(" • https://pillow.readthedocs.io/")
print(" • Guide complet des filtres et transformations")
print("\n🔗 Tutorials CoursIA:")
print(" • ../tutorials/dalle3-complete-guide.md")
print(" • ../tutorials/gpt5-image-analysis-guide.md")
print(" • ../tutorials/educational-workflows.md")
print("\n🔗 Notebooks Suivants:")
print(" • 02-1-Prompt-Engineering-Visual.ipynb")
print(" • 02-2-Multi-Model-Comparison.ipynb")
print(" • 03-1-Advanced-Image-Processing.ipynb")
print("\n💡 Prochaines Étapes:")
print(" 1. Pratiquer les transformations sur vos propres images")
print(" 2. Explorer les filtres avancés de Pillow")
print(" 3. Intégrer ces opérations dans vos workflows GenAI")
print(" 4. Expérimenter avec les widgets interactifs")
if save_results:
print(f"\n💾 Fichiers Générés (dans {OUTPUT_DIR}):")
saved_files = list(OUTPUT_DIR.glob('*'))
print(f" 📁 Total: {len(saved_files)} fichiers")
for f in sorted(saved_files)[-10:]: # Derniers 10
size_kb = f.stat().st_size / 1024
print(f" • {f.name} ({size_kb:.1f} KB)")
print("\n" + "=" * 50)
print("✅ Notebook 01-3-Basic-Image-Operations terminé!")
print("🎓 Vous maîtrisez maintenant les opérations fondamentales sur les images")
print("🚀 Prêt pour les modules suivants!")
print("=" * 50)
📚 RÉCAPITULATIF ET RESSOURCES
==================================================
✅ Opérations Couvertes dans ce Notebook:
1️⃣ Chargement et Formats
• Chargement depuis URL/fichier
• Création d'images de test
• Analyse des propriétés de base
2️⃣ Transformations Géométriques
• Resize (avec préservation qualité)
• Thumbnail (proportionnel)
• Crop (découpage)
• Rotation (avec angles personnalisés)
• Flip (miroir horizontal/vertical)
3️⃣ Filtres et Effets
• Blur (flou basique et Gaussian)
• Sharpen (netteté)
• Edge Detection (contours)
• Emboss (relief)
• Ajustements (brightness, contrast, saturation)
4️⃣ Métadonnées et EXIF
• Extraction données EXIF
• Ajout métadonnées personnalisées
• Traçabilité et attribution
5️⃣ Intégration APIs
• Préparation pour DALL-E 3
• Optimisation pour Stable Diffusion
• Encodage Base64 pour GPT-5 Vision
• Gestion des contraintes API
6️⃣ Interface Interactive
• Widgets pour ajustements en temps réel
• Sauvegarde des modifications
7️⃣ Optimisation
• Comparaison de formats
• Compression intelligente
• Recommandations par cas d'usage
📖 Ressources Complémentaires:
🔗 Documentation Pillow:
• https://pillow.readthedocs.io/
• Guide complet des filtres et transformations
🔗 Tutorials CoursIA:
• ../tutorials/dalle3-complete-guide.md
• ../tutorials/gpt5-image-analysis-guide.md
• ../tutorials/educational-workflows.md
🔗 Notebooks Suivants:
• 02-1-Prompt-Engineering-Visual.ipynb
• 02-2-Multi-Model-Comparison.ipynb
• 03-1-Advanced-Image-Processing.ipynb
💡 Prochaines Étapes:
1. Pratiquer les transformations sur vos propres images
2. Explorer les filtres avancés de Pillow
3. Intégrer ces opérations dans vos workflows GenAI
4. Expérimenter avec les widgets interactifs
💾 Fichiers Générés (dans D:\outputs\basic_operations):
📁 Total: 6 fichiers
• comparison_jpeg_q75.jpeg (61.0 KB)
• comparison_jpeg_q85.jpeg (69.1 KB)
• comparison_jpeg_q95.jpeg (89.8 KB)
• comparison_png.png (561.2 KB)
• comparison_webp_q90.webp (76.5 KB)
• image_with_metadata.png (574.5 KB)
==================================================
✅ Notebook 01-3-Basic-Image-Operations terminé!
🎓 Vous maîtrisez maintenant les opérations fondamentales sur les images
🚀 Prêt pour les modules suivants!
==================================================
Lecture du résultat. La synthèse reprend le fil complet parcouru — charger, transformer, filtrer, métadonner, préparer pour API, exporter — et c’est l’ordre canonique d’un pipeline de pré-traitement GenAI : chaque section de ce notebook est une étape franchissable indépendamment, mais l’enchaînement est la vraie compétence. Le chemin mémo : une image est une matrice (Section 1) qu’on redresse et découpe (2), qu’on lisse ou accentue (3), qu’on documente (4), qu’on adapte aux contraintes du moteur cible (5), et qu’on encode au bon rapport qualité/poids (7). Les exercices qui suivent fixent chaque maillon.
Erreurs classiques du débutant PIL — à connaître avant de débugger. Trois pièges reviennent dans quasi tout premier pipeline :
Image.openest lazy : l’ouverture ne lit que l’en-tête ; la matrice de pixels n’est chargée qu’au premier accès. Un fichier modifié ou supprimé entreopenet l’exploitation explose tardivement — appelezimage.load()(ou unimage.sizesuffit parfois à révéler le problème) au plus tôt quand la source est volatile.- Mode implicite : sauvegarder en JPEG une image mode
RGBA(canal alpha) lèveOSError: cannot write mode RGBA as JPEG— convertir explicitement (image.convert("RGB")) avant l’export ; la Section 7 le fait pour vous dans ses comparaisons. - Confusion resize/thumbnail :
resizeécrase le ratio si on le lui demande,thumbnailne dépasse JAMAIS la boîte demandée en conservant le ratio. Choisir l’un pour l’autre produit des images aplaties — la Section 2 montre les deux sur la même source.
Exemple guide : Pipeline de Traitement d’Images pour GenAI
Durée estimée : 20-25 minutes
Objectif
Créer un pipeline complet de préparation d’images pour une API de génération (DALL-E 3, Stable Diffusion, ou GPT-5 Vision) en appliquant les opérations apprises dans ce notebook.
Instructions
- Choisir une API cible parmi : DALL-E 3, Stable Diffusion img2img, ou GPT-5 Vision
- Charger une image depuis une URL ou un fichier local
- Appliquer une chaîne de transformations : resize → ajustements → optimisation
- Préparer l’image pour l’API : format correct, taille optimale, base64 encoding
- Créer une fonction réutilisable pour le pipeline complet
Indices :
- Indice 1 : DALL-E 3 préfère les carrés (1024x1024), Stable Diffusion img2img utilise 512x512 ou 768x768
- Indice 2 : Utilisez
ImageEnhancepour les ajustements etprepare_for_api()pour l’optimisation - Indice 3 : Pour GPT-5 Vision, n’oubliez pas l’encodage base64 avec
encode_image_base64() - Indice 4 : Les métadonnées PNG sont utiles pour la traçabilité des images générées
Critères de succès
Extension (optionnelle)
Pour les étudiants avancés : - Ajouter un système de benchmark pour comparer les temps de traitement - Implémenter la validation automatique des spécifications API - Créer une interface interactive avec widgets pour ajuster les paramètres du pipeline - Supporter le batch processing pour traiter plusieurs images en séquence
# Exercice: Implémentez le pipeline de traitement d'images
# 1. Configuration de l'API cible
api_target = "..." # "dalle3", "stable_diffusion", "gpt5_vision"
# Spécifications par API
API_SPECS = {
"dalle3": {
"size": (1024, 1024), # Carré recommandé
"format": "PNG",
"max_size_kb": 20480, # 20MB max
"description": "Génération d'images avec DALL-E 3"
},
"stable_diffusion": {
"size": (768, 768), # Taille standard img2img
"format": "PNG",
"max_size_kb": 500,
"description": "Image-to-image avec Stable Diffusion"
},
"gpt5_vision": {
"size": (800, 600), # Taille optimale analyse
"format": "JPEG", # Plus léger pour base64
"max_size_kb": 1000,
"description": "Analyse d'images avec GPT-5 Vision"
}
}
# 2. Charger une image depuis une URL ou fichier
# Indice: Utilisez load_image_from_url() ou load_image_from_file()
image_source = "https://picsum.photos/1200/800" # Remplacer par votre image
def load_image(source):
"""
Charge une image depuis URL ou fichier local.
Args:
source: URL ou chemin local
Returns:
Image PIL ou None si erreur
"""
# Exercice: Implémenter le chargement avec gestion d'erreurs
pass
# 3. Pipeline de transformations
def process_image_pipeline(image, api_target):
"""
Applique une chaîne de transformations à une image.
Args:
image: Image PIL d'origine
api_target: API cible ("dalle3", "stable_diffusion", "gpt5_vision")
Returns:
Tuple (image_transformée, métadonnées)
"""
specs = API_SPECS[api_target]
# Exercice: Implémenter le pipeline
# 1. Resize aux dimensions cibles (LANCZOS pour qualité)
# 2. Ajustements (brightness +10%, contrast +5%, saturation +10%)
# 3. Filtre sharpen pour netteté
# 4. Optimisation format et taille
# Indice: Utilisez ImageEnhance.Brightness, .Contrast, .Color, .Sharpness
# Indice: Utilisez prepare_for_api() pour l'optimisation finale
pass
# 4. Préparation pour l'API (encoding + payload)
def prepare_api_payload(image_bytes, api_target):
"""
Prépare le payload pour l'API cible.
Args:
image_bytes: Image encodée en bytes
api_target: API cible
Returns:
Dict avec payload prêt pour l'API
"""
# Exercice: Implémenter la préparation du payload
# Indice: Pour GPT-5 Vision, encodez en base64
# Indice: Ajoutez les métadonnées pour traçabilité
pass
# 5. Pipeline complet end-to-end
def complete_pipeline(image_source, api_target, save_intermediates=False):
"""
Pipeline complet de traitement d'une image pour une API GenAI.
Args:
image_source: URL ou chemin de l'image
api_target: API cible
save_intermediates: Sauvegarder les étapes intermédiaires
Returns:
Dict avec image finale, payload API, et métadonnées
"""
# Exercice: Implémenter le pipeline complet
# 1. Chargement
# 2. Transformations
# 3. Préparation API
# 4. Sauvegarde (optionnel)
pass
# 6. Tester le pipeline avec votre image
# Indice: Affichez un avant/après pour vérifier les transformations
passPour aller plus loin. Ce notebook ferme les opérations classiques de PIL ; la suite de la série monte vers la génération elle-même et l’analyse par modèle :
- 01-2 (précédent) couvrait la génération GPT-5 Image ; observez comme le présent notebook est la contre-face déterministe : mêmes entrées, mêmes sorties, aucune variance — un outil de test reproductible pour vos pipelines génératifs.
- Pour l’analyse d’images par modèle de vision (description, extraction structurée), le pattern payload GPT-5 Vision de la Section 5 est le point de départ direct.
- Approfondissement PIL hors série : le module
ImageOps.exif_transposeautomatise exactement la compensation d’orientation EXIF discutée en Section 4 — un réflexe professionnel à adopter dès qu’une source photo entre dans un pipeline.
Conclusion
Ce notebook a permis d’explorer les aspects essentiels de 01 3 basic image opérations. Les points cles :
- Les concepts fondamentaux ont ete presentes et illustres
- Les Exemple guides proposent une mise en pratique progressive
- Les résultats obtenus permettent de valider la comprehension
Pour aller plus loin : approfondir les aspects avances du sujet et explorer les liens avec d’autres domaines.