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 --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)