diff --git a/.gitignore b/.gitignore index 4d047da..1b3d78e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.gpx *.pyc *.villes.json +.venv/ diff --git a/README.md b/README.md index 1934269..3a0f528 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - Vidéo accélérée à durée fixe (60 s par défaut) : le trajet complet est parcouru du début à la fin. - Compteur analogique 0-200 km/h (paramétrable), aiguille et vitesse numérique, placé automatiquement dans le coin le moins encombré par la trace ; l'aiguille part de 0 et y revient sur la première et la dernière seconde. - Villes traversées obtenues par géocodage inverse [Nominatim](https://nominatim.org/) (OpenStreetMap), affichées sur la carte dès le début, avec placement des étiquettes qui évite chevauchements et trace. +- Option zoom : à l'arrivée du véhicule dans une ville, son nom s'affiche en grand au centre de l'image (moitié de la largeur), avec fondu d'apparition et de disparition. - Cache des villes dans `.villes.json` : les exécutions suivantes n'accèdent plus au réseau. - Rendu anticrénelé (suréchantillonnage ×2), encodage H.264 `yuv420p` via ffmpeg, sans fichiers intermédiaires. @@ -47,25 +48,43 @@ python gpx_video.py Trajet_16-09-2026_15h38.gpx Produit `Trajet_16-09-2026_15h38.mp4` à côté du GPX. -| Option | Défaut | Rôle | -| ---------------- | -------------------- | -------------------------------------- | -| `-o`, `--sortie` | nom du GPX en `.mp4` | fichier de sortie | -| `--duree` | `60` | durée de la vidéo (secondes) | -| `--fps` | `30` | images par seconde | -| `--vmax` | `200` | graduation max du compteur (km/h) | -| `--sans-villes` | — | ne pas géocoder ni afficher les villes | +| Option | Défaut | Rôle | +| ---------------- | -------------------- | ----------------------------------------------------- | +| `-o`, `--sortie` | nom du GPX en `.mp4` | fichier de sortie | +| `--duree` | `60` | durée de la vidéo (secondes) | +| `--fps` | `30` | images par seconde | +| `--vmax` | `200` | graduation max du compteur (km/h) | +| `--sans-villes` | — | ne pas géocoder ni afficher les villes | +| `--zoom-villes` | — | nom de la ville en grand quand le véhicule y arrive | +| `--duree-zoom` | `2` | durée d'affichage du nom zoomé (secondes de vidéo) | -Exemple : `python gpx_video.py trace.gpx -o balade.mp4 --duree 90 --fps 25` +Exemple : `python gpx_video.py trace.gpx -o balade.mp4 --duree 90 --fps 25 --zoom-villes` Notes : - Le premier lancement interroge Nominatim (1 requête/s, un point par km de trajet) : compter environ 1 s par km. - En cas d'erreurs réseau, la vidéo est générée sans villes et le cache n'est pas écrit. Supprimer `.villes.json` pour forcer un nouveau géocodage. +- Les itinéraires planifiés (ex. export Liberty Rider en ``) ne contiennent pas d'horodatage et sont refusés : il faut une trace enregistrée. + +### Traitement par lot (PowerShell) + +`gpx_video.ps1` génère une vidéo pour chaque fichier `*.gpx` du **répertoire courant**, avec le Python du venv `.venv` placé à côté du script (créé et équipé de `requirements.txt` automatiquement s'il n'existe pas ; inutile de l'activer). + +```powershell +cd D:\Traces\Septembre +& "H:\...\GPX - Compteur de vitesse\gpx_video.ps1" +& "H:\...\GPX - Compteur de vitesse\gpx_video.ps1" --duree 90 --zoom-villes +``` + +Les arguments sont transmis tels quels à `gpx_video.py` pour chaque trace (sauf `-o`, qui écraserait la même sortie). Les traces en échec sont listées à la fin et le script retourne le code 1. En cas d'erreur, la fenêtre reste ouverte jusqu'à un appui sur Entrée (utile en double-clic / « Exécuter avec PowerShell ») ; pas de pause avec `-NonInteractive`. + +Si la politique d'exécution bloque le script : `powershell -ExecutionPolicy Bypass -File gpx_video.ps1`. ## Structure du projet ``` gpx_video.py script principal (lecture GPX, villes, rendu, encodage) +gpx_video.ps1 traitement par lot des GPX du répertoire courant requirements.txt dépendances Python (numpy, Pillow) docs/exemple.png image extraite de la vidéo d'exemple (README) Trajet_16-09-2026_15h38.gpx trace d'exemple (GeoRide) diff --git a/gpx_video.ps1 b/gpx_video.ps1 new file mode 100644 index 0000000..b534003 --- /dev/null +++ b/gpx_video.ps1 @@ -0,0 +1,68 @@ +<# +.SYNOPSIS + Lance gpx_video.py sur toutes les traces GPX du répertoire courant. + +.DESCRIPTION + Utilise le venv .venv situé à côté de ce script (il est créé et les + dépendances de requirements.txt installées s'il n'existe pas encore). + Chaque fichier *.gpx du répertoire courant produit une vidéo MP4 du même nom. + Les arguments supplémentaires sont transmis tels quels à gpx_video.py. + +.EXAMPLE + .\gpx_video.ps1 + .\gpx_video.ps1 --duree 90 --zoom-villes +#> + +$ErrorActionPreference = 'Stop' +$code = 0 + +try { + $racine = $PSScriptRoot + $venv = Join-Path $racine '.venv' + $python = Join-Path $venv 'Scripts\python.exe' + $script = Join-Path $racine 'gpx_video.py' + + if (-not (Test-Path $python)) { + Write-Host "Création du venv dans $venv ..." + & python -m venv $venv + if ($LASTEXITCODE -ne 0) { throw "Échec de la création du venv." } + & $python -m pip install --upgrade pip + & $python -m pip install -r (Join-Path $racine 'requirements.txt') + if ($LASTEXITCODE -ne 0) { throw "Échec de l'installation des dépendances." } + } + + if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + throw "ffmpeg introuvable dans le PATH." + } + + $traces = @(Get-ChildItem -Path (Get-Location) -Filter '*.gpx' -File | Sort-Object Name) + if ($traces.Count -eq 0) { + throw "Aucun fichier .gpx dans $(Get-Location)." + } + + $echecs = @() + $i = 0 + foreach ($trace in $traces) { + $i++ + Write-Host "[$i/$($traces.Count)] $($trace.Name)" -ForegroundColor Cyan + & $python $script $trace.FullName @args + if ($LASTEXITCODE -ne 0) { $echecs += $trace.Name } + } + + if ($echecs.Count -gt 0) { + throw "Échec pour : $($echecs -join ', ')" + } + Write-Host "Terminé : $($traces.Count) vidéo(s) générée(s)." -ForegroundColor Green +} +catch { + Write-Host "ERREUR : $($_.Exception.Message)" -ForegroundColor Red + $code = 1 +} + +# Laisse la fenêtre ouverte en cas d'échec (lancement par double-clic ou +# « Exécuter avec PowerShell »), sauf en mode non interactif. +if ($code -ne 0 -and [Environment]::UserInteractive -and + -not ([Environment]::GetCommandLineArgs() -match '^-NonI')) { + Read-Host "Appuyez sur Entrée pour fermer" | Out-Null +} +exit $code diff --git a/gpx_video.py b/gpx_video.py index 7f7fb98..1d4147b 100644 --- a/gpx_video.py +++ b/gpx_video.py @@ -34,6 +34,9 @@ PAS_VILLES_M = 1000 NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse" USER_AGENT = "gpx-video-compteur/1.0 (script personnel)" CLES_VILLE = ("city", "town", "village", "municipality", "hamlet") +LARGEUR_ZOOM = TAILLE // 2 # largeur du nom de ville zoomé +HAUTEUR_ZOOM_MAX = TAILLE // 4 # limite pour les noms très courts +FONDU_ZOOM = 0.3 # durée des fondus d'apparition/disparition (s de vidéo) BLANC = (255, 255, 255) GRIS = (170, 170, 170) @@ -295,6 +298,46 @@ def rendu_patch(fond_ss, boite, dessin): return patch.resize((x1 - x0, y1 - y0), Image.LANCZOS) +def rendu_zoom(nom): + """Masques 1x (contour, texte) du nom de ville zoomé et position du coin haut-gauche.""" + l_max, h_max = LARGEUR_ZOOM * SS, HAUTEUR_ZOOM_MAX * SS + + def mesure(taille): + police = charger_police(taille, gras=True) + contour = max(taille // 14, SS) + b = police.getbbox(nom, anchor="mm", stroke_width=contour) + return police, contour, b[2] - b[0], b[3] - b[1] + + # Taille de police la plus grande dont le texte, contour compris, tient + # dans la zone : un nom long est réduit, jamais tronqué. + ref = 100 * SS + _, _, largeur, hauteur = mesure(ref) + taille = max(int(ref * min(l_max / max(largeur, 1), h_max / max(hauteur, 1))), 1) + police, contour, largeur, hauteur = mesure(taille) + while taille > 1 and (largeur > l_max or hauteur > h_max): + taille = max(taille - max(taille // 50, 1), 1) + police, contour, largeur, hauteur = mesure(taille) + + # Dessin sur un canevas largement dimensionné puis recadrage sur l'encre + # réelle : aucun pixel (anticrénelage, accents, contour) n'est coupé. + marge = 4 * contour + 4 * SS + canevas = (largeur + 2 * marge, hauteur + 2 * marge) + centre_ss = (canevas[0] // 2, canevas[1] // 2) + masques_ss = [] + for largeur_trait in (contour, 0): + m = Image.new("L", canevas, 0) + ImageDraw.Draw(m).text(centre_ss, nom, font=police, fill=255, anchor="mm", + stroke_width=largeur_trait, stroke_fill=255) + masques_ss.append(m) + x0, y0, x1, y1 = masques_ss[0].getbbox() or (0, 0, SS, SS) + # boîte alignée sur la grille 1x, avec 1 px de marge pour le filtre LANCZOS + x0, y0 = (x0 // SS - 1) * SS, (y0 // SS - 1) * SS + x1, y1 = (math.ceil(x1 / SS) + 1) * SS, (math.ceil(y1 / SS) + 1) * SS + l1, h1 = (x1 - x0) // SS, (y1 - y0) // SS + masques = [m.crop((x0, y0, x1, y1)).resize((l1, h1), Image.LANCZOS) for m in masques_ss] + return masques[0], masques[1], (TAILLE // 2 - l1 // 2, TAILLE // 2 - h1 // 2) + + def generer_video(args): lat, lon, t, v_gpx = charger_gpx(args.gpx) vitesse = calculer_vitesses(lat, lon, t, v_gpx) @@ -320,6 +363,17 @@ def generer_video(args): police_vitesse = charger_police(int(46 * SS), gras=True) nb_images = int(round(args.duree * args.fps)) + + # Zoom sur les villes : image d'arrivée du véhicule et masques précalculés + zooms = [] + if args.zoom_villes: + for nom, la, lo in villes: + proche = int(np.argmin((lat - la) ** 2 + ((lon - lo) * math.cos(math.radians(la))) ** 2)) + n_arrivee = t[proche] / t[-1] * (nb_images - 1) + zooms.append((n_arrivee, *rendu_zoom(nom))) + zooms.sort(key=lambda z: z[0]) + duree_zoom = args.duree_zoom * args.fps + fondu = max(FONDU_ZOOM * args.fps, 1) sortie = args.sortie or str(Path(args.gpx).with_suffix(".mp4")) cmd = ["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{TAILLE}x{TAILLE}", "-r", str(args.fps), "-i", "-", @@ -366,6 +420,17 @@ def generer_video(args): font=police_vitesse, fill=BLANC, anchor="mm") image.paste(rendu_patch(fond_ss, boite_compteur, dessin_aiguille), boite_compteur[:2], masque_compteur) + # Nom de la ville en grand (la plus récemment atteinte) + actives = [z for z in zooms if z[0] <= n < z[0] + duree_zoom] + if actives: + n_arrivee, masque_contour, masque_texte, pos = actives[-1] + f = min(1.0, (n - n_arrivee) / fondu, (n_arrivee + duree_zoom - n) / fondu) + if f < 1.0: + masque_contour = masque_contour.point(lambda p: int(p * f)) + masque_texte = masque_texte.point(lambda p: int(p * f)) + image.paste(NOIR, pos, masque_contour) + image.paste(BLANC, pos, masque_texte) + ffmpeg.stdin.write(image.tobytes()) if n % 10 == 0 or n == nb_images - 1: ecoule = time.time() - debut @@ -388,6 +453,10 @@ def main(): parser.add_argument("--fps", type=int, default=30, help="images par seconde (défaut 30)") parser.add_argument("--vmax", type=float, default=200, help="graduation max du compteur en km/h (défaut 200)") parser.add_argument("--sans-villes", action="store_true", help="ne pas afficher les villes (pas d'accès réseau)") + parser.add_argument("--zoom-villes", action="store_true", + help="afficher le nom de chaque ville en grand au centre quand le véhicule y arrive") + parser.add_argument("--duree-zoom", type=float, default=2.0, + help="durée d'affichage du nom zoomé en secondes de vidéo (défaut 2)") generer_video(parser.parse_args())