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
|
||||
*.pyc
|
||||
*.villes.json
|
||||
.venv/
|
||||
|
||||
35
README.md
35
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 `<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
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"
|
||||
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())
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user