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-testDockerfile
# 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()