Compare commits
No commits in common. "f08c59911364f58fb937960b60a41bfc546db30e" and "e30de3346163eb57b4f398bf35be5985817cf456" have entirely different histories.
f08c599113
...
e30de33461
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,3 @@
|
|||||||
*.gpx
|
*.gpx
|
||||||
*.pyc
|
*.pyc
|
||||||
*.villes.json
|
*.villes.json
|
||||||
.venv/
|
|
||||||
|
|||||||
62
README.md
62
README.md
@ -26,8 +26,6 @@
|
|||||||
- 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.
|
|
||||||
- Découpage des longs trajets en plusieurs vidéos (de même durée de trajet ou avec un nombre de villes donné), chacune cadrée sur sa portion et n'affichant que ses villes (évite les noms qui se superposent) ; l'aiguille ne retombe à 0 qu'au vrai début et à la vraie fin du trajet.
|
|
||||||
- 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.
|
||||||
|
|
||||||
@ -49,69 +47,25 @@ 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, de chaque morceau si découpage (s) |
|
| `--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) |
|
|
||||||
| `--morceaux N` | — | découpe le trajet en N vidéos de même durée de trajet |
|
|
||||||
| `--duree-morceau MIN` | — | découpe en morceaux égaux d'au plus MIN minutes de trajet |
|
|
||||||
| `--villes-par-morceau [N]` | `20` si N omis | découpe en morceaux d'au plus N villes chacun |
|
|
||||||
|
|
||||||
Exemple : `python gpx_video.py trace.gpx -o balade.mp4 --duree 90 --fps 25 --zoom-villes`
|
Exemple : `python gpx_video.py trace.gpx -o balade.mp4 --duree 90 --fps 25`
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Découpage en plusieurs vidéos
|
|
||||||
|
|
||||||
Sur un long trajet, la carte entière est trop petite et les noms de villes se superposent. Le trajet peut alors être découpé en plusieurs vidéos, chacune cadrée sur sa portion de trace et n'affichant que les villes de cette portion. Trois modes, exclusifs entre eux :
|
|
||||||
|
|
||||||
| Mode | Coupe | Exemple (trajet de 10 h 15, 66 villes) |
|
|
||||||
| -------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
|
|
||||||
| `--morceaux N` | N morceaux de même durée de trajet | `--morceaux 3` → 3 × 3 h 25 |
|
|
||||||
| `--duree-morceau MIN` | morceaux égaux d'au plus MIN minutes de trajet | `--duree-morceau 120` → 6 × 1 h 42 |
|
|
||||||
| `--villes-par-morceau [N]` | groupes d'au plus N villes consécutives (20 si N omis) | `--villes-par-morceau` → 4 morceaux de 16-17 villes |
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python gpx_video.py trace.gpx --villes-par-morceau # 20 villes max par vidéo
|
|
||||||
python gpx_video.py trace.gpx --villes-par-morceau 10 --zoom-villes
|
|
||||||
python gpx_video.py trace.gpx --morceaux 3 -o balade.mp4 # balade_1.mp4 … balade_3.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
- Fichiers produits : `<sortie>_1.mp4`, `<sortie>_2.mp4`… (numéros complétés de zéros au-delà de 9 morceaux). Une seule vidéo, sans suffixe, si le découpage ne donne qu'un morceau.
|
|
||||||
- `--duree` fixe la durée de **chaque** vidéo : les morceaux d'un même trajet n'ont donc pas la même vitesse de défilement si leurs durées de trajet diffèrent (cas de `--villes-par-morceau`).
|
|
||||||
- `--villes-par-morceau` équilibre les groupes plutôt que de remplir les premiers : 66 villes avec N = 20 donnent 16, 17, 17 et 16 villes, et non 20, 20, 20 et 6. La coupure se place à mi-temps entre la dernière ville d'un morceau et la première du suivant. Sans villes (`--sans-villes` ou géocodage en échec), ce mode est ignoré avec un avertissement et une seule vidéo est produite.
|
|
||||||
- Chaque ville est affectée au morceau où le véhicule passe à son étiquette ; une ville à cheval sur une coupure n'apparaît que dans un des deux morceaux.
|
|
||||||
- L'aiguille ne part de 0 qu'au début du premier morceau et n'y revient qu'à la fin du dernier.
|
|
||||||
|
|
||||||
### Traitement par lot (PowerShell)
|
|
||||||
|
|
||||||
`gpx_video.ps1` génère les vidéos de chaque fichier `*.gpx` du **répertoire courant**, avec zoom sur les villes (`--zoom-villes`) et découpage en morceaux d'au plus 20 villes (`--villes-par-morceau 20`), 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
|
|
||||||
& "H:\...\GPX - Compteur de vitesse\gpx_video.ps1" --morceaux 3
|
|
||||||
```
|
|
||||||
|
|
||||||
Les arguments sont transmis tels quels à `gpx_video.py` pour chaque trace (sauf `-o`, qui écraserait la même sortie). Une autre option de découpage (`--morceaux`, `--duree-morceau`, `--villes-par-morceau N`) ou `--sans-villes` remplace le découpage par défaut. 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)
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
<#
|
|
||||||
.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 ou plusieurs vidéos
|
|
||||||
MP4 du même nom : noms des villes zoomés (--zoom-villes) et découpage en
|
|
||||||
morceaux d'au plus 20 villes (--villes-par-morceau 20).
|
|
||||||
Les arguments supplémentaires sont transmis tels quels à gpx_video.py ; une
|
|
||||||
autre option de découpage (--morceaux, --duree-morceau, --villes-par-morceau)
|
|
||||||
ou --sans-villes remplace le découpage par défaut.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\gpx_video.ps1
|
|
||||||
.\gpx_video.ps1 --duree 90
|
|
||||||
.\gpx_video.ps1 --villes-par-morceau 10
|
|
||||||
#>
|
|
||||||
|
|
||||||
$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)."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Options par défaut, placées avant celles de l'utilisateur. Le découpage
|
|
||||||
# par villes est omis si l'utilisateur en choisit un autre (options
|
|
||||||
# exclusives) ou désactive les villes.
|
|
||||||
$defaut = @('--zoom-villes')
|
|
||||||
$autreDecoupage = @($args | Where-Object {
|
|
||||||
$_ -match '^--(morceaux|duree-morceau|villes-par-morceau|sans-villes)(=|$)' })
|
|
||||||
if ($autreDecoupage.Count -eq 0) { $defaut += @('--villes-par-morceau', '20') }
|
|
||||||
|
|
||||||
$echecs = @()
|
|
||||||
$i = 0
|
|
||||||
foreach ($trace in $traces) {
|
|
||||||
$i++
|
|
||||||
Write-Host "[$i/$($traces.Count)] $($trace.Name)" -ForegroundColor Cyan
|
|
||||||
& $python $script $trace.FullName @defaut @args
|
|
||||||
if ($LASTEXITCODE -ne 0) { $echecs += $trace.Name }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($echecs.Count -gt 0) {
|
|
||||||
throw "Échec pour : $($echecs -join ', ')"
|
|
||||||
}
|
|
||||||
Write-Host "Terminé : $($traces.Count) trace(s) traité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
|
|
||||||
160
gpx_video.py
160
gpx_video.py
@ -4,14 +4,8 @@ Contenu de la vidéo : trace blanche sur fond noir, cercle blanc figurant le
|
|||||||
véhicule, compteur de vitesse analogique à aiguille et noms des villes
|
véhicule, compteur de vitesse analogique à aiguille et noms des villes
|
||||||
traversées (géocodage inverse Nominatim / OpenStreetMap, mis en cache).
|
traversées (géocodage inverse Nominatim / OpenStreetMap, mis en cache).
|
||||||
|
|
||||||
Une trace longue peut être découpée en plusieurs vidéos, chacune cadrée sur sa
|
|
||||||
portion : de durée de trajet identique (--morceaux, --duree-morceau) ou avec un
|
|
||||||
nombre de villes donné (--villes-par-morceau).
|
|
||||||
|
|
||||||
Usage :
|
Usage :
|
||||||
python gpx_video.py trace.gpx [-o sortie.mp4] [--duree 60] [--fps 30]
|
python gpx_video.py trace.gpx [-o sortie.mp4] [--duree 60] [--fps 30]
|
||||||
[--morceaux N | --duree-morceau MIN |
|
|
||||||
--villes-par-morceau [N]]
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@ -40,9 +34,6 @@ 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)
|
||||||
@ -304,111 +295,16 @@ 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 bornes_morceaux(args, duree_trace, t_villes):
|
|
||||||
"""Temps (s) de début/fin des morceaux, selon l'option de découpage."""
|
|
||||||
if args.villes_par_morceau:
|
|
||||||
# Groupes consécutifs de villes (au plus N, tailles équilibrées),
|
|
||||||
# coupés à mi-temps entre la dernière ville d'un groupe et la suivante.
|
|
||||||
tv = sorted(t_villes)
|
|
||||||
nb = max(math.ceil(len(tv) / args.villes_par_morceau), 1)
|
|
||||||
debuts = [round(k * len(tv) / nb) for k in range(1, nb)]
|
|
||||||
coupes = [(tv[i - 1] + tv[i]) / 2 for i in debuts]
|
|
||||||
return np.array([0.0, *coupes, duree_trace])
|
|
||||||
nb = 1
|
|
||||||
if args.morceaux:
|
|
||||||
nb = args.morceaux
|
|
||||||
elif args.duree_morceau:
|
|
||||||
nb = max(math.ceil(duree_trace / 60 / args.duree_morceau), 1)
|
|
||||||
return np.linspace(0, duree_trace, nb + 1)
|
|
||||||
|
|
||||||
|
|
||||||
def extraire_morceau(lat, lon, t, vitesse, t0, t1):
|
|
||||||
"""Portion de trace entre t0 et t1 (s), bornes interpolées, temps ramené à 0."""
|
|
||||||
dedans = (t > t0) & (t < t1)
|
|
||||||
tt = np.concatenate([[t0], t[dedans], [t1]])
|
|
||||||
return (np.interp(tt, t, lat), np.interp(tt, t, lon), tt - t0,
|
|
||||||
np.interp(tt, t, vitesse))
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
print(f"{len(lat)} points, durée {t[-1] / 60:.1f} min, vitesse max {vitesse.max():.0f} km/h")
|
print(f"{len(lat)} points, durée {t[-1] / 60:.1f} min, vitesse max {vitesse.max():.0f} km/h")
|
||||||
|
|
||||||
villes = [] if args.sans_villes else trouver_villes(args.gpx, lat, lon)
|
|
||||||
sortie = Path(args.sortie or Path(args.gpx).with_suffix(".mp4"))
|
|
||||||
|
|
||||||
if args.villes_par_morceau and not villes:
|
|
||||||
print("Attention : aucune ville (géocodage indisponible ou --sans-villes), "
|
|
||||||
"--villes-par-morceau ignoré : vidéo unique.")
|
|
||||||
args.villes_par_morceau = None
|
|
||||||
|
|
||||||
# Chaque ville va dans le morceau où le véhicule passe à son point d'étiquette.
|
|
||||||
t_villes = [t[int(np.argmin((lat - la) ** 2 + ((lon - lo) * math.cos(math.radians(la))) ** 2))]
|
|
||||||
for _, la, lo in villes]
|
|
||||||
bornes = bornes_morceaux(args, t[-1], t_villes)
|
|
||||||
nb = len(bornes) - 1
|
|
||||||
if nb == 1:
|
|
||||||
rendre_video(lat, lon, t, vitesse, villes, sortie, args)
|
|
||||||
return
|
|
||||||
|
|
||||||
chiffres = len(str(nb))
|
|
||||||
for k in range(nb):
|
|
||||||
t0, t1 = bornes[k], bornes[k + 1]
|
|
||||||
dernier = k == nb - 1
|
|
||||||
villes_k = [v for v, tv in zip(villes, t_villes) if t0 <= tv < t1 or (dernier and tv == t1)]
|
|
||||||
sortie_k = sortie.with_name(f"{sortie.stem}_{k + 1:0{chiffres}d}{sortie.suffix}")
|
|
||||||
print(f"Morceau {k + 1}/{nb} : {t0 / 60:.1f}-{t1 / 60:.1f} min, {len(villes_k)} villes")
|
|
||||||
rendre_video(*extraire_morceau(lat, lon, t, vitesse, t0, t1), villes_k, sortie_k, args,
|
|
||||||
rampe_debut=k == 0, rampe_fin=dernier)
|
|
||||||
|
|
||||||
|
|
||||||
def rendre_video(lat, lon, t, vitesse, villes, sortie, args, rampe_debut=True, rampe_fin=True):
|
|
||||||
"""Rend une vidéo complète (cadrage, fond, images) pour la trace donnée."""
|
|
||||||
proj = Projection(lat, lon)
|
proj = Projection(lat, lon)
|
||||||
px, py = proj(lat, lon)
|
px, py = proj(lat, lon)
|
||||||
centre = choisir_coin_compteur(px, py)
|
centre = choisir_coin_compteur(px, py)
|
||||||
|
|
||||||
|
villes = [] if args.sans_villes else trouver_villes(args.gpx, lat, lon)
|
||||||
villes_px = [(nom, *map(float, proj(la, lo))) for nom, la, lo in villes]
|
villes_px = [(nom, *map(float, proj(la, lo))) for nom, la, lo in villes]
|
||||||
|
|
||||||
fond_ss = dessiner_fond(px, py, villes_px, centre, args.vmax)
|
fond_ss = dessiner_fond(px, py, villes_px, centre, args.vmax)
|
||||||
@ -424,21 +320,11 @@ def rendre_video(lat, lon, t, vitesse, villes, sortie, args, rampe_debut=True, r
|
|||||||
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))
|
||||||
|
sortie = args.sortie or str(Path(args.gpx).with_suffix(".mp4"))
|
||||||
# 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)
|
|
||||||
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", "-",
|
||||||
"-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
|
"-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
|
||||||
"-movflags", "+faststart", str(sortie)]
|
"-movflags", "+faststart", sortie]
|
||||||
ffmpeg = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
ffmpeg = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||||
|
|
||||||
debut = time.time()
|
debut = time.time()
|
||||||
@ -447,11 +333,9 @@ def rendre_video(lat, lon, t, vitesse, villes, sortie, args, rampe_debut=True, r
|
|||||||
la, lo = np.interp(tt, t, lat), np.interp(tt, t, lon)
|
la, lo = np.interp(tt, t, lat), np.interp(tt, t, lon)
|
||||||
v = float(np.interp(tt, t, vitesse))
|
v = float(np.interp(tt, t, vitesse))
|
||||||
# Le GPS démarre/s'arrête souvent sans point à vitesse nulle :
|
# Le GPS démarre/s'arrête souvent sans point à vitesse nulle :
|
||||||
# l'aiguille part de 0 et y revient sur la première/dernière seconde
|
# l'aiguille part de 0 et y revient sur la première/dernière seconde.
|
||||||
# (seulement aux extrémités du trajet, pas aux coupures entre morceaux).
|
|
||||||
rampe = max(args.fps, 1)
|
rampe = max(args.fps, 1)
|
||||||
v *= min(1.0, n / rampe if rampe_debut else 1.0,
|
v *= min(1.0, n / rampe, (nb_images - 1 - n) / rampe)
|
||||||
(nb_images - 1 - n) / rampe if rampe_fin else 1.0)
|
|
||||||
x, y = (float(c) for c in proj(la, lo))
|
x, y = (float(c) for c in proj(la, lo))
|
||||||
|
|
||||||
image = fond.copy()
|
image = fond.copy()
|
||||||
@ -482,17 +366,6 @@ def rendre_video(lat, lon, t, vitesse, villes, sortie, args, rampe_debut=True, r
|
|||||||
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
|
||||||
@ -511,30 +384,11 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description="Génère une vidéo 1024x1024 (trace, véhicule, compteur, villes) depuis un GPX.")
|
parser = argparse.ArgumentParser(description="Génère une vidéo 1024x1024 (trace, véhicule, compteur, villes) depuis un GPX.")
|
||||||
parser.add_argument("gpx", help="fichier GPX d'entrée")
|
parser.add_argument("gpx", help="fichier GPX d'entrée")
|
||||||
parser.add_argument("-o", "--sortie", help="fichier MP4 de sortie (défaut : même nom que le GPX)")
|
parser.add_argument("-o", "--sortie", help="fichier MP4 de sortie (défaut : même nom que le GPX)")
|
||||||
parser.add_argument("--duree", type=float, default=60,
|
parser.add_argument("--duree", type=float, default=60, help="durée de la vidéo en secondes (défaut 60)")
|
||||||
help="durée de la vidéo (de chaque morceau si découpage) en secondes (défaut 60)")
|
|
||||||
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",
|
generer_video(parser.parse_args())
|
||||||
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)")
|
|
||||||
decoupage = parser.add_mutually_exclusive_group()
|
|
||||||
decoupage.add_argument("--morceaux", type=int,
|
|
||||||
help="découper le trajet en N vidéos de même durée de trajet (sortie_1.mp4, ...)")
|
|
||||||
decoupage.add_argument("--duree-morceau", type=float,
|
|
||||||
help="découper le trajet en morceaux égaux d'au plus MIN minutes de trajet chacun")
|
|
||||||
decoupage.add_argument("--villes-par-morceau", type=int, nargs="?", const=20, metavar="N",
|
|
||||||
help="découper le trajet en morceaux d'au plus N villes chacun (défaut 20)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.morceaux is not None and args.morceaux < 1:
|
|
||||||
parser.error("--morceaux doit être >= 1")
|
|
||||||
if args.duree_morceau is not None and args.duree_morceau <= 0:
|
|
||||||
parser.error("--duree-morceau doit être > 0")
|
|
||||||
if args.villes_par_morceau is not None and args.villes_par_morceau < 1:
|
|
||||||
parser.error("--villes-par-morceau doit être >= 1")
|
|
||||||
generer_video(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user