# -*- coding: utf-8 -*- """ Gradio Web Interface for City Map Poster Generator """ import os import json import tempfile # Monkey-patch gradio_client bug before importing gradio # Fix for: TypeError: argument of type 'bool' is not iterable # and APIInfoParseError: Cannot parse schema True try: import gradio_client.utils as client_utils # Patch _json_schema_to_python_type to handle boolean schemas _original_json_schema_to_python_type = client_utils._json_schema_to_python_type def _patched_json_schema_to_python_type(schema, defs): # Handle boolean schema (e.g., additionalProperties: true/false) if schema is True: return "Any" if schema is False: return "None" return _original_json_schema_to_python_type(schema, defs) client_utils._json_schema_to_python_type = _patched_json_schema_to_python_type except Exception as e: print(f"Warning: Could not patch gradio_client: {e}") import gradio as gr # Import from the main module from cities_data import ( get_countries, get_provinces, get_cities, get_districts, translate, get_country_key, ) # --- Constants --- THEMES_DIR = "themes" FONTS_DIR = "fonts" POSTERS_DIR = "posters" # Layer Constants LAYERS_EN = ["Motorway", "Primary Roads", "Secondary Roads", "Water", "Parks"] LAYERS_CN = ["高速公路", "主干道", "次干道", "水域", "公园"] LAYER_KEYS = ["motorway", "primary", "secondary", "water", "parks"] def get_available_themes(): """Scans the themes directory and returns a list of available theme names.""" if not os.path.exists(THEMES_DIR): return [] themes = [] for file in sorted(os.listdir(THEMES_DIR)): if file.endswith(".json"): theme_name = file[:-5] themes.append(theme_name) return themes def get_theme_choices(lang="en"): """Return a list of (Display Name, Internal Name) tuples for themes.""" internal_names = get_available_themes() choices = [] for internal_name in internal_names: theme_info = load_theme_info(internal_name) if theme_info: proper_name = theme_info.get("name", internal_name) display_name = translate(proper_name, lang) choices.append((display_name, internal_name)) else: choices.append((internal_name, internal_name)) return choices def load_theme_info(theme_name): """Load theme details for preview.""" theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json") if os.path.exists(theme_file): with open(theme_file, "r") as f: return json.load(f) return None def get_theme_preview_html(theme_name, lang="en"): """Generate HTML preview for a theme.""" theme = load_theme_info(theme_name) lang_code = "en" if lang == "English" else "cn" if not theme: return f"

{'Theme load failed' if lang_code == 'en' else '主题加载失败'}

" # Translated labels labels = { "bg": "Background" if lang_code == "en" else "背景", "text": "Text" if lang_code == "en" else "文字", "motorway": "Motorway" if lang_code == "en" else "高速公路", "primary": "Primary Road" if lang_code == "en" else "主干道", "secondary": "Secondary Road" if lang_code == "en" else "次干道", "water": "Water" if lang_code == "en" else "水域", "parks": "Parks" if lang_code == "en" else "公园", } # Create color swatches colors = [ (labels["bg"], theme.get("bg", "#FFFFFF")), (labels["text"], theme.get("text", "#000000")), (labels["motorway"], theme.get("road_motorway", "#000000")), (labels["primary"], theme.get("road_primary", "#333333")), (labels["secondary"], theme.get("road_secondary", "#666666")), (labels["water"], theme.get("water", "#C0C0C0")), (labels["parks"], theme.get("parks", "#F0F0F0")), ] display_name = translate(theme.get("name", theme_name), lang_code) description = theme.get("description", "") # Optional: translate description if we really want to, but might be too much work html = f"""

{display_name}

{description}

