Prettymaps в Docker: генератор картографических постеров

Поигрался с либой PrettyMaps, которая умеет строить из OSM прикольные постеры.

Собрал контейнер, который позволяет сделать карту.

Сборка без прокси:

DOCKER_BUILDKIT=1 docker build -t prettymaps-docker-test .

Сборка с SOCKS5-прокси:

DOCKER_BUILDKIT=1 docker build --network=host --build-arg PROXY_SOCKS5=$PROXY_IP:$PROXY_PORT -t prettymaps-docker-test .

Запуск:

docker run --rm \
  -v "$(pwd)":/work \
  -e LAT=54.8482742 \
  -e LON=83.0973263 \
  -e RADIUS_M=1500 \
  -e TITLE="Академгородок" \
  -e OUTPUT_NAME=akadem_coords \
  prettymaps-docker-test

Dockerfile

# syntax=docker/dockerfile:1
#
# Build without proxy:
# DOCKER_BUILDKIT=1 docker build -t prettymaps-docker-test .
#
# Build with SOCKS5 proxy:
# DOCKER_BUILDKIT=1 docker build --network=host \
#     --build-arg PROXY_SOCKS5=$PROXY_IP:$PROXY_PORT \
#     -t prettymaps-docker-test .
#
# Do not upgrade pip (newer pip+urllib3 may break SOCKS).
# Pre-install shapely to avoid resolver loops on shapely[vectorized].

# --------------------------------------------------------------------------
# Stage 1: builder
# --------------------------------------------------------------------------

FROM debian:bookworm-slim AS builder

ARG PROXY_SOCKS5=""
ARG DEBIAN_FRONTEND=noninteractive

ENV LANG=C.UTF-8 \
    LC_ALL=C.UTF-8 \
    PYTHONUNBUFFERED=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_DEFAULT_TIMEOUT=120 \
    PIP_PREFER_BINARY=1

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean && \
    apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates \
        curl \
        python3 \
        python3-pip \
        python3-venv \
        python3-dev \
        python3-socks \
        build-essential \
        gdal-bin \
        libgdal-dev \
        libgeos-dev \
        libproj-dev \
        libspatialindex-dev \
        git \
        fonts-dejavu-core

# Python environment
RUN python3 -m venv --system-site-packages /opt/venv

ENV PATH="/opt/venv/bin:$PATH"

# Optional SOCKS5 proxy.
# Empty PROXY_SOCKS5 disables the proxy.
# Proxy settings apply only to the builder stage.

