Files
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

234 lines
10 KiB
Python

"""GIS layer operations: export in any CRS/format, geometry ops, Voronoi, grids.
Layers travel as GeoJSON FeatureCollections in WGS84 (what the map holds).
Metric operations run in a planar CRS given by the caller (the model's CRS),
and results come back in WGS84.
"""
from __future__ import annotations
import io
import os
import tempfile
import zipfile
import numpy as np
import shapely
from pyogrio import raw as ogr_raw
from pyproj import CRS, Transformer
from shapely.geometry import mapping, shape
from .batch import BatchError
EXPORT_FORMATS = {
"shp": ("ESRI Shapefile", ".shp"),
"gpkg": ("GPKG", ".gpkg"),
"dxf": ("DXF", ".dxf"),
"geojson": ("GeoJSON", ".geojson"),
"kml": ("KML", ".kml"),
}
WGS84 = CRS.from_epsg(4326)
MAX_FEATURES = 200_000
MAX_GRID_CELLS = 200_000
def _crs(user: str | None, what: str = "좌표계") -> CRS:
if not user:
raise BatchError(f"{what}가 필요합니다.")
try:
return CRS.from_user_input(user)
except Exception as e:
raise BatchError(f"{what}를 읽을 수 없습니다: {str(user)[:60]}") from e
def _planar(user: str | None) -> CRS:
c = _crs(user, "평면 좌표계")
if c.is_geographic:
raise BatchError("거리 계산에는 평면 좌표계(예: EPSG:5186)가 필요합니다.")
return c
def _project(geoms: np.ndarray, src: CRS, dst: CRS) -> np.ndarray:
if src.equals(dst):
return geoms
tr = Transformer.from_crs(src, dst, always_xy=True)
return shapely.transform(geoms, lambda xy: np.column_stack(tr.transform(xy[:, 0], xy[:, 1])))
def _read_fc(fc: dict) -> tuple[np.ndarray, list[dict]]:
feats = (fc or {}).get("features") or []
if len(feats) > MAX_FEATURES:
raise BatchError(f"도형이 {MAX_FEATURES:,}개를 넘습니다.")
geoms, props = [], []
for f in feats:
g = f.get("geometry")
try:
geoms.append(shape(g) if g else None)
except Exception:
geoms.append(None)
props.append(dict(f.get("properties") or {}))
return np.array(geoms, dtype=object), props
def _to_fc(geoms, props: list[dict]) -> dict:
out = []
for g, p in zip(geoms, props):
if g is None or g.is_empty:
continue
out.append({"type": "Feature", "geometry": mapping(g), "properties": p})
return {"type": "FeatureCollection", "features": out}
# ---------------------------------------------------------------- export
def _field_arrays(props: list[dict], fmt: str):
names: list[str] = []
for p in props:
for k in p:
if k not in names:
names.append(k)
if fmt == "shp":
# dBASE: 10-char unique names
seen, short = set(), []
for n in names:
base = str(n)[:10] or "F"
s, k = base, 1
while s in seen:
k += 1
s = f"{base[:10 - len(str(k))]}{k}"
seen.add(s)
short.append(s)
else:
short = [str(n) for n in names]
arrays = []
for n in names:
vals = [p.get(n) for p in props]
nums = [v for v in vals if v is not None and v != ""]
if nums and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in nums):
arrays.append(np.array([float(v) if v is not None and v != "" else np.nan for v in vals], dtype="float64"))
else:
arrays.append(np.array(["" if v is None else str(v) for v in vals], dtype=object))
return short, arrays
def write_vector(fc: dict, target_crs: str | None, fmt: str, name: str) -> tuple[bytes, str, str]:
"""FeatureCollection (WGS84) -> file bytes in `fmt` and `target_crs` (shapefile zipped)."""
if fmt not in EXPORT_FORMATS:
raise BatchError(f"지원하지 않는 형식입니다: {fmt}")
driver, ext = EXPORT_FORMATS[fmt]
geoms, props = _read_fc(fc)
keep = [i for i, g in enumerate(geoms) if g is not None and not g.is_empty]
if not keep:
raise BatchError("내보낼 도형이 없습니다.")
geoms, props = geoms[keep], [props[i] for i in keep]
dst = _crs(target_crs) if target_crs else WGS84
if fmt == "kml":
dst = WGS84
geoms = _project(geoms, WGS84, dst)
safe = "".join(ch for ch in (name or "layer") if ch.isalnum() or ch in "-_ ").strip() or "layer"
kinds = {g.geom_type.replace("Multi", "") for g in geoms}
if fmt == "shp" and len(kinds) > 1:
raise BatchError("Shapefile은 한 가지 도형(점/선/면)만 담을 수 있습니다. GeoPackage로 내보내세요.")
geometry_type = "Unknown" if len(kinds) > 1 else ("Multi" + kinds.pop() if any(g.geom_type.startswith("Multi") for g in geoms) else geoms[0].geom_type)
# Promote singles to multi when the layer mixes them (pyogrio needs one type).
if geometry_type.startswith("Multi"):
geoms = np.array([g if g.geom_type.startswith("Multi") else getattr(shapely, geometry_type)([g]) for g in geoms], dtype=object)
fields, arrays = _field_arrays(props, fmt) if fmt != "dxf" else ([], [])
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, safe + ext)
kwargs = {"encoding": "UTF-8"} if fmt == "shp" else {}
ogr_raw.write(path, shapely.to_wkb(geoms), arrays, fields, driver=driver, geometry_type=geometry_type,
crs=dst.to_wkt() if fmt != "dxf" else None, **kwargs)
if fmt == "shp":
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for fn in sorted(os.listdir(tmp)):
z.write(os.path.join(tmp, fn), fn)
return buf.getvalue(), safe + ".zip", "application/zip"
with open(path, "rb") as f:
data = f.read()
media = {"gpkg": "application/geopackage+sqlite3", "dxf": "application/dxf", "geojson": "application/geo+json", "kml": "application/vnd.google-earth.kml+xml"}[fmt]
return data, safe + ext, media
# ---------------------------------------------------------------- operations
def reproject_fc(fc: dict, src: str, dst: str) -> dict:
"""Coordinates that were read as `src` but are really `dst`: fix the layer (WGS84 in/out)."""
a, b = _crs(src, "원래 좌표계"), _crs(dst, "새 좌표계")
geoms, props = _read_fc(fc)
raw = _project(geoms, WGS84, a) # back to the numbers in the file
return _to_fc(_project(raw, b, WGS84), props)
def vector_op(op: str, params: dict) -> dict:
planar = _planar(params.get("crs"))
if op == "voronoi":
pts = np.asarray(params.get("points") or [], dtype="float64").reshape(-1, 2) # in planar CRS
ids = [str(i) for i in params.get("ids") or []]
if len(pts) < 3 or len(ids) != len(pts):
raise BatchError("점이 3개 이상 필요합니다.")
margin = float(params.get("margin") or 0)
hull = shapely.MultiPoint(pts).convex_hull
clip = hull.buffer(margin if margin > 0 else max(1.0, 0.05 * np.sqrt(max(hull.area, 1.0))))
cells = shapely.get_parts(shapely.voronoi_polygons(shapely.MultiPoint(pts), extend_to=clip.envelope))
tree = shapely.STRtree(shapely.points(pts))
out_g, out_p = [], []
for c in cells:
hit = tree.query(c, predicate="contains")
if len(hit) == 0:
continue
g = c.intersection(clip)
if g.is_empty:
continue
k = int(hit[0])
out_g.append(g)
out_p.append({"node": ids[k], "area_m2": round(float(g.area), 2)})
return _to_fc(_project(np.array(out_g, dtype=object), planar, WGS84), out_p)
if op == "grid":
b = params.get("bounds") # planar [minx, miny, maxx, maxy]
cell = float(params.get("cell") or 0)
if not b or cell <= 0:
raise BatchError("범위와 격자 크기가 필요합니다.")
x0, y0, x1, y1 = map(float, b)
nx, ny = int(np.ceil((x1 - x0) / cell)), int(np.ceil((y1 - y0) / cell))
if nx * ny > MAX_GRID_CELLS:
raise BatchError(f"격자가 {nx * ny:,}칸입니다. 격자 크기를 키우세요 (최대 {MAX_GRID_CELLS:,}).")
gs, ps = [], []
for j in range(ny):
for i in range(nx):
gs.append(shapely.box(x0 + i * cell, y0 + j * cell, x0 + (i + 1) * cell, y0 + (j + 1) * cell))
ps.append({"col": i, "row": j, "cell": f"{i}_{j}"})
return _to_fc(_project(np.array(gs, dtype=object), planar, WGS84), ps)
geoms, props = _read_fc(params.get("layer") or {})
if not len(geoms):
raise BatchError("대상 레이어가 비어 있습니다.")
g = _project(geoms, WGS84, planar)
if op == "buffer":
d = float(params.get("distance") or 0)
if d == 0:
raise BatchError("버퍼 거리를 입력하세요.")
res = shapely.buffer(g, d)
return _to_fc(_project(res, planar, WGS84), props)
if op == "dissolve":
field = params.get("field")
groups: dict = {}
for geom, p in zip(g, props):
if geom is None:
continue
groups.setdefault(p.get(field) if field else None, []).append(geom)
out_g = [shapely.union_all(v) for v in groups.values()]
out_p = [{field: k, "count": len(v)} if field else {"count": len(v)} for k, v in groups.items()]
return _to_fc(_project(np.array(out_g, dtype=object), planar, WGS84), out_p)
if op in ("intersection", "difference", "clip"):
other_g, _ = _read_fc(params.get("other") or {})
if not len(other_g):
raise BatchError("두 번째 레이어가 비어 있습니다.")
mask = shapely.union_all(_project(other_g, WGS84, planar))
res = shapely.difference(g, mask) if op == "difference" else shapely.intersection(g, mask)
return _to_fc(_project(res, planar, WGS84), props)
raise BatchError(f"알 수 없는 연산입니다: {op}")