ajout script powershell pour execution sous windows

This commit is contained in:
Alex 2026-09-23 18:12:26 +02:00
parent e30de33461
commit 4dad048c20
4 changed files with 165 additions and 8 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
*.gpx
*.pyc
*.villes.json
.venv/

View File

@ -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 `<trace>.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 `<trace>.villes.json` pour forcer un nouveau géocodage.
- Les itinéraires planifiés (ex. export Liberty Rider en `<rte>`) 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)

68
gpx_video.ps1 Normal file
View File

@ -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

View File

@ -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())