MapToPoster / create_map_poster.py
isaachwf's picture
fix: enforce equal aspect ratio to prevent horizontal map compression
6330aff
import osmnx as ox
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties
import matplotlib.colors as mcolors
import numpy as np
from geopy.geocoders import Nominatim
import time
import json
import os
from datetime import datetime
import argparse
# Explicitly enable caching
ox.settings.use_cache = True
THEMES_DIR = "themes"
FONTS_DIR = "fonts"
POSTERS_DIR = "posters"
def load_fonts():
"""
Load fonts for both English (Goudy Old Style) and Chinese (HYWenRunSongYunU).
"""
fonts = {
"en_bold": os.path.join(FONTS_DIR, "GoudyOldStyle-Bold.ttf"),
"en_regular": os.path.join(FONTS_DIR, "GoudyOldStyle-Regular.ttf"),
"cn": os.path.join(FONTS_DIR, "HYWenRunSongYunU.ttf"),
}
# Verify fonts exist
available_fonts = {}
for key, path in fonts.items():
if os.path.exists(path):
available_fonts[key] = path
else:
print(f"⚠ Font not found: {path}")
return available_fonts if available_fonts else None
FONTS = load_fonts()
def generate_output_filename(city, theme_name, output_format, directory=POSTERS_DIR):
"""
Generate unique output filename with city, theme, and datetime.
"""
if not os.path.exists(directory):
os.makedirs(directory)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
city_slug = city.lower().replace(" ", "_")
ext = output_format.lower()
filename = f"{city_slug}_{theme_name}_{timestamp}.{ext}"
return os.path.join(directory, 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",
}
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
# Load theme (can be changed via command line or input)
THEME = None # Will be loaded later
def create_gradient_fade(ax, color, location="bottom", zorder=10):
"""
Creates a fade effect at the top or bottom of the map.
"""
vals = np.linspace(0, 1, 256).reshape(-1, 1)
gradient = np.hstack((vals, vals))
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]
if location == "bottom":
my_colors[:, 3] = np.linspace(1, 0, 256)
extent_y_start = 0
extent_y_end = 0.25
else:
my_colors[:, 3] = np.linspace(0, 1, 256)
extent_y_start = 0.75
extent_y_end = 1.0
custom_cmap = mcolors.ListedColormap(my_colors)
xlim = ax.get_xlim()
ylim = ax.get_ylim()
y_range = ylim[1] - ylim[0]
y_bottom = ylim[0] + y_range * extent_y_start
y_top = ylim[0] + y_range * extent_y_end
ax.imshow(
gradient,
extent=[xlim[0], xlim[1], y_bottom, y_top],
aspect="auto",
cmap=custom_cmap,
zorder=zorder,
origin="lower",
)
def get_edge_colors_by_type(
G, show_motorway=True, show_primary=True, show_secondary=True
):
"""
Assigns colors to edges based on road type hierarchy.
Returns a list of colors corresponding to each edge in the graph.
If a layer is hidden, returns 'none' for that edge.
"""
edge_colors = []
for u, v, data in G.edges(data=True):
# Get the highway type (can be a list or string)
highway = data.get("highway", "unclassified")
# Handle list of highway types (take the first one)
if isinstance(highway, list):
highway = highway[0] if highway else "unclassified"
# Assign color based on road type
if highway in ["motorway", "motorway_link"]:
color = THEME["road_motorway"] if show_motorway else "none"
elif highway in ["trunk", "trunk_link", "primary", "primary_link"]:
color = THEME["road_primary"] if show_primary else "none"
elif highway in ["secondary", "secondary_link"]:
color = THEME["road_secondary"] if show_secondary else "none"
elif highway in ["tertiary", "tertiary_link"]:
color = (
THEME["road_tertiary"] if show_secondary else "none"
) # Group tertiary with secondary
elif highway in [
"residential",
"living_street",
"unclassified",
"service",
"road",
]:
color = THEME["road_residential"]
elif highway in ["path", "footway", "track", "cycleway", "pedestrian"]:
# Use residential color or a lighter version if defined, for now residential
color = THEME["road_residential"]
else:
color = THEME["road_default"]
edge_colors.append(color)
return edge_colors
def get_edge_widths_by_type(G):
"""
Assigns line widths to edges based on road type.
Major roads get thicker lines.
"""
edge_widths = []
for u, v, data in G.edges(data=True):
highway = data.get("highway", "unclassified")
if isinstance(highway, list):
highway = highway[0] if highway else "unclassified"
# Assign width based on road importance (increased for visibility)
if highway in ["motorway", "motorway_link"]:
width = 1.6
elif highway in ["trunk", "trunk_link", "primary", "primary_link"]:
width = 1.3
elif highway in ["secondary", "secondary_link"]:
width = 1.0
elif highway in ["tertiary", "tertiary_link"]:
width = 0.8
elif highway in [
"residential",
"living_street",
"unclassified",
"service",
"road",
]:
width = 0.8
elif highway in ["path", "footway", "track", "cycleway", "pedestrian"]:
width = 0.5
else:
width = 0.6
edge_widths.append(width)
return edge_widths
def has_chinese(text):
"""Check if a string contains any Chinese characters."""
if not text:
return False
for char in text:
if "\u4e00" <= char <= "\u9fff":
return True
return False
def get_coordinates(city, country, parent=None):
"""
Fetches coordinates for a given city and country.
First checks for manual overrides in cities_data, then falls back to geopy.
"""
from cities_data import get_manual_coordinates, get_china_adcode
# For China, try local data first
if country == "中国":
parent_adcode = None
if parent:
parent_adcode = get_china_adcode(parent)
# Fallback if parent is already a city/province name and get_china_adcode failed
# (though it shouldn't for these inputs)
if not parent_adcode:
# Try looking up parent as a direct child of China
parent_adcode = get_china_adcode(parent, 100000)
# Try finding city in province
manual_coords = get_manual_coordinates(city, parent_adcode)
if manual_coords:
print(f"✓ Using local coordinate data for {city}: {manual_coords}")
return manual_coords
# Fallback: if city has its own children (like districts), it might be a city level entry
# but manual_coordinates requires a parent.
# Actually, get_manual_coordinates handles hardcoded ones too.
# Let's try looking it up as a province if parent_adcode is 100000
manual_coords = get_manual_coordinates(city, 100000)
if manual_coords:
return manual_coords
print(f"Looking up coordinates for {city}, {country} via Nominatim...")
geolocator = Nominatim(user_agent="city_map_poster", timeout=10)
# Add a small delay to respect Nominatim's usage policy
time.sleep(1)
location = geolocator.geocode(f"{city}, {country}")
if location:
print(f"✓ Found: {location.address}")
print(f"✓ Coordinates: {location.latitude}, {location.longitude}")
return (location.latitude, location.longitude)
else:
raise ValueError(f"Could not find coordinates for {city}, {country}")
def create_poster(
city,
country,
point,
dist,
output_file,
output_format,
width=12,
height=16,
no_crop=False,
show_text=True,
show_motorway=True,
show_primary=True,
show_secondary=True,
show_water=True,
show_parks=True,
):
msg = f"Generating map for {city}, {country}..."
print(f"\n{msg}")
yield msg
# Calculate non-square distances to match figure aspect ratio
# dist is the vertical (North-South) half-distance
dist_ns = dist
dist_ew = dist * (width / height)
# Progress bar for data fetching
# Note: tqdm writes to stderr, we will just yield status updates for the UI
# 1. Fetch Street Network
# Detect if we are doing a "Whole Province" (large area)
# If the bounding box is very large, we should limit the road types to avoid timeouts
is_large_area = False
if dist > 50000: # Over 50km half-width is likely a province or large region
is_large_area = True
print(f"Large area detected (dist={dist}m). Fetching major roads only.")
yield "Large region detected. Fetching major roads only to avoid timeout..."
import math
lat, lon = point
delta_lat = dist_ns / 111320.0
delta_lon = dist_ew / (111320.0 * math.cos(math.radians(lat)))
north, south = max(lat + delta_lat, lat - delta_lat), min(lat + delta_lat, lat - delta_lat)
west, east = min(lon - delta_lon, lon + delta_lon), max(lon - delta_lon, lon + delta_lon)
bbox = (west, south, east, north)
if is_large_area:
# Use custom filter for major roads only
custom_filter = '["highway"~"motorway|trunk|primary|secondary"]'
yield "Downloading major road network..."
G = ox.graph_from_bbox(bbox, custom_filter=custom_filter, network_type="drive")
else:
yield "Downloading street network..."
G = ox.graph_from_bbox(bbox, network_type="all")
# 2. Fetch Water and Parks in one request
yield "Downloading features (water, parks)..."
tags = {
"natural": ["water", "wood", "scrub"],
"waterway": ["riverbank", "dock"],
"leisure": ["park", "garden", "nature_reserve"],
"landuse": [
"forest",
"grass",
"cemetery",
"recreation_ground",
"village_green",
],
}
water_polys = None
water_lines = None
parks = None
try:
features = ox.features_from_bbox(bbox, tags=tags)
# Separate features and simplify geometries for faster rendering
if features is not None and not features.empty:
# Safe mask creation for Water
water_mask = pd.Series(False, index=features.index)
if "natural" in features.columns:
water_mask |= features["natural"].isin(["water"])
if "waterway" in features.columns:
water_mask |= features["waterway"].notna()
water = features[water_mask]
if not water.empty:
# Water Polygons (Lakes, Wide Rivers)
water_polys = water[
water.geometry.type.isin(["Polygon", "MultiPolygon"])
]
if not water_polys.empty:
# Fix invalid geometries if any, then simplify
water_polys.geometry = water_polys.geometry.apply(lambda g: g.buffer(0) if not g.is_valid else g)
water_polys.geometry = water_polys.geometry.simplify(
tolerance=0.00001, preserve_topology=True
)
# Filter out any that became empty after simplification
water_polys = water_polys[~water_polys.geometry.is_empty]
# Water Lines (Rivers, Streams represented as lines)
water_lines = water[
water.geometry.type.isin(["LineString", "MultiLineString"])
]
else:
water_polys, water_lines = None, None # No water features found
# Green space/Parks features
park_mask = pd.Series(False, index=features.index)
if "leisure" in features.columns:
park_mask |= features["leisure"].notna()
if "landuse" in features.columns:
park_mask |= features["landuse"].isin(
[
"forest",
"grass",
"cemetery",
"recreation_ground",
"village_green",
]
)
if "natural" in features.columns:
park_mask |= features["natural"].isin(["wood", "scrub"])
parks = features[park_mask]
if not parks.empty:
parks = parks[parks.geometry.type.isin(["Polygon", "MultiPolygon"])]
# Fix invalid geometries, then simplify
if not parks.empty:
parks.geometry = parks.geometry.apply(lambda g: g.buffer(0) if not g.is_valid else g)
parks.geometry = parks.geometry.simplify(
tolerance=0.00001, preserve_topology=True
)
# Filter empty
parks = parks[~parks.geometry.is_empty]
else:
parks = None
else:
parks = None # No park features found
else:
water_polys, water_lines, parks = None, None, None
except Exception as e:
print(f"Warning: Could not fetch features: {e}")
water_polys, water_lines, parks = None, None, None
print("✓ All data downloaded successfully!")
yield "Data downloaded. Rendering map..."
# 2. Setup Plot
print("Rendering map...")
fig, ax = plt.subplots(figsize=(width, height), facecolor=THEME["bg"])
ax.set_facecolor(THEME["bg"])
ax.set_position([0, 0, 1, 1])
# 3. Plot Layers
# Layer 1: Polygons (filter to only plot polygon/multipolygon geometries, not points)
# Layer 1: Water (Polygons and Lines)
if show_water:
if water_polys is not None and not water_polys.empty:
water_polys.plot(
ax=ax, facecolor=THEME["water"], edgecolor="none", zorder=1
)
if water_lines is not None and not water_lines.empty:
# Plot water lines (rivers) with some thickness
water_lines.plot(ax=ax, color=THEME["water"], linewidth=2.0, zorder=1)
if show_parks and parks is not None and not parks.empty:
# Filter to only polygon/multipolygon geometries to avoid point features showing as dots
parks_polys = parks[parks.geometry.type.isin(["Polygon", "MultiPolygon"])]
if not parks_polys.empty:
parks_polys.plot(
ax=ax, facecolor=THEME["parks"], edgecolor="none", zorder=2
)
# Layer 2: Roads with hierarchy coloring
print("Applying road hierarchy colors...")
yield "Applying road styles..."
edge_colors = get_edge_colors_by_type(
G,
show_motorway=show_motorway,
show_primary=show_primary,
show_secondary=show_secondary,
)
edge_widths = get_edge_widths_by_type(G)
ox.plot_graph(
G,
ax=ax,
bgcolor=THEME["bg"],
node_size=0,
edge_color=edge_colors,
edge_linewidth=edge_widths,
show=False,
close=False,
)
# Enforce equal aspect ratio to prevent compression/distortion
ax.set_aspect("equal")
# Set explicit limits based on calculated bbox to match figure aspect ratio
ax.set_xlim(west, east)
ax.set_ylim(south, north)
# Layer 3: Gradients (Top and Bottom)
create_gradient_fade(ax, THEME["gradient_color"], location="bottom", zorder=10)
create_gradient_fade(ax, THEME["gradient_color"], location="top", zorder=10)
# 4. Typography
is_chinese = has_chinese(city)
if FONTS:
if is_chinese:
# Chinese styling
font_path = FONTS.get("cn") or FONTS.get("en_bold")
font_main_base = FontProperties(fname=font_path)
font_sub_base = FontProperties(fname=font_path)
font_coords = FontProperties(fname=font_path, size=14)
display_city = city
display_country = country
else:
# English styling (Original)
font_path_bold = FONTS.get("en_bold")
font_path_reg = FONTS.get("en_regular")
font_main_base = FontProperties(fname=font_path_bold)
font_sub_base = FontProperties(fname=font_path_reg)
font_coords = FontProperties(fname=font_path_reg, size=14)
# Spaced and uppercase for English
display_city = " ".join(list(city.upper()))
display_country = country.upper()
else:
# Fallback to system fonts
font_main_base = FontProperties(family="serif", weight="bold")
font_sub_base = FontProperties(family="serif")
font_coords = FontProperties(family="monospace", size=14)
if is_chinese:
display_city = city
display_country = country
else:
display_city = " ".join(list(city.upper()))
display_country = country.upper()
# Dynamically adjust font size
base_font_size = 60 if not is_chinese else 54
city_char_count = len(city)
if not is_chinese and city_char_count > 10:
scale_factor = 10 / city_char_count
adjusted_font_size = max(base_font_size * scale_factor, 24)
elif is_chinese and city_char_count > 6:
scale_factor = 6 / city_char_count
adjusted_font_size = max(base_font_size * scale_factor, 32)
else:
adjusted_font_size = base_font_size
font_main = font_main_base.copy()
font_main.set_size(adjusted_font_size)
font_sub = font_sub_base.copy()
font_sub.set_size(22)
# --- BOTTOM TEXT ---
if show_text:
# Title
if display_city:
ax.text(
0.5,
0.14,
display_city,
transform=ax.transAxes,
color=THEME["text"],
ha="center",
fontproperties=font_main,
zorder=11,
)
# Subtitle
if display_country:
ax.text(
0.5,
0.10,
display_country,
transform=ax.transAxes,
color=THEME["text"],
ha="center",
fontproperties=font_sub,
zorder=11,
)
# Decorative separator line (only show if both exist)
if display_city and display_country:
ax.plot(
[0.4, 0.6],
[0.125, 0.125],
transform=ax.transAxes,
color=THEME["text"],
linewidth=1,
zorder=11,
)
# Coordinates (Third line, smaller)
# Only show if there is at least a title or subtitle, or if specifically desired
if display_city or display_country:
lat, lon = point
coords_text = (
f"{lat:.4f}° N / {lon:.4f}° E"
if lat >= 0
else f"{abs(lat):.4f}° S / {lon:.4f}° E"
)
if lon < 0:
coords_text = coords_text.replace("E", "W")
ax.text(
0.5,
0.07,
coords_text,
transform=ax.transAxes,
color=THEME["text"],
alpha=0.7,
ha="center",
fontproperties=font_coords,
zorder=11,
)
# --- ATTRIBUTION (bottom right) ---
attr_font = font_sub_base.copy()
attr_font.set_size(8)
ax.text(
0.98,
0.02,
"© OpenStreetMap contributors",
transform=ax.transAxes,
color=THEME["text"],
alpha=0.5,
ha="right",
va="bottom",
fontproperties=attr_font,
zorder=11,
)
# 5. Save
print(f"Saving to {output_file}...")
yield f"Saving to {output_file}..."
fmt = output_format.lower()
save_kwargs = dict(facecolor=THEME["bg"], pad_inches=0.05)
if not no_crop:
save_kwargs["bbox_inches"] = "tight"
# DPI matters mainly for raster formats
if fmt == "png":
save_kwargs["dpi"] = 300
plt.savefig(output_file, format=fmt, **save_kwargs)
plt.close()
print(f"✓ Done! Poster saved as {output_file}")
yield "Done!"
def print_examples():
"""Print usage examples."""
print("""
City Map Poster Generator
=========================
Usage:
python create_map_poster.py --city <city> --country <country> [options]
Examples:
# Iconic grid patterns
python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid
python create_map_poster.py -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district grid
# Waterfront & canals
python create_map_poster.py -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network
python create_map_poster.py -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 # Concentric canals
python create_map_poster.py -c "Dubai" -C "UAE" -t midnight_blue -d 15000 # Palm & coastline
# Radial patterns
python create_map_poster.py -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards
python create_map_poster.py -c "Moscow" -C "Russia" -t noir -d 12000 # Ring roads
# Organic old cities
python create_map_poster.py -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets
python create_map_poster.py -c "Marrakech" -C "Morocco" -t terracotta -d 5000 # Medina maze
python create_map_poster.py -c "Rome" -C "Italy" -t warm_beige -d 8000 # Ancient street layout
# Coastal cities
python create_map_poster.py -c "San Francisco" -C "USA" -t sunset -d 10000 # Peninsula grid
python create_map_poster.py -c "Sydney" -C "Australia" -t ocean -d 12000 # Harbor city
python create_map_poster.py -c "Mumbai" -C "India" -t contrast_zones -d 18000 # Coastal peninsula
# River cities
python create_map_poster.py -c "London" -C "UK" -t noir -d 15000 # Thames curves
python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Danube split
# List themes
python create_map_poster.py --list-themes
Options:
--city, -c City name (required)
--country, -C Country name (required)
--theme, -t Theme name (default: feature_based)
--distance, -d Map radius in meters (default: 29000)
--list-themes List all available themes
Distance guide:
4000-6000m Small/dense cities (Venice, Amsterdam old center)
8000-12000m Medium cities, focused downtown (Paris, Barcelona)
15000-20000m Large metros, full city view (Tokyo, Mumbai)
Available themes can be found in the 'themes/' directory.
Generated posters are saved to 'posters/' directory.
""")
def list_themes():
"""List all available themes with descriptions."""
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 "New York" --country "USA"
python create_map_poster.py --city Tokyo --country Japan --theme midnight_blue
python create_map_poster.py --city Paris --country France --theme noir --distance 15000
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(
"--theme",
"-t",
type=str,
default="feature_based",
help="Theme name (default: feature_based)",
)
parser.add_argument(
"--distance",
"-d",
type=int,
default=10000,
help="Map radius in meters (default: 10000)",
)
parser.add_argument(
"--width",
"-W",
type=float,
default=12.0,
help="Poster width in inches (default: 12.0)",
)
parser.add_argument(
"--height",
"-H",
type=float,
default=16.0,
help="Poster height in inches (default: 16.0)",
)
parser.add_argument(
"--no-crop",
action="store_true",
help="Do not crop the image to the data extent (keeps background)",
)
parser.add_argument(
"--list-themes", action="store_true", help="List all available themes"
)
parser.add_argument(
"--format",
"-f",
default="png",
choices=["png", "svg", "pdf"],
help="Output format for the poster (default: png)",
)
args = parser.parse_args()
# If no arguments provided, show examples
if len(os.sys.argv) == 1:
print_examples()
os.sys.exit(0)
# List themes if requested
if args.list_themes:
list_themes()
os.sys.exit(0)
# Validate required arguments
if not args.city or not args.country:
print("Error: --city and --country are required.\n")
print_examples()
os.sys.exit(1)
# Validate theme exists
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)
# Load theme
THEME = load_theme(args.theme)
# Get coordinates and generate poster
try:
coords = get_coordinates(args.city, args.country)
output_file = generate_output_filename(args.city, args.theme, args.format)
# Iterate over the generator to execute it
for status in create_poster(
args.city,
args.country,
coords,
args.distance,
output_file,
args.format,
width=args.width,
height=args.height,
no_crop=args.no_crop,
):
# We already print inside the function, but we act as a consumer here
pass
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)