Files
RoadTripsGenerator/rtg/extraction.py
T
FabriceandClaude Opus 5.5 71779f6f53 Interface : corrections à la souris et accès protégé
Onglet « Carte et corrections » : glisser une photo ou une vidéo, placer
les médias sans position, masquer des zones de trace (pour un voyage, ou
zone privée pour tous). Corrections notées dans voyage.toml ([positions],
[corrections]) ou site.toml ([confidentialite]), appliquées par les pages ;
les originaux ne changent pas. L'extraction garde les médias datés sans
position pour qu'on puisse les placer.

Accès : clé d'accès aléatoire à chaque lancement, session par cookie
HttpOnly/SameSite=Strict, mot de passe facultatif (rtg mot-de-passe,
empreinte scrypt hors du site, blocage après 5 échecs), accès réseau
seulement avec mot de passe (--distant), HTTPS possible (--certificat,
--cle), Content-Security-Policy et interdiction des cadres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 14:35:43 +00:00

206 lines
8.6 KiB
Python

"""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 import corrections
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
# Photos datées restées sans position : lignes sans coordonnées, ignorées
# par les pages mais listées par l'interface pour être placées à la main.
vus = {l.split(",")[0] for l in lignes}
sans_position = [l.split(",", 1) for l in _exiftool(commun + ["-p", FMT_PHOTOS_DATE, dossier])
if l.split(",")[0] not in vus]
lignes += [f"{nom},{date},,," for nom, date in sans_position]
with open(csv, "w", encoding="utf-8") as f:
f.write("".join(l + "\n" for l in lignes))
localisees = len(lignes) - len(sans_position)
msg = f"📸 Photos : {localisees} localisée(s)"
if localisees > len(p1) + deduites:
msg += f" (dont {localisees - len(p1) - deduites} sans altitude)"
if deduites:
msg += f", {deduites} position(s) déduite(s) de la trace"
if sans_position:
placees = corrections.positions(cfg)
a_la_main = sum(1 for nom, _ in sans_position if nom.strip('"') in placees)
reste = len(sans_position) - a_la_main
if a_la_main:
msg += f", {a_la_main} placée(s) à la main"
if reste:
msg += f", {reste} sans position (à placer dans l'interface)"
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]
if date:
return date, None, None # datée mais sans GPS : à placer à la main
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 and r[1] is not None:
lignes.append(f'"{nom}",{r[0]},{r[1]},{r[2]},,')
etat = "✓"
else:
sans += 1
etat = "— pas de GPS"
if r:
lignes.append(f'"{nom}",{r[0]},,,,')
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))
sans_pos = sum(1 for l in lignes if l.split(",")[2:4] == ["", ""])
dire(f"🎬 Vidéos : {len(lignes) - sans_pos} rush(s) localisé(s)"
+ (f", {sans_pos} sans GPS (à placer dans l'interface)" if sans_pos else ""))