Phase 1 : moteur Python et commande rtg

- Paquet roadtripsgenerator (pyproject.toml), commande rtg :
  generer, etat, analyser, compresser, accueil, migrer
- Réglages site.toml / voyage.toml (anciens mymaps.txt et moto.txt
  encore lus) ; qualité vidéo en trois crans
- Sources laissées en place : traces découpées par journée d'après
  leurs horodatages (coupure à 4 h) ou la date du nom de fichier,
  photos copiées, HEIC converties en JPEG, position déduite de la
  trace pour les photos sans GPS
- extraction_infos.sh et compress.sh portés en Python (plus de bash) ;
  sorties vidéo écrites sous un nom temporaire ; positions des rushs
  inchangés reprises du dernier export ; export vidéo conservé quand
  le disque des rushs est débranché
- Pages, accueil et carte globale repris dans le paquet : sortie
  identique aux scripts (123 fichiers comparés sur 7 voyages)
- Tests pytest sur un voyage d'exemple fabriqué à la volée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-09 11:31:31 +00:00
1 parent 97b936fd3e
commit f0e1c2dfce
23 files changed
+3083 -1

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
"""Positions GPS des photos (EXIF) et des rushs GoPro (GPS embarqué).
Produit, comme l'ancien extraction_infos.sh, deux CSV sans en-tête :
export_photos.csv : fichier, date, lat, lon, alt
export_videos.csv : fichier, date GPS, lat, lon, alt, vitesse
Une photo sans GPS mais datée peut recevoir la position de la trace à son
heure (réglage photos.positions_deduites), si la trace est horodatée.
"""
import bisect
import os
import subprocess
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from rtg.outils import dire, exiger
FMT_PHOTOS = '"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude'
FMT_PHOTOS_MIN = '"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude'
FMT_PHOTOS_DATE = '"$FileName",$DateTimeOriginal'
TOLERANCE_POSITION = timedelta(minutes=5)
def _exiftool(args):
r = subprocess.run(["exiftool"] + args, capture_output=True, text=True, errors="replace")
return [l for l in r.stdout.splitlines() if l.strip()]
# --- Photos ------------------------------------------------------------------
def extraire_photos(voyage, cfg, analyse):
dossier = os.path.join(voyage, "photos")
csv = os.path.join(voyage, "export_photos.csv")
if not os.path.isdir(dossier):
open(csv, "w").close()
dire("⚠️ Dossier photos non trouvé, étape ignorée.")
return
exiger("exiftool", "Installe-le : https://exiftool.org (brew install exiftool sous macOS).")
# Un seul appel par passe pour tout le dossier : chaque lancement d'exiftool
# coûte ~200 ms. Seconde passe au format réduit pour les photos sans altitude.
commun = ["-n", "-q", "-ext", "jpg", "-ext", "jpeg"]
p1 = _exiftool(commun + ["-p", FMT_PHOTOS, dossier])
p2 = [l + "," for l in _exiftool(commun + ["-p", FMT_PHOTOS_MIN, dossier])]
vus = {l.split(",")[0] for l in p1}
lignes = p1 + [l for l in p2 if l.split(",")[0] not in vus]
deduites = 0
if cfg["photos"].get("positions_deduites", True):
vus = {l.split(",")[0] for l in lignes}
sans_gps = [l for l in _exiftool(commun + ["-p", FMT_PHOTOS_DATE, dossier])
if l.split(",")[0] not in vus]
if sans_gps:
index = IndexTrace(analyse, cfg)
for l in sans_gps:
nom, date = l.split(",", 1)
pos = index.position(date)
if pos:
lignes.append(f"{nom},{date},{pos[0]},{pos[1]},")
deduites += 1
with open(csv, "w", encoding="utf-8") as f:
f.write("".join(l + "\n" for l in lignes))
msg = f"📸 Photos : {len(lignes)} ligne(s)"
if len(lignes) > len(p1) + deduites:
msg += f" (dont {len(lignes) - len(p1) - deduites} sans altitude)"
if deduites:
msg += f", {deduites} position(s) déduite(s) de la trace"
dire(msg)
class IndexTrace:
"""Points horodatés des traces du voyage, pour retrouver une position à
une heure donnée."""
def __init__(self, analyse, cfg):
self.tz = ZoneInfo(cfg["journees"]["fuseau"])
pts = []
for pistes in analyse.journees.values():
for t in pistes:
for s in t.segments:
for p in s.points:
if p.time:
h = p.time if p.time.tzinfo else p.time.replace(tzinfo=timezone.utc)
pts.append((h.timestamp(), p.latitude, p.longitude))
pts.sort()
self.temps = [p[0] for p in pts]
self.pts = pts
def position(self, date_exif):
"""Date EXIF « AAAA:MM:JJ HH:MM:SS » (heure locale du voyage) → (lat, lon)."""
if not self.pts:
return None
try:
locale = datetime.strptime(date_exif.split(".")[0][:19], "%Y:%m:%d %H:%M:%S")
except ValueError:
return None
t = locale.replace(tzinfo=self.tz).timestamp()
i = bisect.bisect_left(self.temps, t)
voisins = [j for j in (i - 1, i) if 0 <= j < len(self.pts)]
j = min(voisins, key=lambda k: abs(self.temps[k] - t))
if abs(self.temps[j] - t) > TOLERANCE_POSITION.total_seconds():
return None
return self.pts[j][1], self.pts[j][2]
# --- Vidéos ------------------------------------------------------------------
def _premier_point(video):
"""(date, lat, lon) du premier point GPS d'un rush GoPro, ou None.
Lit les tags un par un : `exiftool -p` ne voit que le document principal,
qui porte la position mais pas GPSDateTime (rangé dans [Doc1]). On arrête
exiftool dès qu'on a une date et une position, sans lire tout le fichier.
"""
cmd = ["exiftool", "-ee", "-n", "-q", "-s", "-api", "largefilesupport=1",
"-GPSDateTime", "-GPSLatitude", "-GPSLongitude", "-CreateDate", video]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, errors="replace")
dt = cd = lat = None
premier = None
try:
for ligne in proc.stdout:
if ":" not in ligne:
continue
ligne = ligne.split("]", 1)[-1] if ligne.startswith("[") else ligne
tag, _, val = ligne.partition(":")
tag, val = tag.strip().replace(" ", ""), val.strip()
if tag == "GPSDateTime" and dt is None:
dt = val
elif tag == "CreateDate" and cd is None:
cd = val
elif tag == "GPSLatitude":
lat = val
elif tag == "GPSLongitude" and lat is not None:
if premier is None:
premier = (lat, val)
lat = None
if premier and dt:
break
finally:
proc.kill()
proc.wait()
date = dt or cd
if premier and date:
return date, premier[0], premier[1]
return None
def extraire_videos(voyage, analyse):
csv = os.path.join(voyage, "export_videos.csv")
if not analyse.videos:
open(csv, "w").close()
dire("⚠️ Aucune vidéo source trouvée.")
return
exiger("exiftool", "Installe-le : https://exiftool.org (brew install exiftool sous macOS).")
# Lignes déjà connues : un rush non modifié depuis le dernier export n'est
# pas relu (gain important sur un disque externe).
connues, date_csv = {}, 0
if os.path.exists(csv) and os.path.getsize(csv) > 0:
date_csv = os.path.getmtime(csv)
with open(csv, encoding="utf-8") as f:
for l in f:
if l.strip():
connues[l.split(",")[0].strip('"')] = l.rstrip("\n")
dire(f"🎬 Vidéos : {len(analyse.videos)} rush(s), premier point GPS")
lignes, sans, reprises = [], 0, 0
for i, v in enumerate(analyse.videos, 1):
nom = os.path.basename(v)
if nom in connues and os.path.getmtime(v) <= date_csv:
lignes.append(connues[nom])
reprises += 1
continue
r = _premier_point(v)
if r:
lignes.append(f'"{nom}",{r[0]},{r[1]},{r[2]},,')
etat = "✓"
else:
sans += 1
etat = "— pas de GPS"
dire(f" [{i:3d}/{len(analyse.videos):3d}] {nom:<28} {etat}")
if reprises:
dire(f" {reprises} rush(s) inchangé(s), positions reprises du dernier export")
with open(csv, "w", encoding="utf-8") as f:
f.write("".join(l + "\n" for l in lignes))
dire(f"🎬 Vidéos : {len(lignes)} rush(s) localisé(s)" + (f", {sans} sans GPS" if sans else ""))