diff --git a/README.md b/README.md index 3a0f528..d99c046 100644 --- a/README.md +++ b/README.md @@ -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 `.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. diff --git a/gpx_video.py b/gpx_video.py index 1d4147b..f9fdd31 100644 --- a/gpx_video.py +++ b/gpx_video.py @@ -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__":