""" for label, color in colors: html += f"""
{label}
""" html += "
" return html def generate_poster( location_mode, custom_lat, custom_lon, custom_city_name, custom_country_name, country_display, province, city_dropdown, district_dropdown, theme_name, distance, width, height, output_format, no_crop, show_text, poster_lang, layers_selection, progress=gr.Progress(), ): """ Generate the map poster with given parameters. """ # Import here to avoid circular imports and ensure THEME is set correctly import create_map_poster as cmp # Decode layer selection show_motorway = any(x in ["Motorway", "高速公路"] for x in layers_selection) show_primary = any(x in ["Primary Roads", "主干道"] for x in layers_selection) show_secondary = any(x in ["Secondary Roads", "次干道"] for x in layers_selection) show_water = any(x in ["Water", "水域"] for x in layers_selection) show_parks = any(x in ["Parks", "公园"] for x in layers_selection) # Determine display names based on poster_lang lang_code = "en" if poster_lang == "English" else "cn" actual_distance = distance display_city = "" display_country = "" coords = None progress(0.1, desc="正在加载主题...") # Load theme cmp.THEME = cmp.load_theme(theme_name) if location_mode == "自定义坐标" or location_mode == "Custom Coordinates": lat_val = float(custom_lat) lon_val = float(custom_lon) # Basic validation: Latitude must be between -90 and 90 if lat_val < -90 or lat_val > 90: return None, f"❌ 纬度超出范围 (-90 到 90)。你是否填反了经纬度?(当前填入: {lat_val})" coords = (lat_val, lon_val) display_city = custom_city_name if custom_city_name else "" display_country = custom_country_name if custom_country_name else "" # Determine output filename component selected_location = display_city else: # Standard Dropdown Logic # Parse country # Since we now use (Display, Value) tuples where Value is the key, # country_display is likely already the key. get_country_key remains for safety. selected_country = get_country_key(country_display) # Use dropdown selection # If district is selected and isn't "整个城市", use its coordinates selected_location = ( district_dropdown if (district_dropdown and district_dropdown != city_dropdown) else city_dropdown ) # Detect if we are selecting an entire province is_whole_province = False if selected_country == "中国" and selected_location: if selected_location.endswith("_WHOLE"): is_whole_province = True selected_location = selected_location.replace("_WHOLE", "") if not selected_location: return None, "❌ 请选择城市或区县名称" if not selected_country: return None, "❌ 请选择国家" # For whole province, we might want to override the distance if it's too small if is_whole_province: # A province is much larger than a city. Default 10km is way too small. # We'll use a larger default or just trust the user if they've slid it up, # but let's ensure it's at least 150km for a province. if distance < 100000: actual_distance = 200000 # 200km default for province print( f"Whole province detected ({selected_location}). Increasing distance to {actual_distance}m" ) if selected_country == "中国" and lang_code == "cn": if is_whole_province: display_city = selected_location display_country = "中国" # Hierarchical logic for China (Chinese language) elif district_dropdown and district_dropdown != city_dropdown: # Case 3 & 4: District selected display_city = district_dropdown if province == city_dropdown: # Case 3: District of Municipality display_country = f"中国 {province}" else: # Case 4: District of Regular City display_country = f"中国 {province} {city_dropdown}" else: # Case 1 & 2: City selected (or "Whole City") display_city = city_dropdown if province == city_dropdown: # Case 1: Municipality display_country = "中国" else: # Case 2: Regular City display_country = f"中国 {province}" else: # Standard logic for International or English posters display_city = translate(selected_location, lang_code) display_country = translate(selected_country, lang_code) progress(0.2, desc="正在获取坐标...") try: # We search coordinates using the selection # CMP might need the "original" names if OSM behaves better with them # For China, "广州" is better than "Guangzhou" for geopy sometimes, but geopy is usually good. # Let's ensure we use the 'original' internal key if possible for best OSM matching # However, for cities, we don't have a strict key map like for countries. # We can try to translate to CN if it's a Chinese city. search_city = selected_location search_country = selected_country # Use city as parent if searching for a district, otherwise use province search_parent = ( city_dropdown if (district_dropdown and district_dropdown != city_dropdown) else province ) coords = cmp.get_coordinates( search_city, search_country, parent=search_parent ) except Exception as e: return None, f"❌ 无法找到城市坐标: {str(e)}" progress(0.3, desc="正在生成海报...") # Generate output filename (using English/Slugified names) en_city = translate(selected_location, "en") temp_dir = tempfile.gettempdir() output_file = cmp.generate_output_filename( en_city, theme_name, output_format, directory=temp_dir ) try: # Wrap the generator to yield status updates and final result for status in cmp.create_poster( display_city, # Pass translated names for display display_country, coords, actual_distance, output_file, output_format, width=width, height=height, no_crop=no_crop, show_text=show_text, show_motorway=show_motorway, show_primary=show_primary, show_secondary=show_secondary, show_water=show_water, show_parks=show_parks, ): # Translate common status messages if possible status_display = status if lang_code == "cn": if "Downloading street network" in status: status_display = "正在下载街道数据..." elif "Downloading features" in status: status_display = "正在下载水域和公园数据..." elif "Rendering map" in status: status_display = "正在渲染地图..." elif "Applying road styles" in status: status_display = "正在应用样式..." elif "Saving to" in status: status_display = "正在保存海报..." elif "Done" in status: status_display = "完成!" yield None, f"⏳ {status_display}", None progress(1.0, desc="完成!") yield output_file, f"✅ 海报生成成功!保存至: {output_file}", output_file except Exception as e: import traceback traceback.print_exc() yield None, f"❌ 生成失败: {str(e)}", None def update_provinces(country, lang="en"): """Update province dropdown based on country selection and language.""" # country is the internal key lang_code = "en" if lang == "English" else "cn" provinces = get_provinces(country, lang_code) if provinces: return gr.update(choices=provinces, value=provinces[0][1], visible=True) return gr.update(choices=[], value=None, visible=False) def update_cities(country, province, lang="en"): """Update city dropdown based on province selection and language.""" # country and province are internal keys lang_code = "en" if lang == "English" else "cn" cities = get_cities(country, province, lang_code) if cities: # For China, if it's a municipality, get_cities returns [province_name] # We should automatically trigger update_districts then. return gr.update(choices=cities, value=cities[0][1]) return gr.update(choices=[], value=None) def update_districts(country, province, city, lang="en"): """Update district dropdown based on city selection.""" if country != "中国" or not city or city.endswith("_WHOLE"): return gr.update(choices=[], value=None, visible=False) lang_code = "en" if lang == "English" else "cn" districts = get_districts(country, province, city, lang_code) if districts: return gr.update(choices=districts, value=districts[0][1], visible=True) return gr.update(choices=[], value=None, visible=False) def on_theme_change(theme_name, lang="en"): """Update theme preview when theme changes.""" return get_theme_preview_html(theme_name, lang) # --- Build Gradio Interface --- def create_interface(): """Create and return the Gradio interface.""" # Get initial data (Default English) # Get initial data (Default English) default_lang_code = "cn" default_lang_radio = "中文" countries = get_countries(default_lang_code) theme_choices = get_theme_choices(default_lang_code) default_country_key = "中国" default_provinces = get_provinces(default_country_key, default_lang_code) default_province_key = default_provinces[0][1] if default_provinces else None default_cities = ( get_cities(default_country_key, default_province_key, default_lang_code) if default_province_key else [] ) default_city_key = default_cities[0][1] if default_cities else None # Handle district initialization carefully to avoid "Value not in choices" error default_districts = ( get_districts( default_country_key, default_province_key, default_city_key, default_lang_code, ) if default_city_key else [] ) default_district_key = default_districts[0][1] if default_districts else None default_theme = theme_choices[0][1] if theme_choices else "feature_based" default_theme = theme_choices[0][1] if theme_choices else "feature_based" with gr.Blocks( title="城市地图海报生成器", theme=gr.themes.Default(), css=""" .header-title { text-align: center; font-size: 2em; font-weight: bold; margin-bottom: 0.5em; background: linear-gradient(135deg, #FF8C00 0%, #FFA500 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .header-subtitle { text-align: center; color: #666; margin-bottom: 1.5em; } .section-title { font-weight: 600; font-size: 1.1em; margin-bottom: 0.5em; color: #333; } .output-image { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); } """, ) as demo: # Header gr.HTML("""
城市地图海报生成器
选择任意城市,自定义主题风格,生成精美地图海报
⚠️ 注意!
特大城市(如北京): 当城市面积过大时,中心定位可能不准。
省级行政区生成超级慢!: 正在优化中。
地点中英文不完善 : 由于地点中英文翻译数据量过大,未能显示完善。
小城市 : 由于 OpenStreetMap 数据缺失,部分图层(如公园/水域)可能无法显示。
生成速度 : 国外地点使用国外服务器数据且渲染逻辑较基础,下载和生成速度可能较慢。
数据来源 : © OpenStreetMap contributors
""") with gr.Row(): # Left Column - Controls with gr.Column(scale=1): # City Selection Section gr.HTML('
📍 城市选择
') lang_radio = gr.Radio( choices=["中文", "English"], value="中文", label="海报语言", info="选择海报及界面的显示语言", ) location_mode = gr.Radio( choices=["城市选择", "自定义坐标"], value="城市选择", label="定位方式", info="选择通过列表选择城市或输入自定义经纬度", ) with gr.Group(visible=True) as city_selection_group: country_dropdown = gr.Dropdown( choices=countries, value=default_country_key, label="选择国家", interactive=True, ) province_dropdown = gr.Dropdown( choices=default_provinces, value=default_province_key, label="选择省份/州", interactive=True, ) city_dropdown = gr.Dropdown( choices=default_cities, value=default_city_key, label="选择城市", interactive=True, ) district_dropdown = gr.Dropdown( choices=default_districts, value=default_district_key, label="选择区县", interactive=True, visible=(default_country_key == "中国" and default_district_key is not None), ) with gr.Group(visible=False) as custom_coords_group: custom_coords_info = gr.Markdown( "💡 **填写指南**:\n" "- **纬度 (Latitude)**: 南北向坐标,范围 -90 至 90 (中国约 18~53)。\n" "- **经度 (Longitude)**: 东西向坐标,范围 -180 至 180 (中国约 73~135)。\n\n" "您可以访问 [坐标拾取系统](https://map.jiqrxx.com/jingweidu/) 获取精确数值。" ) with gr.Row(): custom_lat = gr.Number( label="纬度 (Latitude)", value=None, precision=6 ) custom_lon = gr.Number( label="经度 (Longitude)", value=None, precision=6 ) custom_city_name = gr.Textbox( label="主标题 (Main Title)", placeholder="如: Shanghai 或 '我们的家'" ) custom_country_name = gr.Textbox( label="副标题 (Subtitle)", placeholder="如: China 或 '2024.10.20'", value="", ) gr.HTML("
") # Theme Section gr.HTML('
🎨 主题风格
') theme_dropdown = gr.Dropdown( choices=theme_choices, value=default_theme, label="选择主题", interactive=True, ) theme_preview = gr.HTML( value=get_theme_preview_html(default_theme, default_lang_radio), label="主题预览", ) gr.HTML("
") # Parameters Section gr.HTML('
⚙️ 参数设置
') distance_slider = gr.Slider( minimum=4000, maximum=30000, value=10000, step=1000, label="地图范围 (米)", info="4000-6000: 小城区 | 8000-12000: 中等城市 | 15000+: 大都市 (范围越大生成越慢)", ) with gr.Row(): width_input = gr.Number( value=12.0, label="宽度 (英寸)", minimum=6, maximum=24 ) height_input = gr.Number( value=16.0, label="高度 (英寸)", minimum=8, maximum=32 ) format_radio = gr.Radio( choices=["png", "svg", "pdf"], value="png", label="输出格式", info="PNG: 适合打印 | SVG: 矢量图 | PDF: 文档", ) no_crop_checkbox = gr.Checkbox( value=False, label="保留边距 (不裁剪)", info="勾选后保留海报边缘背景", ) show_text_checkbox = gr.Checkbox( value=True, label="显示文字", info="在海报上显示城市名和经纬度", ) layers_checkbox = gr.CheckboxGroup( choices=LAYERS_EN, value=LAYERS_EN, label="图层显示", info="选择需要显示的地图元素", ) # Generate Button generate_btn = gr.Button("🚀 生成海报", variant="primary", size="lg") # Right Column - Output with gr.Column(scale=1): gr.HTML('
🖼️ 生成结果
') output_image = gr.Image( label="海报预览", type="filepath", elem_classes=["output-image"], height=600, interactive=False, ) output_status = gr.Textbox(label="状态", interactive=False) download_btn = gr.DownloadButton(label="📥 下载海报", visible=False) poster_state = gr.State() # --- Event Handlers --- # Language change -> update all dropdowns def on_lang_change( lang, current_country, current_province, current_city, current_district, current_theme, current_layers, ): lang_code = "en" if lang == "English" else "cn" new_countries = get_countries(lang_code) # Use keys to maintain selection new_provinces = get_provinces(current_country, lang_code) new_cities = ( get_cities(current_country, current_province, lang_code) if current_province else [] ) new_districts = ( get_districts( current_country, current_province, current_city, lang_code ) if current_city else [] ) # Ensure district value is valid for new choices valid_district = None if new_districts: if any(v == current_district for d, v in new_districts): valid_district = current_district else: valid_district = new_districts[0][1] # Themes new_theme_choices = get_theme_choices(lang_code) new_preview = get_theme_preview_html(current_theme, lang) # Layers map_en_to_key = dict(zip(LAYERS_EN, LAYER_KEYS)) map_cn_to_key = dict(zip(LAYERS_CN, LAYER_KEYS)) map_key_to_en = dict(zip(LAYER_KEYS, LAYERS_EN)) map_key_to_cn = dict(zip(LAYER_KEYS, LAYERS_CN)) current_keys = [] for x in current_layers: if x in map_en_to_key: current_keys.append(map_en_to_key[x]) elif x in map_cn_to_key: current_keys.append(map_cn_to_key[x]) target_map = map_key_to_en if lang == "English" else map_key_to_cn new_layer_choices = LAYERS_EN if lang == "English" else LAYERS_CN new_layer_values = [target_map[k] for k in current_keys if k in target_map] return ( gr.update( choices=new_countries, value=current_country, label="Select Country" if lang == "English" else "选择国家", ), gr.update( choices=new_provinces, value=current_province, label="Select Province/State" if lang == "English" else "选择省份/州", ), gr.update( choices=new_cities, value=current_city, label="Select City" if lang == "English" else "选择城市", ), gr.update( choices=new_districts, value=valid_district, visible=(current_country == "中国" and valid_district is not None), label="Select District" if lang == "English" else "选择区县", ), gr.update( choices=new_theme_choices, label="Select Theme" if lang == "English" else "选择主题", ), new_preview, gr.update( choices=new_layer_choices, value=new_layer_values, label="Layers" if lang == "English" else "图层显示", ), gr.update( label="Map Range (m)" if lang == "English" else "地图范围 (米)", info="4000-6000: Small | 8000-12000: Medium | 15000+: Large" if lang == "English" else "4000-6000: 小城区 | 8000-12000: 中等城市 | 15000+: 大都市 (范围越大生成越慢)", ), gr.update(label="Width (inch)" if lang == "English" else "宽度 (英寸)"), gr.update( label="Height (inch)" if lang == "English" else "高度 (英寸)" ), gr.update( label="Format" if lang == "English" else "输出格式", info="PNG: Print | SVG: Vector | PDF: Doc" if lang == "English" else "PNG: 适合打印 | SVG: 矢量图 | PDF: 文档", ), gr.update( label="No Crop (Keep Margins)" if lang == "English" else "保留边距 (不裁剪)", info="Keep background margins" if lang == "English" else "勾选后保留海报边缘背景", ), gr.update( label="Show Text" if lang == "English" else "显示文字", info="Show city name and coordinates on poster" if lang == "English" else "在海报上显示城市名和经纬度", ), gr.update( value="🚀 Generate Poster" if lang == "English" else "🚀 生成海报" ), gr.update( label="📥 Download Poster" if lang == "English" else "📥 下载海报" ), gr.update( label="Main Title" if lang == "English" else "主标题 (Main Title)", placeholder="e.g. Shanghai or 'Our Home'" if lang == "English" else "如: Shanghai 或 '我们的家'" ), gr.update( label="Subtitle" if lang == "English" else "副标题 (Subtitle)", placeholder="e.g. China or '2024.10.20'" if lang == "English" else "如: China 或 '2024.10.20'" ), gr.update( value="💡 **Guide**:\n" "- **Latitude**: North-South, range -90 to 90 (China: ~18~53).\n" "- **Longitude**: East-West, range -180 to 180 (China: ~73~135).\n\n" "Use [Coordinate Picker](https://map.jiqrxx.com/jingweidu/) to find exact values." if lang == "English" else "💡 **填写指南**:\n" "- **纬度 (Latitude)**: 南北向坐标,范围 -90 至 90 (中国约 18~53)。\n" "- **经度 (Longitude)**: 东西向坐标,范围 -180 至 180 (中国约 73~135)。\n\n" "您可以访问 [坐标拾取系统](https://map.jiqrxx.com/jingweidu/) 获取精确数值。" ) ) lang_radio.change( fn=on_lang_change, inputs=[ lang_radio, country_dropdown, province_dropdown, city_dropdown, district_dropdown, theme_dropdown, layers_checkbox, ], outputs=[ country_dropdown, province_dropdown, city_dropdown, district_dropdown, theme_dropdown, theme_preview, layers_checkbox, distance_slider, width_input, height_input, format_radio, no_crop_checkbox, show_text_checkbox, generate_btn, download_btn, custom_city_name, custom_country_name, custom_coords_info, ], ) def toggle_location_mode(mode): is_custom = mode in ["自定义坐标", "Custom Coordinates"] return { city_selection_group: gr.update(visible=not is_custom), custom_coords_group: gr.update(visible=is_custom), } location_mode.change( fn=toggle_location_mode, inputs=[location_mode], outputs=[city_selection_group, custom_coords_group], ) # Country change -> update provinces country_dropdown.change( fn=update_provinces, inputs=[country_dropdown, lang_radio], outputs=[province_dropdown], ) # Province change -> update cities province_dropdown.change( fn=update_cities, inputs=[country_dropdown, province_dropdown, lang_radio], outputs=[city_dropdown], ).then( # Update districts after city changes fn=update_districts, inputs=[country_dropdown, province_dropdown, city_dropdown, lang_radio], outputs=[district_dropdown], ) # City change -> update districts city_dropdown.change( fn=update_districts, inputs=[country_dropdown, province_dropdown, city_dropdown, lang_radio], outputs=[district_dropdown], ) # Theme change -> update preview theme_dropdown.change( fn=on_theme_change, inputs=[theme_dropdown, lang_radio], outputs=[theme_preview], ) # Generate button click def on_generate_complete(filepath, status): """Handle generate completion - show download button if successful.""" if filepath and os.path.exists(filepath): return filepath, status, gr.update(visible=True, value=filepath) return filepath, status, gr.update(visible=False) generate_btn.click( fn=generate_poster, inputs=[ location_mode, custom_lat, custom_lon, custom_city_name, custom_country_name, country_dropdown, province_dropdown, city_dropdown, district_dropdown, theme_dropdown, distance_slider, width_input, height_input, format_radio, no_crop_checkbox, show_text_checkbox, lang_radio, layers_checkbox, ], outputs=[output_image, output_status, poster_state], ).then( fn=on_generate_complete, inputs=[poster_state, output_status], outputs=[output_image, output_status, download_btn], ) # Footer gr.HTML("""

项目地址: GitHub - IsaacHuo/maptoposter

✨ 欢迎提 IssuePR | 鸣谢:该项目基于 originalankur/maptoposter 开发

""") return demo # --- Main Entry --- if __name__ == "__main__": demo = create_interface() demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)