import subprocess from pathlib import Path import gradio as gr import re ROOT = Path(__file__).resolve().parent THEMES_DIR = ROOT / "themes" POSTERS_DIR = ROOT / "posters" # ---- UI CSS (mobile/tablet friendly width) ---- APP_CSS = """ .gradio-container { max-width: 768px !important; margin: 0 auto !important; padding-left: 12px !important; padding-right: 12px !important; } """ # ---- ANSI cleanup (prevents weird \x1b[A logs from tqdm) ---- ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") def strip_ansi(s: str) -> str: return ANSI_ESCAPE.sub("", s) # Preset distances (meters) PRESET_DIST = { "Small town": 3000, "Town": 5000, "City": 8000, "Big city": 10000, "Major metro": 12000, "Custom": None, } TOWN_DIST = PRESET_DIST["Town"] # threshold for enabling buildings in Custom mode def list_themes(): if THEMES_DIR.exists(): return sorted([p.stem for p in THEMES_DIR.glob("*.json")]) return [] THEMES = list_themes() def list_pngs(): if not POSTERS_DIR.exists(): return [] return sorted(POSTERS_DIR.glob("*.png"), key=lambda p: p.stat().st_mtime) def latest_new_png(before_paths): """Return the newest PNG created after starting the generation.""" after = list_pngs() before_set = set(before_paths) new_files = [p for p in after if str(p) not in before_set] if new_files: return str(sorted(new_files, key=lambda p: p.stat().st_mtime)[-1]) return str(after[-1]) if after else None def preset_to_distance(preset): return PRESET_DIST.get(preset, None) def distance_to_preset(distance_m: int): """If distance matches a preset exactly, return that preset name; else Custom.""" for name, val in PRESET_DIST.items(): if name == "Custom": continue if val is not None and int(distance_m) == int(val): return name return "Custom" def buildings_allowed(preset: str, distance_m: int): """ Buildings can be enabled only when: - preset is Small town or Town - OR preset is Custom and distance <= Town distance """ if preset in ("Small town", "Town"): return True if preset == "Custom" and int(distance_m) <= int(TOWN_DIST): return True return False def format_distance_hint(preset: str, distance_m: int): preset_line = ( f"**Preset guide** — Small town: {PRESET_DIST['Small town']}m · " f"Town: {PRESET_DIST['Town']}m · City: {PRESET_DIST['City']}m · " f"Big city: {PRESET_DIST['Big city']}m · Major metro: {PRESET_DIST['Major metro']}m" ) current = f"**Current** — Preset: `{preset}` · Distance: `{int(distance_m)}m`" buildings_note = ( f"**Buildings layer** is available for `Small town`/`Town`, " f"or `Custom` when distance ≤ `{TOWN_DIST}m`." ) return f"{current}\n\n{preset_line}\n\n{buildings_note}" def on_preset_change(preset, distance_m, buildings_enabled): """ When preset changes: - If not Custom, force distance slider to preset value - Update buildings availability accordingly """ preset_dist = preset_to_distance(preset) if preset_dist is not None: distance_m = preset_dist allowed = buildings_allowed(preset, distance_m) if not allowed: buildings_enabled = False hint = format_distance_hint(preset, distance_m) return ( gr.update(value=int(distance_m)), # distance slider gr.update(interactive=allowed, value=buildings_enabled), # buildings checkbox gr.update(value=hint), # hint markdown ) def on_distance_change(distance_m, preset, buildings_enabled): """ When distance slider changes: - If it matches a preset exactly => set preset accordingly - Else => set preset to Custom - Update buildings availability accordingly """ new_preset = distance_to_preset(distance_m) preset = new_preset allowed = buildings_allowed(preset, distance_m) if not allowed: buildings_enabled = False hint = format_distance_hint(preset, distance_m) return ( gr.update(value=preset), # preset dropdown gr.update(interactive=allowed, value=buildings_enabled), # buildings checkbox gr.update(value=hint), # hint markdown ) def generate( center_mode, city, country, lat, lon, theme, preset, distance_m, fast_mode, map_format, orientation, buildings_enabled, railroads_enabled, waterways_enabled, forests_enabled, coastline_enabled, text_fade, margins_enabled, ): """ Run the poster script and stream logs into the UI. """ POSTERS_DIR.mkdir(parents=True, exist_ok=True) # Apply preset if selected (except Custom) preset_dist = preset_to_distance(preset) if preset_dist is not None: distance_m = preset_dist # Fast mode: cap distance (biggest perf win on shared Spaces) if fast_mode: distance_m = min(int(distance_m), 12000) # Plan format is always square (orientation ignored) if map_format == "plan": orientation = "portrait" cmd = [ "python", "create_map_poster.py", "--theme", theme, "--distance", str(int(distance_m)), "--format", map_format, "--orientation", orientation, ] # Buildings flag (only if allowed + enabled) if buildings_enabled and buildings_allowed(preset, distance_m): cmd += ["--buildings"] # Railroads flag if railroads_enabled: cmd += ["--railroads"] # Optional layers if waterways_enabled: cmd += ["--waterways"] if forests_enabled: cmd += ["--forests"] # Coastline detection (ocean fill + coastal frame logic in script) if coastline_enabled: cmd += ["--coastline"] if text_fade: cmd += ["--text-fade"] if not margins_enabled: cmd += ["--no-margins"] # Center selection if center_mode == "Coordinates": if lat is None or lon is None: yield "❌ Please provide both latitude and longitude.", None return cmd += ["--lat", str(float(lat)), "--lon", str(float(lon))] # City/Country become labels (optional) if city: cmd += ["--city", city] if country: cmd += ["--country", country] cmd_note = f"Center: Coordinates (lat={lat}, lon={lon})" else: if not city or not country: yield "❌ Please provide both City and Country (or switch to Coordinates).", None return cmd += ["--city", city, "--country", country] cmd_note = f"Center: City ({city}, {country})" before = [str(p) for p in list_pngs()] status_lines = [] status_lines.append("🚀 Starting… (OSM downloads can be slow on shared Spaces)") status_lines.append(cmd_note) status_lines.append(f"Command: {' '.join(cmd)}") status_lines.append("Tip: progress may sit at 0% for a while, then jump (normal).") yield "\n".join(status_lines), None proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) try: for line in proc.stdout: line = strip_ansi(line).rstrip() if not line: continue status_lines.append(line) if len(status_lines) > 250: status_lines = status_lines[-250:] yield "\n".join(status_lines), None finally: rc = proc.wait() if rc != 0: status_lines.append(f"\n❌ Generation failed (exit code {rc}). See logs above.") yield "\n".join(status_lines), None return out_png = latest_new_png(before) if not out_png: status_lines.append("\n❌ No PNG found in /posters.") yield "\n".join(status_lines), None return status_lines.append("\n✅ Done.") yield "\n".join(status_lines), out_png def on_format_change(fmt): """Disable orientation control when Plan is selected.""" if fmt == "plan": return gr.update(value="portrait", interactive=False) return gr.update(interactive=True) def on_center_mode_change(mode): """Show lat/lon inputs only when Coordinates is selected.""" if mode == "Coordinates": return ( gr.update(visible=True), # city label still useful gr.update(visible=True), gr.update(visible=True), gr.update(visible=True), ) return ( gr.update(visible=True), gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), ) with gr.Blocks(css=APP_CSS) as demo: gr.Markdown( """ # MapToPoster – Gradio Original project by Ankur (MapToPoster) Based on the open-source repository: https://github.com/originalankur/maptoposter Many thanks for the original work and inspiration. """ ) center_mode = gr.Radio( label="Center", choices=["City", "Coordinates"], value="City", ) with gr.Row(): city = gr.Textbox(label="City (or label)", value="Paris", visible=True) country = gr.Textbox(label="Country (or label)", value="France", visible=True) with gr.Row(): lat = gr.Number(label="Latitude", value=48.8566, visible=False) lon = gr.Number(label="Longitude", value=2.3522, visible=False) center_mode.change( on_center_mode_change, inputs=[center_mode], outputs=[city, country, lat, lon], queue=False, # UI interaction: do NOT queue ) theme = gr.Dropdown( label="Theme", choices=THEMES, value=(THEMES[0] if THEMES else None), ) with gr.Row(): preset = gr.Dropdown( label="Preset", choices=["Small town", "Town", "City", "Big city", "Major metro", "Custom"], value="City", ) distance = gr.Slider( label="Distance (m) (used when Preset=Custom)", minimum=1000, maximum=20000, step=250, value=PRESET_DIST["City"], ) buildings = gr.Checkbox( label="Buildings layer (small scale only)", value=False, interactive=False, # will be enabled automatically when allowed ) railroads = gr.Checkbox( label="Railroads layer", value=False, ) # Optional layers waterways = gr.Checkbox( label="Waterways (rivers/streams/brooks…)", value=True, ) forests = gr.Checkbox( label="Forests (wood/forest only)", value=True, ) coastline = gr.Checkbox( label="Coastline detection (ocean fill + coastal frame)", value=True, ) distance_hint = gr.Markdown(format_distance_hint("City", PRESET_DIST["City"])) preset.change( on_preset_change, inputs=[preset, distance, buildings], outputs=[distance, buildings, distance_hint], queue=False, ) distance.change( on_distance_change, inputs=[distance, preset, buildings], outputs=[preset, buildings, distance_hint], queue=False, ) with gr.Row(): map_format = gr.Dropdown( label="Format", choices=["poster", "postcard", "plan", "panoramic"], value="poster", ) orientation = gr.Dropdown( label="Orientation", choices=["portrait", "landscape"], value="portrait", ) map_format.change( on_format_change, inputs=[map_format], outputs=[orientation], queue=False, ) with gr.Row(): text_fade = gr.Checkbox( label="Fade under text (improves readability)", value=True, ) margins = gr.Checkbox( label="Margins & edge fading (coastal frame + inland fade)", value=True, ) fast_mode = gr.Checkbox( label="Fast mode (cap ~12km)", value=True, ) btn = gr.Button("Generate") status = gr.Textbox(label="Logs / Status", lines=18) out = gr.Image(label="Poster", type="filepath") btn.click( generate, inputs=[ center_mode, city, country, lat, lon, theme, preset, distance, fast_mode, map_format, orientation, buildings, railroads, waterways, forests, coastline, text_fade, margins, ], outputs=[status, out], ) demo.launch()