Un guide complet pour comprendre et implémenter des algorithmes de compression vidéo de A à Z avec Python. Apprenez la théorie et la pratique derrière les codecs vidéo modernes.
Construire un Codec Vidéo en Python : Une Plongée au Cœur des Algorithmes de Compression
Dans notre monde hyperconnecté, la vidéo est reine. Des services de streaming et des visioconférences aux fils d'actualités des réseaux sociaux, la vidéo numérique domine le trafic Internet. Mais comment est-il possible d'envoyer un film en haute définition via une connexion Internet standard ? La réponse se trouve dans un domaine fascinant et complexe : la compression vidéo. Au cœur de cette technologie se trouve le codec vidéo (COdeur-DÉCodeur), un ensemble sophistiqué d'algorithmes conçus pour réduire considérablement la taille des fichiers tout en préservant la qualité visuelle.
Bien que les codecs standards de l'industrie comme H.264, HEVC (H.265), et le codec libre de droits AV1 soient des œuvres d'ingénierie incroyablement complexes, la compréhension de leurs principes fondamentaux est accessible à tout développeur motivé. Ce guide vous emmènera dans un voyage au plus profond du monde de la compression vidéo. Nous ne nous contenterons pas de parler de théorie ; nous construirons un codec vidéo simplifié et éducatif à partir de zéro en utilisant Python. Cette approche pratique est la meilleure façon de saisir les idées élégantes qui rendent le streaming vidéo moderne possible.
Pourquoi Python ? Bien que ce ne soit pas le langage que vous utiliseriez pour un codec commercial en temps réel et à haute performance (qui sont généralement écrits en C/C++ ou même en assembleur), la lisibilité de Python et ses puissantes bibliothèques comme NumPy, SciPy et OpenCV en font l'environnement parfait pour l'apprentissage, le prototypage et la recherche. Vous pouvez vous concentrer sur les algorithmes sans vous enliser dans la gestion de la mémoire à bas niveau.
Comprendre les Concepts Clés de la Compression Vidéo
Avant d'écrire une seule ligne de code, nous devons comprendre ce que nous essayons d'accomplir. L'objectif de la compression vidéo est d'éliminer les données redondantes. Une vidéo brute, non compressée, est colossale. Une seule minute de vidéo 1080p à 30 images par seconde peut dépasser 7 Go. Pour dompter ce monstre de données, nous exploitons deux principaux types de redondance.
Les Deux Piliers de la Compression : Redondance Spatiale et Temporelle
- Redondance Spatiale (Intra-trame) : C'est la redondance à l'intérieur d'une seule trame. Pensez à une grande étendue de ciel bleu ou à un mur blanc. Au lieu de stocker la valeur de couleur pour chaque pixel de cette zone, nous pouvons la décrire plus efficacement. C'est le même principe que celui qui sous-tend les formats de compression d'images comme JPEG.
- Redondance Temporelle (Inter-trame) : C'est la redondance entre les trames consécutives. Dans la plupart des vidéos, la scène ne change pas complètement d'une trame à l'autre. Une personne qui parle devant un arrière-plan statique, par exemple, présente une énorme quantité de redondance temporelle. L'arrière-plan reste le même ; seule une petite partie de l'image (le visage et le corps de la personne) bouge. C'est la source de compression la plus importante en vidéo.
Types de Trames Clés : Trames I, Trames P et Trames B
Pour exploiter la redondance temporelle, les codecs ne traitent pas chaque trame de la même manière. Ils les classent en différents types, formant une séquence appelée Groupe d'Images (GOP).
- Trame I (Intra-coded Frame) : Une trame I est une image complète et autonome. Elle est compressée en utilisant uniquement la redondance spatiale, un peu comme un JPEG. Les trames I servent de points d'ancrage dans le flux vidéo, permettant à un spectateur de commencer la lecture ou de se déplacer vers une nouvelle position. C'est le type de trame le plus volumineux, mais elles sont essentielles pour régénérer la vidéo.
- Trame P (Predicted Frame) : Une trame P est encodée en regardant la trame I ou P précédente. Au lieu de stocker l'image entière, elle ne stocke que les différences. Par exemple, elle stocke des instructions comme "prenez ce bloc de pixels de la dernière trame, déplacez-le de 5 pixels vers la droite, et voici les changements de couleur mineurs". Ceci est réalisé grâce à un processus appelé estimation de mouvement.
- Trame B (Bi-directionally Predicted Frame) : Une trame B est la plus efficace. Elle peut utiliser à la fois la trame précédente et la trame suivante comme références pour la prédiction. C'est utile pour les scènes où un objet est temporairement caché puis réapparaît. En regardant en avant et en arrière, le codec peut créer une prédiction plus précise et plus efficace en termes de données. Cependant, l'utilisation de trames futures introduit un petit délai (latence), ce qui les rend moins adaptées aux applications en temps réel comme les appels vidéo.
Un GOP typique pourrait ressembler à ceci : I B B P B B P B B I .... L'encodeur décide du motif optimal de trames pour équilibrer l'efficacité de la compression et la possibilité de recherche (seekability).
Le Pipeline de Compression : Une Décomposition Étape par Étape
L'encodage vidéo moderne est un pipeline en plusieurs étapes. Chaque étape transforme les données pour les rendre plus compressibles. Passons en revue les étapes clés pour l'encodage d'une seule trame.

