ajout script powershell pour execution sous windows
This commit is contained in:
parent
e30de33461
commit
4dad048c20
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
*.gpx
|
*.gpx
|
||||||
*.pyc
|
*.pyc
|
||||||
*.villes.json
|
*.villes.json
|
||||||
|
.venv/
|
||||||
|
|||||||
23
README.md
23
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Rendu anticrénelé (suréchantillonnage ×2), encodage H.264 `yuv420p` via ffmpeg, sans fichiers intermédiaires.
|
||||||
|
|
||||||
@ -48,24 +49,42 @@ python gpx_video.py Trajet_16-09-2026_15h38.gpx
|
|||||||
Produit `Trajet_16-09-2026_15h38.mp4` à côté du GPX.
|
Produit `Trajet_16-09-2026_15h38.mp4` à côté du GPX.
|
||||||
|
|
||||||
| Option | Défaut | Rôle |
|
| Option | Défaut | Rôle |
|
||||||
| ---------------- | -------------------- | -------------------------------------- |
|
| ---------------- | -------------------- | ----------------------------------------------------- |
|
||||||
| `-o`, `--sortie` | nom du GPX en `.mp4` | fichier de sortie |
|
| `-o`, `--sortie` | nom du GPX en `.mp4` | fichier de sortie |
|
||||||
| `--duree` | `60` | durée de la vidéo (secondes) |
|
| `--duree` | `60` | durée de la vidéo (secondes) |
|
||||||
| `--fps` | `30` | images par seconde |
|
| `--fps` | `30` | images par seconde |
|
||||||
| `--vmax` | `200` | graduation max du compteur (km/h) |
|
| `--vmax` | `200` | graduation max du compteur (km/h) |
|
||||||
| `--sans-villes` | — | ne pas géocoder ni afficher les villes |
|
| `--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 :
|
Notes :
|
||||||
|
|
||||||
- Le premier lancement interroge Nominatim (1 requête/s, un point par km de trajet) : compter environ 1 s par km.
|
- 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.
|
- 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
|
## Structure du projet
|
||||||
|
|
||||||
```
|
```
|
||||||
gpx_video.py script principal (lecture GPX, villes, rendu, encodage)
|
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)
|
requirements.txt dépendances Python (numpy, Pillow)
|
||||||
docs/exemple.png image extraite de la vidéo d'exemple (README)
|
docs/exemple.png image extraite de la vidéo d'exemple (README)
|
||||||
Trajet_16-09-2026_15h38.gpx trace d'exemple (GeoRide)
|
Trajet_16-09-2026_15h38.gpx trace d'exemple (GeoRide)
|
||||||
|
|||||||
68
gpx_video.ps1
Normal file
68
gpx_video.ps1
Normal 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
|
||||||
69
gpx_video.py
69
gpx_video.py
@ -34,6 +34,9 @@ PAS_VILLES_M = 1000
|
|||||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"
|
NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"
|
||||||
USER_AGENT = "gpx-video-compteur/1.0 (script personnel)"
|
USER_AGENT = "gpx-video-compteur/1.0 (script personnel)"
|
||||||
CLES_VILLE = ("city", "town", "village", "municipality", "hamlet")
|
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)
|
BLANC = (255, 255, 255)
|
||||||
GRIS = (170, 170, 170)
|
GRIS = (170, 170, 170)
|
||||||
@ -295,6 +298,46 @@ def rendu_patch(fond_ss, boite, dessin):
|
|||||||
return patch.resize((x1 - x0, y1 - y0), Image.LANCZOS)
|
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):
|
def generer_video(args):
|
||||||
lat, lon, t, v_gpx = charger_gpx(args.gpx)
|
lat, lon, t, v_gpx = charger_gpx(args.gpx)
|
||||||
vitesse = calculer_vitesses(lat, lon, t, v_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)
|
police_vitesse = charger_police(int(46 * SS), gras=True)
|
||||||
|
|
||||||
nb_images = int(round(args.duree * args.fps))
|
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"))
|
sortie = args.sortie or str(Path(args.gpx).with_suffix(".mp4"))
|
||||||
cmd = ["ffmpeg", "-y", "-loglevel", "error",
|
cmd = ["ffmpeg", "-y", "-loglevel", "error",
|
||||||
"-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{TAILLE}x{TAILLE}", "-r", str(args.fps), "-i", "-",
|
"-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")
|
font=police_vitesse, fill=BLANC, anchor="mm")
|
||||||
image.paste(rendu_patch(fond_ss, boite_compteur, dessin_aiguille), boite_compteur[:2], masque_compteur)
|
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())
|
ffmpeg.stdin.write(image.tobytes())
|
||||||
if n % 10 == 0 or n == nb_images - 1:
|
if n % 10 == 0 or n == nb_images - 1:
|
||||||
ecoule = time.time() - debut
|
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("--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("--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("--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())
|
generer_video(parser.parse_args())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user