Interface locale : rtg interface (voyages, sources, journées, récit, génération)
Serveur web local sans dépendance nouvelle (http.server, Jinja2, htmx et Leaflet fournis). Écrans : Mes voyages, nouveau voyage, un voyage (journées avec carte, rattachement des traces sans heures à un jour avec suggestion d'après les photos, sources avec choix de dossier, infos et récit, génération avec avancement et arrêt), réglages du site et vérification des outils. Chaîne : jours imposés ([dates_traces] dans voyage.toml), arrêt propre de la compression, suivi des étapes, état structuré (site.tableau_etat), titre du voyage repris de voyage.toml sur l'accueil. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
1 parent
608334b9d8
commit
06153a227e
37 files changed
+2618
-57
No files matched your search
+120
-16
@@ -11,10 +11,14 @@ Les vidéos ne sont pas copiées : la compression lit directement les sources.
|
||||
Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/
|
||||
(organisation historique) n'est pas touché.
|
||||
"""
|
||||
import csv
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import gpxpy
|
||||
@@ -33,6 +37,8 @@ class Analyse:
|
||||
self.photos = [] # chemins des photos sources
|
||||
self.videos = [] # chemins des vidéos sources
|
||||
self.a_verifier = [] # messages pour l'utilisateur
|
||||
self.details_traces = [] # un dict par fichier GPX (pour l'interface)
|
||||
self.non_datees = {} # fichier → [GPXTrack] des traces sans jour
|
||||
|
||||
def km(self, jour):
|
||||
total = 0.0
|
||||
@@ -65,21 +71,36 @@ def date_du_nom(nom):
|
||||
return None
|
||||
|
||||
|
||||
def decouper_par_journee(fichier, tz, coupure_h):
|
||||
def jour_texte(valeur):
|
||||
"""« 2025-08-04 » (ou date TOML) → date, sinon None."""
|
||||
try:
|
||||
return valeur if isinstance(valeur, date) else date.fromisoformat(str(valeur).strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def decouper_par_journee(fichier, tz, coupure_h, jour_impose=None, pistes_sans_jour=None, infos=None):
|
||||
"""Pistes d'un GPX réparties par journée locale.
|
||||
|
||||
Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est
|
||||
trouvée. Avec horodatages : date locale dans le fuseau du voyage, avec une
|
||||
coupure à `coupure_h` heures (une étape finie à 1 h reste sur la veille).
|
||||
Sans horodatage : la date du nom de fichier vaut pour tout le fichier.
|
||||
trouvée. `jour_impose` (choisi dans l'interface) place tout le fichier sur
|
||||
ce jour. Sinon, avec horodatages : date locale dans le fuseau du voyage,
|
||||
avec une coupure à `coupure_h` heures (une étape finie à 1 h reste sur la
|
||||
veille) ; sans horodatage : la date du nom de fichier. Les pistes d'un
|
||||
fichier qu'aucune date ne place sont ajoutées à `pistes_sans_jour`. `infos`
|
||||
(dictionnaire) reçoit "datable" : le fichier se date-t-il sans aide ?
|
||||
"""
|
||||
with open(fichier, encoding="utf-8") as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
a_des_heures = any(p.time for t in gpx.tracks for s in t.segments for p in s.points)
|
||||
if infos is not None:
|
||||
infos["datable"] = a_des_heures or date_du_nom(os.path.basename(fichier)) is not None
|
||||
res = {}
|
||||
if not a_des_heures:
|
||||
jour = date_du_nom(os.path.basename(fichier))
|
||||
if jour_impose or not a_des_heures:
|
||||
jour = jour_impose or date_du_nom(os.path.basename(fichier))
|
||||
if jour is None:
|
||||
if pistes_sans_jour is not None:
|
||||
pistes_sans_jour.extend(gpx.tracks)
|
||||
return {}, "trace sans heures et sans date dans son nom"
|
||||
for t in gpx.tracks:
|
||||
res.setdefault(jour, []).append(t)
|
||||
@@ -120,21 +141,41 @@ def analyser(voyage, cfg, leger=False):
|
||||
tz = ZoneInfo(cfg["journees"]["fuseau"])
|
||||
coupure = int(cfg["journees"]["coupure_heure"])
|
||||
|
||||
a.traces = [f for f in lister_fichiers(_sources(voyage, "traces", cfg), (".gpx",), voyage)
|
||||
imposes = {k: jour_texte(v) for k, v in cfg.get("dates_traces", {}).items()}
|
||||
sources_traces = _sources(voyage, "traces", cfg)
|
||||
a.traces = [f for f in lister_fichiers(sources_traces, (".gpx",), voyage)
|
||||
if os.path.basename(f) not in exclus]
|
||||
if not _est_dossier_du_voyage(voyage, sources_traces, "routes"):
|
||||
# Les journées que RTG a lui-même écrites dans routes/ ne sont pas des
|
||||
# sources : les relire les compterait deux fois.
|
||||
routes = os.path.realpath(os.path.join(voyage, "routes"))
|
||||
a.traces = [f for f in a.traces
|
||||
if os.path.realpath(os.path.dirname(f)) != routes or not ecrite_par_rtg(f)]
|
||||
for f in a.traces:
|
||||
if leger and date_du_nom(os.path.basename(f)):
|
||||
a.journees.setdefault(date_du_nom(os.path.basename(f)), [])
|
||||
nom = os.path.basename(f)
|
||||
impose = imposes.get(nom)
|
||||
if leger and (impose or date_du_nom(nom)):
|
||||
a.journees.setdefault(impose or date_du_nom(nom), [])
|
||||
continue
|
||||
sans_jour, infos = [], {}
|
||||
try:
|
||||
parts, raison = decouper_par_journee(f, tz, coupure)
|
||||
parts, raison = decouper_par_journee(f, tz, coupure, impose, sans_jour, infos)
|
||||
except Exception as e:
|
||||
a.a_verifier.append(f"Trace illisible : {os.path.basename(f)} ({e})")
|
||||
a.a_verifier.append(f"Trace illisible : {nom} ({e})")
|
||||
a.details_traces.append({"fichier": f, "nom": nom, "jours": [], "km": 0,
|
||||
"impose": impose, "erreur": str(e)})
|
||||
continue
|
||||
if raison:
|
||||
a.a_verifier.append(f"Trace à rattacher à un jour : {os.path.basename(f)} ({raison})")
|
||||
a.a_verifier.append(f"Trace à rattacher à un jour : {nom} ({raison})")
|
||||
a.non_datees[f] = sans_jour
|
||||
km = 0.0
|
||||
for jour, pistes in parts.items():
|
||||
a.journees.setdefault(jour, []).extend(pistes)
|
||||
km += sum((p.length_2d() or 0) for p in pistes) / 1000
|
||||
if sans_jour:
|
||||
km = sum((p.length_2d() or 0) for p in sans_jour) / 1000
|
||||
a.details_traces.append({"fichier": f, "nom": nom, "jours": sorted(parts), "km": km,
|
||||
"impose": impose, "erreur": None, "datable": infos["datable"]})
|
||||
|
||||
a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage)
|
||||
if os.path.basename(f) not in exclus]
|
||||
@@ -155,6 +196,11 @@ def analyser(voyage, cfg, leger=False):
|
||||
return a
|
||||
|
||||
|
||||
def ecrite_par_rtg(fichier):
|
||||
with open(fichier, encoding="utf-8", errors="replace") as f:
|
||||
return f'creator="{CREATEUR}"' in f.read(600)
|
||||
|
||||
|
||||
def ranger(voyage, cfg, a):
|
||||
"""Range les sources dans le dossier du voyage (routes/, photos/).
|
||||
Renvoie un résumé des écritures."""
|
||||
@@ -187,9 +233,7 @@ def _ecrire_routes(voyage, a):
|
||||
chemin = os.path.join(dossier, nom)
|
||||
if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"):
|
||||
continue
|
||||
with open(chemin, encoding="utf-8", errors="replace") as f:
|
||||
debut = f.read(600)
|
||||
if f'creator="{CREATEUR}"' in debut:
|
||||
if ecrite_par_rtg(chemin):
|
||||
os.remove(chemin)
|
||||
retires += 1
|
||||
return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "")
|
||||
@@ -200,6 +244,8 @@ def _copier_photos(voyage, a):
|
||||
os.makedirs(dossier, exist_ok=True)
|
||||
copiees = converties = 0
|
||||
for src in a.photos:
|
||||
if os.path.realpath(os.path.dirname(src)) == os.path.realpath(dossier):
|
||||
continue # déjà dans photos/
|
||||
base, ext = os.path.splitext(os.path.basename(src))
|
||||
heic = ext.lower() in (".heic", ".heif")
|
||||
dest = os.path.join(dossier, base + (".jpeg" if heic else ext))
|
||||
@@ -225,3 +271,61 @@ def convertir_heic(src, dest):
|
||||
exif = im.info.get("exif")
|
||||
im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"")
|
||||
os.replace(dest + ".tmp", dest)
|
||||
|
||||
|
||||
# --- Suggestion d'un jour pour une trace sans heures ---------------------------
|
||||
def positions_medias(voyage, a):
|
||||
"""[(date, lat, lon)] des photos et vidéos du voyage : exports CSV s'ils
|
||||
existent, sinon lecture rapide des photos sources par exiftool."""
|
||||
res = []
|
||||
for nom in ("export_photos.csv", "export_videos.csv"):
|
||||
chemin = os.path.join(voyage, nom)
|
||||
if not os.path.exists(chemin):
|
||||
continue
|
||||
with open(chemin, encoding="utf-8", errors="replace") as f:
|
||||
for ligne in csv.reader(f):
|
||||
try:
|
||||
res.append((date(*map(int, ligne[1][:10].replace("-", ":").split(":"))),
|
||||
float(ligne[2]), float(ligne[3])))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if res or not a.photos or not shutil.which("exiftool"):
|
||||
return res
|
||||
r = subprocess.run(["exiftool", "-n", "-q", "-p", "$DateTimeOriginal,$GPSLatitude,$GPSLongitude"]
|
||||
+ a.photos[:2000], capture_output=True, text=True, errors="replace")
|
||||
for ligne in r.stdout.splitlines():
|
||||
try:
|
||||
d, lat, lon = ligne.split(",")
|
||||
res.append((date(*map(int, d[:10].split(":"))), float(lat), float(lon)))
|
||||
except ValueError:
|
||||
pass
|
||||
return res
|
||||
|
||||
|
||||
def suggerer_jour(pistes, medias, rayon_km=1.5):
|
||||
"""Jour le plus probable pour une trace sans heures : celui dont le plus de
|
||||
photos et vidéos ont été prises à moins de `rayon_km` de la trace.
|
||||
Renvoie (date, nombre de médias) ou (None, 0)."""
|
||||
pts = [(p.latitude, p.longitude) for t in pistes for s in t.segments for p in s.points]
|
||||
if not pts or not medias:
|
||||
return None, 0
|
||||
pas = max(1, len(pts) // 2000)
|
||||
pts = pts[::pas]
|
||||
# Grille d'environ 1 km : on ne compare un média qu'aux points voisins.
|
||||
cellule = 0.01
|
||||
grille = {}
|
||||
for lat, lon in pts:
|
||||
grille.setdefault((math.floor(lat / cellule), math.floor(lon / cellule)), []).append((lat, lon))
|
||||
portee = int(rayon_km / 1.1 / (cellule * 100)) + 1
|
||||
compte = Counter()
|
||||
for jour, lat, lon in medias:
|
||||
i, j = math.floor(lat / cellule), math.floor(lon / cellule)
|
||||
cos = math.cos(math.radians(lat))
|
||||
proche = any(math.hypot((la - lat) * 111.2, (lo - lon) * 111.2 * cos) <= rayon_km
|
||||
for di in range(-portee, portee + 1) for dj in range(-portee, portee + 1)
|
||||
for la, lo in grille.get((i + di, j + dj), ()))
|
||||
if proche:
|
||||
compte[jour] += 1
|
||||
if not compte:
|
||||
return None, 0
|
||||
return compte.most_common(1)[0]
|
||||
Reference in new issue
Block a user