maptoposter / app.py
fffiloni's picture
ui: add optional layers controls (waterways, forests, coastline)
ef90405 verified
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()