Files
FabriceandClaude Opus 5.5 f0e1c2dfce 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
2026-10-09 11:31:31 +00:00

380 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Carte globale du site (carte_globale.html), affichée en grand sur l'accueil.
Fond OpenStreetMap (ou satellite Esri), sans Google. Elle réunit, en calques
que l'on peut afficher ou masquer :
- le contenu récupéré de cartes Google My Maps (anciennes balades et
voyages), avec leurs couleurs d'origine, un calque par calque My Maps ;
- les voyages du site (rubrique Voyages, pas les balades du site), chaque
trace menant à la page du voyage.
Récupération des cartes My Maps :
- réglage carte_globale.mymaps de site.toml (ou ancien mymaps.txt) : liens de
cartes My Maps partagées « Tous les utilisateurs disposant du lien ». Le KML est téléchargé UNE fois dans _mymaps/<id>.kml ; ensuite
la carte ne dépend plus de Google. MYMAPS_MAJ=1 pour le récupérer à nouveau
après avoir modifié la carte Google.
- ou tout export .kml / .kmz déposé à la main dans _mymaps/.
Appelé par rtg.accueil.
"""
import json
import math
import os
import re
import shutil
import subprocess
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from urllib.parse import parse_qs, urlparse
import folium
import gpxpy
from folium import plugins
from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup, escape
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
DOSSIER_MYMAPS = "_mymaps"
SORTIE = "carte_globale.html"
# Une couleur par voyage, du plus récent au plus ancien.
COULEURS_VOYAGES = ['#E74C3C', '#2980B9', '#27AE60', '#8E44AD', '#F39C12',
'#16A085', '#D35400', '#2C3E50', '#C0392B', '#7F8C8D']
COULEUR_MYMAPS = '#8E44AD' # si le KML ne précise pas de couleur
# Écart toléré (m) en simplifiant les traces : à l'échelle de la France,
# 30 m sont invisibles et allègent beaucoup la page.
TOLERANCE_M = 30
env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True,
trim_blocks=True, lstrip_blocks=True)
def liens_et_lignes(texte):
"""Texte brut → HTML sûr : échappé, adresses http(s) cliquables, retours à la ligne."""
h = str(escape(texte))
h = re.sub(r'(https?://[^\s<]+)', r'<a href="\1" target="_blank" rel="noopener">\1</a>', h)
return Markup(h.replace('\n', '<br>'))
env.filters['liens_et_lignes'] = liens_et_lignes
F = env.get_template("fragments_globale.html").module
# --- Traces des voyages du site ---------------------------------------------
def eclaircir(points, ecart_m):
"""Ne garde que les points distants d'au moins ecart_m du précédent gardé
(calcul approché, rapide). Divise d'autant le travail de simplify(), lent
en Python sur les 100 000 points d'un grand voyage."""
if len(points) < 3:
return points
garde = [points[0]]
seuil = (ecart_m / 111_320.0) ** 2 # degrés², à peu près
for p in points[1:-1]:
q = garde[-1]
dlat = p.latitude - q.latitude
dlon = (p.longitude - q.longitude) * math.cos(math.radians(p.latitude))
if dlat * dlat + dlon * dlon >= seuil:
garde.append(p)
garde.append(points[-1])
return garde
def longueur_km(lignes):
total = 0.0
for l in lignes:
for (la1, lo1), (la2, lo2) in zip(l, l[1:]):
dlat = math.radians(la2 - la1)
dlon = math.radians(lo2 - lo1) * math.cos(math.radians((la1 + la2) / 2))
total += 6371.0 * math.hypot(dlat, dlon)
return total
def trace_voyage(dossier):
"""Lignes simplifiées [[lat, lon], …] d'un voyage et sa longueur en km.
Source : assets/trace.json (journées séparées, déjà simplifiées par
genere_carte.py) ; à défaut <voyage>.kml (une seule ligne : les journées
y sont reliées en ligne droite) ou <voyage>_complet.gpx (lent).
"""
nom = os.path.basename(dossier)
json_trace = os.path.join(dossier, 'assets', 'trace.json')
if os.path.exists(json_trace):
with open(json_trace, encoding='utf-8') as f:
lignes = json.load(f)
km = None
kml = os.path.join(dossier, f"{nom}.kml")
if os.path.exists(kml):
with open(kml, encoding='utf-8') as f:
m = re.search(r'(\d+) km<br>', f.read())
km = float(m.group(1)) if m else None
return lignes, km if km is not None else longueur_km(lignes)
kml = os.path.join(dossier, f"{nom}.kml")
gpx_complet = os.path.join(dossier, f"{nom}_complet.gpx")
brutes, km = [], None
if os.path.exists(kml):
with open(kml, encoding='utf-8') as f:
texte = f.read()
# Distance exacte calculée par genere_carte.py sur la trace complète
# (recalculée ici, elle compterait les sauts entre deux journées).
m = re.search(r'(\d+) km<br>', texte)
km = float(m.group(1)) if m else None
for bloc in re.findall(r'<coordinates>(.*?)</coordinates>', texte, flags=re.S):
pts = []
for c in bloc.split():
v = c.split(',')
pts.append(gpxpy.gpx.GPXTrackPoint(float(v[1]), float(v[0])))
brutes.append(pts)
elif os.path.exists(gpx_complet):
with open(gpx_complet, encoding='utf-8') as f:
gpx = gpxpy.parse(f)
brutes = [seg.points for t in gpx.tracks for seg in t.segments]
km = gpx.length_2d() / 1000.0
lignes = []
for pts in brutes:
s = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2))
s.simplify(max_distance=TOLERANCE_M)
if len(s.points) > 1:
lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in s.points])
return lignes, km if km is not None else longueur_km(lignes)
# --- Cartes My Maps ----------------------------------------------------------
def mid_de(lien):
mid = parse_qs(urlparse(lien).query).get('mid')
return mid[0] if mid else None
def telecharger_mymaps(racine, liens, mettre_a_jour=False):
"""Récupère le KML des cartes My Maps vers _mymaps/<mid>.kml (une fois ;
mettre_a_jour=True ou MYMAPS_MAJ=1 pour forcer)."""
if not liens:
return
dossier = os.path.join(racine, DOSSIER_MYMAPS)
os.makedirs(dossier, exist_ok=True)
for lien in liens:
mid = mid_de(lien)
if not mid:
print(f" ⚠️ Pas un lien My Maps (mid=… manquant) : {lien}")
continue
dest = os.path.join(dossier, f"{mid}.kml")
if os.path.exists(dest) and not mettre_a_jour and os.environ.get("MYMAPS_MAJ") != "1":
continue
url = f"https://www.google.com/maps/d/kml?forcekml=1&mid={mid}"
contenu, erreur = telecharger(url)
if contenu is not None and b"<kml" not in contenu[:3000]:
contenu, erreur = None, ("Google a renvoyé une page au lieu de la carte : est-elle partagée "
"« Tous les utilisateurs disposant du lien » ?")
if contenu is None:
etat = "copie précédente utilisée" if os.path.exists(dest) else "ignorée"
print(f" ⚠️ My Maps {mid[:10]}… non récupérée — {erreur} ({etat})")
continue
with open(dest, 'wb') as f:
f.write(contenu)
print(f" 🌐 My Maps {mid[:10]}… récupérée dans {DOSSIER_MYMAPS}/ ({len(contenu) // 1024} Ko)")
def telecharger(url):
"""Renvoie (contenu, None) ou (None, message d'erreur).
Python d'abord, puis curl : le Python de python.org sous macOS n'a souvent
pas les certificats racine (CERTIFICATE_VERIFY_FAILED), curl utilise ceux
du système.
"""
try:
with urllib.request.urlopen(url, timeout=30) as r:
return r.read(), None
except Exception as e:
erreur = str(e)
if shutil.which("curl"):
r = subprocess.run(["curl", "-sSfL", "--max-time", "60", url], capture_output=True)
if r.returncode == 0 and r.stdout:
return r.stdout, None
erreur += f" ; curl : {r.stderr.decode(errors='replace').strip() or 'code ' + str(r.returncode)}"
return None, erreur
def lire_kml(chemin):
if chemin.lower().endswith('.kmz'):
with zipfile.ZipFile(chemin) as z:
nom = next((n for n in z.namelist() if n.lower().endswith('.kml')), None)
return z.read(nom) if nom else None
with open(chemin, 'rb') as f:
return f.read()
def couleur_kml(aabbggrr):
"""aabbggrr (KML) → #RRGGBB."""
c = aabbggrr.strip().lstrip('#')
return f"#{c[6:8]}{c[4:6]}{c[2:4]}".upper() if len(c) == 8 else None
def texte_simple(description):
"""Description My Maps → texte brut (balises retirées) ; le gabarit
l'échappe et rend les liens cliquables."""
if not description:
return ""
t = re.sub(r'<(script|style)\b.*?</\1>', '', description, flags=re.I | re.S)
t = re.sub(r'<br\s*/?>', '\n', t, flags=re.I)
t = re.sub(r'<[^>]+>', '', t)
return t.strip()
def nettoyer_nom(nom):
"""« Track-Pyrenees 2014 _ aller.kmz » → « Pyrenees 2014 – aller »."""
n = re.sub(r'\.km[lz]$', '', nom.strip(), flags=re.I)
n = re.sub(r'^(Track|T)\s*-\s*', '', n, flags=re.I)
n = re.sub(r'\s+_\s+|\s*:\s+', ' – ', n)
return re.sub(r'\s{2,}', ' ', n).strip()
def resumer_description(texte):
"""Les statistiques automatiques de My Maps (« Statistiques calculées… »,
altitudes à 0) sont réduites à la distance ; une vraie description est gardée."""
if texte.startswith("Statistiques calculées"):
m = re.search(r'Distance\s*:\s*([\d\s,.]+)\s*km', texte.replace('\xa0', ' '))
return f"{m.group(1).strip()} km" if m else ""
return texte
def elements_mymaps(contenu):
"""KML → (titre du document, {nom de calque: [éléments]}).
Élément : {'type': 'ligne'|'point', 'nom', 'description', 'couleur', 'coords'}.
"""
ns = {'k': 'http://www.opengis.net/kml/2.2'}
racine = ET.fromstring(contenu)
doc = racine.find('k:Document', ns)
if doc is None:
return None, {}
# Styles : id → couleur ; les StyleMap renvoient vers le style « normal ».
styles = {}
for st in doc.iter('{http://www.opengis.net/kml/2.2}Style'):
# (pas de « or » : un élément XML sans enfant est évalué comme faux)
c = st.find('k:LineStyle/k:color', ns)
if c is None:
c = st.find('k:IconStyle/k:color', ns)
if st.get('id') and c is not None and c.text:
styles['#' + st.get('id')] = couleur_kml(c.text)
for sm in doc.iter('{http://www.opengis.net/kml/2.2}StyleMap'):
for paire in sm.findall('k:Pair', ns):
if paire.findtext('k:key', '', ns) == 'normal':
styles['#' + (sm.get('id') or '')] = styles.get(paire.findtext('k:styleUrl', '', ns))
def coords(el):
pts = []
for c in (el.findtext('k:coordinates', '', ns) or '').split():
v = c.split(',')
if len(v) >= 2:
pts.append([round(float(v[1]), 5), round(float(v[0]), 5)])
return pts
calques = {}
def visiter(noeud, calque):
for enfant in noeud:
tag = enfant.tag.split('}')[-1]
if tag == 'Folder':
visiter(enfant, enfant.findtext('k:name', calque, ns))
elif tag == 'Placemark':
base = {'nom': nettoyer_nom(enfant.findtext('k:name', '', ns)),
'description': resumer_description(texte_simple(enfant.findtext('k:description', '', ns))),
'couleur': styles.get(enfant.findtext('k:styleUrl', '', ns)) or COULEUR_MYMAPS}
for ls in enfant.iter('{http://www.opengis.net/kml/2.2}LineString'):
pts = coords(ls)
if len(pts) > 1:
calques.setdefault(calque, []).append(dict(base, type='ligne', coords=pts))
for pt in enfant.iter('{http://www.opengis.net/kml/2.2}Point'):
pts = coords(pt)
if pts:
calques.setdefault(calque, []).append(dict(base, type='point', coords=pts[0]))
titre = doc.findtext('k:name', 'My Maps', ns)
visiter(doc, titre)
return titre, calques
def simplifier(coords):
pts = [gpxpy.gpx.GPXTrackPoint(la, lo) for la, lo in coords]
seg = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2))
seg.simplify(max_distance=TOLERANCE_M)
return [[round(p.latitude, 5), round(p.longitude, 5)] for p in seg.points]
# --- Carte -------------------------------------------------------------------
def generer_carte_globale(racine, rubriques, liens_mymaps=(), mettre_a_jour=False):
"""rubriques : liste de {'titre', 'voyages': [{'titre', 'chemin', 'periode', 'dossier', 'moto'}]}."""
racine = os.path.abspath(racine)
telecharger_mymaps(racine, list(liens_mymaps), mettre_a_jour)
m = folium.Map(tiles=None, scrollWheelZoom=False)
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m)
folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite', show=False).add_to(m)
tous = []
resume = []
# Voyages du site : un calque par voyage, coloré, que la légende permet
# d'afficher ou de masquer ; le plus récent en tête de légende.
voyages = [v for r in rubriques for v in r['voyages']]
calques = []
for i, v in enumerate(voyages):
lignes, km = trace_voyage(v['dossier'])
if not lignes:
continue
couleur = COULEURS_VOYAGES[i % len(COULEURS_VOYAGES)]
libelle = f"{v['titre']} ({v['moto']})" if v.get('moto') else v['titre']
groupe = folium.FeatureGroup(name=str(F.legende(libelle, couleur)))
popup = str(F.popup_voyage(libelle, v['periode'], km, f"{v['chemin']}/index.html"))
for l in lignes:
folium.PolyLine(l, color=couleur, weight=4, opacity=0.85, tooltip=libelle,
popup=folium.Popup(popup, max_width=260)).add_to(groupe)
tous.extend(l)
calques.append(groupe)
for groupe in calques:
groupe.add_to(m)
resume.append(f"{len(calques)} voyage(s)")
# Cartes My Maps
dossier = os.path.join(racine, DOSSIER_MYMAPS)
fichiers = sorted(f for f in os.listdir(dossier) if f.lower().endswith(('.kml', '.kmz'))) \
if os.path.isdir(dossier) else []
for f in fichiers:
try:
titre, calques = elements_mymaps(lire_kml(os.path.join(dossier, f)))
except Exception as e:
print(f" ⚠️ {DOSSIER_MYMAPS}/{f} illisible : {e}")
continue
# Calques du plus récent au plus ancien (leurs noms commencent par l'année).
for nom_calque, elements in sorted(calques.items(), key=lambda kv: kv[0], reverse=True):
couleur_calque = elements[0]['couleur'] if elements else COULEUR_MYMAPS
groupe = folium.FeatureGroup(name=str(F.legende(nom_calque, couleur_calque)))
for e in elements:
popup = folium.Popup(str(F.popup_mymaps(e['nom'], e['description'])), max_width=300)
if e['type'] == 'ligne':
pts = simplifier(e['coords'])
folium.PolyLine(pts, color=e['couleur'], weight=4, opacity=0.85,
tooltip=e['nom'] or None, popup=popup).add_to(groupe)
tous.extend(pts)
else:
folium.CircleMarker(e['coords'], radius=6, color='#fff', weight=2,
fill=True, fill_color=e['couleur'], fill_opacity=1,
tooltip=e['nom'] or None, popup=popup).add_to(groupe)
tous.append(e['coords'])
groupe.add_to(m)
resume.append(f"{nom_calque} : {len(elements)}")
if tous:
lats = [p[0] for p in tous]
lons = [p[1] for p in tous]
m.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]], padding=(20, 20))
folium.LayerControl(collapsed=False).add_to(m)
plugins.Fullscreen(position='topleft').add_to(m)
m.get_root().html.add_child(folium.Element(str(F.bouton_accueil())))
# Molette active seulement quand la carte est ouverte seule : intégrée dans
# l'accueil, elle capterait le défilement de la page.
m.get_root().script.add_child(folium.Element(str(F.script_page(m.get_name()))))
m.save(os.path.join(racine, SORTIE))
print(f"🗺️ Carte globale : {SORTIE} ({', '.join(resume)})")