Janvier 2022. Notre conteneur Docker plante en prod après quelques heures sur certains sites. Le diagnostic tombe : memory leak dans la bibliothèque propriétaire qui gère le dewarping des caméras fisheye 360°.
Contexte rapide : notre IA analyse des flux vidéo pour détecter des gestes. Nos modèles sont entraînés sur des vues plates, pas sur des vues circulaires déformées. Sans dewarping, zéro détection sur les caméras fisheye, juste 1% du parc, mais des clients importants.
La lib qui plante ? Fournie par le fabricant des caméras. On ne peut ni la patcher, ni attendre un correctif.
Verdict : on code notre propre version.
Quinze jours de plongée dans les bouquins de géométrie projective, une implémentation C++ à base de quaternions et projections sphériques, quelques semaines d’optimisation… et on avait notre solution en prod.
Trois ans plus tard, avec un peu de recul et du temps libre, une question me trotte dans la tête : et si on l’avait fait autrement ?
Quelle aurait été la meilleure approche avec nos contraintes ?
- Caméras fisheye fixes au plafond
- Besoin de plusieurs vues plates par frame
- Points de vue des caméras virtuelles statique (pas de rotation dynamique)
- Performance critique : traitement temps réel de multiples flux vidéo
- Qualité « suffisante » pour la détection (pas besoin de perfection photographique)
Cet article compare 6 implémentations différentes pour ce cas d’usage précis, avec benchmarks à l’appui.
Plan de l’article
- Les bases théoriques du dewarping fisheye (version courte, promis)
- Trois implémentations du même algorithme :
- FFmpeg en ligne de commande
- Python natif (boucles, pas de lib)
- NumPy vectorisé
- Benchmarks comparatifs : temps d’exécution, RAM, complexité de mise en œuvre
- Une mise en bouche pour la partie 2 au cours de laquelle nous explorons OpenCV et une implémentation C++ ad hoc.
Important : Cette comparaison est spécifique à notre cas d’usage (caméras fixes, vues statiques, perf temps réel). Si vos besoins différent – caméras mobiles, recalibration dynamique, qualité maximale, etc. – adaptez en conséquence.
Un peu de théorie (juste ce qu’il faut)
Le problème en image
Une caméra fisheye 360° au plafond capture tout l’espace environnant dans une image circulaire déformée. Pour que nos algorithmes de détection puissent travailler, nous en tirons plusieurs vues plates rectangulaires. Le plus simple est de montrer une image.