ENV ALL_PROXY=${PROXY_SOCKS5:+socks5h://}${PROXY_SOCKS5}

ENV HTTP_PROXY=${ALL_PROXY} \
    HTTPS_PROXY=${ALL_PROXY} \
    http_proxy=${ALL_PROXY} \
    https_proxy=${ALL_PROXY} \
    no_proxy=localhost,127.0.0.1

# --------------------------------------------------------------------------
# Python dependencies
# --------------------------------------------------------------------------

# 1. Base tooling, NumPy and Shapely

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install \
        setuptools \
        wheel \
        pysocks \
        "numpy>=1.26.4" \
        "shapely>=2.0.0"

# 2. Prettymaps runtime dependencies
# Streamlit and Marimo are intentionally excluded.

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install \
        "matplotlib>=3.9.0" \
        "osmnx>=2.0.5" \
        "rasterio>=1.4.3" \
        "rioxarray>=0.18.2" \
        "opencv-python-headless>=4.11.0.86" \
        "scikit-image>=0.25.2" \
        "thefuzz>=0.22.1" \
        "elevation>=1.1.3" \
        "tqdm>=4.70.0" \
        "scikit-learn>=1.6.1" \
        "vsketch>=1.0.0" \
        "ipython" \
        "ipykernel>=6.29.5"

# 3. Prettymaps without optional dependencies

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install prettymaps --no-deps

# --------------------------------------------------------------------------
# Stage 2: runtime
# --------------------------------------------------------------------------

FROM debian:bookworm-slim AS runtime

ARG DEBIAN_FRONTEND=noninteractive

ENV LANG=C.UTF-8 \
    LC_ALL=C.UTF-8 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean && \
    apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates \
        python3 \
        python3-socks \
        gdal-bin \
        libgdal32 \
        libgeos-c1v5 \
        libproj25 \
        libspatialindex6 \
        fonts-dejavu-core \
        libgl1 \
        libglib2.0-0 \
        libxkbcommon0 \
        libdbus-1-3 \
        libxcb-cursor0 \
        libxcb-xinerama0

# Copy Python virtual environment
COPY --from=builder /opt/venv /opt/venv

# Application
WORKDIR /work

VOLUME /work

COPY generate.py /opt/generate.py

# Prefer user-provided script from /work.
# Fall back to the bundled script.

ENTRYPOINT ["sh", "-c", "script=generate.py; if [ -f \"/work/$script\" ]; then exec python3 \"/work/$script\" \"$@\"; else exec python3 \"/opt/$script\" \"$@\"; fi", "--"]

CMD []

Скрипт generate.py

#!/usr/bin/env python3
"""
Generate a prettymaps poster.

Parameters (priority: CLI args > env vars > defaults):

  QUERY / --query              location string (geocoded by osmnx)
  LAT / --lat                  latitude (use with LON instead of QUERY)
  LON / --lon                  longitude
  RADIUS_M / --radius          radius in metres
  CIRCLE / --circle            circular clip (true/false, 1/0, yes/no)
  TITLE / --title              map title
  PRESET / --preset            prettymaps preset name
  OUTPUT_NAME / --output-name  basename for png/pdf (no extension)
  DPI / --dpi                  output dpi
"""

import argparse
import os
from pathlib import Path

import osmnx as ox

# osmnx 2.x: project_gdf left the top-level namespace
if not hasattr(ox, "project_gdf"):
    ox.project_gdf = ox.projection.project_gdf
if not hasattr(ox, "project_graph"):
    if hasattr(ox, "projection") and hasattr(ox.projection, "project_graph"):
        ox.project_graph = ox.projection.project_graph

import prettymaps
import prettymaps.draw as _pm_draw
import matplotlib

matplotlib.use("Agg")

# --- patch: KeyError 'highway' when streets gdf has no highway column ---
_orig_graph_to_shapely = _pm_draw.graph_to_shapely


def _safe_graph_to_shapely(gdf, width=1.0):
    if gdf is None or getattr(gdf, "empty", True):
        from shapely.geometry import GeometryCollection

        return GeometryCollection()
    if isinstance(width, dict) and "highway" not in gdf.columns:
        width = width.get("default", next(iter(width.values()), 1.0))
    return _orig_graph_to_shapely(gdf, width)


_pm_draw.graph_to_shapely = _safe_graph_to_shapely
# -----------------------------------------------------------------------

# --- defaults (single place) ---
DEFAULT_QUERY = "Akademgorodok, Novosibirsk, Russia"
DEFAULT_LAT = None
DEFAULT_LON = None
DEFAULT_RADIUS_M = 2200
DEFAULT_CIRCLE = True
DEFAULT_TITLE = "Академгородок · Новосибирск"
DEFAULT_PRESET = "default"
DEFAULT_OUTPUT_NAME = "akademgorodok_poster"
DEFAULT_DPI = 300
DEFAULT_FIGSIZE = (14, 14)
# --------------------------------


def _env(name, default=None):
    val = os.environ.get(name)
    if val is None or val == "":
        return default
    return val


def _env_bool(name, default):
    val = os.environ.get(name)
    if val is None or val == "":
        return default
    return val.strip().lower() in ("1", "true", "yes", "y", "on")


def _env_float(name, default):
    val = os.environ.get(name)
    if val is None or val == "":
        return default
    return float(val)


def _env_int(name, default):
    val = os.environ.get(name)
    if val is None or val == "":
        return default
    return int(val)


def parse_args():
    p = argparse.ArgumentParser(description="Generate a prettymaps poster")
    p.add_argument(
        "--query",
        default=_env("QUERY", DEFAULT_QUERY),
        help="Location string for geocoding",
    )
    p.add_argument(
        "--lat",
        type=float,
        default=_env_float("LAT", DEFAULT_LAT),
        help="Latitude (use with --lon instead of --query)",
    )
    p.add_argument(
        "--lon",
        type=float,
        default=_env_float("LON", DEFAULT_LON),
        help="Longitude (use with --lat instead of --query)",
    )
    p.add_argument(
        "--radius",
        type=int,
        default=_env_int("RADIUS_M", DEFAULT_RADIUS_M),
        help="Radius in metres",
    )
    p.add_argument(
        "--circle",
        default=None,
        help="Circular clip: true/false (env CIRCLE)",
    )
    p.add_argument(
        "--title",
        default=_env("TITLE", DEFAULT_TITLE),
        help="Map title",
    )
    p.add_argument(
        "--preset",
        default=_env("PRESET", DEFAULT_PRESET),
        help="prettymaps preset name",
    )
    p.add_argument(
        "--output-name",
        default=_env("OUTPUT_NAME", DEFAULT_OUTPUT_NAME),
        help="Output basename without extension",
    )
    p.add_argument(
        "--dpi",
        type=int,
        default=_env_int("DPI", DEFAULT_DPI),
        help="Output DPI",
    )
    args = p.parse_args()

    if args.circle is None:
        args.circle = _env_bool("CIRCLE", DEFAULT_CIRCLE)
    else:
        args.circle = str(args.circle).strip().lower() in (
            "1",
            "true",
            "yes",
            "y",
            "on",
        )

    return args


def main():
    args = parse_args()

    output_dir = Path("/work") if Path("/work").is_dir() else Path(".")
    output_dir.mkdir(parents=True, exist_ok=True)
    out_png = output_dir / f"{args.output_name}.png"
    out_pdf = output_dir / f"{args.output_name}.pdf"

    if args.lat is not None and args.lon is not None:
        location = (args.lat, args.lon)
        location_desc = f"({args.lat}, {args.lon})"
    else:
        location = args.query
        location_desc = args.query

    print(f"Location: {location_desc}")
    print(f"Radius: {args.radius} m, circle={args.circle}, preset={args.preset}")
    print(f"Title: {args.title}")
    print("Downloading OSM data and rendering (this can take several minutes)...")

    plot = prettymaps.plot(
        location,
        radius=args.radius,
        circle=args.circle,
        figsize=DEFAULT_FIGSIZE,
        preset=args.preset,
    )

    if args.title:
        plot.ax.set_title(
            args.title,
            fontsize=28,
            pad=20,
            fontfamily="DejaVu Sans",
        )

    plot.fig.savefig(
        out_png,
        dpi=args.dpi,
        bbox_inches="tight",
        facecolor=plot.fig.get_facecolor(),
        edgecolor="none",
    )
    print(f"Saved: {out_png}")

    plot.fig.savefig(
        out_pdf,
        bbox_inches="tight",
        facecolor=plot.fig.get_facecolor(),
        edgecolor="none",
    )
    print(f"Saved: {out_pdf}")
    print("Done.")


if __name__ == "__main__":
    main()
Поделиться
Отправить
Запинить
 2   20 ч   prettymaps   python   Карты   постер