Spaces:
Runtime error
Runtime error
Arnab Dey commited on
Commit ·
282197c
1
Parent(s): 6577b7a
Refactor code structure for improved readability and maintainability
Browse files- app.py +147 -20
- create_map_poster.py +181 -87
- pyproject.toml +1 -0
- requirements.txt +1 -0
- uv.lock +62 -84
app.py
CHANGED
|
@@ -6,11 +6,13 @@ os.environ.setdefault("MPLBACKEND", "Agg")
|
|
| 6 |
import re
|
| 7 |
import tempfile
|
| 8 |
import time
|
|
|
|
| 9 |
from functools import lru_cache
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
import gradio as gr
|
| 13 |
from geopy.geocoders import Nominatim
|
|
|
|
| 14 |
|
| 15 |
import osmnx as ox
|
| 16 |
|
|
@@ -21,11 +23,90 @@ APP_TITLE = "MapToPoster"
|
|
| 21 |
DEFAULT_DISTANCE_M = 10000
|
| 22 |
MIN_DISTANCE_M = 2000
|
| 23 |
MAX_DISTANCE_M = 20000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
_REPO_ROOT = Path(__file__).resolve().parent
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
def _load_readme_example_posters() -> list[tuple[str, str]]:
|
| 30 |
"""Return (absolute_path, caption) pairs for example posters referenced in README."""
|
| 31 |
|
|
@@ -107,48 +188,70 @@ def _rate_limit_geocode(min_interval_s: float = 1.05) -> None:
|
|
| 107 |
_last_geocode_ts = time.time()
|
| 108 |
|
| 109 |
|
|
|
|
| 110 |
def _geocoder() -> Nominatim:
|
| 111 |
user_agent = os.environ.get("MAPTOP_POSTER_USER_AGENT", "maptoposter-hf-space")
|
| 112 |
return Nominatim(user_agent=user_agent)
|
| 113 |
|
| 114 |
|
| 115 |
@lru_cache(maxsize=256)
|
| 116 |
-
def _geocode(city: str, country: str) ->
|
| 117 |
_rate_limit_geocode()
|
| 118 |
location = _geocoder().geocode(f"{city}, {country}")
|
| 119 |
if not location:
|
| 120 |
raise ValueError(f"Could not find coordinates for {city}, {country}")
|
| 121 |
-
return
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
city = (city or "").strip()
|
| 126 |
-
country = (country or "").strip()
|
| 127 |
|
| 128 |
-
if not city or not country:
|
| 129 |
-
raise gr.Error("City and Country are required.")
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
)
|
|
|
|
|
|
|
| 136 |
|
| 137 |
available_themes = maptoposter.get_available_themes()
|
| 138 |
-
if theme not in available_themes:
|
| 139 |
-
raise gr.Error(f"Unknown theme: {theme}")
|
| 140 |
|
| 141 |
-
maptoposter.THEME = maptoposter.load_theme(theme)
|
| 142 |
|
| 143 |
-
|
| 144 |
|
| 145 |
tmp_dir = tempfile.gettempdir()
|
| 146 |
output_path = os.path.join(
|
| 147 |
tmp_dir,
|
| 148 |
-
f"{_slugify(city)}_{_slugify(theme)}_{int(time.time())}.png",
|
| 149 |
)
|
| 150 |
|
| 151 |
-
maptoposter.create_poster(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
return output_path
|
| 153 |
|
| 154 |
|
|
@@ -304,6 +407,26 @@ def build_demo() -> gr.Blocks:
|
|
| 304 |
value=DEFAULT_DISTANCE_M,
|
| 305 |
)
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
btn = gr.Button("Generate poster", elem_classes=["mtp-primary"])
|
| 308 |
gr.HTML(
|
| 309 |
"""<div class="mtp-subtle">Uses public geocoding + OSM services. Please keep distances modest.</div>"""
|
|
@@ -313,7 +436,11 @@ def build_demo() -> gr.Blocks:
|
|
| 313 |
with gr.Group(elem_classes=["mtp-card"]):
|
| 314 |
out = gr.Image(label="Poster", type="filepath", show_label=True)
|
| 315 |
|
| 316 |
-
btn.click(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
if example_posters:
|
| 319 |
gr.Markdown("## Example gallery")
|
|
|
|
| 6 |
import re
|
| 7 |
import tempfile
|
| 8 |
import time
|
| 9 |
+
from enum import Enum
|
| 10 |
from functools import lru_cache
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
import gradio as gr
|
| 14 |
from geopy.geocoders import Nominatim
|
| 15 |
+
from pydantic import BaseModel, ValidationError, field_validator
|
| 16 |
|
| 17 |
import osmnx as ox
|
| 18 |
|
|
|
|
| 23 |
DEFAULT_DISTANCE_M = 10000
|
| 24 |
MIN_DISTANCE_M = 2000
|
| 25 |
MAX_DISTANCE_M = 20000
|
| 26 |
+
DEFAULT_DPI = 300
|
| 27 |
+
MIN_DPI = 150
|
| 28 |
+
MAX_DPI = 600
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class NetworkType(str, Enum):
|
| 32 |
+
ALL = "all"
|
| 33 |
+
ALL_PUBLIC = "all_public"
|
| 34 |
+
DRIVE = "drive"
|
| 35 |
+
DRIVE_SERVICE = "drive_service"
|
| 36 |
+
WALK = "walk"
|
| 37 |
+
BIKE = "bike"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class DistanceType(str, Enum):
|
| 41 |
+
BBOX = "bbox"
|
| 42 |
+
NETWORK = "network"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
NETWORK_TYPES = [item.value for item in NetworkType]
|
| 46 |
+
DIST_TYPES = [item.value for item in DistanceType]
|
| 47 |
|
| 48 |
|
| 49 |
_REPO_ROOT = Path(__file__).resolve().parent
|
| 50 |
|
| 51 |
|
| 52 |
+
class GenerateRequest(BaseModel):
|
| 53 |
+
city: str
|
| 54 |
+
country: str
|
| 55 |
+
theme: str
|
| 56 |
+
distance_m: int
|
| 57 |
+
dpi: int
|
| 58 |
+
network_type: NetworkType
|
| 59 |
+
dist_type: DistanceType
|
| 60 |
+
|
| 61 |
+
@field_validator("city", "country", "theme")
|
| 62 |
+
@classmethod
|
| 63 |
+
def _strip_and_require(cls, value: str) -> str:
|
| 64 |
+
value = (value or "").strip()
|
| 65 |
+
if not value:
|
| 66 |
+
raise ValueError("must be provided")
|
| 67 |
+
return value
|
| 68 |
+
|
| 69 |
+
@field_validator("distance_m")
|
| 70 |
+
@classmethod
|
| 71 |
+
def _validate_distance(cls, value: int) -> int:
|
| 72 |
+
value = int(value)
|
| 73 |
+
if value < MIN_DISTANCE_M or value > MAX_DISTANCE_M:
|
| 74 |
+
raise ValueError(
|
| 75 |
+
f"Distance must be between {MIN_DISTANCE_M} and {MAX_DISTANCE_M} meters."
|
| 76 |
+
)
|
| 77 |
+
return value
|
| 78 |
+
|
| 79 |
+
@field_validator("dpi")
|
| 80 |
+
@classmethod
|
| 81 |
+
def _validate_dpi(cls, value: int) -> int:
|
| 82 |
+
value = int(value)
|
| 83 |
+
if value < MIN_DPI or value > MAX_DPI:
|
| 84 |
+
raise ValueError(f"DPI must be between {MIN_DPI} and {MAX_DPI}.")
|
| 85 |
+
return value
|
| 86 |
+
|
| 87 |
+
@field_validator("network_type", mode="before")
|
| 88 |
+
@classmethod
|
| 89 |
+
def _validate_network_type(cls, value: str | NetworkType) -> NetworkType:
|
| 90 |
+
if isinstance(value, NetworkType):
|
| 91 |
+
return value
|
| 92 |
+
value = (value or "").strip()
|
| 93 |
+
try:
|
| 94 |
+
return NetworkType(value)
|
| 95 |
+
except ValueError as exc:
|
| 96 |
+
raise ValueError("Invalid network type.") from exc
|
| 97 |
+
|
| 98 |
+
@field_validator("dist_type", mode="before")
|
| 99 |
+
@classmethod
|
| 100 |
+
def _validate_dist_type(cls, value: str | DistanceType) -> DistanceType:
|
| 101 |
+
if isinstance(value, DistanceType):
|
| 102 |
+
return value
|
| 103 |
+
value = (value or "").strip()
|
| 104 |
+
try:
|
| 105 |
+
return DistanceType(value)
|
| 106 |
+
except ValueError as exc:
|
| 107 |
+
raise ValueError("Invalid distance type.") from exc
|
| 108 |
+
|
| 109 |
+
|
| 110 |
def _load_readme_example_posters() -> list[tuple[str, str]]:
|
| 111 |
"""Return (absolute_path, caption) pairs for example posters referenced in README."""
|
| 112 |
|
|
|
|
| 188 |
_last_geocode_ts = time.time()
|
| 189 |
|
| 190 |
|
| 191 |
+
@lru_cache(maxsize=1)
|
| 192 |
def _geocoder() -> Nominatim:
|
| 193 |
user_agent = os.environ.get("MAPTOP_POSTER_USER_AGENT", "maptoposter-hf-space")
|
| 194 |
return Nominatim(user_agent=user_agent)
|
| 195 |
|
| 196 |
|
| 197 |
@lru_cache(maxsize=256)
|
| 198 |
+
def _geocode(city: str, country: str) -> maptoposter.Coordinates:
|
| 199 |
_rate_limit_geocode()
|
| 200 |
location = _geocoder().geocode(f"{city}, {country}")
|
| 201 |
if not location:
|
| 202 |
raise ValueError(f"Could not find coordinates for {city}, {country}")
|
| 203 |
+
return maptoposter.Coordinates(
|
| 204 |
+
lat=float(location.latitude),
|
| 205 |
+
lon=float(location.longitude),
|
| 206 |
+
)
|
|
|
|
|
|
|
| 207 |
|
|
|
|
|
|
|
| 208 |
|
| 209 |
+
def generate(
|
| 210 |
+
city: str,
|
| 211 |
+
country: str,
|
| 212 |
+
theme: str,
|
| 213 |
+
distance_m: int,
|
| 214 |
+
dpi: int,
|
| 215 |
+
network_type: str,
|
| 216 |
+
dist_type: str,
|
| 217 |
+
) -> str:
|
| 218 |
+
try:
|
| 219 |
+
request = GenerateRequest(
|
| 220 |
+
city=city,
|
| 221 |
+
country=country,
|
| 222 |
+
theme=theme,
|
| 223 |
+
distance_m=distance_m,
|
| 224 |
+
dpi=dpi,
|
| 225 |
+
network_type=network_type,
|
| 226 |
+
dist_type=dist_type,
|
| 227 |
)
|
| 228 |
+
except ValidationError as exc:
|
| 229 |
+
raise gr.Error(str(exc))
|
| 230 |
|
| 231 |
available_themes = maptoposter.get_available_themes()
|
| 232 |
+
if request.theme not in available_themes:
|
| 233 |
+
raise gr.Error(f"Unknown theme: {request.theme}")
|
| 234 |
|
| 235 |
+
maptoposter.THEME = maptoposter.load_theme(request.theme)
|
| 236 |
|
| 237 |
+
coords = _geocode(request.city, request.country)
|
| 238 |
|
| 239 |
tmp_dir = tempfile.gettempdir()
|
| 240 |
output_path = os.path.join(
|
| 241 |
tmp_dir,
|
| 242 |
+
f"{_slugify(request.city)}_{_slugify(request.theme)}_{int(time.time())}.png",
|
| 243 |
)
|
| 244 |
|
| 245 |
+
maptoposter.create_poster(
|
| 246 |
+
request.city,
|
| 247 |
+
request.country,
|
| 248 |
+
coords,
|
| 249 |
+
request.distance_m,
|
| 250 |
+
output_path,
|
| 251 |
+
network_type=request.network_type.value,
|
| 252 |
+
dist_type=request.dist_type.value,
|
| 253 |
+
dpi=request.dpi,
|
| 254 |
+
)
|
| 255 |
return output_path
|
| 256 |
|
| 257 |
|
|
|
|
| 407 |
value=DEFAULT_DISTANCE_M,
|
| 408 |
)
|
| 409 |
|
| 410 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 411 |
+
with gr.Row():
|
| 412 |
+
dpi = gr.Slider(
|
| 413 |
+
label="DPI",
|
| 414 |
+
minimum=MIN_DPI,
|
| 415 |
+
maximum=MAX_DPI,
|
| 416 |
+
step=10,
|
| 417 |
+
value=DEFAULT_DPI,
|
| 418 |
+
)
|
| 419 |
+
network_type = gr.Dropdown(
|
| 420 |
+
label="Network type",
|
| 421 |
+
choices=NETWORK_TYPES,
|
| 422 |
+
value="all",
|
| 423 |
+
)
|
| 424 |
+
dist_type = gr.Dropdown(
|
| 425 |
+
label="Distance type",
|
| 426 |
+
choices=DIST_TYPES,
|
| 427 |
+
value="bbox",
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
btn = gr.Button("Generate poster", elem_classes=["mtp-primary"])
|
| 431 |
gr.HTML(
|
| 432 |
"""<div class="mtp-subtle">Uses public geocoding + OSM services. Please keep distances modest.</div>"""
|
|
|
|
| 436 |
with gr.Group(elem_classes=["mtp-card"]):
|
| 437 |
out = gr.Image(label="Poster", type="filepath", show_label=True)
|
| 438 |
|
| 439 |
+
btn.click(
|
| 440 |
+
generate,
|
| 441 |
+
inputs=[city, country, theme, distance, dpi, network_type, dist_type],
|
| 442 |
+
outputs=[out],
|
| 443 |
+
)
|
| 444 |
|
| 445 |
if example_posters:
|
| 446 |
gr.Markdown("## Example gallery")
|
create_map_poster.py
CHANGED
|
@@ -8,35 +8,98 @@ from tqdm import tqdm
|
|
| 8 |
import time
|
| 9 |
import json
|
| 10 |
import os
|
|
|
|
| 11 |
from datetime import datetime
|
| 12 |
import argparse
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
THEMES_DIR = "themes"
|
| 15 |
FONTS_DIR = "fonts"
|
| 16 |
POSTERS_DIR = "posters"
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
Load Roboto fonts from the fonts directory.
|
| 21 |
-
Returns
|
| 22 |
"""
|
| 23 |
-
fonts =
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
for weight, path in fonts.items():
|
| 31 |
if not os.path.exists(path):
|
| 32 |
print(f"⚠ Font not found: {path}")
|
| 33 |
return None
|
| 34 |
-
|
| 35 |
return fonts
|
| 36 |
|
| 37 |
FONTS = load_fonts()
|
| 38 |
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
"""
|
| 41 |
Generate unique output filename with city, theme, and datetime.
|
| 42 |
"""
|
|
@@ -48,7 +111,7 @@ def generate_output_filename(city, theme_name):
|
|
| 48 |
filename = f"{city_slug}_{theme_name}_{timestamp}.png"
|
| 49 |
return os.path.join(POSTERS_DIR, filename)
|
| 50 |
|
| 51 |
-
def get_available_themes():
|
| 52 |
"""
|
| 53 |
Scans the themes directory and returns a list of available theme names.
|
| 54 |
"""
|
|
@@ -63,7 +126,7 @@ def get_available_themes():
|
|
| 63 |
themes.append(theme_name)
|
| 64 |
return themes
|
| 65 |
|
| 66 |
-
def load_theme(theme_name="feature_based"):
|
| 67 |
"""
|
| 68 |
Load theme from JSON file in themes directory.
|
| 69 |
"""
|
|
@@ -71,34 +134,25 @@ def load_theme(theme_name="feature_based"):
|
|
| 71 |
|
| 72 |
if not os.path.exists(theme_file):
|
| 73 |
print(f"⚠ Theme file '{theme_file}' not found. Using default feature_based theme.")
|
| 74 |
-
|
| 75 |
-
return {
|
| 76 |
-
"name": "Feature-Based Shading",
|
| 77 |
-
"bg": "#FFFFFF",
|
| 78 |
-
"text": "#000000",
|
| 79 |
-
"gradient_color": "#FFFFFF",
|
| 80 |
-
"water": "#C0C0C0",
|
| 81 |
-
"parks": "#F0F0F0",
|
| 82 |
-
"road_motorway": "#0A0A0A",
|
| 83 |
-
"road_primary": "#1A1A1A",
|
| 84 |
-
"road_secondary": "#2A2A2A",
|
| 85 |
-
"road_tertiary": "#3A3A3A",
|
| 86 |
-
"road_residential": "#4A4A4A",
|
| 87 |
-
"road_default": "#3A3A3A"
|
| 88 |
-
}
|
| 89 |
|
| 90 |
-
with open(theme_file,
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
return theme
|
| 96 |
|
| 97 |
# Load theme (can be changed via command line or input)
|
| 98 |
-
THEME = None # Will be loaded later
|
| 99 |
|
| 100 |
|
| 101 |
-
def main(argv=None):
|
| 102 |
parser = argparse.ArgumentParser(
|
| 103 |
description="Generate beautiful map posters for any city",
|
| 104 |
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
@@ -152,9 +206,15 @@ Examples:
|
|
| 152 |
|
| 153 |
# Get coordinates and generate poster
|
| 154 |
try:
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
print("\n" + "=" * 50)
|
| 160 |
print("✓ Poster generation complete!")
|
|
@@ -167,7 +227,7 @@ Examples:
|
|
| 167 |
traceback.print_exc()
|
| 168 |
return 1
|
| 169 |
|
| 170 |
-
def create_gradient_fade(ax, color, location=
|
| 171 |
"""
|
| 172 |
Creates a fade effect at the top or bottom of the map.
|
| 173 |
"""
|
|
@@ -201,12 +261,19 @@ def create_gradient_fade(ax, color, location='bottom', zorder=10):
|
|
| 201 |
ax.imshow(gradient, extent=[xlim[0], xlim[1], y_bottom, y_top],
|
| 202 |
aspect='auto', cmap=custom_cmap, zorder=zorder, origin='lower')
|
| 203 |
|
| 204 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
"""
|
| 206 |
Assigns colors to edges based on road type hierarchy.
|
| 207 |
Returns a list of colors corresponding to each edge in the graph.
|
| 208 |
"""
|
| 209 |
-
|
|
|
|
| 210 |
|
| 211 |
for u, v, data in G.edges(data=True):
|
| 212 |
# Get the highway type (can be a list or string)
|
|
@@ -217,29 +284,29 @@ def get_edge_colors_by_type(G):
|
|
| 217 |
highway = highway[0] if highway else 'unclassified'
|
| 218 |
|
| 219 |
# Assign color based on road type
|
| 220 |
-
if highway in [
|
| 221 |
-
color =
|
| 222 |
-
elif highway in [
|
| 223 |
-
color =
|
| 224 |
-
elif highway in [
|
| 225 |
-
color =
|
| 226 |
-
elif highway in [
|
| 227 |
-
color =
|
| 228 |
-
elif highway in [
|
| 229 |
-
color =
|
| 230 |
else:
|
| 231 |
-
color =
|
| 232 |
|
| 233 |
edge_colors.append(color)
|
| 234 |
|
| 235 |
return edge_colors
|
| 236 |
|
| 237 |
-
def get_edge_widths_by_type(G):
|
| 238 |
"""
|
| 239 |
Assigns line widths to edges based on road type.
|
| 240 |
Major roads get thicker lines.
|
| 241 |
"""
|
| 242 |
-
edge_widths = []
|
| 243 |
|
| 244 |
for u, v, data in G.edges(data=True):
|
| 245 |
highway = data.get('highway', 'unclassified')
|
|
@@ -263,14 +330,15 @@ def get_edge_widths_by_type(G):
|
|
| 263 |
|
| 264 |
return edge_widths
|
| 265 |
|
| 266 |
-
|
|
|
|
| 267 |
"""
|
| 268 |
Fetches coordinates for a given city and country using geopy.
|
| 269 |
Includes rate limiting to be respectful to the geocoding service.
|
| 270 |
"""
|
| 271 |
print("Looking up coordinates...")
|
| 272 |
-
geolocator =
|
| 273 |
-
|
| 274 |
# Add a small delay to respect Nominatim's usage policy
|
| 275 |
time.sleep(1)
|
| 276 |
|
|
@@ -279,25 +347,50 @@ def get_coordinates(city, country):
|
|
| 279 |
if location:
|
| 280 |
print(f"✓ Found: {location.address}")
|
| 281 |
print(f"✓ Coordinates: {location.latitude}, {location.longitude}")
|
| 282 |
-
return (location.latitude, location.longitude)
|
| 283 |
else:
|
| 284 |
raise ValueError(f"Could not find coordinates for {city}, {country}")
|
| 285 |
|
| 286 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
print(f"\nGenerating map for {city}, {country}...")
|
|
|
|
|
|
|
| 288 |
|
| 289 |
# Progress bar for data fetching
|
| 290 |
with tqdm(total=3, desc="Fetching map data", unit="step", bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}') as pbar:
|
| 291 |
# 1. Fetch Street Network
|
| 292 |
pbar.set_description("Downloading street network")
|
| 293 |
-
G = ox.graph_from_point(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
pbar.update(1)
|
| 295 |
time.sleep(0.5) # Rate limit between requests
|
| 296 |
|
| 297 |
# 2. Fetch Water Features
|
| 298 |
pbar.set_description("Downloading water features")
|
| 299 |
try:
|
| 300 |
-
water = ox.features_from_point(
|
| 301 |
except:
|
| 302 |
water = None
|
| 303 |
pbar.update(1)
|
|
@@ -306,7 +399,7 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 306 |
# 3. Fetch Parks
|
| 307 |
pbar.set_description("Downloading parks/green spaces")
|
| 308 |
try:
|
| 309 |
-
parks = ox.features_from_point(
|
| 310 |
except:
|
| 311 |
parks = None
|
| 312 |
pbar.update(1)
|
|
@@ -315,16 +408,16 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 315 |
|
| 316 |
# 2. Setup Plot
|
| 317 |
print("Rendering map...")
|
| 318 |
-
fig, ax = plt.subplots(figsize=(12, 16), facecolor=
|
| 319 |
-
ax.set_facecolor(
|
| 320 |
ax.set_position([0, 0, 1, 1])
|
| 321 |
|
| 322 |
# 3. Plot Layers
|
| 323 |
# Layer 1: Polygons
|
| 324 |
if water is not None and not water.empty:
|
| 325 |
-
water.plot(ax=ax, facecolor=
|
| 326 |
if parks is not None and not parks.empty:
|
| 327 |
-
parks.plot(ax=ax, facecolor=
|
| 328 |
|
| 329 |
# Layer 2: Roads with hierarchy coloring
|
| 330 |
print("Applying road hierarchy colors...")
|
|
@@ -332,7 +425,7 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 332 |
edge_widths = get_edge_widths_by_type(G)
|
| 333 |
|
| 334 |
ox.plot_graph(
|
| 335 |
-
G, ax=ax, bgcolor=
|
| 336 |
node_size=0,
|
| 337 |
edge_color=edge_colors,
|
| 338 |
edge_linewidth=edge_widths,
|
|
@@ -340,15 +433,15 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 340 |
)
|
| 341 |
|
| 342 |
# Layer 3: Gradients (Top and Bottom)
|
| 343 |
-
create_gradient_fade(ax,
|
| 344 |
-
create_gradient_fade(ax,
|
| 345 |
|
| 346 |
# 4. Typography using Roboto font
|
| 347 |
if FONTS:
|
| 348 |
-
font_main = FontProperties(fname=FONTS
|
| 349 |
-
font_top = FontProperties(fname=FONTS
|
| 350 |
-
font_sub = FontProperties(fname=FONTS
|
| 351 |
-
font_coords = FontProperties(fname=FONTS
|
| 352 |
else:
|
| 353 |
# Fallback to system fonts
|
| 354 |
font_main = FontProperties(family='monospace', weight='bold', size=60)
|
|
@@ -360,35 +453,35 @@ def create_poster(city, country, point, dist, output_file):
|
|
| 360 |
|
| 361 |
# --- BOTTOM TEXT ---
|
| 362 |
ax.text(0.5, 0.14, spaced_city, transform=ax.transAxes,
|
| 363 |
-
color=
|
| 364 |
|
| 365 |
ax.text(0.5, 0.10, country.upper(), transform=ax.transAxes,
|
| 366 |
-
color=
|
| 367 |
|
| 368 |
-
lat, lon =
|
| 369 |
coords = f"{lat:.4f}° N / {lon:.4f}° E" if lat >= 0 else f"{abs(lat):.4f}° S / {lon:.4f}° E"
|
| 370 |
if lon < 0:
|
| 371 |
coords = coords.replace("E", "W")
|
| 372 |
|
| 373 |
ax.text(0.5, 0.07, coords, transform=ax.transAxes,
|
| 374 |
-
color=
|
| 375 |
|
| 376 |
ax.plot([0.4, 0.6], [0.125, 0.125], transform=ax.transAxes,
|
| 377 |
-
color=
|
| 378 |
|
| 379 |
# --- ATTRIBUTION (bottom right) ---
|
| 380 |
if FONTS:
|
| 381 |
-
font_attr = FontProperties(fname=FONTS
|
| 382 |
else:
|
| 383 |
font_attr = FontProperties(family='monospace', size=8)
|
| 384 |
|
| 385 |
ax.text(0.98, 0.02, "© OpenStreetMap contributors", transform=ax.transAxes,
|
| 386 |
-
color=
|
| 387 |
fontproperties=font_attr, zorder=11)
|
| 388 |
|
| 389 |
# 5. Save
|
| 390 |
print(f"Saving to {output_file}...")
|
| 391 |
-
plt.savefig(output_file, dpi=
|
| 392 |
plt.close()
|
| 393 |
print(f"✓ Done! Poster saved as {output_file}")
|
| 394 |
|
|
@@ -460,13 +553,14 @@ def list_themes():
|
|
| 460 |
for theme_name in available_themes:
|
| 461 |
theme_path = os.path.join(THEMES_DIR, f"{theme_name}.json")
|
| 462 |
try:
|
| 463 |
-
with open(theme_path,
|
| 464 |
theme_data = json.load(f)
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
|
|
|
| 468 |
display_name = theme_name
|
| 469 |
-
description =
|
| 470 |
print(f" {theme_name}")
|
| 471 |
print(f" {display_name}")
|
| 472 |
if description:
|
|
|
|
| 8 |
import time
|
| 9 |
import json
|
| 10 |
import os
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
from datetime import datetime
|
| 13 |
import argparse
|
| 14 |
+
from functools import lru_cache
|
| 15 |
+
from typing import Optional, Sequence
|
| 16 |
+
|
| 17 |
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
| 18 |
|
| 19 |
THEMES_DIR = "themes"
|
| 20 |
FONTS_DIR = "fonts"
|
| 21 |
POSTERS_DIR = "posters"
|
| 22 |
|
| 23 |
+
|
| 24 |
+
def _configure_osmnx_cache() -> None:
|
| 25 |
+
cache_dir = os.environ.get("OSMNX_CACHE_DIR", "/tmp/osmnx_cache")
|
| 26 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 27 |
+
ox.settings.use_cache = True
|
| 28 |
+
ox.settings.cache_folder = cache_dir
|
| 29 |
+
ox.settings.log_console = False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
_configure_osmnx_cache()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class FontPaths:
|
| 37 |
+
bold: str
|
| 38 |
+
regular: str
|
| 39 |
+
light: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass(frozen=True)
|
| 43 |
+
class Coordinates:
|
| 44 |
+
lat: float
|
| 45 |
+
lon: float
|
| 46 |
+
|
| 47 |
+
def as_tuple(self) -> tuple[float, float]:
|
| 48 |
+
return (self.lat, self.lon)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass(frozen=True)
|
| 52 |
+
class PosterRequest:
|
| 53 |
+
city: str
|
| 54 |
+
country: str
|
| 55 |
+
theme: str
|
| 56 |
+
distance_m: int
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ThemeConfig(BaseModel):
|
| 60 |
+
model_config = ConfigDict(extra="ignore")
|
| 61 |
+
|
| 62 |
+
name: str = "Feature-Based Shading"
|
| 63 |
+
description: Optional[str] = None
|
| 64 |
+
bg: str = "#FFFFFF"
|
| 65 |
+
text: str = "#000000"
|
| 66 |
+
gradient_color: str = "#FFFFFF"
|
| 67 |
+
water: str = "#C0C0C0"
|
| 68 |
+
parks: str = "#F0F0F0"
|
| 69 |
+
road_motorway: str = "#0A0A0A"
|
| 70 |
+
road_primary: str = "#1A1A1A"
|
| 71 |
+
road_secondary: str = "#2A2A2A"
|
| 72 |
+
road_tertiary: str = "#3A3A3A"
|
| 73 |
+
road_residential: str = "#4A4A4A"
|
| 74 |
+
road_default: str = "#3A3A3A"
|
| 75 |
+
|
| 76 |
+
def load_fonts() -> Optional[FontPaths]:
|
| 77 |
"""
|
| 78 |
Load Roboto fonts from the fonts directory.
|
| 79 |
+
Returns FontPaths for different weights.
|
| 80 |
"""
|
| 81 |
+
fonts = FontPaths(
|
| 82 |
+
bold=os.path.join(FONTS_DIR, "Roboto-Bold.ttf"),
|
| 83 |
+
regular=os.path.join(FONTS_DIR, "Roboto-Regular.ttf"),
|
| 84 |
+
light=os.path.join(FONTS_DIR, "Roboto-Light.ttf"),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
for path in (fonts.bold, fonts.regular, fonts.light):
|
|
|
|
| 88 |
if not os.path.exists(path):
|
| 89 |
print(f"⚠ Font not found: {path}")
|
| 90 |
return None
|
| 91 |
+
|
| 92 |
return fonts
|
| 93 |
|
| 94 |
FONTS = load_fonts()
|
| 95 |
|
| 96 |
+
|
| 97 |
+
@lru_cache(maxsize=1)
|
| 98 |
+
def _geocoder() -> Nominatim:
|
| 99 |
+
user_agent = os.environ.get("MAPTOP_POSTER_USER_AGENT", "maptoposter-cli")
|
| 100 |
+
return Nominatim(user_agent=user_agent)
|
| 101 |
+
|
| 102 |
+
def generate_output_filename(city: str, theme_name: str) -> str:
|
| 103 |
"""
|
| 104 |
Generate unique output filename with city, theme, and datetime.
|
| 105 |
"""
|
|
|
|
| 111 |
filename = f"{city_slug}_{theme_name}_{timestamp}.png"
|
| 112 |
return os.path.join(POSTERS_DIR, filename)
|
| 113 |
|
| 114 |
+
def get_available_themes() -> list[str]:
|
| 115 |
"""
|
| 116 |
Scans the themes directory and returns a list of available theme names.
|
| 117 |
"""
|
|
|
|
| 126 |
themes.append(theme_name)
|
| 127 |
return themes
|
| 128 |
|
| 129 |
+
def load_theme(theme_name: str = "feature_based") -> ThemeConfig:
|
| 130 |
"""
|
| 131 |
Load theme from JSON file in themes directory.
|
| 132 |
"""
|
|
|
|
| 134 |
|
| 135 |
if not os.path.exists(theme_file):
|
| 136 |
print(f"⚠ Theme file '{theme_file}' not found. Using default feature_based theme.")
|
| 137 |
+
return ThemeConfig()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
with open(theme_file, "r") as f:
|
| 140 |
+
raw_theme = json.load(f)
|
| 141 |
+
try:
|
| 142 |
+
theme = ThemeConfig(**raw_theme)
|
| 143 |
+
except ValidationError as exc:
|
| 144 |
+
print(f"⚠ Theme file '{theme_file}' is invalid: {exc}")
|
| 145 |
+
theme = ThemeConfig()
|
| 146 |
+
print(f"✓ Loaded theme: {theme.name or theme_name}")
|
| 147 |
+
if theme.description:
|
| 148 |
+
print(f" {theme.description}")
|
| 149 |
return theme
|
| 150 |
|
| 151 |
# Load theme (can be changed via command line or input)
|
| 152 |
+
THEME: Optional[ThemeConfig] = None # Will be loaded later
|
| 153 |
|
| 154 |
|
| 155 |
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
| 156 |
parser = argparse.ArgumentParser(
|
| 157 |
description="Generate beautiful map posters for any city",
|
| 158 |
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
| 206 |
|
| 207 |
# Get coordinates and generate poster
|
| 208 |
try:
|
| 209 |
+
request = PosterRequest(
|
| 210 |
+
city=args.city,
|
| 211 |
+
country=args.country,
|
| 212 |
+
theme=args.theme,
|
| 213 |
+
distance_m=args.distance,
|
| 214 |
+
)
|
| 215 |
+
coords = get_coordinates(request.city, request.country)
|
| 216 |
+
output_file = generate_output_filename(request.city, request.theme)
|
| 217 |
+
create_poster(request.city, request.country, coords, request.distance_m, output_file)
|
| 218 |
|
| 219 |
print("\n" + "=" * 50)
|
| 220 |
print("✓ Poster generation complete!")
|
|
|
|
| 227 |
traceback.print_exc()
|
| 228 |
return 1
|
| 229 |
|
| 230 |
+
def create_gradient_fade(ax, color: str, location: str = "bottom", zorder: int = 10) -> None:
|
| 231 |
"""
|
| 232 |
Creates a fade effect at the top or bottom of the map.
|
| 233 |
"""
|
|
|
|
| 261 |
ax.imshow(gradient, extent=[xlim[0], xlim[1], y_bottom, y_top],
|
| 262 |
aspect='auto', cmap=custom_cmap, zorder=zorder, origin='lower')
|
| 263 |
|
| 264 |
+
def _require_theme() -> ThemeConfig:
|
| 265 |
+
if THEME is None:
|
| 266 |
+
raise RuntimeError("Theme is not loaded. Call load_theme() first.")
|
| 267 |
+
return THEME
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def get_edge_colors_by_type(G) -> list[str]:
|
| 271 |
"""
|
| 272 |
Assigns colors to edges based on road type hierarchy.
|
| 273 |
Returns a list of colors corresponding to each edge in the graph.
|
| 274 |
"""
|
| 275 |
+
theme = _require_theme()
|
| 276 |
+
edge_colors: list[str] = []
|
| 277 |
|
| 278 |
for u, v, data in G.edges(data=True):
|
| 279 |
# Get the highway type (can be a list or string)
|
|
|
|
| 284 |
highway = highway[0] if highway else 'unclassified'
|
| 285 |
|
| 286 |
# Assign color based on road type
|
| 287 |
+
if highway in ["motorway", "motorway_link"]:
|
| 288 |
+
color = theme.road_motorway
|
| 289 |
+
elif highway in ["trunk", "trunk_link", "primary", "primary_link"]:
|
| 290 |
+
color = theme.road_primary
|
| 291 |
+
elif highway in ["secondary", "secondary_link"]:
|
| 292 |
+
color = theme.road_secondary
|
| 293 |
+
elif highway in ["tertiary", "tertiary_link"]:
|
| 294 |
+
color = theme.road_tertiary
|
| 295 |
+
elif highway in ["residential", "living_street", "unclassified"]:
|
| 296 |
+
color = theme.road_residential
|
| 297 |
else:
|
| 298 |
+
color = theme.road_default
|
| 299 |
|
| 300 |
edge_colors.append(color)
|
| 301 |
|
| 302 |
return edge_colors
|
| 303 |
|
| 304 |
+
def get_edge_widths_by_type(G) -> list[float]:
|
| 305 |
"""
|
| 306 |
Assigns line widths to edges based on road type.
|
| 307 |
Major roads get thicker lines.
|
| 308 |
"""
|
| 309 |
+
edge_widths: list[float] = []
|
| 310 |
|
| 311 |
for u, v, data in G.edges(data=True):
|
| 312 |
highway = data.get('highway', 'unclassified')
|
|
|
|
| 330 |
|
| 331 |
return edge_widths
|
| 332 |
|
| 333 |
+
@lru_cache(maxsize=256)
|
| 334 |
+
def get_coordinates(city: str, country: str) -> Coordinates:
|
| 335 |
"""
|
| 336 |
Fetches coordinates for a given city and country using geopy.
|
| 337 |
Includes rate limiting to be respectful to the geocoding service.
|
| 338 |
"""
|
| 339 |
print("Looking up coordinates...")
|
| 340 |
+
geolocator = _geocoder()
|
| 341 |
+
|
| 342 |
# Add a small delay to respect Nominatim's usage policy
|
| 343 |
time.sleep(1)
|
| 344 |
|
|
|
|
| 347 |
if location:
|
| 348 |
print(f"✓ Found: {location.address}")
|
| 349 |
print(f"✓ Coordinates: {location.latitude}, {location.longitude}")
|
| 350 |
+
return Coordinates(lat=float(location.latitude), lon=float(location.longitude))
|
| 351 |
else:
|
| 352 |
raise ValueError(f"Could not find coordinates for {city}, {country}")
|
| 353 |
|
| 354 |
+
def _coerce_coordinates(point: Coordinates | Sequence[float]) -> Coordinates:
|
| 355 |
+
if isinstance(point, Coordinates):
|
| 356 |
+
return point
|
| 357 |
+
if isinstance(point, Sequence) and len(point) == 2:
|
| 358 |
+
return Coordinates(lat=float(point[0]), lon=float(point[1]))
|
| 359 |
+
raise TypeError("point must be Coordinates or (lat, lon) sequence")
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def create_poster(
|
| 363 |
+
city: str,
|
| 364 |
+
country: str,
|
| 365 |
+
point: Coordinates | Sequence[float],
|
| 366 |
+
dist: int,
|
| 367 |
+
output_file: str,
|
| 368 |
+
*,
|
| 369 |
+
network_type: str = "all",
|
| 370 |
+
dist_type: str = "bbox",
|
| 371 |
+
dpi: int = 300,
|
| 372 |
+
) -> None:
|
| 373 |
print(f"\nGenerating map for {city}, {country}...")
|
| 374 |
+
theme = _require_theme()
|
| 375 |
+
coords = _coerce_coordinates(point)
|
| 376 |
|
| 377 |
# Progress bar for data fetching
|
| 378 |
with tqdm(total=3, desc="Fetching map data", unit="step", bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}') as pbar:
|
| 379 |
# 1. Fetch Street Network
|
| 380 |
pbar.set_description("Downloading street network")
|
| 381 |
+
G = ox.graph_from_point(
|
| 382 |
+
coords.as_tuple(),
|
| 383 |
+
dist=dist,
|
| 384 |
+
dist_type=dist_type,
|
| 385 |
+
network_type=network_type,
|
| 386 |
+
)
|
| 387 |
pbar.update(1)
|
| 388 |
time.sleep(0.5) # Rate limit between requests
|
| 389 |
|
| 390 |
# 2. Fetch Water Features
|
| 391 |
pbar.set_description("Downloading water features")
|
| 392 |
try:
|
| 393 |
+
water = ox.features_from_point(coords.as_tuple(), tags={"natural": "water", "waterway": "riverbank"}, dist=dist)
|
| 394 |
except:
|
| 395 |
water = None
|
| 396 |
pbar.update(1)
|
|
|
|
| 399 |
# 3. Fetch Parks
|
| 400 |
pbar.set_description("Downloading parks/green spaces")
|
| 401 |
try:
|
| 402 |
+
parks = ox.features_from_point(coords.as_tuple(), tags={"leisure": "park", "landuse": "grass"}, dist=dist)
|
| 403 |
except:
|
| 404 |
parks = None
|
| 405 |
pbar.update(1)
|
|
|
|
| 408 |
|
| 409 |
# 2. Setup Plot
|
| 410 |
print("Rendering map...")
|
| 411 |
+
fig, ax = plt.subplots(figsize=(12, 16), facecolor=theme.bg)
|
| 412 |
+
ax.set_facecolor(theme.bg)
|
| 413 |
ax.set_position([0, 0, 1, 1])
|
| 414 |
|
| 415 |
# 3. Plot Layers
|
| 416 |
# Layer 1: Polygons
|
| 417 |
if water is not None and not water.empty:
|
| 418 |
+
water.plot(ax=ax, facecolor=theme.water, edgecolor="none", zorder=1)
|
| 419 |
if parks is not None and not parks.empty:
|
| 420 |
+
parks.plot(ax=ax, facecolor=theme.parks, edgecolor="none", zorder=2)
|
| 421 |
|
| 422 |
# Layer 2: Roads with hierarchy coloring
|
| 423 |
print("Applying road hierarchy colors...")
|
|
|
|
| 425 |
edge_widths = get_edge_widths_by_type(G)
|
| 426 |
|
| 427 |
ox.plot_graph(
|
| 428 |
+
G, ax=ax, bgcolor=theme.bg,
|
| 429 |
node_size=0,
|
| 430 |
edge_color=edge_colors,
|
| 431 |
edge_linewidth=edge_widths,
|
|
|
|
| 433 |
)
|
| 434 |
|
| 435 |
# Layer 3: Gradients (Top and Bottom)
|
| 436 |
+
create_gradient_fade(ax, theme.gradient_color, location="bottom", zorder=10)
|
| 437 |
+
create_gradient_fade(ax, theme.gradient_color, location="top", zorder=10)
|
| 438 |
|
| 439 |
# 4. Typography using Roboto font
|
| 440 |
if FONTS:
|
| 441 |
+
font_main = FontProperties(fname=FONTS.bold, size=60)
|
| 442 |
+
font_top = FontProperties(fname=FONTS.bold, size=40)
|
| 443 |
+
font_sub = FontProperties(fname=FONTS.light, size=22)
|
| 444 |
+
font_coords = FontProperties(fname=FONTS.regular, size=14)
|
| 445 |
else:
|
| 446 |
# Fallback to system fonts
|
| 447 |
font_main = FontProperties(family='monospace', weight='bold', size=60)
|
|
|
|
| 453 |
|
| 454 |
# --- BOTTOM TEXT ---
|
| 455 |
ax.text(0.5, 0.14, spaced_city, transform=ax.transAxes,
|
| 456 |
+
color=theme.text, ha="center", fontproperties=font_main, zorder=11)
|
| 457 |
|
| 458 |
ax.text(0.5, 0.10, country.upper(), transform=ax.transAxes,
|
| 459 |
+
color=theme.text, ha="center", fontproperties=font_sub, zorder=11)
|
| 460 |
|
| 461 |
+
lat, lon = coords.lat, coords.lon
|
| 462 |
coords = f"{lat:.4f}° N / {lon:.4f}° E" if lat >= 0 else f"{abs(lat):.4f}° S / {lon:.4f}° E"
|
| 463 |
if lon < 0:
|
| 464 |
coords = coords.replace("E", "W")
|
| 465 |
|
| 466 |
ax.text(0.5, 0.07, coords, transform=ax.transAxes,
|
| 467 |
+
color=theme.text, alpha=0.7, ha="center", fontproperties=font_coords, zorder=11)
|
| 468 |
|
| 469 |
ax.plot([0.4, 0.6], [0.125, 0.125], transform=ax.transAxes,
|
| 470 |
+
color=theme.text, linewidth=1, zorder=11)
|
| 471 |
|
| 472 |
# --- ATTRIBUTION (bottom right) ---
|
| 473 |
if FONTS:
|
| 474 |
+
font_attr = FontProperties(fname=FONTS.light, size=8)
|
| 475 |
else:
|
| 476 |
font_attr = FontProperties(family='monospace', size=8)
|
| 477 |
|
| 478 |
ax.text(0.98, 0.02, "© OpenStreetMap contributors", transform=ax.transAxes,
|
| 479 |
+
color=theme.text, alpha=0.5, ha="right", va="bottom",
|
| 480 |
fontproperties=font_attr, zorder=11)
|
| 481 |
|
| 482 |
# 5. Save
|
| 483 |
print(f"Saving to {output_file}...")
|
| 484 |
+
plt.savefig(output_file, dpi=int(dpi), facecolor=theme.bg)
|
| 485 |
plt.close()
|
| 486 |
print(f"✓ Done! Poster saved as {output_file}")
|
| 487 |
|
|
|
|
| 553 |
for theme_name in available_themes:
|
| 554 |
theme_path = os.path.join(THEMES_DIR, f"{theme_name}.json")
|
| 555 |
try:
|
| 556 |
+
with open(theme_path, "r") as f:
|
| 557 |
theme_data = json.load(f)
|
| 558 |
+
theme = ThemeConfig(**theme_data)
|
| 559 |
+
display_name = theme.name or theme_name
|
| 560 |
+
description = theme.description or ""
|
| 561 |
+
except Exception:
|
| 562 |
display_name = theme_name
|
| 563 |
+
description = ""
|
| 564 |
print(f" {theme_name}")
|
| 565 |
print(f" {display_name}")
|
| 566 |
if description:
|
pyproject.toml
CHANGED
|
@@ -32,6 +32,7 @@ dependencies = [
|
|
| 32 |
"packaging==25.0",
|
| 33 |
"pandas==2.3.3",
|
| 34 |
"pillow==12.1.0",
|
|
|
|
| 35 |
"pyogrio==0.12.1",
|
| 36 |
"pyparsing==3.3.1",
|
| 37 |
"pyproj==3.7.2",
|
|
|
|
| 32 |
"packaging==25.0",
|
| 33 |
"pandas==2.3.3",
|
| 34 |
"pillow==12.1.0",
|
| 35 |
+
"pydantic==2.11.1",
|
| 36 |
"pyogrio==0.12.1",
|
| 37 |
"pyparsing==3.3.1",
|
| 38 |
"pyproj==3.7.2",
|
requirements.txt
CHANGED
|
@@ -21,6 +21,7 @@ osmnx==2.0.7
|
|
| 21 |
packaging==25.0
|
| 22 |
pandas==2.3.3
|
| 23 |
pillow==12.1.0
|
|
|
|
| 24 |
pyogrio==0.12.1
|
| 25 |
pyparsing==3.3.1
|
| 26 |
pyproj==3.7.2
|
|
|
|
| 21 |
packaging==25.0
|
| 22 |
pandas==2.3.3
|
| 23 |
pillow==12.1.0
|
| 24 |
+
pydantic==2.11.1
|
| 25 |
pyogrio==0.12.1
|
| 26 |
pyparsing==3.3.1
|
| 27 |
pyproj==3.7.2
|
uv.lock
CHANGED
|
@@ -758,6 +758,7 @@ dependencies = [
|
|
| 758 |
{ name = "packaging" },
|
| 759 |
{ name = "pandas" },
|
| 760 |
{ name = "pillow" },
|
|
|
|
| 761 |
{ name = "pyogrio" },
|
| 762 |
{ name = "pyparsing" },
|
| 763 |
{ name = "pyproj" },
|
|
@@ -792,6 +793,7 @@ requires-dist = [
|
|
| 792 |
{ name = "packaging", specifier = "==25.0" },
|
| 793 |
{ name = "pandas", specifier = "==2.3.3" },
|
| 794 |
{ name = "pillow", specifier = "==12.1.0" },
|
|
|
|
| 795 |
{ name = "pyogrio", specifier = "==0.12.1" },
|
| 796 |
{ name = "pyparsing", specifier = "==3.3.1" },
|
| 797 |
{ name = "pyproj", specifier = "==3.7.2" },
|
|
@@ -1290,7 +1292,7 @@ wheels = [
|
|
| 1290 |
|
| 1291 |
[[package]]
|
| 1292 |
name = "pydantic"
|
| 1293 |
-
version = "2.
|
| 1294 |
source = { registry = "https://pypi.org/simple" }
|
| 1295 |
dependencies = [
|
| 1296 |
{ name = "annotated-types" },
|
|
@@ -1298,98 +1300,74 @@ dependencies = [
|
|
| 1298 |
{ name = "typing-extensions" },
|
| 1299 |
{ name = "typing-inspection" },
|
| 1300 |
]
|
| 1301 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 1302 |
wheels = [
|
| 1303 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1304 |
]
|
| 1305 |
|
| 1306 |
[[package]]
|
| 1307 |
name = "pydantic-core"
|
| 1308 |
-
version = "2.
|
| 1309 |
source = { registry = "https://pypi.org/simple" }
|
| 1310 |
dependencies = [
|
| 1311 |
{ name = "typing-extensions" },
|
| 1312 |
]
|
| 1313 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 1314 |
-
wheels = [
|
| 1315 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1316 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1317 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1318 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1319 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1320 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1321 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1322 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1323 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1324 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1325 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1326 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1327 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1328 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1329 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1330 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1331 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1332 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1333 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1334 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1335 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1336 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1337 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1338 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1339 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1340 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1341 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1342 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1343 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1344 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1345 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1346 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1347 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1348 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1349 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1350 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1351 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1352 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1353 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1354 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1355 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1356 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1357 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1358 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1359 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1360 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1361 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1362 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1363 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1364 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1365 |
-
{ url = "https://files.pythonhosted.org/packages/d3/
|
| 1366 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1367 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1368 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 1369 |
-
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 },
|
| 1370 |
-
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 },
|
| 1371 |
-
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 },
|
| 1372 |
-
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 },
|
| 1373 |
-
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 },
|
| 1374 |
-
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 },
|
| 1375 |
-
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 },
|
| 1376 |
-
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 },
|
| 1377 |
-
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 },
|
| 1378 |
-
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 },
|
| 1379 |
-
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 },
|
| 1380 |
-
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 },
|
| 1381 |
-
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 },
|
| 1382 |
-
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 },
|
| 1383 |
-
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 },
|
| 1384 |
-
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 },
|
| 1385 |
-
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
|
| 1386 |
-
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
|
| 1387 |
-
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },
|
| 1388 |
-
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 },
|
| 1389 |
-
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 },
|
| 1390 |
-
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 },
|
| 1391 |
-
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 },
|
| 1392 |
-
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 },
|
| 1393 |
]
|
| 1394 |
|
| 1395 |
[[package]]
|
|
|
|
| 758 |
{ name = "packaging" },
|
| 759 |
{ name = "pandas" },
|
| 760 |
{ name = "pillow" },
|
| 761 |
+
{ name = "pydantic" },
|
| 762 |
{ name = "pyogrio" },
|
| 763 |
{ name = "pyparsing" },
|
| 764 |
{ name = "pyproj" },
|
|
|
|
| 793 |
{ name = "packaging", specifier = "==25.0" },
|
| 794 |
{ name = "pandas", specifier = "==2.3.3" },
|
| 795 |
{ name = "pillow", specifier = "==12.1.0" },
|
| 796 |
+
{ name = "pydantic", specifier = "==2.11.1" },
|
| 797 |
{ name = "pyogrio", specifier = "==0.12.1" },
|
| 798 |
{ name = "pyparsing", specifier = "==3.3.1" },
|
| 799 |
{ name = "pyproj", specifier = "==3.7.2" },
|
|
|
|
| 1292 |
|
| 1293 |
[[package]]
|
| 1294 |
name = "pydantic"
|
| 1295 |
+
version = "2.11.1"
|
| 1296 |
source = { registry = "https://pypi.org/simple" }
|
| 1297 |
dependencies = [
|
| 1298 |
{ name = "annotated-types" },
|
|
|
|
| 1300 |
{ name = "typing-extensions" },
|
| 1301 |
{ name = "typing-inspection" },
|
| 1302 |
]
|
| 1303 |
+
sdist = { url = "https://files.pythonhosted.org/packages/93/a3/698b87a4d4d303d7c5f62ea5fbf7a79cab236ccfbd0a17847b7f77f8163e/pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968", size = 782817 }
|
| 1304 |
wheels = [
|
| 1305 |
+
{ url = "https://files.pythonhosted.org/packages/cc/12/f9221a949f2419e2e23847303c002476c26fbcfd62dc7f3d25d0bec5ca99/pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8", size = 442648 },
|
| 1306 |
]
|
| 1307 |
|
| 1308 |
[[package]]
|
| 1309 |
name = "pydantic-core"
|
| 1310 |
+
version = "2.33.0"
|
| 1311 |
source = { registry = "https://pypi.org/simple" }
|
| 1312 |
dependencies = [
|
| 1313 |
{ name = "typing-extensions" },
|
| 1314 |
]
|
| 1315 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b9/05/91ce14dfd5a3a99555fce436318cc0fd1f08c4daa32b3248ad63669ea8b4/pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3", size = 434080 }
|
| 1316 |
+
wheels = [
|
| 1317 |
+
{ url = "https://files.pythonhosted.org/packages/f0/93/9e97af2619b4026596487a79133e425c7d3c374f0a7f100f3d76bcdf9c83/pydantic_core-2.33.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a608a75846804271cf9c83e40bbb4dab2ac614d33c6fd5b0c6187f53f5c593ef", size = 2042784 },
|
| 1318 |
+
{ url = "https://files.pythonhosted.org/packages/42/b4/0bba8412fd242729feeb80e7152e24f0e1a1c19f4121ca3d4a307f4e6222/pydantic_core-2.33.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e1c69aa459f5609dec2fa0652d495353accf3eda5bdb18782bc5a2ae45c9273a", size = 1858179 },
|
| 1319 |
+
{ url = "https://files.pythonhosted.org/packages/69/1f/c1c40305d929bd08af863df64b0a26203b70b352a1962d86f3bcd52950fe/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9ec80eb5a5f45a2211793f1c4aeddff0c3761d1c70d684965c1807e923a588b", size = 1909396 },
|
| 1320 |
+
{ url = "https://files.pythonhosted.org/packages/0f/99/d2e727375c329c1e652b5d450fbb9d56e8c3933a397e4bd46e67c68c2cd5/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e925819a98318d17251776bd3d6aa9f3ff77b965762155bdad15d1a9265c4cfd", size = 1998264 },
|
| 1321 |
+
{ url = "https://files.pythonhosted.org/packages/9c/2e/3119a33931278d96ecc2e9e1b9d50c240636cfeb0c49951746ae34e4de74/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bf68bb859799e9cec3d9dd8323c40c00a254aabb56fe08f907e437005932f2b", size = 2140588 },
|
| 1322 |
+
{ url = "https://files.pythonhosted.org/packages/35/bd/9267bd1ba55f17c80ef6cb7e07b3890b4acbe8eb6014f3102092d53d9300/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b2ea72dea0825949a045fa4071f6d5b3d7620d2a208335207793cf29c5a182d", size = 2746296 },
|
| 1323 |
+
{ url = "https://files.pythonhosted.org/packages/6f/ed/ef37de6478a412ee627cbebd73e7b72a680f45bfacce9ff1199de6e17e88/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1583539533160186ac546b49f5cde9ffc928062c96920f58bd95de32ffd7bffd", size = 2005555 },
|
| 1324 |
+
{ url = "https://files.pythonhosted.org/packages/dd/84/72c8d1439585d8ee7bc35eb8f88a04a4d302ee4018871f1f85ae1b0c6625/pydantic_core-2.33.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23c3e77bf8a7317612e5c26a3b084c7edeb9552d645742a54a5867635b4f2453", size = 2124452 },
|
| 1325 |
+
{ url = "https://files.pythonhosted.org/packages/a7/8f/cb13de30c6a3e303423751a529a3d1271c2effee4b98cf3e397a66ae8498/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7a7f2a3f628d2f7ef11cb6188bcf0b9e1558151d511b974dfea10a49afe192b", size = 2087001 },
|
| 1326 |
+
{ url = "https://files.pythonhosted.org/packages/83/d0/e93dc8884bf288a63fedeb8040ac8f29cb71ca52e755f48e5170bb63e55b/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:f1fb026c575e16f673c61c7b86144517705865173f3d0907040ac30c4f9f5915", size = 2261663 },
|
| 1327 |
+
{ url = "https://files.pythonhosted.org/packages/4c/ba/4b7739c95efa0b542ee45fd872c8f6b1884ab808cf04ce7ac6621b6df76e/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:635702b2fed997e0ac256b2cfbdb4dd0bf7c56b5d8fba8ef03489c03b3eb40e2", size = 2257786 },
|
| 1328 |
+
{ url = "https://files.pythonhosted.org/packages/cc/98/73cbca1d2360c27752cfa2fcdcf14d96230e92d7d48ecd50499865c56bf7/pydantic_core-2.33.0-cp311-cp311-win32.whl", hash = "sha256:07b4ced28fccae3f00626eaa0c4001aa9ec140a29501770a88dbbb0966019a86", size = 1925697 },
|
| 1329 |
+
{ url = "https://files.pythonhosted.org/packages/9a/26/d85a40edeca5d8830ffc33667d6fef329fd0f4bc0c5181b8b0e206cfe488/pydantic_core-2.33.0-cp311-cp311-win_amd64.whl", hash = "sha256:4927564be53239a87770a5f86bdc272b8d1fbb87ab7783ad70255b4ab01aa25b", size = 1949859 },
|
| 1330 |
+
{ url = "https://files.pythonhosted.org/packages/7e/0b/5a381605f0b9870465b805f2c86c06b0a7c191668ebe4117777306c2c1e5/pydantic_core-2.33.0-cp311-cp311-win_arm64.whl", hash = "sha256:69297418ad644d521ea3e1aa2e14a2a422726167e9ad22b89e8f1130d68e1e9a", size = 1907978 },
|
| 1331 |
+
{ url = "https://files.pythonhosted.org/packages/a9/c4/c9381323cbdc1bb26d352bc184422ce77c4bc2f2312b782761093a59fafc/pydantic_core-2.33.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43", size = 2025127 },
|
| 1332 |
+
{ url = "https://files.pythonhosted.org/packages/6f/bd/af35278080716ecab8f57e84515c7dc535ed95d1c7f52c1c6f7b313a9dab/pydantic_core-2.33.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd", size = 1851687 },
|
| 1333 |
+
{ url = "https://files.pythonhosted.org/packages/12/e4/a01461225809c3533c23bd1916b1e8c2e21727f0fea60ab1acbffc4e2fca/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6", size = 1892232 },
|
| 1334 |
+
{ url = "https://files.pythonhosted.org/packages/51/17/3d53d62a328fb0a49911c2962036b9e7a4f781b7d15e9093c26299e5f76d/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6", size = 1977896 },
|
| 1335 |
+
{ url = "https://files.pythonhosted.org/packages/30/98/01f9d86e02ec4a38f4b02086acf067f2c776b845d43f901bd1ee1c21bc4b/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4", size = 2127717 },
|
| 1336 |
+
{ url = "https://files.pythonhosted.org/packages/3c/43/6f381575c61b7c58b0fd0b92134c5a1897deea4cdfc3d47567b3ff460a4e/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61", size = 2680287 },
|
| 1337 |
+
{ url = "https://files.pythonhosted.org/packages/01/42/c0d10d1451d161a9a0da9bbef023b8005aa26e9993a8cc24dc9e3aa96c93/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862", size = 2008276 },
|
| 1338 |
+
{ url = "https://files.pythonhosted.org/packages/20/ca/e08df9dba546905c70bae44ced9f3bea25432e34448d95618d41968f40b7/pydantic_core-2.33.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a", size = 2115305 },
|
| 1339 |
+
{ url = "https://files.pythonhosted.org/packages/03/1f/9b01d990730a98833113581a78e595fd40ed4c20f9693f5a658fb5f91eff/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099", size = 2068999 },
|
| 1340 |
+
{ url = "https://files.pythonhosted.org/packages/20/18/fe752476a709191148e8b1e1139147841ea5d2b22adcde6ee6abb6c8e7cf/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6", size = 2241488 },
|
| 1341 |
+
{ url = "https://files.pythonhosted.org/packages/81/22/14738ad0a0bf484b928c9e52004f5e0b81dd8dabbdf23b843717b37a71d1/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3", size = 2248430 },
|
| 1342 |
+
{ url = "https://files.pythonhosted.org/packages/e8/27/be7571e215ac8d321712f2433c445b03dbcd645366a18f67b334df8912bc/pydantic_core-2.33.0-cp312-cp312-win32.whl", hash = "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2", size = 1908353 },
|
| 1343 |
+
{ url = "https://files.pythonhosted.org/packages/be/3a/be78f28732f93128bd0e3944bdd4b3970b389a1fbd44907c97291c8dcdec/pydantic_core-2.33.0-cp312-cp312-win_amd64.whl", hash = "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48", size = 1955956 },
|
| 1344 |
+
{ url = "https://files.pythonhosted.org/packages/21/26/b8911ac74faa994694b76ee6a22875cc7a4abea3c381fdba4edc6c6bef84/pydantic_core-2.33.0-cp312-cp312-win_arm64.whl", hash = "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6", size = 1903259 },
|
| 1345 |
+
{ url = "https://files.pythonhosted.org/packages/79/20/de2ad03ce8f5b3accf2196ea9b44f31b0cd16ac6e8cfc6b21976ed45ec35/pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555", size = 2032214 },
|
| 1346 |
+
{ url = "https://files.pythonhosted.org/packages/f9/af/6817dfda9aac4958d8b516cbb94af507eb171c997ea66453d4d162ae8948/pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d", size = 1852338 },
|
| 1347 |
+
{ url = "https://files.pythonhosted.org/packages/44/f3/49193a312d9c49314f2b953fb55740b7c530710977cabe7183b8ef111b7f/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365", size = 1896913 },
|
| 1348 |
+
{ url = "https://files.pythonhosted.org/packages/06/e0/c746677825b2e29a2fa02122a8991c83cdd5b4c5f638f0664d4e35edd4b2/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da", size = 1986046 },
|
| 1349 |
+
{ url = "https://files.pythonhosted.org/packages/11/ec/44914e7ff78cef16afb5e5273d480c136725acd73d894affdbe2a1bbaad5/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0", size = 2128097 },
|
| 1350 |
+
{ url = "https://files.pythonhosted.org/packages/fe/f5/c6247d424d01f605ed2e3802f338691cae17137cee6484dce9f1ac0b872b/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885", size = 2681062 },
|
| 1351 |
+
{ url = "https://files.pythonhosted.org/packages/f0/85/114a2113b126fdd7cf9a9443b1b1fe1b572e5bd259d50ba9d5d3e1927fa9/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9", size = 2007487 },
|
| 1352 |
+
{ url = "https://files.pythonhosted.org/packages/e6/40/3c05ed28d225c7a9acd2b34c5c8010c279683a870219b97e9f164a5a8af0/pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181", size = 2121382 },
|
| 1353 |
+
{ url = "https://files.pythonhosted.org/packages/8a/22/e70c086f41eebd323e6baa92cc906c3f38ddce7486007eb2bdb3b11c8f64/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d", size = 2072473 },
|
| 1354 |
+
{ url = "https://files.pythonhosted.org/packages/3e/84/d1614dedd8fe5114f6a0e348bcd1535f97d76c038d6102f271433cd1361d/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3", size = 2249468 },
|
| 1355 |
+
{ url = "https://files.pythonhosted.org/packages/b0/c0/787061eef44135e00fddb4b56b387a06c303bfd3884a6df9bea5cb730230/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b", size = 2254716 },
|
| 1356 |
+
{ url = "https://files.pythonhosted.org/packages/ae/e2/27262eb04963201e89f9c280f1e10c493a7a37bc877e023f31aa72d2f911/pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585", size = 1916450 },
|
| 1357 |
+
{ url = "https://files.pythonhosted.org/packages/13/8d/25ff96f1e89b19e0b70b3cd607c9ea7ca27e1dcb810a9cd4255ed6abf869/pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606", size = 1956092 },
|
| 1358 |
+
{ url = "https://files.pythonhosted.org/packages/1b/64/66a2efeff657b04323ffcd7b898cb0354d36dae3a561049e092134a83e9c/pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225", size = 1908367 },
|
| 1359 |
+
{ url = "https://files.pythonhosted.org/packages/52/54/295e38769133363d7ec4a5863a4d579f331728c71a6644ff1024ee529315/pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87", size = 1813331 },
|
| 1360 |
+
{ url = "https://files.pythonhosted.org/packages/4c/9c/0c8ea02db8d682aa1ef48938abae833c1d69bdfa6e5ec13b21734b01ae70/pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b", size = 1986653 },
|
| 1361 |
+
{ url = "https://files.pythonhosted.org/packages/8e/4f/3fb47d6cbc08c7e00f92300e64ba655428c05c56b8ab6723bd290bae6458/pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7", size = 1931234 },
|
| 1362 |
+
{ url = "https://files.pythonhosted.org/packages/2b/b2/553e42762e7b08771fca41c0230c1ac276f9e79e78f57628e1b7d328551d/pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d8dc9f63a26f7259b57f46a7aab5af86b2ad6fbe48487500bb1f4b27e051e4c", size = 2041207 },
|
| 1363 |
+
{ url = "https://files.pythonhosted.org/packages/85/81/a91a57bbf3efe53525ab75f65944b8950e6ef84fe3b9a26c1ec173363263/pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30369e54d6d0113d2aa5aee7a90d17f225c13d87902ace8fcd7bbf99b19124db", size = 1873736 },
|
| 1364 |
+
{ url = "https://files.pythonhosted.org/packages/9c/d2/5ab52e9f551cdcbc1ee99a0b3ef595f56d031f66f88e5ca6726c49f9ce65/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3eb479354c62067afa62f53bb387827bee2f75c9c79ef25eef6ab84d4b1ae3b", size = 1903794 },
|
| 1365 |
+
{ url = "https://files.pythonhosted.org/packages/2f/5f/a81742d3f3821b16f1265f057d6e0b68a3ab13a814fe4bffac536a1f26fd/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0310524c833d91403c960b8a3cf9f46c282eadd6afd276c8c5edc617bd705dc9", size = 2083457 },
|
| 1366 |
+
{ url = "https://files.pythonhosted.org/packages/b5/2f/e872005bc0fc47f9c036b67b12349a8522d32e3bda928e82d676e2a594d1/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eddb18a00bbb855325db27b4c2a89a4ba491cd6a0bd6d852b225172a1f54b36c", size = 2119537 },
|
| 1367 |
+
{ url = "https://files.pythonhosted.org/packages/d3/13/183f13ce647202eaf3dada9e42cdfc59cbb95faedd44d25f22b931115c7f/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ade5dbcf8d9ef8f4b28e682d0b29f3008df9842bb5ac48ac2c17bc55771cc976", size = 2080069 },
|
| 1368 |
+
{ url = "https://files.pythonhosted.org/packages/23/8b/b6be91243da44a26558d9c3a9007043b3750334136c6550551e8092d6d96/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2c0afd34f928383e3fd25740f2050dbac9d077e7ba5adbaa2227f4d4f3c8da5c", size = 2251618 },
|
| 1369 |
+
{ url = "https://files.pythonhosted.org/packages/aa/c5/fbcf1977035b834f63eb542e74cd6c807177f383386175b468f0865bcac4/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7da333f21cd9df51d5731513a6d39319892947604924ddf2e24a4612975fb936", size = 2255374 },
|
| 1370 |
+
{ url = "https://files.pythonhosted.org/packages/2f/f8/66f328e411f1c9574b13c2c28ab01f308b53688bbbe6ca8fb981e6cabc42/pydantic_core-2.33.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b6d77c75a57f041c5ee915ff0b0bb58eabb78728b69ed967bc5b780e8f701b8", size = 2082099 },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1371 |
]
|
| 1372 |
|
| 1373 |
[[package]]
|