La conversion de coordonnées géographiques entre WGS-84 et UTM revient à chaque projet géospatial : tracking de flottes, cartographie immobilière, capteurs IoT en plein air, analyse de trajectoires sportives. Sur le papier, c'est une transformation mathématique bien définie. En production, c'est l'endroit où nos applications cassent quand on inverse latitude et longitude, qu'on confond datums, ou qu'on traverse une frontière de zone UTM sans s'en rendre compte.
Cet article rassemble ce qu'on applique chez Platane sur les projets qui manipulent des coordonnées : les définitions exactes des deux systèmes, le code qui marche en Python, TypeScript et PostGIS, et la liste des pièges qu'on voit revenir.
Prérequis avant de commencer
Avant de coder une reprojection, on vérifie quatre choses :
Le datum source est connu et constant. Les GPS modernes renvoient du WGS-84 (EPSG), mais des données livrées en NAD83, ETRS89 ou RGF93 supposent un autre datum, avec des écarts non négligeables (jusqu'à 200 mètres en Europe entre WGS-84 et NAD27 par exemple).
L'ordre des axes dans les coordonnées en entrée : [lat, lon] ou [lon, lat] ? La réponse n'est pas la même selon qu'on lit du GeoJSON, du WKT ou la base PostGIS.
La zone UTM cible est arrêtée. On travaille avec une fenêtre géographique stable, ou des données qui peuvent chevaucher deux zones ?
La précision attendue. Suit-on des véhicules au mètre, ou des athlètes au centimètre près ? La réponse change les choix de stockage (float, double, numeric) et le nombre de décimales conservées.
Si l'un de ces points n'est pas fixé, on n'a pas encore le bon problème en main.
WGS-84 et UTM : ce qu'on convertit, exactement
WGS-84 (EPSG) est un système géodésique mondial fondé sur un ellipsoïde géocentrique. Il exprime une position en latitude et longitude (degrés décimaux), avec optionnellement une altitude ellipsoïdale. C'est le système de référence du GPS, et le format de stockage standard pour la donnée brute de géolocalisation.
UTM (Universal Transverse Mercator) est une projection conforme cylindrique qui découpe la Terre en 60 zones de 6° de longitude chacune, projetées indépendamment. Les coordonnées sortent en mètres (easting, northing), donc directement utilisables pour des calculs de distance, d'aire ou de buffer. Chaque zone a son propre code EPSG : 326xx au nord (xx = numéro de zone) et 327xx au sud. La France métropolitaine continentale tombe sur la zone 31N (EPSG), l'est de la France et la Corse sur la zone 32N (EPSG).
On garde les deux parce qu'ils répondent à deux questions différentes :
WGS-84 répond à « où sur la planète ? ». C'est ce qu'on stocke et ce qu'on échange.
UTM répond à « à quelle distance en mètres ? ». C'est ce qu'on utilise pour calculer.
Pourquoi la conversion de coordonnées géographiques est piégeuse
Il y a six pièges récurrents, qu'on voit autant chez nos clients qu'on entend décrire sur les issues GitHub des bibliothèques de reprojection :
Le datum shift. Convertir WGS-84 vers UTM n'implique pas de changement de datum (les deux reposent sur le datum WGS-84). Mais convertir WGS-84 vers NAD83 / UTM zone 17N implique une transformation Helmert, des grilles de correction (ntv2_0.gsb, conus.gsb), et selon la disponibilité de ces grilles le résultat peut varier d'un mètre à dix.
Le choix de la zone UTM. La logique « zone = floor((lon + 180) / 6) + 1 » fonctionne sauf cas particuliers : la zone 32 est élargie en Norvège, les zones 31 à 37 sont redessinées au Svalbard.
Le chevauchement de zones. Si vos données traversent une frontière (par exemple une flotte logistique qui couvre la France et l'Italie), tout reprojeter en une seule zone introduit une déformation qui augmente avec la distance au méridien central. Au-delà de 3° hors de la zone, l'erreur dépasse plusieurs mètres.
Les pôles. UTM n'est défini qu'entre 80°S et 84°N. Au-delà, on utilise UPS (Universal Polar Stereographic, EPSG au nord, EPSG au sud).
L'antimeridian. Une géométrie qui traverse la ligne ±180° (Pacifique) casse la plupart des bibliothèques naïves : elles dessinent un trait qui fait le tour de la Terre dans le mauvais sens.
L'ordre des axes. Le piège le plus fréquent, qu'on documente en détail plus bas.
Convertir WGS-84 vers UTM en Python avec pyproj
pyproj est le binding Python du projet PROJ. C'est l'outil de référence en data engineering et data science géospatiale. Pour une conversion simple WGS-84 vers UTM zone 31N :
from pyproj import Transformer
# always_xy=True force l'ordre (lon, lat) en entree comme en sortie,# independamment de la definition officielle EPSG.transformer = Transformer.from_crs("EPSG:4326","EPSG:32631", always_xy=True)# Tour Eiffel : lon=2.2945, lat=48.8584easting, northing = transformer.transform(2.2945,48.8584)print(f"E={easting:.2f}m, N={northing:.2f}m")# E=448253.04m, N=5411938.46m
Pour un batch (vectorisé via NumPy, beaucoup plus rapide) :
import numpy as np
from pyproj import Transformer
transformer = Transformer.from_crs("EPSG:4326","EPSG:32631", always_xy=True)lons = np.array([2.2945,2.3522,2.3488])lats = np.array([48.8584,48.8566,48.8534])e, n = transformer.transform(lons, lats)
Quand on ne connaît pas la zone UTM à l'avance (cas des données mondiales), on peut la déduire :
from pyproj import CRS
from pyproj.aoi import AreaOfInterest
from pyproj.database import query_utm_crs_info
zones = query_utm_crs_info( datum_name="WGS 84", area_of_interest=AreaOfInterest( west_lon_degree=2.29, south_lat_degree=48.85, east_lon_degree=2.36, north_lat_degree=48.86,),)utm_crs = CRS.from_epsg(zones[0].code)# EPSG:32631 pour Paris
Convertir en TypeScript ou JavaScript avec proj4js
Côté front et Node.js, proj4js est le portage JavaScript de PROJ. Il ne couvre pas toutes les grilles de datum shift de PROJ complet, mais il suffit pour la majorité des cas WGS-84 vers UTM.
import proj4 from"proj4";// proj4 connait EPSG:4326 par defaut.// Il faut declarer la projection cible.proj4.defs("EPSG:32631","+proj=utm +zone=31 +datum=WGS84 +units=m +no_defs +type=crs");const wgs84 ="EPSG:4326";const utm31n ="EPSG:32631";// proj4js utilise toujours [lon, lat]. Pas d'ambiguite d'axes.const[easting, northing]=proj4(wgs84, utm31n,[2.2945,48.8584]);// [448253.04, 5411938.46]
Pour gérer plusieurs zones dynamiquement, on charge les définitions depuis un fichier statique généré à partir d'epsg.io ou de la base PROJ.
Côté carto interactive, OpenLayers et MapLibre intègrent proj4 et gèrent la reprojection automatiquement, à un détail près : les tuiles raster (OSM, IGN, Mapbox) sont servies en EPSG (Web Mercator), pas en UTM. Si une couche vectorielle est en UTM et qu'on l'affiche sur des tuiles Web Mercator, la reprojection est faite par la bibliothèque côté client à chaque rendu, ce qui coûte cher en CPU sur de gros datasets. Pour du tracking temps réel à plus de 10 000 features visibles, on précalcule la version Web Mercator côté serveur.
ST_Transform : la conversion de coordonnées géographiques en PostGIS
PostGIS embarque la bibliothèque PROJ et expose la reprojection via ST_Transform. Le SRID est attaché à la géométrie au niveau du type, ce qui rend les manipulations explicites.
-- Une table de capteurs IoT stockes en WGS-84CREATETABLE sensors ( id bigserial PRIMARYKEY, label text, position geometry(Point,4326)NOTNULL);-- Calculer la distance en metres entre deux capteurs-- via une reprojection en UTM zone 31NSELECT a.label, b.label, ST_Distance( ST_Transform(a.position,32631), ST_Transform(b.position,32631))AS distance_m
FROM sensors a, sensors b
WHERE a.id < b.id;
Sur une table de plusieurs centaines de milliers de points, on évite de reprojeter à chaque requête : on stocke directement une seconde colonne projetée, indexée GiST, et on met à jour via trigger. On constate sur nos charges PostGIS typiques (autour de 500 000 lignes) un facteur 5 à 10 entre une requête qui projette à la volée et la même requête sur une colonne déjà projetée et indexée.
ALTERTABLE sensors ADDCOLUMN position_utm geometry(Point,32631);UPDATE sensors SET position_utm = ST_Transform(position,32631);CREATEINDEX idx_sensors_utm ON sensors USING gist (position_utm);-- Trigger pour maintenir la coherenceCREATEORREPLACEFUNCTION sync_utm()RETURNStriggerAS $$
BEGIN NEW.position_utm := ST_Transform(NEW.position,32631);RETURN NEW;END;$$ LANGUAGE plpgsql;CREATETRIGGER trg_sync_utm BEFORE INSERTORUPDATEOF position
ON sensors FOR EACH ROWEXECUTEFUNCTION sync_utm();
Le piège qu'on rencontre le plus en prod : l'ordre des axes
C'est le piège qu'on isole quasi systématiquement quand un client nous appelle pour « des points qui sortent du fond de carte ».
Symptôme. Des coordonnées qui devraient être à Paris se retrouvent au milieu de l'océan Indien, ou symétriquement projetées par rapport à l'équateur. L'application ne renvoie aucune erreur, le code passe les tests unitaires sur des cas synthétiques, parce que les bonnes valeurs (48,85, 2,29) restent dans le domaine valide même quand on les inverse en (2,29, 48,85).
Diagnostic. Le code mixe deux conventions d'ordre d'axes. EPSG est officiellement défini en [lat, lon]. GeoJSON, par convention RFC 7946, sérialise en [lon, lat]. PostGIS stocke en (x, y) = (lon, lat). Leaflet attend [lat, lon] dans ses API. Quand on parse du GeoJSON pour le passer à un Transformer pyproj sans always_xy=True, les axes sont inversés.
Fix. On standardise sur [lon, lat] (ordre x, y) dans tout le code applicatif, on force always_xy=True sur tous les Transformer pyproj, et on utilise des dataclasses ou des types nommés ({latitude: number, longitude: number}) plutôt que des tuples anonymes aux frontières du code. Le piège revient toujours quand une signature de fonction prend (coords: number[]) sans préciser l'ordre. Délai de résolution typique sur un projet déjà en prod : 2 à 4 heures pour identifier le point d'inversion, plus un audit pour vérifier que les données déjà stockées sont cohérentes.
Précision, formats et accumulation d'erreurs
Trois métriques qu'on garde en tête pour décider du stockage :
Stocker des degrés décimaux en float32 plafonne à environ 5 décimales utiles, soit 1 m de précision. Pour de la cartographie au centimètre, on stocke en float64 (double precision en PostgreSQL) ou en types géographiques natifs (geometry, geography).
Les formats DMS (48°51'30.2"N) sont un piège côté ingestion : 3°44'27.8"N n'est pas la même chose que 3,74106°N parce que 3°44'27.8" ≈ 3,7410555°. On utilise une bibliothèque qui parse explicitement (pyproj, geographiclib, proj4), jamais une regex maison.
Pourquoi on évite la reprojection en chaîne
On voit régulièrement du code qui enchaîne WGS-84 → Web Mercator → UTM → Web Mercator pour afficher la même donnée à différents endroits du pipeline. Chaque étape introduit une erreur d'arrondi de l'ordre de 0,01 à 1 mètre selon les CRS impliqués. Sur 4 reprojections, on accumule plusieurs mètres d'erreur, suffisants pour qu'un véhicule « saute » d'un côté à l'autre d'une rue d'un rendu à l'autre.
Notre règle : une seule source de vérité (WGS-84), et toutes les reprojections sont faites depuis cette source, pas en chaîne. Si une étape nécessite de l'UTM pour un calcul intermédiaire, on revient ensuite en WGS-84 avant la prochaine étape, ou on garde un parallèle entre les deux représentations.
Comparatif des outils qu'on utilise pour cette source unique :
La bibliothèque utm de Turbo87 (530 étoiles GitHub, 4 600 projets dépendants) est une alternative légère quand on n'a que des conversions WGS-84 ↔ UTM à faire, sans grilles de datum shift. Elle est plus rapide que pyproj sur le cas étroit qu'elle couvre, mais elle ne gère pas les autres CRS.
Foire aux questions
Q : Comment convertir WGS-84 vers UTM en Python ?
R : On utilise pyproj.Transformer.from_crs("EPSG:4326", "EPSG:326xx", always_xy=True) où xx est le numéro de zone UTM. Pour la France métropolitaine, EPSG:32631 (zone 31N) couvre l'ouest et le centre, EPSG:32632 (zone 32N) couvre l'est et la Corse. Le flag always_xy=True est obligatoire pour éviter le piège de l'ordre des axes.
Q : Quelle zone UTM utiliser pour la France métropolitaine ?
R : La France continentale est partagée entre la zone 31N (longitudes 0 à 6° E, soit de Brest jusqu'à Strasbourg) et la zone 32N (6 à 12° E, soit l'est de l'Alsace et la Corse). Pour éviter de gérer plusieurs zones, on recommande Lambert-93 (EPSG) qui couvre tout le territoire métropolitain en un seul système métrique, conçu pour ce cas précis.
Q : Quelle différence entre EPSG et EPSG ?
R : EPSG (WGS-84) est un système géographique non projeté, exprimé en degrés décimaux, axe officiel [lat, lon]. EPSG (Web Mercator) est une projection en mètres utilisée par les tuiles cartographiques web (OpenStreetMap, IGN, Mapbox). Web Mercator déforme fortement les surfaces aux hautes latitudes : c'est un format d'affichage, pas un format de mesure.
Q : Comment convertir des coordonnées GPS en mètres ?
R : On reprojette les coordonnées WGS-84 vers une projection métrique adaptée à la zone : UTM si on est à moins de 3° du méridien central choisi, Lambert-93 pour la France métropolitaine, ou une projection azimutale locale pour des données très resserrées. L'opération s'écrit ST_Transform(geom, 32631) en PostGIS ou Transformer.from_crs("EPSG:4326", "EPSG:32631", always_xy=True).transform(lon, lat) en Python.
Q : Comment éviter l'erreur d'ordre des axes en pyproj ?
R : On force always_xy=True à la création du Transformer, on documente l'ordre (lon, lat) dans toutes les signatures de fonctions internes, et on type les coordonnées par des structures nommées plutôt que par des tuples positionnels. La règle qu'on applique : si l'ordre n'est pas évident à la lecture, c'est un bug en attente.
Pour aller plus loin
L'agence Platane (https://platane.io) accompagne des PME françaises sur des projets backend, data et géospatiaux. La conversion de coordonnées géographiques est une brique récurrente sur les projets de cartographie, de logistique et d'IoT : on documente ici ce qu'on applique pour que l'équipe en face puisse repartir avec du code et des décisions tranchées.
Pour creuser le couple PostgreSQL + PostGIS, lire aussi nos retours d'expérience sur Postgres pg_hint_plan en RAG qui détaille notre approche du tuning PostgreSQL en production, et pgvector en production pour notre méthode d'indexation sur des tables volumineuses, transposable au cas des index GiST PostGIS.
Postgres pg_hint_plan : forcer GIN vs GiST trigram en prod RAG
En production, un mot a fait scanner notre Postgres 38 minutes. Comment pg_hint_plan a remplacé l'espoir par un BitmapScan déterministe sur GIN trigram.
pgvector en production : indexer un RAG d'1 To sans downtime
Construire un index HNSW pgvector sur 9 millions d'embeddings dans un cluster Postgres de prod, sans interrompre les utilisateurs. Retour d'expérience.
Analyser un plan architectural par IA en 2026 : SAM 2 et agents
Analyser automatiquement un plan architectural par IA en 2026 : la stack moderne (Segment Anything 2.1, Florence-2, agents LLM) et l'hébergement souverain.
Platane a rejoint l'initiative France Num pour accompagner les TPE PME dans leur transformation numérique : diagnostics, formations et aides financières.