Spaces:
Running
Running
| 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() |