Étape 1 : Conversion de l'Espace Colorimétrique (RVB vers YCbCr)
La plupart des vidéos commencent dans l'espace colorimétrique RVB (Rouge, Vert, Bleu). Cependant, l'œil humain est beaucoup plus sensible aux changements de luminosité (luma) qu'aux changements de couleur (chroma). Les codecs exploitent cela en convertissant le RVB vers un format luma/chroma comme YCbCr.
- Y : Le composant de luma (luminosité).
- Cb : Le composant de chrominance de différence de bleu.
- Cr : Le composant de chrominance de différence de rouge.
En séparant la luminosité de la couleur, nous pouvons appliquer le sous-échantillonnage de la chrominance. Cette technique réduit la résolution des canaux de couleur (Cb et Cr) tout en conservant la pleine résolution pour le canal de luminosité (Y), auquel nos yeux sont les plus sensibles. Un schéma courant est le 4:2:0, qui élimine 75% des informations de couleur avec une perte de qualité presque imperceptible, réalisant ainsi une compression instantanée.
Étape 2 : Partitionnement de la Trame (Macroblocs)
L'encodeur ne traite pas toute la trame en une seule fois. Il divise la trame en blocs plus petits, généralement de 16x16 ou 8x8 pixels, appelés macroblocs. Toutes les étapes de traitement ultérieures (prédiction, transformation, etc.) sont effectuées bloc par bloc.
Étape 3 : Prédiction (Inter et Intra)
C'est ici que la magie opère. Pour chaque macrobloc, l'encodeur décide d'utiliser la prédiction intra-trame ou inter-trame.
- Pour une trame I (Prédiction Intra) : L'encodeur prédit le bloc actuel en se basant sur les pixels de ses voisins déjà encodés (les blocs au-dessus et à gauche) dans la même trame. Il n'a alors besoin que d'encoder la petite différence (le résidu) entre la prédiction et le bloc réel.
- Pour une trame P ou B (Prédiction Inter) : C'est l'estimation de mouvement. L'encodeur recherche un bloc correspondant dans une trame de référence. Lorsqu'il trouve la meilleure correspondance, il enregistre un vecteur de mouvement (par ex., "déplacer de 10 pixels vers la droite, 2 pixels vers le bas") et calcule le résidu. Souvent, le résidu est proche de zéro, ne nécessitant que très peu de bits pour être encodé.
Étape 4 : Transformation (ex. : Transformée en Cosinus Discrète - DCT)
Après la prédiction, nous avons un bloc de résidus. Ce bloc passe par une transformation mathématique comme la Transformée en Cosinus Discrète (DCT). La DCT ne compresse pas les données elle-même, mais elle change fondamentalement la façon dont elles sont représentées. Elle convertit les valeurs spatiales des pixels en coefficients de fréquence. La magie de la DCT est que pour la plupart des images naturelles, elle concentre la majeure partie de l'énergie visuelle dans seulement quelques coefficients dans le coin supérieur gauche du bloc (les composantes de basse fréquence), tandis que le reste des coefficients (bruit de haute fréquence) est proche de zéro.
Étape 5 : Quantification
C'est la principale étape destructive (lossy) du pipeline et la clé pour contrôler le compromis qualité/débit binaire. Le bloc de coefficients DCT transformé est divisé par une matrice de quantification, et les résultats sont arrondis à l'entier le plus proche. La matrice de quantification a des valeurs plus grandes pour les coefficients de haute fréquence, écrasant efficacement beaucoup d'entre eux à zéro. C'est là qu'une énorme quantité de données est supprimée. Un paramètre de quantification plus élevé conduit à plus de zéros, une compression plus élevée et une qualité visuelle inférieure (souvent visible sous forme d'artefacts de bloc).
Étape 6 : Codage Entropique
L'étape finale est une étape de compression sans perte (lossless). Les coefficients quantifiés, les vecteurs de mouvement et autres métadonnées sont balayés et convertis en un flux binaire. Des techniques comme le Codage par Plages (RLE) et le Codage de Huffman ou des méthodes plus avancées comme le CABAC (Codage Arithmétique Binaire Adaptatif au Contexte) sont utilisées. Ces algorithmes attribuent des codes plus courts aux symboles plus fréquents (comme les nombreux zéros créés par la quantification) et des codes plus longs à ceux moins fréquents, extrayant les derniers bits du flux de données.
Le décodeur effectue simplement ces étapes en sens inverse : Décodage Entropique -> Déquantification -> Transformation Inverse -> Compensation de Mouvement -> Reconstruction de la trame.
Implémenter un Codec Vidéo Simplifié en Python
Maintenant, mettons la théorie en pratique. Nous allons construire un codec éducatif qui utilise des trames I et des trames P. Il démontrera le pipeline principal : Estimation de Mouvement, DCT, Quantification, et les étapes de décodage correspondantes.
Avertissement : Ceci est un codec *jouet* conçu pour l'apprentissage. Il n'est pas optimisé et ne produira pas de résultats comparables à H.264. Notre objectif est de voir les algorithmes en action.
Prérequis
Vous aurez besoin des bibliothèques Python suivantes. Vous pouvez les installer avec pip :
pip install numpy opencv-python scipy
Structure du Projet
Organisons notre code en quelques fichiers :
main.py: Le script principal pour lancer le processus d'encodage et de décodage.encoder.py: Contient la logique de l'encodeur.decoder.py: Contient la logique du décodeur.utils.py: Fonctions utilitaires pour les E/S vidéo et les transformations.
Partie 1 : Les Utilitaires de Base (`utils.py`)
Nous commencerons par des fonctions utilitaires pour la DCT, la Quantification, et leurs inverses. Nous aurons également besoin d'une fonction pour diviser une trame en blocs.
# utils.py
import numpy as np
from scipy.fftpack import dct, idct
BLOCK_SIZE = 8
# Une matrice de quantification JPEG standard (ajustée pour nos besoins)
QUANTIZATION_MATRIX = np.array([
[16, 11, 10, 16, 24, 40, 51, 61],
[12, 12, 14, 19, 26, 58, 60, 55],
[14, 13, 16, 24, 40, 57, 69, 56],
[14, 17, 22, 29, 51, 87, 80, 62],
[18, 22, 37, 56, 68, 109, 103, 77],
[24, 35, 55, 64, 81, 104, 113, 92],
[49, 64, 78, 87, 103, 121, 120, 101],
[72, 92, 95, 98, 112, 100, 103, 99]
])
def apply_dct(block):
"""Applique une DCT 2D Ă un bloc."""
# Centre les valeurs des pixels autour de 0
block = block - 128
return dct(dct(block.T, norm='ortho').T, norm='ortho')
def apply_idct(dct_block):
"""Applique une DCT 2D Inverse Ă un bloc."""
block = idct(idct(dct_block.T, norm='ortho').T, norm='ortho')
# DĂ©-centre et limite Ă la plage de pixels valide
return np.round(block + 128).clip(0, 255)
def quantize(dct_block, qp=1):
"""Quantifie un bloc DCT. qp est un paramètre de qualité."""
return np.round(dct_block / (QUANTIZATION_MATRIX * qp)).astype(int)
def dequantize(quantized_block, qp=1):
"""Déquantifie un bloc."""
return quantized_block * (QUANTIZATION_MATRIX * qp)
def frame_to_blocks(frame):
"""Divise une trame en blocs de 8x8."""
blocks = []
h, w = frame.shape
for i in range(0, h, BLOCK_SIZE):
for j in range(0, w, BLOCK_SIZE):
blocks.append(frame[i:i+BLOCK_SIZE, j:j+BLOCK_SIZE])
return blocks
def blocks_to_frame(blocks, h, w):
"""Reconstruit une trame Ă partir de blocs de 8x8."""
frame = np.zeros((h, w), dtype=np.uint8)
k = 0
for i in range(0, h, BLOCK_SIZE):
for j in range(0, w, BLOCK_SIZE):
frame[i:i+BLOCK_SIZE, j:j+BLOCK_SIZE] = blocks[k]
k += 1
return frame
Partie 2 : L'Encodeur (`encoder.py`)
L'encodeur est la partie la plus complexe. Nous implémenterons un algorithme simple de recherche de blocs (block-matching) pour l'estimation de mouvement, puis nous traiterons les trames I et les trames P.
# encoder.py
import numpy as np
from utils import apply_dct, quantize, frame_to_blocks, BLOCK_SIZE
def get_motion_vectors(current_frame, reference_frame, search_range=8):
"""Un algorithme simple de recherche de blocs pour l'estimation de mouvement."""
h, w = current_frame.shape
motion_vectors = []
for i in range(0, h, BLOCK_SIZE):
for j in range(0, w, BLOCK_SIZE):
current_block = current_frame[i:i+BLOCK_SIZE, j:j+BLOCK_SIZE]
best_match_sad = float('inf')
best_match_vector = (0, 0)
# Recherche dans la trame de référence
for y in range(-search_range, search_range + 1):
for x in range(-search_range, search_range + 1):
ref_i, ref_j = i + y, j + x
if 0 <= ref_i <= h - BLOCK_SIZE and 0 <= ref_j <= w - BLOCK_SIZE:
ref_block = reference_frame[ref_i:ref_i+BLOCK_SIZE, ref_j:ref_j+BLOCK_SIZE]
sad = np.sum(np.abs(current_block - ref_block))
if sad < best_match_sad:
best_match_sad = sad
best_match_vector = (y, x)
motion_vectors.append(best_match_vector)
return motion_vectors
def encode_iframe(frame, qp=1):
"""Encode une trame I."""
h, w = frame.shape
blocks = frame_to_blocks(frame)
quantized_blocks = []
for block in blocks:
dct_block = apply_dct(block.astype(float))
quantized_block = quantize(dct_block, qp)
quantized_blocks.append(quantized_block)
return {'type': 'I', 'h': h, 'w': w, 'data': quantized_blocks, 'qp': qp}
def encode_pframe(current_frame, reference_frame, qp=1):
"""Encode une trame P."""
h, w = current_frame.shape
motion_vectors = get_motion_vectors(current_frame, reference_frame)
quantized_residuals = []
k = 0
for i in range(0, h, BLOCK_SIZE):
for j in range(0, w, BLOCK_SIZE):
current_block = current_frame[i:i+BLOCK_SIZE, j:j+BLOCK_SIZE]
mv_y, mv_x = motion_vectors[k]
ref_block = reference_frame[i+mv_y : i+mv_y+BLOCK_SIZE, j+mv_x : j+mv_x+BLOCK_SIZE]
residual = current_block.astype(float) - ref_block.astype(float)
dct_residual = apply_dct(residual)
quantized_residual = quantize(dct_residual, qp)
quantized_residuals.append(quantized_residual)
k += 1
return {'type': 'P', 'motion_vectors': motion_vectors, 'data': quantized_residuals, 'qp': qp}
Partie 3 : Le Décodeur (`decoder.py`)
Le décodeur inverse le processus. Pour les trames P, il effectue une compensation de mouvement en utilisant les vecteurs de mouvement stockés.
# decoder.py
import numpy as np
from utils import apply_idct, dequantize, blocks_to_frame, BLOCK_SIZE
def decode_iframe(encoded_frame):
"""Décode une trame I."""
h, w = encoded_frame['h'], encoded_frame['w']
qp = encoded_frame['qp']
quantized_blocks = encoded_frame['data']
reconstructed_blocks = []
for q_block in quantized_blocks:
dct_block = dequantize(q_block, qp)
block = apply_idct(dct_block)
reconstructed_blocks.append(block.astype(np.uint8))
return blocks_to_frame(reconstructed_blocks, h, w)
def decode_pframe(encoded_frame, reference_frame):
"""Décode une trame P en utilisant sa trame de référence."""
h, w = reference_frame.shape
qp = encoded_frame['qp']
motion_vectors = encoded_frame['motion_vectors']
quantized_residuals = encoded_frame['data']
reconstructed_blocks = []
k = 0
for i in range(0, h, BLOCK_SIZE):
for j in range(0, w, BLOCK_SIZE):
# Décode le résidu
dct_residual = dequantize(quantized_residuals[k], qp)
residual = apply_idct(dct_residual)
# Effectue la compensation de mouvement
mv_y, mv_x = motion_vectors[k]
ref_block = reference_frame[i+mv_y : i+mv_y+BLOCK_SIZE, j+mv_x : j+mv_x+BLOCK_SIZE]
# Reconstruit le bloc
reconstructed_block = (ref_block.astype(float) + residual).clip(0, 255)
reconstructed_blocks.append(reconstructed_block.astype(np.uint8))
k += 1
return blocks_to_frame(reconstructed_blocks, h, w)
Partie 4 : Assembler le Tout (`main.py`)
Ce script orchestre l'ensemble du processus : lire une vidéo, l'encoder trame par trame, puis la décoder pour produire une sortie finale.
# main.py
import cv2
import pickle # Pour sauvegarder/charger notre structure de données compressées
from encoder import encode_iframe, encode_pframe
from decoder import decode_iframe, decode_pframe
def main(input_path, output_path, compressed_file_path):
cap = cv2.VideoCapture(input_path)
frames = []
while True:
ret, frame = cap.read()
if not ret:
break
# Nous travaillerons avec le canal de niveaux de gris (luma) pour plus de simplicité
frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))
cap.release()
# --- ENCODAGE --- #
print("Encodage...")
compressed_data = []
reference_frame = None
gop_size = 12 # Une trame I toutes les 12 trames
for i, frame in enumerate(frames):
if i % gop_size == 0:
# Encoder comme une trame I
encoded_frame = encode_iframe(frame, qp=2.5)
compressed_data.append(encoded_frame)
print(f"Trame {i} encodée comme trame I")
else:
# Encoder comme une trame P
encoded_frame = encode_pframe(frame, reference_frame, qp=2.5)
compressed_data.append(encoded_frame)
print(f"Trame {i} encodée comme trame P")
# La référence pour la prochaine trame P doit être la dernière trame *reconstruite*
if encoded_frame['type'] == 'I':
reference_frame = decode_iframe(encoded_frame)
else:
reference_frame = decode_pframe(encoded_frame, reference_frame)
with open(compressed_file_path, 'wb') as f:
pickle.dump(compressed_data, f)
print(f"Données compressées sauvegardées dans {compressed_file_path}")
# --- DÉCODAGE --- #
print("\nDécodage...")
with open(compressed_file_path, 'rb') as f:
loaded_compressed_data = pickle.load(f)
decoded_frames = []
reference_frame = None
for i, encoded_frame in enumerate(loaded_compressed_data):
if encoded_frame['type'] == 'I':
decoded_frame = decode_iframe(encoded_frame)
print(f"Trame {i} décodée (trame I)")
else:
decoded_frame = decode_pframe(encoded_frame, reference_frame)
print(f"Trame {i} décodée (trame P)")
decoded_frames.append(decoded_frame)
reference_frame = decoded_frame
# --- ÉCRITURE DE LA VIDÉO DE SORTIE --- #
h, w = decoded_frames[0].shape
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
out = cv2.VideoWriter(output_path, fourcc, 30.0, (w, h), isColor=False)
for frame in decoded_frames:
out.write(frame)
out.release()
print(f"Vidéo décodée sauvegardée dans {output_path}")
if __name__ == '__main__':
main('input.mp4', 'output.mp4', 'compressed.bin')
Analyser les Résultats et Explorer d'Autres Pistes
Après avoir exécuté le script `main.py` avec un fichier `input.mp4`, vous obtiendrez deux fichiers : `compressed.bin`, qui contient nos données vidéo compressées personnalisées, et `output.mp4`, la vidéo reconstruite. Comparez la taille de `input.mp4` à celle de `compressed.bin` pour voir le taux de compression. Inspectez visuellement `output.mp4` pour voir la qualité. Vous verrez probablement des artefacts de bloc, surtout avec une valeur de `qp` plus élevée, ce qui est un signe classique de la quantification.
Mesurer la Qualité : Rapport Signal sur Bruit de Crête (PSNR)
Une métrique objective courante pour mesurer la qualité de la reconstruction est le PSNR (Peak Signal-to-Noise Ratio). Il compare la trame originale avec la trame décodée. Un PSNR plus élevé indique généralement une meilleure qualité.
import numpy as np
import math
def calculate_psnr(original, compressed):
mse = np.mean((original - compressed) ** 2)
if mse == 0:
return float('inf')
max_pixel = 255.0
psnr = 20 * math.log10(max_pixel / math.sqrt(mse))
return psnr
Limitations et Prochaines Étapes
Notre codec simple est un excellent début, mais il est loin d'être parfait. Voici quelques limitations et améliorations potentielles qui reflètent l'évolution des codecs du monde réel :
- Estimation de Mouvement : Notre recherche exhaustive est lente et basique. Les vrais codecs utilisent des algorithmes de recherche hiérarchiques et sophistiqués pour trouver les vecteurs de mouvement beaucoup plus rapidement.
- Trames B : Nous n'avons implémenté que les trames P. L'ajout de trames B améliorerait considérablement l'efficacité de la compression au prix d'une complexité et d'une latence accrues.
- Codage Entropique : Nous n'avons pas implémenté une véritable étape de codage entropique. Nous avons simplement "picklé" les structures de données Python. L'ajout d'un encodeur Run-Length pour les zéros quantifiés, suivi d'un codeur Huffman ou Arithmétique, réduirait davantage la taille du fichier.
- Filtre de Déblocage : Les bords nets entre nos blocs de 8x8 provoquent des artefacts visibles. Les codecs modernes appliquent un filtre de déblocage après la reconstruction pour lisser ces bords et améliorer la qualité visuelle.
- Tailles de Bloc Variables : Les codecs modernes n'utilisent pas seulement des macroblocs fixes de 16x16. Ils peuvent partitionner la trame de manière adaptative en différentes tailles et formes de blocs pour mieux correspondre au contenu (par ex., en utilisant des blocs plus grands pour les zones plates et des blocs plus petits pour les zones détaillées).
Conclusion
Construire un codec vidéo, même simplifié, est un exercice profondément gratifiant. Il démystifie la technologie qui alimente une partie importante de nos vies numériques. Nous avons parcouru les concepts fondamentaux de la redondance spatiale et temporelle, passé en revue les étapes essentielles du pipeline d'encodage — prédiction, transformation et quantification — et implémenté ces idées en Python.
Le code fourni ici est un point de départ. Je vous encourage à expérimenter avec ce code. Essayez de changer la taille des blocs, le paramètre de quantification (`qp`), ou la longueur du GOP. Tentez d'implémenter un schéma simple de Codage par Plages (Run-Length Encoding) ou même de vous attaquer au défi d'ajouter des trames B. En construisant et en modifiant des choses, vous acquerrez une profonde appréciation pour l'ingéniosité derrière les expériences vidéo fluides que nous tenons souvent pour acquises. Le monde de la compression vidéo est vaste et en constante évolution, offrant des opportunités infinies d'apprentissage et d'innovation.