import osmnx as ox import matplotlib.pyplot as plt from matplotlib.font_manager import FontProperties import matplotlib.colors as mcolors from matplotlib.patches import Rectangle import numpy as np from geopy.geocoders import Nominatim from tqdm import tqdm import time import json import os import random from datetime import datetime import argparse from shapely.geometry import Point, box from shapely.ops import linemerge, unary_union, polygonize import geopandas as gpd THEMES_DIR = "themes" FONTS_DIR = "fonts" POSTERS_DIR = "posters" # ---- Layout tuning defaults (can be overridden by CLI flags) ---- DEFAULT_DPI = 300 MAP_PAD = 0.03 OVERFETCH = 1.20 # Buildings are only practical at small scale BUILDINGS_MAX_DIST = 5000 # Town threshold import re from shapely.geometry import LineString, MultiLineString def first_tag_value(v): """OSM tags can be scalars or lists. Always return a single scalar.""" if isinstance(v, list): return v[0] if v else None return v def _safe_float(x): try: if x is None: return None if isinstance(x, (int, float)): return float(x) # Handle strings like "3", "3.5", "3 m", "3,5" s = str(x).strip().lower().replace(",", ".") m = re.search(r"[-+]?\d*\.?\d+", s) return float(m.group(0)) if m else None except Exception: return None def get_waterway_widths_by_type(waterways_gdf, theme=None): """ Return a list of linewidths (floats) aligned with waterways_gdf rows. If a numeric 'width' tag exists in OSM data, it is used as a hint. Otherwise uses a simple hierarchy by waterway type. Notes: - OSM 'width' is in meters (often missing / inconsistent). We map it to a plotting linewidth with a gentle clamp. - 'riverbank' is a polygon, so it won't normally be in waterways_gdf (lines). """ if waterways_gdf is None or getattr(waterways_gdf, "empty", True): return [] # Base widths in "plot linewidth units" base = { "river": 1.00, "canal": 0.85, "stream": 0.55, "brook": 0.45, "tidal_channel": 0.65, "ditch": 0.40, "drain": 0.38, } default_w = 0.45 # Allow theme overrides if you want later # e.g. theme["waterway_river_width"] etc. theme = theme or {} default_w = float(theme.get("waterway_default_width", default_w)) base["river"] = float(theme.get("waterway_river_width", base["river"])) base["canal"] = float(theme.get("waterway_canal_width", base["canal"])) base["stream"] = float(theme.get("waterway_stream_width", base["stream"])) base["brook"] = float(theme.get("waterway_brook_width", base["brook"])) base["ditch"] = float(theme.get("waterway_ditch_width", base["ditch"])) base["drain"] = float(theme.get("waterway_drain_width", base["drain"])) base["tidal_channel"] = float(theme.get("waterway_tidal_width", base["tidal_channel"])) widths = [] # Pre-check columns has_type = "waterway" in waterways_gdf.columns has_width = "width" in waterways_gdf.columns for _, row in waterways_gdf.iterrows(): wtype = first_tag_value(row.get("waterway")) if has_type else None lw = base.get(wtype, default_w) # Optional: use OSM "width" tag as a hint when present if has_width: osm_w = _safe_float(first_tag_value(row.get("width"))) if osm_w is not None: # Map meters -> linewidth. Keep it subtle. # 0..30m -> 0.4..2.2 (clamped) mapped = 0.4 + (min(max(osm_w, 0.0), 30.0) / 30.0) * 1.8 # Blend with hierarchy so width doesn't explode on bad tags lw = 0.6 * lw + 0.4 * mapped # Safety clamp (avoids ugly giant strokes) lw = float(np.clip(lw, 0.25, 2.4)) widths.append(lw) return widths def get_waterway_alphas_by_type(waterways_gdf, theme=None): """ Optional: alpha hierarchy (bigger waterways slightly more opaque). """ if waterways_gdf is None or getattr(waterways_gdf, "empty", True): return [] theme = theme or {} base = { "river": 0.75, "canal": 0.70, "stream": 0.60, "brook": 0.55, "tidal_channel": 0.65, "ditch": 0.50, "drain": 0.50, } default_a = float(theme.get("waterway_default_alpha", 0.60)) alphas = [] has_type = "waterway" in waterways_gdf.columns for _, row in waterways_gdf.iterrows(): wtype = first_tag_value(row.get("waterway")) if has_type else None a = float(base.get(wtype, default_a)) a = float(np.clip(a, 0.25, 0.90)) alphas.append(a) return alphas def plot_waterways_by_hierarchy( ax, waterways_gdf, *, color, theme=None, zorder=2.2, dash=None, ): """ Plot waterways with per-feature linewidth/alpha (like road hierarchy). - dash=None => solid line - dash=(0,(3,3)) => dashed etc. """ if waterways_gdf is None or getattr(waterways_gdf, "empty", True): return theme = theme or {} widths = get_waterway_widths_by_type(waterways_gdf, theme=theme) alphas = get_waterway_alphas_by_type(waterways_gdf, theme=theme) # Iterate geometries and plot with their style i = 0 for geom in waterways_gdf.geometry: if geom is None or geom.is_empty: i += 1 continue lw = widths[i] if i < len(widths) else 0.45 alpha = alphas[i] if i < len(alphas) else 0.60 i += 1 if isinstance(geom, LineString): xs, ys = geom.xy ax.plot( xs, ys, color=color, linewidth=lw, alpha=alpha, zorder=zorder, linestyle=dash if dash is not None else "solid", ) elif isinstance(geom, MultiLineString): for part in geom.geoms: xs, ys = part.xy ax.plot( xs, ys, color=color, linewidth=lw, alpha=alpha, zorder=zorder, linestyle=dash if dash is not None else "solid", ) def finalize_map_view(ax_map, target_ratio: float, *, pad: float): """ Vue finale sans distorsion et sans letterboxing (bandes blanches). """ # 1) Empêcher les marges auto (GeoPandas en ajoute parfois) ax_map.margins(0) ax_map.set_anchor("C") # 2) IMPORTANT : on veut que l'ASPECT ajuste les LIMITES (pas la boîte) ax_map.set_aspect("equal", adjustable="datalim") # 3) Crop au ratio cible (pad optionnel) crop_axes_to_ratio(ax_map, target_ratio=target_ratio, pad=pad) # 4) IMPORTANT : l'axes doit remplir la figure (sinon bandes blanches) ax_map.set_position([0, 0, 1, 1]) def get_figsize(map_format: str, orientation: str): """ Return (width, height) in inches for a given format + orientation. 'plan' is always square (1:1) and ignores orientation. """ map_format = (map_format or "poster").lower() orientation = (orientation or "portrait").lower() # Plan = square (orientation ignored) if map_format == "plan": return (12, 12) # Portrait vs Landscape mappings if map_format == "poster": return (12, 16) if orientation == "portrait" else (16, 12) # 3:4 / 4:3 if map_format == "postcard": return (12, 15) if orientation == "portrait" else (15, 12) # 4:5 / 5:4 if map_format == "panoramic": return (9, 16) if orientation == "portrait" else (16, 9) # 9:16 / 16:9 # Fallback return (12, 16) def load_fonts(): """ Load Roboto fonts from the fonts directory. Returns dict with font paths for different weights. """ fonts = { 'bold': os.path.join(FONTS_DIR, 'Roboto-Bold.ttf'), 'regular': os.path.join(FONTS_DIR, 'Roboto-Regular.ttf'), 'light': os.path.join(FONTS_DIR, 'Roboto-Light.ttf') } # Verify fonts exist for weight, path in fonts.items(): if not os.path.exists(path): print(f"⚠ Font not found: {path}") return None return fonts FONTS = load_fonts() def generate_output_filename(city, theme_name): """ Generate unique output filename with city, theme, and datetime. """ if not os.path.exists(POSTERS_DIR): os.makedirs(POSTERS_DIR) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") city_slug = (city or "custom").lower().replace(' ', '_') filename = f"{city_slug}_{theme_name}_{timestamp}.png" return os.path.join(POSTERS_DIR, filename) def get_available_themes(): """ Scans the themes directory and returns a list of available theme names. """ if not os.path.exists(THEMES_DIR): os.makedirs(THEMES_DIR) return [] themes = [] for file in sorted(os.listdir(THEMES_DIR)): if file.endswith('.json'): theme_name = file[:-5] # Remove .json extension themes.append(theme_name) return themes def load_theme(theme_name="feature_based"): """ Load theme from JSON file in themes directory. """ theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json") if not os.path.exists(theme_file): print(f"⚠ Theme file '{theme_file}' not found. Using default feature_based theme.") # Fallback to embedded default theme return { "name": "Feature-Based Shading", "bg": "#FFFFFF", "text": "#000000", "gradient_color": "#FFFFFF", "water": "#C0C0C0", "parks": "#F0F0F0", "road_motorway": "#0A0A0A", "road_primary": "#1A1A1A", "road_secondary": "#2A2A2A", "road_tertiary": "#3A3A3A", "road_residential": "#4A4A4A", "road_default": "#3A3A3A", # buildings fallback "buildings": "#E6E6E6", "buildings_alpha": 0.25, } with open(theme_file, 'r') as f: theme = json.load(f) print(f"✓ Loaded theme: {theme.get('name', theme_name)}") if 'description' in theme: print(f" {theme['description']}") return theme # Loaded later THEME = None def crop_axes_to_ratio(ax, target_ratio, pad=0.03): """ Crop ax limits to match target_ratio (width/height) with a consistent padding feel. pad shrinks the final view (same spirit as before): pad=0.03 => ~3% tighter. """ x0, x1 = ax.get_xlim() y0, y1 = ax.get_ylim() w = x1 - x0 h = y1 - y0 if w <= 0 or h <= 0: return cx = (x0 + x1) / 2 cy = (y0 + y1) / 2 current_ratio = w / h # Fit to ratio using the *current* limits as the starting envelope if current_ratio > target_ratio: # too wide -> reduce width half_h = h / 2 half_w = half_h * target_ratio else: # too tall -> reduce height half_w = w / 2 half_h = half_w / target_ratio # Shrink uniformly (keeps margins visually consistent in the "inland+fade" case) pad = float(max(0.0, min(0.2, pad))) shrink = 1.0 - pad half_w *= shrink half_h *= shrink ax.set_xlim(cx - half_w, cx + half_w) ax.set_ylim(cy - half_h, cy + half_h) def create_gradient_fade(ax, color, location='bottom', zorder=10, thickness=0.25): """ Creates a fade effect at the edges of the map without altering aspect/limits. location: 'bottom', 'top', 'left', 'right' thickness: fraction of the axis range to cover (e.g. 0.25 = 25%) """ thickness = float(max(0.02, min(0.40, thickness))) # clamp safe vals = np.linspace(0, 1, 256).reshape(-1, 1) rgb = mcolors.to_rgb(color) my_colors = np.zeros((256, 4)) my_colors[:, 0] = rgb[0] my_colors[:, 1] = rgb[1] my_colors[:, 2] = rgb[2] # Save current view/aspect so imshow can't distort the map xlim = ax.get_xlim() ylim = ax.get_ylim() aspect = ax.get_aspect() x_range = xlim[1] - xlim[0] y_range = ylim[1] - ylim[0] if location == 'bottom': gradient = np.hstack((vals, vals)) # vertical my_colors[:, 3] = np.linspace(1, 0, 256) y0 = ylim[0] y1 = ylim[0] + y_range * thickness extent = [xlim[0], xlim[1], y0, y1] elif location == 'top': gradient = np.hstack((vals, vals)) # vertical my_colors[:, 3] = np.linspace(0, 1, 256) y0 = ylim[1] - y_range * thickness y1 = ylim[1] extent = [xlim[0], xlim[1], y0, y1] elif location == 'left': gradient = np.vstack((vals.T, vals.T)) # horizontal my_colors[:, 3] = np.linspace(1, 0, 256) x0 = xlim[0] x1 = xlim[0] + x_range * thickness extent = [x0, x1, ylim[0], ylim[1]] elif location == 'right': gradient = np.vstack((vals.T, vals.T)) # horizontal my_colors[:, 3] = np.linspace(0, 1, 256) x0 = xlim[1] - x_range * thickness x1 = xlim[1] extent = [x0, x1, ylim[0], ylim[1]] else: return custom_cmap = mcolors.ListedColormap(my_colors) ax.imshow( gradient, extent=extent, aspect='auto', cmap=custom_cmap, zorder=zorder, origin='lower' ) # Restore view/aspect ax.set_xlim(xlim) ax.set_ylim(ylim) ax.set_aspect(aspect) def margin_thickness_from_layout(theme: dict) -> float: """ Margin thickness in axes fraction for the "solid frame" used on coastal maps (when fading is disabled). Same thickness on all 4 sides, orientation-invariant. """ fmt = (theme.get("_format") or "poster").lower() if fmt == "plan": return 0.035 if fmt == "panoramic": return 0.025 if fmt == "postcard": return 0.03 # poster default return 0.03 def add_margin_frame(ax_page, color, thickness=0.03, zorder=10): """ Draw a solid margin/frame over the map with uniform thickness on all 4 sides. thickness is in axes fraction (0..1). """ t = float(max(0.0, min(0.15, thickness))) if t <= 0: return # left ax_page.add_patch(Rectangle( (0, 0), t, 1, transform=ax_page.transAxes, facecolor=color, edgecolor="none", zorder=zorder )) # right ax_page.add_patch(Rectangle( (1 - t, 0), t, 1, transform=ax_page.transAxes, facecolor=color, edgecolor="none", zorder=zorder )) # bottom ax_page.add_patch(Rectangle( (0, 0), 1, t, transform=ax_page.transAxes, facecolor=color, edgecolor="none", zorder=zorder )) # top ax_page.add_patch(Rectangle( (0, 1 - t), 1, t, transform=ax_page.transAxes, facecolor=color, edgecolor="none", zorder=zorder )) def buildings_style_from_text(theme: dict): """ Buildings default to the theme text color (solid). Alpha is only applied if explicitly provided in the theme JSON. """ text = theme.get("text", "#000000") # Default: same color as text, fully opaque face = text alpha = 1.0 # Optional explicit overrides from theme JSON if "buildings" in theme: face = theme["buildings"] if "buildings_alpha" in theme: alpha = float(theme["buildings_alpha"]) alpha = max(0.05, min(1.0, alpha)) return face, alpha def get_edge_colors_by_type(G): """ Assign colors to edges based on road type hierarchy. """ edge_colors = [] for _, _, data in G.edges(data=True): highway = data.get('highway', 'unclassified') if isinstance(highway, list): highway = highway[0] if highway else 'unclassified' if highway in ['motorway', 'motorway_link']: color = THEME['road_motorway'] elif highway in ['trunk', 'trunk_link', 'primary', 'primary_link']: color = THEME['road_primary'] elif highway in ['secondary', 'secondary_link']: color = THEME['road_secondary'] elif highway in ['tertiary', 'tertiary_link']: color = THEME['road_tertiary'] elif highway in ['residential', 'living_street', 'unclassified']: color = THEME['road_residential'] else: color = THEME['road_default'] edge_colors.append(color) return edge_colors def get_edge_widths_by_type(G): """ Assign line widths to edges based on road type. """ edge_widths = [] for _, _, data in G.edges(data=True): highway = data.get('highway', 'unclassified') if isinstance(highway, list): highway = highway[0] if highway else 'unclassified' if highway in ['motorway', 'motorway_link']: width = 1.2 elif highway in ['trunk', 'trunk_link', 'primary', 'primary_link']: width = 1.0 elif highway in ['secondary', 'secondary_link']: width = 0.8 elif highway in ['tertiary', 'tertiary_link']: width = 0.6 else: width = 0.4 edge_widths.append(width) return edge_widths def safe_city_width_from_layout(theme: dict) -> float: """ Return safe horizontal width (axes fraction) for the CITY label, depending on output format/orientation. """ fmt = (theme.get("_format") or "poster").lower() ori = (theme.get("_orientation") or "portrait").lower() if fmt == "plan": return 0.84 if fmt == "panoramic": return 0.92 if ori == "landscape" else 0.82 if fmt == "postcard": return 0.88 if ori == "landscape" else 0.84 return 0.90 if ori == "landscape" else 0.86 def layout_bottom_text(ax_page, city_text, country_text, coords_text, theme, fonts, bottom_offset=0.0, zorder=11): """ Place city/country/coords + separator line using measured text extents. Smart fit for long city names: 1) reduce tracking (spaces between letters) 2) shrink font size (smart scaling jump, then fine steps) Preserves word boundaries: smaller spacing between letters than between words. """ if fonts: font_main_size = 60 font_main = FontProperties(fname=fonts['bold'], size=font_main_size) font_sub = FontProperties(fname=fonts['light'], size=22) font_coords = FontProperties(fname=fonts['regular'], size=14) font_attr = FontProperties(fname=fonts['light'], size=8) else: font_main_size = 60 font_main = FontProperties(family='monospace', weight='bold', size=font_main_size) font_sub = FontProperties(family='monospace', weight='normal', size=22) font_coords = FontProperties(family='monospace', size=14) font_attr = FontProperties(family='monospace', size=8) fig = ax_page.figure raw_city = " ".join((city_text or "").split()) if not raw_city: raw_city = "CUSTOM" def make_tracked(spaces_between_letters: int) -> str: spaces_between_letters = int(max(1, min(6, spaces_between_letters))) letter_sep = " " * spaces_between_letters # subtle larger gap between words word_spaces = spaces_between_letters + 2 word_sep = " " * word_spaces words = raw_city.split(" ") tracked_words = [letter_sep.join(list(w)) for w in words if w] return word_sep.join(tracked_words) t_city = ax_page.text( 0.5, 0.5, make_tracked(2), transform=ax_page.transAxes, color=theme['text'], ha='center', va='bottom', fontproperties=font_main, zorder=zorder ) t_country = ax_page.text( 0.5, 0.5, country_text, transform=ax_page.transAxes, color=theme['text'], ha='center', va='bottom', fontproperties=font_sub, zorder=zorder ) t_coords = ax_page.text( 0.5, 0.5, coords_text, transform=ax_page.transAxes, color=theme['text'], alpha=0.7, ha='center', va='bottom', fontproperties=font_coords, zorder=zorder ) fig.canvas.draw() renderer = fig.canvas.get_renderer() def width_in_axes(text_obj): bbox = text_obj.get_window_extent(renderer=renderer) inv = ax_page.transAxes.inverted() x0 = inv.transform((bbox.x0, 0))[0] x1 = inv.transform((bbox.x1, 0))[0] return max(0.0, x1 - x0) def height_in_axes(text_obj): bbox = text_obj.get_window_extent(renderer=renderer) inv = ax_page.transAxes.inverted() y0 = inv.transform((0, bbox.y0))[1] y1 = inv.transform((0, bbox.y1))[1] return max(0.0, y1 - y0) safe_width = safe_city_width_from_layout(theme) fmt = (theme.get("_format") or "poster").lower() ori = (theme.get("_orientation") or "portrait").lower() if fmt == "panoramic" and ori == "portrait": min_city_size = 18 elif fmt == "plan": min_city_size = 20 else: min_city_size = 22 max_iters = 30 current_spaces = 2 w_city = width_in_axes(t_city) while w_city > safe_width and current_spaces > 1: current_spaces -= 1 t_city.set_text(make_tracked(current_spaces)) fig.canvas.draw() renderer = fig.canvas.get_renderer() w_city = width_in_axes(t_city) if w_city > safe_width: scale = safe_width / max(w_city, 1e-6) font_main_size = max(min_city_size, int(font_main_size * scale)) if fonts: font_main = FontProperties(fname=fonts['bold'], size=font_main_size) else: font_main = FontProperties(family='monospace', weight='bold', size=font_main_size) t_city.set_fontproperties(font_main) fig.canvas.draw() renderer = fig.canvas.get_renderer() w_city = width_in_axes(t_city) for _ in range(max_iters): if w_city <= safe_width or font_main_size <= min_city_size: break font_main_size -= 2 if fonts: font_main = FontProperties(fname=fonts['bold'], size=font_main_size) else: font_main = FontProperties(family='monospace', weight='bold', size=font_main_size) t_city.set_fontproperties(font_main) fig.canvas.draw() renderer = fig.canvas.get_renderer() w_city = width_in_axes(t_city) fig.canvas.draw() renderer = fig.canvas.get_renderer() h_city = height_in_axes(t_city) h_country = height_in_axes(t_country) h_coords = height_in_axes(t_coords) bottom_offset = float(max(0.0, min(0.20, bottom_offset))) bottom_margin = 0.04 + bottom_offset gap_small = 0.012 gap_line = 0.012 y_coords = bottom_margin y_country = y_coords + h_coords + gap_small y_line = y_country + h_country + gap_line y_city = y_line + gap_line t_coords.set_position((0.5, y_coords)) t_country.set_position((0.5, y_country)) t_city.set_position((0.5, y_city)) ax_page.plot( [0.4, 0.6], [y_line, y_line], transform=ax_page.transAxes, color=theme['text'], linewidth=1, zorder=zorder ) ax_page.text( 0.98, 0.02 + bottom_offset, "© OpenStreetMap contributors", transform=ax_page.transAxes, color=theme['text'], alpha=0.5, ha='right', va='bottom', fontproperties=font_attr, zorder=zorder ) def add_bottom_text_fade(ax_page, color, y0=0.0, height=0.22, alpha_max=0.85, zorder=10): """ Add a vertical fade from solid `color` at the very bottom to transparent upward. Drawn in axes coordinates on ax_page (0..1). """ height = float(max(0.02, min(0.60, height))) alpha_max = float(max(0.0, min(1.0, alpha_max))) vals = np.linspace(0, 1, 256).reshape(-1, 1) # 0..1 bottom->top rgb = mcolors.to_rgb(color) rgba = np.zeros((256, 1, 4)) rgba[..., 0] = rgb[0] rgba[..., 1] = rgb[1] rgba[..., 2] = rgb[2] # bottom: alpha_max, top: 0 rgba[..., 3] = (1 - vals) * alpha_max extent = [0, 1, y0, y0 + height] ax_page.imshow( rgba, extent=extent, transform=ax_page.transAxes, origin="lower", aspect="auto", zorder=zorder, interpolation="bicubic", ) def ensure_ssl_certs(): """ Help requests/geopy/osmnx find a valid CA bundle (common macOS python.org issue). Does NOT disable SSL verification. """ try: import certifi ca_path = certifi.where() # Only set if user hasn't explicitly set them os.environ.setdefault("SSL_CERT_FILE", ca_path) os.environ.setdefault("REQUESTS_CA_BUNDLE", ca_path) except Exception: # If certifi isn't installed, do nothing (user must fix system certs) pass GEOCODE_CACHE_FILE = ".geocode_cache.json" def _load_geocode_cache(): try: if os.path.exists(GEOCODE_CACHE_FILE): with open(GEOCODE_CACHE_FILE, "r") as f: return json.load(f) except Exception: pass return {} def _save_geocode_cache(cache: dict): try: with open(GEOCODE_CACHE_FILE, "w") as f: json.dump(cache, f) except Exception: pass def get_coordinates(city, country, max_retries=5): """ Fetch coordinates for a given city and country using geopy (Nominatim), with retry/backoff to handle throttling (429/503) and transient network issues. Also uses a small local cache to avoid repeated queries. """ ensure_ssl_certs() # safe no-op if not needed city = (city or "").strip() country = (country or "").strip() if not city or not country: raise ValueError("City and country are required for geocoding.") key = f"{city.lower()}|{country.lower()}" cache = _load_geocode_cache() if key in cache: lat, lon = cache[key] print(f"✓ Cache hit for {city}, {country}: {lat}, {lon}") return (lat, lon) print("Looking up coordinates...") geolocator = Nominatim(user_agent="maptoposter/1.0 (contact: none)") # keep a stable UA query = f"{city}, {country}" # Nominatim etiquette: don't hammer it base_sleep = 1.0 last_err = None for attempt in range(1, max_retries + 1): try: # small spacing even on first attempt if attempt == 1: time.sleep(base_sleep) location = geolocator.geocode(query, timeout=10) if location: lat, lon = float(location.latitude), float(location.longitude) print(f"✓ Found: {location.address}") print(f"✓ Coordinates: {lat}, {lon}") cache[key] = [lat, lon] _save_geocode_cache(cache) return (lat, lon) # If geocode returns None, don't spam retries too aggressively last_err = ValueError(f"Could not find coordinates for {city}, {country}") except Exception as e: last_err = e # Backoff with jitter (handles 429/503/timeouts etc.) # 1s, 2s, 4s, 8s... capped a bit + jitter backoff = min(30.0, (2 ** (attempt - 1)) * base_sleep) jitter = random.uniform(0.0, 0.5 * backoff) wait = backoff + jitter print(f"⚠ Geocoding failed (attempt {attempt}/{max_retries}): {last_err}") if attempt < max_retries: print(f" Retrying in {wait:.1f}s...") time.sleep(wait) raise last_err if last_err else ValueError(f"Could not find coordinates for {city}, {country}") def _clean_polygons(gdf): """Keep only Polygon/MultiPolygon geometries (drop points/lines).""" if gdf is None or getattr(gdf, "empty", True): return None try: gdf = gdf[gdf.geometry.notnull()].copy() gdf = gdf[gdf.geom_type.isin(["Polygon", "MultiPolygon"])].copy() return gdf except Exception: return gdf def _clean_lines(gdf): """Keep only LineString/MultiLineString geometries (drop points/polygons).""" if gdf is None or getattr(gdf, "empty", True): return None try: gdf = gdf[gdf.geometry.notnull()].copy() gdf = gdf[gdf.geom_type.isin(["LineString", "MultiLineString"])].copy() return gdf except Exception: return gdf def ensure_graph_xy(G): """ Ensure every node has 'x'/'y'. Some OSMnx/networkx graphs can miss them (or only have geometry/lat/lon). This prevents KeyError in ox.plot_graph(). """ fixed = 0 for n, data in G.nodes(data=True): if "x" in data and "y" in data: continue # fallback: lat/lon naming if "lon" in data and "lat" in data: data["x"] = float(data["lon"]) data["y"] = float(data["lat"]) fixed += 1 continue # fallback: geometry point geom = data.get("geometry") if geom is not None and hasattr(geom, "x") and hasattr(geom, "y"): data["x"] = float(geom.x) data["y"] = float(geom.y) fixed += 1 if fixed: print(f"⚠ Fixed {fixed} nodes missing x/y (to satisfy ox.plot_graph).") return G def drop_nodes_missing_xy(G, label="graph"): bad = [n for n, d in G.nodes(data=True) if ("x" not in d) or ("y" not in d)] if bad: print(f"⚠ {label}: dropping {len(bad)} nodes missing x/y (pre-plot safety)") G.remove_nodes_from(bad) return G def plot_lines_dashed(ax, gdf, color, lw, alpha, zorder, dash=(0, (3, 3))): if gdf is None or getattr(gdf, "empty", True): return for geom in gdf.geometry: if geom is None or geom.is_empty: continue if isinstance(geom, LineString): xs, ys = geom.xy ax.plot(xs, ys, color=color, linewidth=lw, alpha=alpha, zorder=zorder, linestyle=dash) elif isinstance(geom, MultiLineString): for part in geom.geoms: xs, ys = part.xy ax.plot(xs, ys, color=color, linewidth=lw, alpha=alpha, zorder=zorder, linestyle=dash) def coastline_land_polygon(coast_gdf, center_latlon, crs, bbox_poly): """ Build a land polygon inside bbox using coastline lines. Works even when coastline is not closed: we add bbox boundary and polygonize. Returns a shapely Polygon/MultiPolygon in projected CRS, or None. """ if coast_gdf is None or getattr(coast_gdf, "empty", True): return None coast_gdf = _clean_lines(coast_gdf) if coast_gdf is None or getattr(coast_gdf, "empty", True): return None try: if hasattr(coast_gdf, "to_crs") and crs is not None and not coast_gdf.empty: coast_gdf = coast_gdf.to_crs(crs) except Exception: pass # Center point in projected CRS lat, lon = center_latlon try: center_pt = gpd.GeoSeries([Point(lon, lat)], crs="EPSG:4326").to_crs(crs).iloc[0] except Exception: return None try: coast_union = unary_union(list(coast_gdf.geometry)) coast_merged = linemerge(coast_union) # Add bbox boundary so polygonize can succeed even with open coastlines merged = unary_union([coast_merged, bbox_poly.boundary]) polys = list(polygonize(merged)) if not polys: return None except Exception: return None # Pick polygon containing center (usually land) containing = [p for p in polys if p.contains(center_pt)] if containing: containing.sort(key=lambda p: p.area, reverse=True) return containing[0] polys.sort(key=lambda p: p.area, reverse=True) return polys[0] def create_poster( city, country, point, dist, output_file, enable_buildings=False, enable_railroads=False, enable_margins=True, enable_waterways=True, enable_forests=True, enable_coastline=True, ): print(f"\nGenerating map for {city}, {country}...") dist_fetch = int(dist * OVERFETCH) if enable_buildings and int(dist) > int(BUILDINGS_MAX_DIST): print(f"⚠ Buildings layer disabled (distance {dist}m > {BUILDINGS_MAX_DIST}m).") enable_buildings = False # ---- Build dynamic progress steps (avoid downloading unused layers) ---- # Always: streets + water polygons + green (parks/grass) steps = 3 if enable_coastline: steps += 1 if enable_waterways: steps += 1 if enable_forests: steps += 1 if enable_buildings: steps += 1 if enable_railroads: steps += 1 with tqdm( total=steps, desc="Fetching map data", unit="step", bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}", ) as pbar: # ---- Streets ---- pbar.set_description("Downloading street network") G = ox.graph_from_point(point, dist=dist_fetch, dist_type="bbox", network_type="all") pbar.update(1) time.sleep(0.2) # ---- Coastline (optional) ---- coast = None if enable_coastline: pbar.set_description("Downloading coastline") try: coast = ox.features_from_point(point, tags={"natural": "coastline"}, dist=dist_fetch) except Exception: coast = None pbar.update(1) time.sleep(0.1) # ---- Water polygons (always) ---- pbar.set_description("Downloading water features") try: water = ox.features_from_point( point, tags={"natural": "water", "waterway": "riverbank"}, dist=dist_fetch ) except Exception: water = None pbar.update(1) time.sleep(0.15) # ---- Waterways lines (optional) ---- waterways = None if enable_waterways: pbar.set_description("Downloading waterways") try: waterways = ox.features_from_point( point, tags={"waterway": ["river", "stream", "brook", "canal", "ditch", "drain", "tidal_channel"]}, dist=dist_fetch, ) except Exception: waterways = None pbar.update(1) time.sleep(0.1) # ---- Green (parks + grass) (always) ---- green = None pbar.set_description("Downloading parks / grass") try: green = ox.features_from_point( point, tags={ "leisure": "park", "landuse": ["grass"], }, dist=dist_fetch, ) except Exception: green = None pbar.update(1) time.sleep(0.1) # ---- Forests (optional, separated) ---- forests = None if enable_forests: pbar.set_description("Downloading forests") try: forests = ox.features_from_point( point, tags={ "landuse": "forest", "natural": "wood", }, dist=dist_fetch, ) except Exception: forests = None pbar.update(1) time.sleep(0.1) # ---- Buildings (optional) ---- buildings = None if enable_buildings: pbar.set_description("Downloading buildings") try: buildings = ox.features_from_point(point, tags={"building": True}, dist=dist_fetch) except Exception: buildings = None pbar.update(1) time.sleep(0.1) # ---- Railroads (optional) ---- rails = None if enable_railroads: pbar.set_description("Downloading railroads") try: rails = ox.features_from_point(point, tags={"railway": "rail"}, dist=dist_fetch) except Exception: rails = None pbar.update(1) time.sleep(0.1) print("✓ All data downloaded successfully!") # ---- Project to metric CRS ---- G = ox.project_graph(G) G = ensure_graph_xy(G) G = drop_nodes_missing_xy(G, label="projected G") crs = G.graph.get("crs", None) # Build bbox polygon in projected CRS from graph nodes bbox_poly = None try: xs = [data["x"] for _, data in G.nodes(data=True)] ys = [data["y"] for _, data in G.nodes(data=True)] if xs and ys: bbox_poly = box(min(xs), min(ys), max(xs), max(ys)) except Exception: bbox_poly = None # Reproject layers to graph CRS if crs is not None: try: if water is not None and hasattr(water, "to_crs") and not water.empty: water = water.to_crs(crs) except Exception: pass try: if waterways is not None and hasattr(waterways, "to_crs") and not waterways.empty: waterways = waterways.to_crs(crs) except Exception: pass try: if green is not None and hasattr(green, "to_crs") and not green.empty: green = green.to_crs(crs) except Exception: pass try: if forests is not None and hasattr(forests, "to_crs") and not forests.empty: forests = forests.to_crs(crs) except Exception: pass try: if buildings is not None and hasattr(buildings, "to_crs") and not buildings.empty: buildings = buildings.to_crs(crs) except Exception: pass try: if rails is not None and hasattr(rails, "to_crs") and not rails.empty: rails = rails.to_crs(crs) except Exception: pass try: if coast is not None and hasattr(coast, "to_crs") and crs is not None and not coast.empty: coast = coast.to_crs(crs) except Exception: pass # Clean geometries buildings = _clean_polygons(buildings) water = _clean_polygons(water) green = _clean_polygons(green) forests = _clean_polygons(forests) waterways = _clean_lines(waterways) rails = _clean_lines(rails) coast = _clean_lines(coast) if buildings is not None and not buildings.empty: try: buildings["geometry"] = buildings.geometry.simplify(1.5, preserve_topology=True) except Exception: pass # Optional rail noise filters if rails is not None and not getattr(rails, "empty", True): if "service" in rails.columns: rails = rails[rails["service"].isna()].copy() if "tunnel" in rails.columns: rails = rails[rails["tunnel"] != "yes"].copy() print("Rendering map...") map_format = THEME.get("_format", "poster") orientation = THEME.get("_orientation", "portrait") figsize = get_figsize(map_format, orientation) target_ratio = figsize[0] / figsize[1] # Fade thicknesses (only used inland when margins enabled) if map_format == "panoramic" and orientation == "landscape": fade_tb = 0.12 fade_lr = 0.22 elif orientation == "landscape": fade_tb = 0.15 fade_lr = 0.18 elif map_format == "plan": fade_tb = 0.18 fade_lr = 0.18 else: fade_tb = 0.22 fade_lr = 0.10 fig = plt.figure(figsize=figsize, facecolor=THEME["bg"]) ax_map = fig.add_axes([0, 0, 1, 1]) ax_map.set_axis_off() # ---- Coastline-aware ocean fill (optional) ---- land_poly = None ocean_poly = None if enable_coastline and bbox_poly is not None and coast is not None and not getattr(coast, "empty", True): land_poly = coastline_land_polygon(coast, point, crs, bbox_poly) if land_poly is not None: ocean_poly = bbox_poly.difference(land_poly) # Paint ocean + land if available if ocean_poly is not None: try: gpd.GeoSeries([ocean_poly], crs=crs).plot( ax=ax_map, facecolor=THEME["water"], edgecolor="none", zorder=0 ) gpd.GeoSeries([land_poly], crs=crs).plot( ax=ax_map, facecolor=THEME["bg"], edgecolor="none", zorder=0.5 ) print("✓ Coastline detected: ocean fill enabled.") except Exception: ocean_poly = None land_poly = None is_coastal = (ocean_poly is not None) # Fallback background ax_map.set_facecolor(THEME["bg"] if not is_coastal else THEME["water"]) ax_page = fig.add_axes([0, 0, 1, 1], facecolor="none") ax_page.set_axis_off() # ---- Layer 1: polygons (bottom) ---- if water is not None and not getattr(water, "empty", True): water.plot(ax=ax_map, facecolor=THEME["water"], edgecolor="none", zorder=0.6) if green is not None and not getattr(green, "empty", True): green.plot(ax=ax_map, facecolor=THEME["parks"], edgecolor="none", zorder=0.7) # Forests (separate optional layer) if enable_forests and forests is not None and not getattr(forests, "empty", True): forest_color = THEME.get("forests", THEME.get("parks", "#F0F0F0")) forest_alpha = float(THEME.get("forests_alpha", 1.0)) forest_alpha = max(0.05, min(1.0, forest_alpha)) forests.plot(ax=ax_map, facecolor=forest_color, edgecolor="none", alpha=forest_alpha, zorder=0.75) # ---- Waterways (optional) ---- if enable_waterways and waterways is not None and not getattr(waterways, "empty", True): plot_waterways_by_hierarchy( ax_map, waterways, color=THEME["water"], theme=THEME, zorder=2.2, dash=None, ) # ---- Buildings ---- if enable_buildings and buildings is not None and not getattr(buildings, "empty", True): face, alpha = buildings_style_from_text(THEME) buildings.plot(ax=ax_map, facecolor=face, edgecolor="none", alpha=alpha, zorder=3.0) # ---- Roads ---- print("Applying road hierarchy colors...") edge_colors = get_edge_colors_by_type(G) edge_widths = get_edge_widths_by_type(G) bgcolor_roads = THEME["water"] if is_coastal else THEME["bg"] ox.plot_graph( G, ax=ax_map, bgcolor=bgcolor_roads, node_size=0, node_alpha=0, node_color=bgcolor_roads, node_edgecolor=bgcolor_roads, node_zorder=-1, edge_color=edge_colors, edge_linewidth=edge_widths, show=False, close=False, ) # Ensure plotted roads sit above polygons/waterways from matplotlib.collections import LineCollection for coll in ax_map.collections: if isinstance(coll, LineCollection): coll.set_zorder(2.6) # ---- Railroads ---- if enable_railroads and rails is not None and not getattr(rails, "empty", True): rail_color = THEME.get("rail_color", THEME.get("text", "#000000")) rail_width = float(THEME.get("rail_width", 0.6)) rail_alpha = float(THEME.get("rail_alpha", 0.55)) plot_lines_dashed( ax_map, rails, color=rail_color, lw=rail_width, alpha=rail_alpha, zorder=4.0, dash=(0, (3, 3)), ) # ---- Hard clamp view to bbox ---- if bbox_poly is not None: minx, miny, maxx, maxy = bbox_poly.bounds ax_map.set_xlim(minx, maxx) ax_map.set_ylim(miny, maxy) ax_map.margins(0) # ---- Cropping + edge treatment ---- frame_t = 0.0 if enable_margins: if not is_coastal: finalize_map_view(ax_map, target_ratio, pad=MAP_PAD) if orientation == "portrait": create_gradient_fade(ax_map, THEME["gradient_color"], "bottom", thickness=fade_tb) create_gradient_fade(ax_map, THEME["gradient_color"], "top", thickness=fade_tb) elif orientation == "landscape": create_gradient_fade(ax_map, THEME["gradient_color"], "left", thickness=fade_lr) create_gradient_fade(ax_map, THEME["gradient_color"], "right", thickness=fade_lr) else: finalize_map_view(ax_map, target_ratio, pad=0.0) frame_t = margin_thickness_from_layout(THEME) add_margin_frame(ax_page, color=THEME["bg"], thickness=frame_t, zorder=10) else: finalize_map_view(ax_map, target_ratio, pad=0.0) if THEME.get("_text_fade", False): add_bottom_text_fade( ax_page=ax_page, color=THEME["bg"], y0=0.0, height=0.40, alpha_max=0.80, zorder=10, ) # ---- Bottom text ---- city_label = city or "Custom" lat, lon = point coords = f"{lat:.4f}° N / {lon:.4f}° E" if lat >= 0 else f"{abs(lat):.4f}° S / {lon:.4f}° E" if lon < 0: coords = coords.replace("E", "W") text_offset = 0.0 if enable_margins and is_coastal: text_offset = frame_t layout_bottom_text( ax_page=ax_page, city_text=city_label.upper(), country_text=(country or "").upper(), coords_text=coords, theme=THEME, fonts=FONTS, zorder=11, bottom_offset=text_offset, ) dpi = int(THEME.get("_dpi", DEFAULT_DPI)) print(f"Saving to {output_file}...") plt.savefig(output_file, dpi=dpi, facecolor=THEME["bg"]) plt.close() print(f"✓ Done! Poster saved as {output_file}") def print_examples(): print(""" City Map Poster Generator ========================= Usage: python create_map_poster.py --city --country [options] Examples: python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 python create_map_poster.py -c "Cahors" -C "France" -t noir -d 4000 Formats: python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 --format poster --orientation portrait python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 --format postcard --orientation landscape python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 --format plan python create_map_poster.py -c "Paris" -C "France" -t noir -d 10000 --format panoramic --orientation landscape Buildings (small scale): python create_map_poster.py -c "Cahors" -C "France" -t noir -d 5000 --buildings """) def list_themes(): available_themes = get_available_themes() if not available_themes: print("No themes found in 'themes/' directory.") return print("\nAvailable Themes:") print("-" * 60) for theme_name in available_themes: theme_path = os.path.join(THEMES_DIR, f"{theme_name}.json") try: with open(theme_path, 'r') as f: theme_data = json.load(f) display_name = theme_data.get('name', theme_name) description = theme_data.get('description', '') except Exception: display_name = theme_name description = '' print(f" {theme_name}") print(f" {display_name}") if description: print(f" {description}") print() if __name__ == "__main__": parser = argparse.ArgumentParser( description="Generate beautiful map posters for any city", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python create_map_poster.py --city "Paris" --country "France" --theme noir --distance 10000 python create_map_poster.py --city "Paris" --country "France" --theme noir --distance 10000 --format plan python create_map_poster.py --list-themes """ ) parser.add_argument('--city', '-c', type=str, help='City name') parser.add_argument('--country', '-C', type=str, help='Country name') parser.add_argument('--lat', type=float, help='Latitude to center the map (optional)') parser.add_argument('--lon', type=float, help='Longitude to center the map (optional)') parser.add_argument('--theme', '-t', type=str, default='feature_based', help='Theme name (default: feature_based)') parser.add_argument('--distance', '-d', type=int, default=29000, help='Map radius in meters (default: 29000)') parser.add_argument('--list-themes', action='store_true', help='List all available themes') parser.add_argument( '--format', type=str, default='poster', choices=['poster', 'postcard', 'plan', 'panoramic'], help='Output format (default: poster)' ) parser.add_argument( '--orientation', type=str, default='portrait', choices=['portrait', 'landscape'], help='Orientation (default: portrait). Ignored for format=plan.' ) parser.add_argument( '--dpi', type=int, default=DEFAULT_DPI, help=f'Output DPI (default: {DEFAULT_DPI})' ) parser.add_argument( "--text-fade", action="store_true", help="Enable fade under bottom text" ) parser.add_argument( '--no-margins', action='store_true', help='Disable inland fading and coastal margin frame.' ) parser.add_argument( '--buildings', action='store_true', help=f'Enable buildings layer (only effective when distance <= {BUILDINGS_MAX_DIST}m).' ) parser.add_argument( '--railroads', action='store_true', help='Enable railroads layer (OSM railway=rail).' ) parser.add_argument( '--waterways', action='store_true', help='Enable waterways layer (rivers, streams, canals, etc.)' ) parser.add_argument( '--forests', action='store_true', help='Enable forests layer (wooded areas)' ) parser.add_argument( '--coastline', action='store_true', help='Enable coastline detection and ocean fill' ) args = parser.parse_args() if len(os.sys.argv) == 1: print_examples() os.sys.exit(0) if args.list_themes: list_themes() os.sys.exit(0) if not args.city or not args.country: print("Error: --city and --country are required.\n") print_examples() os.sys.exit(1) available_themes = get_available_themes() if args.theme not in available_themes: print(f"Error: Theme '{args.theme}' not found.") print(f"Available themes: {', '.join(available_themes)}") os.sys.exit(1) print("=" * 50) print("City Map Poster Generator") print("=" * 50) THEME = load_theme(args.theme) THEME["_format"] = args.format THEME["_orientation"] = args.orientation THEME["_dpi"] = args.dpi THEME["_text_fade"] = args.text_fade try: using_coords = (args.lat is not None and args.lon is not None) if not using_coords: if not args.city or not args.country: print("Error: --city and --country are required (unless --lat/--lon are provided).\n") print_examples() os.sys.exit(1) if args.lat is not None and args.lon is not None: coords = (args.lat, args.lon) else: coords = get_coordinates(args.city, args.country) output_file = generate_output_filename(args.city, args.theme) create_poster( args.city, args.country, coords, args.distance, output_file, enable_buildings=bool(args.buildings), enable_railroads=bool(args.railroads), enable_margins=(not bool(args.no_margins)), enable_waterways=bool(args.waterways), enable_forests=bool(args.forests), enable_coastline=bool(args.coastline), ) print("\n" + "=" * 50) print("✓ Poster generation complete!") print("=" * 50) except Exception as e: print(f"\n✗ Error: {e}") import traceback traceback.print_exc() os.sys.exit(1)