maptoposter / create_map_poster.py
fffiloni's picture
core: refactor map layers with optional forests, waterways and coastline
9e6ae60 verified
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 <city> --country <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)