Découpage des longs trajets en plusieurs vidéos (#1)

Nouvelles options exclusives --morceaux N et --duree-morceau MIN : le
trajet est découpé en morceaux de même durée de trajet, rendus chacun
dans sa propre vidéo (sortie_1.mp4, sortie_2.mp4, ...) avec un cadrage
sur la portion et uniquement les villes qui s'y trouvent. L'aiguille ne
retombe à 0 qu'aux extrémités réelles du trajet.

Closes #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alex 2026-09-23 18:14:59 +02:00
parent 4dad048c20
commit c81724a7ed
2 changed files with 71 additions and 8 deletions

View File

@ -27,6 +27,7 @@
- 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.
- Découpage des longs trajets en plusieurs vidéos de même durée de trajet, 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.
- Rendu anticrénelé (suréchantillonnage ×2), encodage H.264 `yuv420p` via ffmpeg, sans fichiers intermédiaires.
@ -51,15 +52,19 @@ 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) |
| `--duree` | `60` | durée de la vidéo, de chaque morceau si découpage (s) |
| `--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) |
| `--morceaux` | — | découpe le trajet en N vidéos de même durée de trajet |
| `--duree-morceau`| — | découpe en morceaux égaux d'au plus N minutes de trajet |
Exemple : `python gpx_video.py trace.gpx -o balade.mp4 --duree 90 --fps 25 --zoom-villes`
Découpage : `python gpx_video.py trace.gpx --morceaux 3` produit `trace_1.mp4`, `trace_2.mp4`, `trace_3.mp4` (avec `-o balade.mp4` : `balade_1.mp4`, ...). `--duree-morceau 120` sur un trajet de 10 h 15 donne 6 morceaux d'environ 1 h 42. Les deux options sont exclusives. Chaque ville est affectée au morceau où le véhicule passe à son étiquette.
Notes :
- Le premier lancement interroge Nominatim (1 requête/s, un point par km de trajet) : compter environ 1 s par km.

View File

@ -4,8 +4,12 @@ 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
traversées (géocodage inverse Nominatim / OpenStreetMap, mis en cache).
Une trace longue peut être découpée en plusieurs vidéos de durée de trajet
identique (--morceaux, --duree-morceau), chacune cadrée sur sa portion.
Usage :
python gpx_video.py trace.gpx [-o sortie.mp4] [--duree 60] [--fps 30]
[--morceaux N | --duree-morceau MIN]
"""
import argparse
@ -338,16 +342,58 @@ def rendu_zoom(nom):
return masques[0], masques[1], (TAILLE // 2 - l1 // 2, TAILLE // 2 - h1 // 2)
def nombre_morceaux(args, duree_trace):
"""Nombre de vidéos à produire selon --morceaux ou --duree-morceau."""
if args.morceaux:
return args.morceaux
if args.duree_morceau:
return max(math.ceil(duree_trace / 60 / args.duree_morceau), 1)
return 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):
lat, lon, t, v_gpx = charger_gpx(args.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")
villes = [] if args.sans_villes else trouver_villes(args.gpx, lat, lon)
sortie = Path(args.sortie or Path(args.gpx).with_suffix(".mp4"))
nb = nombre_morceaux(args, t[-1])
if nb == 1:
rendre_video(lat, lon, t, vitesse, villes, sortie, args)
return
# Découpage en morceaux de même durée de trajet ; 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 = np.linspace(0, t[-1], nb + 1)
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)
px, py = proj(lat, lon)
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]
fond_ss = dessiner_fond(px, py, villes_px, centre, args.vmax)
@ -374,11 +420,10 @@ def generer_video(args):
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", "-",
"-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", sortie]
"-movflags", "+faststart", str(sortie)]
ffmpeg = subprocess.Popen(cmd, stdin=subprocess.PIPE)
debut = time.time()
@ -387,9 +432,11 @@ def generer_video(args):
la, lo = np.interp(tt, t, lat), np.interp(tt, t, lon)
v = float(np.interp(tt, t, vitesse))
# 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)
v *= min(1.0, n / rampe, (nb_images - 1 - n) / rampe)
v *= min(1.0, n / rampe if rampe_debut else 1.0,
(nb_images - 1 - n) / rampe if rampe_fin else 1.0)
x, y = (float(c) for c in proj(la, lo))
image = fond.copy()
@ -449,7 +496,8 @@ def main():
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("-o", "--sortie", help="fichier MP4 de sortie (défaut : même nom que le GPX)")
parser.add_argument("--duree", type=float, default=60, help="durée de la vidéo en secondes (défaut 60)")
parser.add_argument("--duree", type=float, default=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("--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)")
@ -457,7 +505,17 @@ def main():
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())
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")
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")
generer_video(args)
if __name__ == "__main__":