386 lines
19 KiB
Python
386 lines
19 KiB
Python
"""Серверный статик-рендер карт для PDF-отчёта ПТИЦА (эпик #2259 PR-C).
|
||
|
||
Рендерит два вида PNG-карт БЕЗ браузера (headless matplotlib + geopandas + contextily):
|
||
|
||
• :func:`render_parcel_map_png` — карта участка (§1/§3): полигон участка (заливка +
|
||
контур), поверх — риск-зоны НСПД / красные линии / ЗОУИТ-пересечения (если геометрии
|
||
присутствуют в persist-payload) + конкуренты точками. Подложка — OSM-тайлы через
|
||
contextily; auto-bbox = участок + буфер.
|
||
• :func:`render_concept_footprint_png` — карта концепции (§7): контур участка + пятна
|
||
застройки (footprint'ы корпусов из ``ConceptVariant.buildings_geojson``) с подписью
|
||
этажности (``properties.floors``).
|
||
|
||
Плюс :func:`embed_map_png` — подстановка ``<img src="data:image/png;base64,…">`` вместо
|
||
строки-плейсхолдера в готовом HTML (``png=None`` → плашка «карта недоступна»).
|
||
|
||
CRS-контракт: все входные геометрии — EPSG:4326 (GeoJSON ``geom_geojson`` /
|
||
``geometry_geojson``, WKT ``geom_wkt`` в 4326, конкуренты lat/lng). Рисуем в Web Mercator
|
||
(EPSG:3857), т.к. OSM-тайлы contextily идут в 3857 — иначе подложка не совпадёт со слоями.
|
||
|
||
GRACEFUL: нет ``geom_geojson`` → ``None`` (нечего рисовать). Тайлы OSM недоступны/таймаут →
|
||
белый фон + слои, PNG всё равно возвращается (никогда не роняем экспорт из-за сети).
|
||
Битые отдельные геометрии слоёв пропускаются с ``logger.warning``, не валят всю карту.
|
||
|
||
HEADLESS: ``matplotlib.use("Agg")`` вызывается на уровне модуля ДО импорта pyplot — импорт
|
||
``report_maps`` не требует X11/display (запускается в Celery-воркере/Docker без GUI).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import io
|
||
import logging
|
||
from collections.abc import Iterator
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||
from contextlib import contextmanager
|
||
from typing import Any
|
||
|
||
import matplotlib
|
||
|
||
# Headless-бэкенд ДО импорта pyplot — модуль импортируется в Celery-воркере без display.
|
||
matplotlib.use("Agg")
|
||
|
||
import matplotlib.pyplot as plt
|
||
from matplotlib.figure import Figure
|
||
from shapely.geometry import Point, shape
|
||
from shapely.geometry.base import BaseGeometry
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Константы рендера ──────────────────────────────────────────────────────────
|
||
_WGS84 = "EPSG:4326"
|
||
_WEB_MERCATOR = "EPSG:3857"
|
||
|
||
# Буфер вокруг участка при auto-bbox (доля от большей стороны bbox). Даёт «воздух»
|
||
# по краям, чтобы контур участка не липнул к рамке и был виден контекст подложки.
|
||
_BBOX_PAD_FRAC: float = 0.25
|
||
|
||
# Таймаут ожидания OSM-тайлов (сек). Тайлы тянутся синхронно внутри contextily —
|
||
# оборачиваем в поток с bounded-таймаутом, чтобы медленный/недоступный tile-server
|
||
# не подвешивал воркер. Превышение → graceful fallback на белый фон.
|
||
_BASEMAP_TIMEOUT_S: float = 10.0
|
||
|
||
# Базовый физический размер фигуры (дюймы). Итоговый пиксель-размер = fig_inches * dpi,
|
||
# ограничивается сверху ``max_px`` пересчётом dpi (см. ``_figure_to_png``).
|
||
_FIG_INCHES: float = 6.4
|
||
|
||
# Палитра слоёв (edgecolor, facecolor, alpha) — консистентно с семантикой отчёта.
|
||
_PARCEL_STYLE = {"edgecolor": "#1d4ed8", "facecolor": "#3b82f6", "alpha": 0.28, "linewidth": 1.8}
|
||
_RISK_STYLE = {"edgecolor": "#b91c1c", "facecolor": "#ef4444", "alpha": 0.30, "linewidth": 1.0}
|
||
_REDLINE_STYLE = {"color": "#dc2626", "linewidth": 1.6, "linestyle": "--"}
|
||
_ZOUIT_STYLE = {"edgecolor": "#b45309", "facecolor": "#f59e0b", "alpha": 0.22, "linewidth": 1.0}
|
||
_OPPORTUNITY_STYLE = {
|
||
"edgecolor": "#15803d",
|
||
"facecolor": "#22c55e",
|
||
"alpha": 0.20,
|
||
"linewidth": 0.9,
|
||
}
|
||
_COMPETITOR_STYLE = {"color": "#7c3aed", "marker": "o", "markersize": 5.0, "linestyle": "none"}
|
||
_CONCEPT_FOOTPRINT_STYLE = {
|
||
"edgecolor": "#0f766e",
|
||
"facecolor": "#14b8a6",
|
||
"alpha": 0.55,
|
||
"linewidth": 1.2,
|
||
}
|
||
|
||
_MAP_UNAVAILABLE_HTML = (
|
||
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
|
||
"в отчёте</div>"
|
||
)
|
||
|
||
|
||
# ── Парсинг входных геометрий (EPSG:4326) ──────────────────────────────────────
|
||
def _geom_from_geojson(geojson: dict[str, Any] | None) -> BaseGeometry | None:
|
||
"""GeoJSON-geometry dict → shapely-geометрия (EPSG:4326). Битый вход → None+warning."""
|
||
if not isinstance(geojson, dict):
|
||
return None
|
||
try:
|
||
geom = shape(geojson)
|
||
except Exception as exc: # любой битый GeoJSON: пропустить слой, не валить всю карту
|
||
logger.warning("report_maps: не разобрал GeoJSON-геометрию: %s", exc)
|
||
return None
|
||
if geom.is_empty:
|
||
return None
|
||
return geom
|
||
|
||
|
||
def _geom_from_wkt(wkt: str | None) -> BaseGeometry | None:
|
||
"""WKT-строка (EPSG:4326) → shapely-геометрия. Битый вход → None+warning."""
|
||
if not wkt or not isinstance(wkt, str):
|
||
return None
|
||
try:
|
||
from shapely import wkt as shapely_wkt
|
||
|
||
geom = shapely_wkt.loads(wkt)
|
||
except Exception as exc: # битый WKT слоя: пропустить геометрию, не валить всю карту
|
||
logger.warning("report_maps: не разобрал WKT-геометрию: %s", exc)
|
||
return None
|
||
if geom.is_empty:
|
||
return None
|
||
return geom
|
||
|
||
|
||
def _to_mercator(geoms: list[BaseGeometry]) -> Any:
|
||
"""Список shapely-геометрий (EPSG:4326) → GeoSeries в Web Mercator (EPSG:3857).
|
||
|
||
Импорт geopandas — локальный (тяжёлый), вызывается только когда есть что рисовать.
|
||
"""
|
||
import geopandas as gpd
|
||
|
||
series = gpd.GeoSeries(geoms, crs=_WGS84)
|
||
return series.to_crs(_WEB_MERCATOR)
|
||
|
||
|
||
# ── Basemap (OSM-тайлы) с bounded-таймаутом и graceful fallback ────────────────
|
||
def _add_basemap(ax: Any) -> bool:
|
||
"""Подложить OSM-тайлы под слои с таймаутом. True — легли, False — fallback (белый фон).
|
||
|
||
contextily тянет тайлы синхронно (сеть) — оборачиваем в поток с таймаутом, чтобы
|
||
недоступный tile-сервер не подвешивал воркер. Любая ошибка/таймаут → белый фон,
|
||
слои остаются видны, PNG всё равно рендерится.
|
||
"""
|
||
import contextily as cx
|
||
|
||
def _fetch() -> None:
|
||
cx.add_basemap(ax, crs=_WEB_MERCATOR, source=cx.providers.OpenStreetMap.Mapnik)
|
||
|
||
try:
|
||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||
pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)
|
||
return True
|
||
except FuturesTimeoutError:
|
||
logger.warning(
|
||
"report_maps: OSM basemap timeout (%.0fs) — fallback белый фон", _BASEMAP_TIMEOUT_S
|
||
)
|
||
return False
|
||
except Exception as exc: # тайлы недоступны: graceful fallback на белый фон, не валим экспорт
|
||
logger.warning("report_maps: OSM basemap недоступен (%s) — fallback белый фон", exc)
|
||
return False
|
||
|
||
|
||
# ── Общие хелперы фигуры ───────────────────────────────────────────────────────
|
||
@contextmanager
|
||
def _managed_figure() -> Iterator[tuple[Figure, Any]]:
|
||
"""Контекст-менеджер фигуры: создать axes, ГАРАНТИРОВАННО закрыть в finally.
|
||
|
||
pyplot держит созданные фигуры в глобальном реестре (``plt.get_fignums()``) до
|
||
явного ``plt.close``. В долгоживущем Celery-воркере любое исключение между
|
||
созданием фигуры и ``savefig`` без close оставляло бы residual-окно → растущий
|
||
RSS (утечка на 1 фигуру за каждый упавший рендер). ``finally`` закрывает фигуру
|
||
на ЛЮБОМ пути выхода — успех, per-geometry-ошибка, неожиданный raise.
|
||
"""
|
||
fig = plt.figure(figsize=(_FIG_INCHES, _FIG_INCHES))
|
||
try:
|
||
ax = fig.add_subplot(1, 1, 1)
|
||
ax.set_axis_off()
|
||
ax.set_aspect("equal")
|
||
yield fig, ax
|
||
finally:
|
||
plt.close(fig)
|
||
|
||
|
||
def _apply_bbox(ax: Any, parcel_merc: Any) -> None:
|
||
"""Выставить границы axes по bbox участка + буфер (``_BBOX_PAD_FRAC``)."""
|
||
minx, miny, maxx, maxy = parcel_merc.total_bounds
|
||
span = max(maxx - minx, maxy - miny) or 1.0
|
||
pad = span * _BBOX_PAD_FRAC
|
||
ax.set_xlim(minx - pad, maxx + pad)
|
||
ax.set_ylim(miny - pad, maxy + pad)
|
||
|
||
|
||
def _figure_to_png(fig: Figure, max_px: int) -> bytes:
|
||
"""Фигуру → PNG-bytes с dpi, подобранным так, чтобы длинная сторона ≤ ``max_px``.
|
||
|
||
Закрытие фигуры — НЕ здесь: им владеет ``_managed_figure`` (finally покрывает и
|
||
путь, где ``savefig`` сам кинул исключение).
|
||
"""
|
||
dpi = max(72.0, min(300.0, float(max_px) / _FIG_INCHES))
|
||
buf = io.BytesIO()
|
||
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.05)
|
||
return buf.getvalue()
|
||
|
||
|
||
# ── Публичный API ──────────────────────────────────────────────────────────────
|
||
def render_parcel_map_png(analyze_result: dict[str, Any], *, max_px: int = 1600) -> bytes | None:
|
||
"""Карта участка + слои НСПД + конкуренты → PNG-bytes. Нет ``geom_geojson`` → None.
|
||
|
||
Args:
|
||
analyze_result: persist-payload ``analysis_runs.result`` (schema analyze-1.0).
|
||
Читаемые ключи с геометрией: ``geom_geojson`` (обязателен), ``nspd_risk_zones``,
|
||
``nspd_red_lines``, ``nspd_opportunity_parcels`` (WKT ``geom_wkt``),
|
||
``nspd_zouit_overlaps`` (``geometry_geojson``), ``competitors`` (``lat``/``lng``).
|
||
max_px: верхняя граница длинной стороны PNG в пикселях.
|
||
|
||
Returns:
|
||
PNG-bytes, либо None если геометрии участка нет (нечего рисовать).
|
||
"""
|
||
parcel = _geom_from_geojson(analyze_result.get("geom_geojson"))
|
||
if parcel is None:
|
||
return None
|
||
|
||
with _managed_figure() as (fig, ax):
|
||
# Участок — базовый слой, задаёт bbox.
|
||
parcel_merc = _to_mercator([parcel])
|
||
parcel_merc.plot(ax=ax, **_PARCEL_STYLE)
|
||
_apply_bbox(ax, parcel_merc)
|
||
|
||
# Риск-зоны (WKT).
|
||
_plot_wkt_layer(ax, analyze_result.get("nspd_risk_zones"), _RISK_STYLE)
|
||
# ЗОУИТ-пересечения (GeoJSON geometry_geojson).
|
||
_plot_geojson_layer(ax, analyze_result.get("nspd_zouit_overlaps"), _ZOUIT_STYLE)
|
||
# Opportunity-участки (WKT).
|
||
_plot_wkt_layer(ax, analyze_result.get("nspd_opportunity_parcels"), _OPPORTUNITY_STYLE)
|
||
# Красные линии (WKT, линии — без заливки: _REDLINE_STYLE несёт color, не facecolor).
|
||
_plot_wkt_layer(ax, analyze_result.get("nspd_red_lines"), _REDLINE_STYLE)
|
||
# Конкуренты — точки lat/lng.
|
||
_plot_competitors(ax, analyze_result.get("competitors"))
|
||
|
||
_add_basemap(ax) # graceful: False → просто белый фон, PNG всё равно вернётся
|
||
return _figure_to_png(fig, max_px)
|
||
|
||
|
||
def render_concept_footprint_png(
|
||
concept_variant: dict[str, Any],
|
||
parcel_geojson: dict[str, Any] | None,
|
||
*,
|
||
max_px: int = 1600,
|
||
) -> bytes | None:
|
||
"""Карта концепции: контур участка + пятна корпусов с подписью этажности → PNG-bytes.
|
||
|
||
Args:
|
||
concept_variant: dict-представление ``ConceptVariant`` с ключом ``buildings_geojson``
|
||
(FeatureCollection, ``properties.floors`` / ``footprint_sqm`` / ``section_id``).
|
||
parcel_geojson: GeoJSON-геометрия участка (контур-подложка). None → рисуем только
|
||
корпуса (bbox по ним).
|
||
max_px: верхняя граница длинной стороны PNG в пикселях.
|
||
|
||
Returns:
|
||
PNG-bytes, либо None если нет ни участка, ни корпусов (нечего рисовать).
|
||
"""
|
||
fc = concept_variant.get("buildings_geojson") if isinstance(concept_variant, dict) else None
|
||
features = fc.get("features", []) if isinstance(fc, dict) else []
|
||
|
||
footprints: list[BaseGeometry] = []
|
||
floors_labels: list[int | None] = []
|
||
for feat in features:
|
||
if not isinstance(feat, dict):
|
||
continue
|
||
geom = _geom_from_geojson(feat.get("geometry"))
|
||
if geom is None:
|
||
continue
|
||
footprints.append(geom)
|
||
props = feat.get("properties") or {}
|
||
raw_floors = props.get("floors") if isinstance(props, dict) else None
|
||
floors_labels.append(int(raw_floors) if isinstance(raw_floors, int | float) else None)
|
||
|
||
parcel = _geom_from_geojson(parcel_geojson)
|
||
if parcel is None and not footprints:
|
||
return None
|
||
|
||
with _managed_figure() as (fig, ax):
|
||
# bbox: по участку если есть, иначе по совокупности footprint'ов.
|
||
bbox_source = _to_mercator([parcel]) if parcel is not None else _to_mercator(footprints)
|
||
|
||
if parcel is not None:
|
||
# Участок — только контур (без заливки), корпуса рисуются поверх.
|
||
_to_mercator([parcel]).plot(ax=ax, facecolor="none", edgecolor="#1d4ed8", linewidth=1.8)
|
||
|
||
if footprints:
|
||
fp_merc = _to_mercator(footprints)
|
||
fp_merc.plot(ax=ax, **_CONCEPT_FOOTPRINT_STYLE)
|
||
# Подпись этажности в центроиде каждого корпуса.
|
||
for geom_merc, floors in zip(fp_merc.geometry, floors_labels, strict=True):
|
||
if floors is None:
|
||
continue
|
||
c = geom_merc.centroid
|
||
ax.annotate(
|
||
f"{floors} эт.",
|
||
(c.x, c.y),
|
||
ha="center",
|
||
va="center",
|
||
fontsize=7.0,
|
||
color="#0f172a",
|
||
)
|
||
|
||
_apply_bbox(ax, bbox_source)
|
||
_add_basemap(ax)
|
||
return _figure_to_png(fig, max_px)
|
||
|
||
|
||
def embed_map_png(html: str, placeholder: str, png: bytes | None) -> str:
|
||
"""Заменить строку-``placeholder`` в HTML на ``<img>`` с data-URI PNG.
|
||
|
||
``png=None`` → плашка «карта недоступна» (тот же класс ``.map-placeholder``, что и
|
||
в full_report_html — стиль уже есть в шаблоне).
|
||
"""
|
||
if png is None:
|
||
return html.replace(placeholder, _MAP_UNAVAILABLE_HTML)
|
||
b64 = base64.b64encode(png).decode("ascii")
|
||
img = (
|
||
f'<img class="report-map" src="data:image/png;base64,{b64}" '
|
||
'style="width:100%;max-width:100%;height:auto;display:block;margin:8pt 0;'
|
||
'border:1px solid #e5e7eb;border-radius:6pt;" alt="Карта участка" />'
|
||
)
|
||
return html.replace(placeholder, img)
|
||
|
||
|
||
# ── Приватные слой-плоттеры ────────────────────────────────────────────────────
|
||
def _plot_wkt_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
|
||
"""Отрисовать слой из списка dict'ов с ключом ``geom_wkt`` (EPSG:4326).
|
||
|
||
Заливка/линия задаётся ``style`` (наличие ``facecolor`` → полигон, ``color`` без
|
||
``facecolor`` → линия) — geopandas ``plot`` разбирает это сам по типу геометрии.
|
||
"""
|
||
geoms = _collect_wkt_geoms(items)
|
||
if not geoms:
|
||
return
|
||
_to_mercator(geoms).plot(ax=ax, **style)
|
||
|
||
|
||
def _plot_geojson_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
|
||
"""Отрисовать слой из списка dict'ов с ключом ``geometry_geojson`` (EPSG:4326)."""
|
||
if not isinstance(items, list):
|
||
return
|
||
geoms = [
|
||
g
|
||
for it in items
|
||
if isinstance(it, dict)
|
||
and (g := _geom_from_geojson(it.get("geometry_geojson"))) is not None
|
||
]
|
||
if not geoms:
|
||
return
|
||
_to_mercator(geoms).plot(ax=ax, **style)
|
||
|
||
|
||
def _collect_wkt_geoms(items: Any) -> list[BaseGeometry]:
|
||
"""Собрать shapely-геометрии из списка dict'ов по ключу ``geom_wkt``."""
|
||
if not isinstance(items, list):
|
||
return []
|
||
out: list[BaseGeometry] = []
|
||
for it in items:
|
||
if not isinstance(it, dict):
|
||
continue
|
||
geom = _geom_from_wkt(it.get("geom_wkt"))
|
||
if geom is not None:
|
||
out.append(geom)
|
||
return out
|
||
|
||
|
||
def _plot_competitors(ax: Any, competitors: Any) -> None:
|
||
"""Отрисовать конкурентов точками из ``lat``/``lng`` (EPSG:4326).
|
||
|
||
Внимание: у ``Competitor`` долгота в ключе ``lng`` (не ``lon``) — см. schemas/parcel.py.
|
||
"""
|
||
if not isinstance(competitors, list):
|
||
return
|
||
pts: list[BaseGeometry] = []
|
||
for c in competitors:
|
||
if not isinstance(c, dict):
|
||
continue
|
||
lat, lng = c.get("lat"), c.get("lng")
|
||
if isinstance(lat, int | float) and isinstance(lng, int | float):
|
||
pts.append(Point(float(lng), float(lat)))
|
||
if not pts:
|
||
return
|
||
_to_mercator(pts).plot(ax=ax, **_COMPETITOR_STYLE)
|