En pratique le dewarping se décompose en deux phases :
Phase 1 :
- Calcul du mapping : On crée une table de correspondance : pour chaque pixel de nos 5 vues plates de sortie, on calcule quel pixel de l’image fisheye il faut aller chercher. Concrètement, chaque point de l’image fisheye est projeté sur une demi-sphère. On définit des caméras virtuelles braquées dans la direction qui nous intéresse et on calcule quelle portion de la demi-sphère elle « voit ». Au final on obtient pour chaque point de l’image finale, celle vue par la caméra virtuelle, les coordonnées du point de l’image fisheye qu’elle voit. Ce qui importe à ce niveau c’est que le calcul soit juste. Pour un rendu optimal on devrait d’ailleurs prendre en compte les déformations optiques des lentilles, dans notre cas d’usage on s’en fiche un peu.
- Application du mapping : le mapping n’est calculé qu’une fois, c’est un peu lourd à calculer, mais c’est juste de la géométrie et ça ne dépend pas de l’image. On l’utilise ensuite pour chaque image et cette partie est beaucoup plus lourde. Elle doit parcourir tous les pixels de l’image finale et appliquer le mapping, c’est ) dire aller chercher les points dans l’image fisheye initiale. C’est la partie à optimiser si on veut des performances.
Un détail à préciser: lors de la première phase les coordonnées du pixel à aller chercher dans l’image d’origine ne tombent généralement pas sur des valeurs entières, pour trouver la bonne couleur on réalise généralement une moyenne des valeurs des pixels environnants. Le terme technique est interpolation. Comme on recherche la vitesse plus que la précision, l’interpolation est un peu zappée et on ne conservera que le pixel le plus proche (technique d’interpolation du « plus proche voisin »).
FFmpeg – Rien à coder
FFmpeg supporte nativement ce type de dewarping via son filtre `v360`. L’implémentation est donc directe et on appelle « simplement » l’outil ligne de commande. Bien sûr, ce n’est jamais aussi simple et trouver les bons paramètres pour les vues qui nous intéressent est une galère.
ffmpeg -y -i "fisheye.mp4" -vf "crop=1920:1920,v360=input=fisheye:output=flat:interp=near:yaw=0:pitch=45:roll=0:v_fov=90:w=960:h=960" "unwarped_1.mp4" -vf "rotate=4*72*PI/180,crop=1920:1920,v360=input=fisheye:output=flat:interp=near:yaw=0:pitch=45:roll=0:v_fov=90:w=960:h=960" "unwarped_2.mp4" -vf "rotate=3*72*PI/180,crop=1920:1920,v360=input=fisheye:output=flat:interp=near:yaw=0:pitch=45:roll=0:v_fov=90:w=960:h=960" "unwarped_3.mp4" -vf "rotate=2*72*PI/180,crop=1920:1920,v360=input=fisheye:output=flat:interp=near:yaw=0:pitch=45:roll=0:v_fov=90:w=960:h=960" "unwarped_4.mp4" -vf "rotate=72*PI/180,crop=1920:1920,v360=input=fisheye:output=flat:interp=near:yaw=0:pitch=45:roll=0:v_fov=90:w=960:h=960" "unwarped_5.mp4"En théorie: `yaw`, `pitch`, `roll` (en français, respectivement lacet, tangage et roulis, mais on conservera les termes anglophones) sont les angles décrivant l’orientation de la caméra fisheye. Ces angles, très utilisés en aviation, viennent de la navigation. yaw est une rotation autour de l’axe optique, c’est à dire une rotation à plat vers une autre direction. Le pitch est un degré de cabrage ou de piqué et le roll une inclinaison des ailes à gauche ou à droite. Mais notre caméra fisheye est en quelque sorte un avion en piqué, ce qui rend leur interprétation moins intuitive.
Le rôle du pitch est à peu près clair, on ne souhaite pas un pitch de zéro (caméra regardant vers le bas), donc on la cabre de 45 degrés pour la braquer au milieu du dôme. Pas moyen d’obtenir la rotation désirée pour les différentes vues en jouant sur yaw et roll, toutes les valeurs reviennent à pencher ou déformer l’image plate finale à gauche ou à droite. Seuls yaw=0 et roll=0 donnent l’orientation « image plate horizon en haut » désirée. C’est un peu étonnant car comme nous le verrons plus loin nous n’avons pas du tout ce problème en programmant nous même la matrice de rotation, dans ce cas faire varier le roll est suffisant, ce qui est ce à quoi on s’attends pour une caméra fisheye.
Il est heureusement assez simple de contourner l’obstacle en appliquant une rotation préalable à l’image fisheye d’origine.
v_fov=90 et w=960:h=960 décrivent le champ de vision de la caméra virtuelle de la vue de sortie, v_fov, angle vertical de 90° (du sol à l’horizon depuis le centre) et largeur et hauteur de 960 pixels, soit la moitié dans chaque dimension de l’image de départ (1920×1920). Comme déjà indiqué nous optons pour l’interpolation la plus rapide: interp=near (linéaire par défaut).
Les mesures de performances de ce cas spécifique nous donnent une base de référence mais sont aussi un peu biaisées, car le temps mesuré inclu le décodage de la vidéo originale et le réencodage des cinq vidéo résultantes (ce qui n’est pas utile dans notre cas d’usage). La création de ces cinq vidéos se fait en même temps en parallèle et ffmpeg est globalement très efficace.
Pour pouvoir comparer les résultats obtenus avec d’autres méthodes aux performances de ffmpeg, j’ai alimenté ffmpeg avec un film comportant 1024 frames et les autres méthodes sont appliquées successivement à 1024 frames, mais 1024 fois la même frames ne manière à ne pas effectuer plusieurs fois les calculs de géométrie projective.
Commande: ./unwarper_ffmpeg.sh fisheye_video.mp4
======================================================================
RESULTATS BENCHMARK
======================================================================
Wall time: 208.64s
User time: 1401.84s
System time: 10.00s
CPU utilization: 676%
Cores used: ~6.8
Peak memory: 1784.20 MB (1827016 KB)
Page faults: 387273 minor, 0 major
Context switches: 328327 vol, 712451 invol
Exit status: 0
Parallel speedup: 6.77x
(CPU time / Wall time = 1411.84s / 208.64s)
======================================================================Résultats : 208 secondes pour traiter 1024 frames et générer 5 vues, soit 5120 images plates extraites, environ 40ms par image plate.
FFmpeg exploite très bien les 8 cores disponibles (~676% d’utilisation CPU), avec un speedup parallèle de 6.8x. L’utilisation mémoire grimpe toutefois à 1.7 GB, ce qui est significatif. Cette consommation mémoire ne dépend pas de la longueur du film.
✅ Points forts
- Setup immédiat : une seule commande, aucune lib à installer (FFmpeg suffit)
- Parallélisation native et efficace : utilisation automatique du multi-core
- Pas de maintenance : dépendance externe stable, bugs déjà corrigés par la communauté
❌ Points faibles
- Boîte noire totale : impossible d’auditer ou modifier l’algo de dewarping
- Flexibilité limitée : on est coincé avec les paramètres exposés par `v360`
- Pas intégrable finement : nécessite de spawner un process externe, impossible d’appeler directement comme une fonction Python (mais passer par un appel à libav
- Consommation mémoire élevée : 1.7 GB pour une vidéo 1920×1920, potentiellement problématique à grande échelle
FFmpeg est adapté à un POC rapide ou pour convertir une vidéo isolée. Parfait pour : tester si le dewarping résout le problème métier, des scripts one-shot ou batch processing occasionnel dans des cas où la RAM n’est pas une contrainte.
Mais inadapté pour intégrer le dewarping dans un pipeline Python complexe (cas considéré), ou besoin de comprendre ou adapter l’algorithme sous-jacent. Dans le cas de memory leak évoqué, ffmpeg aurait pu servir de solution de secours, à condition d’accepter la consommation de ressources RAM et CPU (donc matériel surdimensionné).
Python pur – tout à coder
Après la solution « boîte noire » avec FFmpeg, deuxième implémentation en Python pur qui utilise uniquement les bibliothèques standard et NumPy pour la manipulation de tableaux. Par contre on ne cherche pas encore à optimiser le code numpy en utilisant ses capacités de vectorisation.
L’objectif ici n’est pas la performance, mais la compréhension. C’est la référence pédagogique qui sert de baseline pour toutes les optimisations ultérieures : une preuve de concept.
#!/usr/bin/env python3
"""
Pure Python Fisheye Unwarper
Cette implémentation en Python pur du dewarping fisheye utilise uniquement
des bibliothèques standard et numpy pour manipuler des tableaux,
sans optimisation vectorielle.
"""
def multiply_quaternion(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""
Multiply two quaternions using Python primitives.
Args:
a: First quaternion [w, x, y, z]
b: Second quaternion [w, x, y, z]
Returns:
Result quaternion [w, x, y, z]
"""
w1, x1, y1, z1 = a
w2, x2, y2, z2 = b
w = w1*w2 - x1*x2 - y1*y2 - z1*z2
x = w1*x2 + x1*w2 + y1*z2 - z1*y2
y = w1*y2 - x1*z2 + y1*w2 + z1*x2
z = w1*z2 + x1*y2 - y1*x2 + z1*w2
return np.array([w, x, y, z], dtype=np.float64)
def get_rotation_matrix(yaw: float, pitch: float, roll: float) -> np.ndarray:
"""
Generate rotation matrix from yaw, pitch, roll angles (in degrees).
Args:
yaw: Rotation around Y axis in degrees
pitch: Rotation around X axis in degrees
roll: Rotation around Z axis in degrees
Returns:
3x3 rotation matrix as numpy array
"""
# Yaw quaternion, rotate view around Y axis
yaw = np.deg2rad(0)
yaw_q = np.array([np.cos(yaw/2.0), 0.0, np.sin(yaw/2.0), 0.0], dtype=np.float64)
# Pitch quaternion, rotate view around X axis (look up 45 degrees)
pitch = np.deg2rad(45)
pitch_q = np.array([np.cos(pitch/2.0), np.sin(pitch/2.0), 0.0, 0.0], dtype=np.float64)
# Roll quaternion, rotate view around Z axis (look in different direction)
roll = np.deg2rad(roll)
roll_q = np.array([np.cos(roll/2.0), 0.0, 0.0, np.sin(roll/2.0)], dtype=np.float64)
rq = multiply_quaternion(roll_q, multiply_quaternion(pitch_q, yaw_q))
# Build spherical projection matrix from quaternions
w, x, y, z = rq
return np.array([
[ (w*w + x*x - y*y - z*z), 2.0 * (x*y - z*w), 2.0 * (w*y + x*z)],
[ 2.0 * (w*z + x*y), (w*w - x*x + y*y - z*z), 2.0 * (y*z - w*x)],
[ 2.0 * (x*z - y*w), 2.0 * (w*x + y*z), (w*w - x*x - y*y + z*z)]], dtype=np.float64)
def project2D(xyz: np.ndarray) -> Tuple[int, int]:
"""Project 3D Dome point to 2D Fisheye image"""
hs = np.hypot(xyz[0],xyz[1])
phi = np.arctan2(hs, xyz[2])
coeff = phi / (hs * np.pi)
src_x = xyz[0] * coeff + 0.5
src_y = xyz[1] * coeff + 0.5
return src_x, src_y
class PythonDewarper:
"""
Pure Python implementation of the fisheye dewarper.
"""
def __init__(self, width: int, height: int, zones: int = 3):
"""
Initialize dewarper with image dimensions.
Args:
width: Image width in pixels
height: Image height in pixels
"""
self.width = width
self.height = height
self.output_width = self.width // 2
self.output_height = self.height // 2
self.zones = zones
# Remapping tables for each view
self.remap = self._dewarp_mapping()
self.output_buffer = np.zeros((self.zones, self.output_height, self.output_width, 3), dtype=np.uint8)
def _dewarp_mapping(self) -> List[List[List[Tuple[int, int]]]]:
"""
Create pixel remapping table for specific view.
This is the core dewarping algorithm using spherical projection.
"""
remap = []
for zone_id in range(self.zones):
# Get rotation matrix for this zone
R = get_rotation_matrix(0, 45, zone_id * (360.0 / self.zones))
remap_zone = []
for j in range(self.output_height):
line = []
for i in range(self.output_width):
v = np.array([i / (0.25 * self.width) - 1.0, j / (0.25 * self.height) - 1.0, 1.0])
xyz = R @ v.T
src_x, src_y = project2D(xyz)
map_y = int(src_y * self.height)
map_x = int(src_x * self.width)
if 0 <= map_y < self.height and 0 <= map_x < self.width:
line.append((map_y, map_x))
else:
line.append((0, 0))
remap_zone.append(line)
remap.append(remap_zone)
return remap
def dewarp_frame(self, image: np.ndarray, zone_id: int = -1):
"""
Apply dewarping transformation to image.
Args:
image: Input image as NumPy array (H, W, 3)
"""
remap_table = self.remap[zone_id]
output_buffer = self.output_buffer[zone_id]
for i in range(self.output_height):
for j in range(self.output_width):
# Note: never out of bound as it is ensured when building remapping
output_buffer[i, j] = image[remap_table[i][j]]
return output_buffer.reshape((self.output_height, self.output_width, 3))Décortiquons ce qui se passe sous le capot. Nous allons parler un peu de maths, les allergiques peuvent sauter à la suite, mais ce serait dommage c’est intéressant.
Tout d’abord nous avons choisi de représenter les rotations par des quaternions. Pourquoi des quaternions plutôt qu’une matrice de rotation ? Les quaternions sont une sorte de « super nombres complexes », à quatre dimensions au lieu de deux, inventés en 1843 par William Hamilton – oui celui du Hamitonien – pour représenter les rotations dans l’espace 3D. Un peu dénigrés à l’origine car plus compliqués en apparence que les angles d’Euler, ils ont depuis conquis la 3D, la robotique, l’aérospatiale et… le dewarping fisheye.
Pourquoi pas des angles d’Euler classiques ? Les angles d’Euler (yaw/pitch/roll) sont intuitifs mais ont des défauts. Le principal est le Gimbal Lock (ou Blocage de Cardan en français qui est sans doute la source des difficultés rencontrées plus haut avec ffmpeg). Je met le lien wikipédia pour ceux que ça intéresse. https://fr.wikipedia.org/wiki/Blocage_de_cardan
L’autre raison pour utiliser les quaternions, c’est que les calculs sont plus simples sur ordinateur et qu’ils sont faciles à manipuler et évitent l’accumulation d’erreurs numériques. Un quaternion est une liste de quatre nombres [w, x, y, z] avec w la partie réelle du quaternion et (x, y, z) sa partie vectorielle qui représente la direction de l’axe de rotation. Une rotation de θ autour d’un axe unitaire `(ax, ay, az)` s’écrit simplement :
q = [cos(θ/2), ax·sin(θ/2), ay·sin(θ/2), az·sin(θ/2)]
Pour retrouver les trois directions yaw, pitch et roll il suffit de ne conserver que l’une des trois composantes x, y ou z du quaternion. Pour les combiner on utilise la formule de multiplication des quaternions. Attention dans le cas des quaternions l’ordre des multiplications compte ! Cette fois pour observer dans les différentes directions il suffit de faire changer l’angle de la composante roll et on obtient le comportement attendu.
De quaternion à matrice de projection
Le quaternion nous donne l’orientation de la caméra, mais pour projeter les pixels sur le disque fisheye on a besoin d’une matrice 3×3 de projection sphérique.
Cette matrice transforme un point `(x, y)` de l’image de sortie en un point `(X, Y, Z)` sur la demi-sphère virtuelle centrée sur la caméra fisheye.
Les formules proviennent de la conversion quaternion → matrice de rotation, adaptée pour la projection sphérique fisheye. Chaque coefficient de la matrice `m[i,j]` encode comment les coordonnées `(x, y)` de l’output contribuent aux coordonnées `(X, Y, Z)` du point 3D.
Les 9 coefficients de la matrice sont calculés selon ce même principe, chacun combinant les composantes du quaternion de manière spécifique pour encoder la rotation 3D complète.
Projection sphérique finale
Une fois qu’on a le point 3D `(X, Y, Z)` sur la demi-sphère, on doit le re-projeter dans l’image fisheye source:
hs = sqrt(X**2 + Y**2) # Distance horizontale du point
phi = atan2(hs, Z) # Angle depuis le zénith (0 à pi/2)
src_x = width × (X * phi / (pi * hs) + 0.5)
src_y = height × (Y * phi / (pi * hs) + 0.5)
Cette formule implémente la projection équidistante (equidistant projection), le modèle standard pour les objectifs fisheye :
- L’angle `phi` (angle depuis le zénith) est proportionnel à la distance radiale dans l’image fisheye
- `X / hs` et `Y / hs` donnent la direction azimutale normalisée
- Le facteur `phi / (π × hs)` convertit l’angle en distance radiale normalisée [0, 0.5]
- Le `+0.5` centre l’image (passage de [-0.5, 0.5] à [0, 1])
Ce modèle équidistant signifie qu’un objet à 45° du centre apparaît à mi-chemin entre le centre et le bord de l’image fisheye, un objet à 90° est exactement au bord.
Fin des maths. Ceux qui souhaitaient sauter cette partie peuvent recommencer à lire ici. En résumé: c’est magique, utilisez la bonne formule pour les incantations.
Benchmark
🔍 Commande: uv run ./unwarper_python.py ../images/fisheye.jpg -r 1024
======================================================================
📈 RÉSULTATS BENCHMARK
======================================================================
⏱️ Wall time: 1889.36s
⚙️ CPU time (user+sys): 1889.34s
├─ User time: 1888.94s
└─ System time: 0.40s
🔥 CPU utilization: 99%
💻 Cores utilisés: ~1.0
🧠 Mémoire pic: 646 MB (662148 KB)
======================================================================
💡 Speedup parallèle: 1.00x
(CPU time / Wall time = 1889.36s / 1889.34s)Inutile de s’apesantir sur les performances, d’autant que le benchmark n’est pas très précis (il est perturbé par le fonctionnement de l’OS qui ne fait pas que ça). Mais on constate que si ffmpeg est bien plus rapide en temps horloge grace au fonctionnement multicore, la consommation CPU globale des deux configurations est du même ordre de grandeur car FFMpeg utilise 6.8 cores, tandis que notre version python n’utilise qu’1 core. C’est étonnament efficace pour du Python interprété!
✅ Points forts
- Code lisible et compréhensible : 200 lignes de Python clair où chaque étape mathématique est explicite. Idéal pour comprendre l’algorithme, le debugger, ou l’adapter à un nouveau cas d’usage.
- Consommation mémoire modérée : 650 MB vs 1800 MB pour FFmpeg, soit 2.8× moins. La table de remapping pré-calculée est compacte (~9 MB pour 5 vues), et on ne charge qu’une frame à la fois.
- Baseline de référence solide : Implémentation correcte et vérifiée qu’on peut utiliser comme point de comparaison pour toutes les optimisations futures.
- Facilement modifiable : Besoin de changer l’angle de vue ? Les paramètres de calibration ? Tout est accessible et modifiable sans recompiler quoi que ce soit.
- Mono-core total : CPU à 100% signifie qu’on utilise un seul core. Le GIL (Global Interpreter Lock) de Python empêche le parallélisme. FFmpeg utilisait 7 cores en parallèle, on reste à 1. Dans notre cas d’usage on peut considérer cela comme une qualité, car les ressources CPU sont utilisées efficacement. Python ne consomme que 3 fois plus de ressources CPU que ffmpeg pour le même traitement.
❌ Points faibles
- (Très) lent : 6.7× plus lent que FFmpeg, pas utilisable en production pour du temps réel.
- Boucles Python catastrophiques : On a 960 × 960 × 5 = 4,6 millions d’itérations de boucles Python par frame. Chaque itération implique des accès dictionnaire, indexation NumPy, assignation, gestion d’exceptions, bien plus lent que du code natif.
- Aucune vectorisation : NumPy est utilisé uniquement comme conteneur de données. On n’exploite aucune des optimisations SIMD ou des opérations vectorielles batch possibles.
Pas de soucis, la version Python pur est un outil pédagogique, pas une solution de production.
Ce code est parfait pour :
- Comprendre exactement comment fonctionne le dewarping fisheye
- Servir de référence pour vérifier la correction des implémentations optimisées
- Prototyper rapidement des variations de l’algorithme (nouveaux angles, calibrations différentes)
- Apprendre les maths derrière (quaternions, projections sphériques)
Inadapté pour :
- Production ou temps réel (trop lent)
- Traitement de gros volumes de vidéos
- Tout cas d’usage où la performance compte
La question maintenant : peut-on garder la simplicité de Python tout en rattrapant FFmpeg ? La prochaine section explore la vectorisation NumPy – première étape vers des performances acceptables sans quitter Python.
Code complet : github.com/pykoder/fisheye-dewarping
NumPy vectorisé – boost de performance majeur
On garde exactement le même algorithme que la version Python pur, mais on élimine toutes les boucles Python en utilisant les opérations vectorisées de NumPy. L’idée : laisser NumPy (écrit en C optimisé) gérer les millions d’itérations au lieu de l’interpréteur Python.
Le calcul du mapping reste identique (quaternions, matrice de projection), mais la phase d’application devient massivement parallèle grâce au broadcasting et à la vectorisation.
Les modifications clés
Au lieu d’itérer pixel par pixel avec des boucles Python imbriquées, on calcule tout d’un coup en manipulant des tableaux entiers.
Phase 1 : Calcul du mapping vectorisé
Avant (Python pur) :
remap_zone = []
for j in range(self.output_height):
line = []
y = j - offset_height
for i in range(self.output_width):
x = i - offset_width
# Calculs pour ce pixel...
line.append((src_y, src_x))
remap_zone.append(line)Après (NumPy vectorisé) :
[code language="python"]
# Créer une grille de toutes les coordonnées d'un coup
i_coords, j_coords = np.meshgrid(
np.arange(self.output_width),
np.arange(self.output_height),
indexing='xy')
# Aplatir et recentrer les coordonnées
x_coords = i_coords.flatten() * inv_width - 1.0
y_coords = j_coords.flatten() * inv_height - 1.0
# Empiler en une matrice de coordonnées homogènes
coords = np.column_stack([x_coords, y_coords, np.ones_like(x_coords)]).T
# UNE multiplication matricielle pour TOUS les pixels
xyz = R @ coords
# Calculs vectorisés (appliqués à tous les pixels simultanément)
hs = np.hypot(xyz[0, :],xyz[1, :])
phi = np.arctan2(hs, xyz[2, :])
coeff = phi / (hs * np.pi)
src_x = (self.width * (xyz[0, :] * coeff + 0.5)).astype(np.int32)
src_y = (self.height * (xyz[1, :] * coeff + 0.5)).astype(np.int32)
# Clipper pour rester dans les bornes de l'image
src_x = np.clip(src_x, 0, self.width - 1)
src_y = np.clip(src_y, 0, self.height - 1)
# Reshape en 2D et stocker
zone_mapping = np.stack([
src_x.reshape((self.output_height, self.output_width)),
src_y.reshape((self.output_height, self.output_width))
], axis=-1)
[/code]Gain : Au lieu de 960×960 = 921,600 itérations de boucles Python, on a une seule multiplication matricielle optimisée en C + quelques opérations vectorisées. NumPy utilise les instructions SIMD du CPU (SSE, AVX) pour traiter plusieurs valeurs simultanément. C’est bien, mais c’est l’étapoe qu’on ne fait qu’une fois.
Phase 2 : Application du mapping vectorisé
Avant (Python pur) :
for i in range(self.output_height):
for j in range(self.output_width):
try:
output_buffer[i, j] = image[remap_table[i][j]]
except IndexError:
output_buffer[i, j] = [0, 0, 0]Après (NumPy vectorisé) :
# Extraire les coordonnées sources
src_x = remap_table[:, :, 0]
src_y = remap_table[:, :, 1]
# Indexation avancée NumPy : copie TOUS les pixels d'un coup
output_buffer = image[src_y, src_x]Détail critique : Le piège du masque de validité
Une première version tentait de gérer explicitement les pixels hors-limites avec un masque booléen :
valid_mask = ((src_y >= 0) & (src_y < self.height) &
(src_x >= 0) & (src_x < self.width))
output_buffer[valid_mask] = image[src_y[valid_mask], src_x[valid_mask]]Impact désastreux : le benchmark passe de 7.97s à 18.58s ! Pourquoi un simple masque de validité ralentit-il de 2.3× ?
Le masque booléen casse la localité mémoire. Sans masque, NumPy accède aux pixels de façon relativement séquentielle, exploitant les caches CPU. Avec le masque, les accès deviennent aléatoires et dispersés – chaque pixel valide peut être n’importe où dans l’image source. Le CPU passe son temps à attendre des données du RAM au lieu de calculer.
Solution élégante : Clipper les coordonnées lors du calcul du mapping (Phase 1) :
src_x = np.clip(src_x, 0, self.width - 1)
src_y = np.clip(src_y, 0, self.height - 1)Les quelques pixels qui dépassent pointent maintenant vers le bord de l’image (artefact visuel négligeable sur quelques pixels) mais tous les accès mémoire restent valides et séquentiels. NumPy peut optimiser agressivement l’indexation.
Commande: python3 unwarper_numpy.py -r 1024 ../images/fisheye.jpg
======================================================================
RESULTATS BENCHMARK
======================================================================
Wall time: 110.11s
User time: 113.14s
System time: 0.69s
CPU utilization: 103%
Cores utilises: ~1.0
Memoire pic: 255.73 MB (261868 KB)
======================================================================
Speedup parallele: 1.03xRésultats : 110 secondes pour traiter 1024 frames × 5 vues. Soit environ 21.5 ms par frame et par vue.
- FFmpeg : 208s, , 1411.84s CPU (6.8 cores) → 276 ms/frame/vue/CP
- Python + NumPy vectorisé : 110.11s → 21.5 ms/frame/vue/CPU
Gains significatifs :
- 17× plus rapide que Python pur
- 1.9× plus rapide que FFmpeg
- 12.4× moins de CPU utilisé que FFmpeg
La vectorisation NumPy élimine le coût catastrophique des boucles Python.
La magie de NumPy :
- ✅ Code C optimisé : Les opérations NumPy sont implémentées en C hautement optimisé
- ✅ Vectorisation SIMD limitée : Les calculs mathématiques (`hypot`, `arctan2`) exploitent les instructions AVX pour traiter 4-8 float64 simultanément. Gain réel sur la Phase 1.
- ✅ Localité mémoire : Les opérations vectorisées accèdent à la mémoire séquentiellement, maximisant l’efficacité du cache (sauf si on utilise le masque de validité !).
- ❌ Parallélisme limité : NumPy ne parallélise que les grosses multiplications matricielles. L’indexation avancée reste mono-thread. Contrairement à FFmpeg qui parallélise le décodage vidéo + les filtres sur tous les cores.
- ❌ GIL partiellement présent : Certaines opérations NumPy relâchent le GIL, d’autres non. L’indexation avancée garde souvent le GIL, limitant le parallélisme.
✅ Points forts
- Performance acceptable : 1.9× plus rapide que FFmpeg, 17× plus rapide que Python pur. Utilisable en production pour des volumes modérés.
- Code toujours en Python : Gardé la simplicité et la lisibilité de Python. Facile à modifier, debugger, intégrer dans un pipeline existant.
- Pas de compilation : Aucun toolchain C++, CMake ou dépendances système complexes. Juste `pip install numpy` et ça tourne.
- Mémoire optimisée : 256 MB vs 638 MB (Python pur) et 1761 MB (FFmpeg). La table de mapping NumPy est compacte (array dense contiguë en mémoire).
❌ Points faibles
- Parallélisme décevant : 1 core vs 6.8 pour FFmpeg. On n’exploite pas le potentiel multi-core de la machine. L’indexation avancée reste le goulot mono-thread. Mais dans notre cas d’usage ce n’est pas un inconvénient.
- Courbe d’apprentissage : Broadcasting, indexation avancée, pièges de performance (masque de validité). Indispensable pour les performances.
- Optimisations limitées : On ne peut pas tweaker finement les stratégies d’accès mémoire ou le threading. NumPy décide pour nous.
Cette version est déjà utilisable par exemple pour des pipelines Python existants où ajouter du C++ serait compliqué, ou pour du prototypage rapide. Par contre on travaille encore en mono-core, on peut donc certainement faire mieux en termes de temps de calcul, même si pour notre cas d’usage ce serait suffisant.
Peut-on faire mieux en restant en Python ? Oui – la prochaine section explore OpenCV Python, qui offre des fonctions dédiées au dewarping fisheye avec des optimisations spécifiques au traitement d’image. Mais nous verrons cela dans la seconde partie de l’article. Déjà assez long.
Code complet : github.com/pykoder/fisheye-dewarping
Encore plus vite ? La seconde partie de cet article explore trois implémentations supplémentaires : OpenCV Python (en utilisant la primitive `cv2.remap()`), OpenCV C++ (code OpenCV natif compilé), pour finir par une bibliothèque C++ personnalisée qui offre des performances 42× plus rapide que FFmpeg. Comment ? A lire dans la seconde partie !
Quoi de neuf dans la partie 2 2
Dans le prochain article nous explorons trois autres implémentations qui poussent le gain en performances toujours plus loin.
- OpenCV Python: en utilisant `cv2.remap()` avec du parallèlisme multi-core (~4.8 cores)
- OpenCV C++: code openCV natif compilé, pour éliminer l’overhead Python.
- Bibliothèque C++ personnalisée: l’optimisation ultime – 42× plus rapide que FFmpeg en consommant seulement 80 MB de RAM
Nous verrons:
- Comment le multi-threading d’OpenCV permet de dépasser l’efficacité mono-core de NumPy’s en temps horloge.
- Si du code C++ natif appelant OpenCV permet des gains significatifs par rapport à Python
- Et enfin ce que donne la bibliothèque C++ sur mesure appelée via ctypes.
Points à retenir de la partie 1:
- FFmpeg: rapide mais coûteux en mémoire (1.78 GB), forte consommation CPU (1411s)
- Pur Python: lent mais efficace dans l’utilisation du CPU (seulement 1.3× pire que FFmpeg par core)
- NumPy: Champion de l’efficacité – 12.4× moins d’utilisation du CPU que FFmpeg, 7× moins de mémoire
Spoiler alert: la bibliothèque C++ personnalisée parvient à traiter le même nombre de frames en seulement 4.91 secondesen en utilisant 1.1 cores et 80 MB RAM. Ce qui est:
- 42.5× plus rapide que FFmpeg en temps horloge
- 256× moins de CPU utilisé que FFmpeg
- 22× moins de mémoire que FFmpeg
Comment est-ce possible ? Découvrez le dans la partie 2 !
Code complet de toutes les implémentations: [github.com/pykoder/fisheye-dewarping](https://github.com/pykoder/fisheye-dewarping)
Article rédigé en December 2025. Benchmarks réalisés sur un Lenovo ThinkPad P14s – Ubuntu 25.04, Intel Core i7-1185G7 (4 core physiques, 8 threads), 16GB RAM. Tous les tests traitent 1024 frames × 5 vues = 5,120 images dewarpées.