SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
+432
@@ -0,0 +1,432 @@
|
||||
"""DEM (digital elevation model) store: upload once, preview, sample, build from points.
|
||||
|
||||
Rasters are kept under SPIPE_DEM_DIR (default: <repo>/data/dem) as GeoTIFF,
|
||||
keyed by a random id; only the newest SPIPE_DEM_KEEP files are retained.
|
||||
Elevations are returned in the raster's own units (normally metres).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import rasterio
|
||||
import shapely
|
||||
import shapely.geometry
|
||||
from rasterio.enums import Resampling
|
||||
from rasterio.io import MemoryFile
|
||||
from rasterio.transform import from_origin
|
||||
from pyproj import CRS, Transformer
|
||||
|
||||
from .batch import BatchError
|
||||
|
||||
DEM_DIR = Path(os.environ.get("SPIPE_DEM_DIR", Path(__file__).resolve().parents[1] / "data" / "dem"))
|
||||
DEM_KEEP = int(os.environ.get("SPIPE_DEM_KEEP", "8"))
|
||||
RASTER_EXTENSIONS = (".tif", ".tiff", ".asc", ".img", ".dem", ".bil", ".flt", ".hgt", ".vrt")
|
||||
MAX_CELLS_FROM_POINTS = 25_000_000
|
||||
_ID_OK = set("0123456789abcdef")
|
||||
|
||||
|
||||
def _path(dem_id: str) -> Path:
|
||||
if len(dem_id) != 32 or not set(dem_id) <= _ID_OK:
|
||||
raise BatchError("DEM id가 올바르지 않습니다.")
|
||||
p = DEM_DIR / f"{dem_id}.tif"
|
||||
if not p.exists():
|
||||
raise BatchError("DEM을 찾을 수 없습니다. 다시 불러오세요 (서버가 오래된 DEM을 정리했을 수 있습니다).")
|
||||
return p
|
||||
|
||||
|
||||
def _trim() -> None:
|
||||
files = sorted(DEM_DIR.glob("*.tif"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for old in files[DEM_KEEP:]:
|
||||
old.unlink(missing_ok=True)
|
||||
old.with_suffix(".json").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _crs_of(user: str | None) -> CRS:
|
||||
if not user:
|
||||
raise BatchError("좌표계가 필요합니다 (로컬 좌표계에서는 DEM을 쓸 수 없습니다).")
|
||||
try:
|
||||
return CRS.from_user_input(user)
|
||||
except Exception as e: # pyproj raises several types
|
||||
raise BatchError(f"좌표계를 읽을 수 없습니다: {user[:60]}") from e
|
||||
|
||||
|
||||
def _info(dem_id: str, src, name: str) -> dict:
|
||||
if src.crs is None:
|
||||
raise BatchError("DEM에 좌표계 정보가 없습니다 (.prj 또는 GeoTIFF 좌표계를 넣어 주세요).")
|
||||
band = src.read(1, masked=True, out_shape=(min(src.height, 1024), min(src.width, 1024)), resampling=Resampling.nearest)
|
||||
to_wgs = Transformer.from_crs(src.crs, "EPSG:4326", always_xy=True)
|
||||
l, b, r, t = src.bounds
|
||||
corners = [list(to_wgs.transform(x, y)) for x, y in ((l, t), (r, t), (r, b), (l, b))]
|
||||
return {
|
||||
"id": dem_id,
|
||||
"name": name,
|
||||
"crs": src.crs.to_string(),
|
||||
"width": src.width,
|
||||
"height": src.height,
|
||||
"resolution": [abs(src.res[0]), abs(src.res[1])],
|
||||
"bounds": [l, b, r, t],
|
||||
"corners": corners,
|
||||
"min": float(band.min()) if band.count() else None,
|
||||
"max": float(band.max()) if band.count() else None,
|
||||
"nodata": src.nodata,
|
||||
}
|
||||
|
||||
|
||||
def _store(src_path: str, name: str) -> dict:
|
||||
DEM_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dem_id = uuid.uuid4().hex
|
||||
out = DEM_DIR / f"{dem_id}.tif"
|
||||
try:
|
||||
with rasterio.open(src_path) as src:
|
||||
if src.count < 1:
|
||||
raise BatchError("래스터 밴드가 없습니다.")
|
||||
profile = src.profile.copy()
|
||||
profile.update(driver="GTiff", count=1, compress="deflate", tiled=True, blockxsize=256, blockysize=256, BIGTIFF="IF_SAFER")
|
||||
if src.width < 256 or src.height < 256:
|
||||
profile.update(tiled=False)
|
||||
profile.pop("blockxsize", None)
|
||||
profile.pop("blockysize", None)
|
||||
with rasterio.open(out, "w", **profile) as dst:
|
||||
for _, window in src.block_windows(1):
|
||||
dst.write(src.read(1, window=window), 1, window=window)
|
||||
with rasterio.open(out) as src:
|
||||
info = _info(dem_id, src, name)
|
||||
except rasterio.errors.RasterioIOError as e:
|
||||
out.unlink(missing_ok=True)
|
||||
raise BatchError(f"래스터를 읽을 수 없습니다: {e}") from e
|
||||
except BatchError:
|
||||
out.unlink(missing_ok=True)
|
||||
raise
|
||||
out.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False), encoding="utf-8")
|
||||
_trim()
|
||||
return info
|
||||
|
||||
|
||||
def import_dem(upload_path: str, filename: str) -> dict:
|
||||
"""Store an uploaded raster (or a zip holding one, e.g. ASC + PRJ) as a DEM."""
|
||||
ext = os.path.splitext(filename.lower())[1]
|
||||
if ext == ".zip":
|
||||
try:
|
||||
with zipfile.ZipFile(upload_path) as z:
|
||||
names = [n for n in z.namelist() if os.path.splitext(n.lower())[1] in RASTER_EXTENSIONS and not n.startswith("__MACOSX")]
|
||||
except zipfile.BadZipFile as e:
|
||||
raise BatchError("zip 파일이 손상되었습니다.") from e
|
||||
if not names:
|
||||
raise BatchError(f"zip 안에 래스터({', '.join(RASTER_EXTENSIONS)})가 없습니다.")
|
||||
return _store("/vsizip/" + upload_path.replace("\\", "/") + "/" + names[0], filename)
|
||||
if ext not in RASTER_EXTENSIONS:
|
||||
raise BatchError(f"지원하지 않는 형식입니다: {ext} (GeoTIFF, ASC, IMG 또는 이를 담은 zip)")
|
||||
# GDAL picks the driver from the extension; keep it on the temp copy.
|
||||
named = upload_path + ext
|
||||
shutil.copyfile(upload_path, named)
|
||||
try:
|
||||
return _store(named, filename)
|
||||
finally:
|
||||
os.remove(named)
|
||||
|
||||
|
||||
def dem_info(dem_id: str) -> dict:
|
||||
p = _path(dem_id)
|
||||
return json.loads(p.with_suffix(".json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def preview_png(dem_id: str, max_size: int = 1600) -> bytes:
|
||||
"""Hillshaded colour relief, transparent where there is no data."""
|
||||
with rasterio.open(_path(dem_id)) as src:
|
||||
k = max(1, int(np.ceil(max(src.width, src.height) / max_size)))
|
||||
h, w = max(1, src.height // k), max(1, src.width // k)
|
||||
z = src.read(1, masked=True, out_shape=(h, w), resampling=Resampling.bilinear).astype("float64")
|
||||
cell = abs(src.res[0]) * src.width / w
|
||||
geographic = src.crs and src.crs.is_geographic
|
||||
if geographic:
|
||||
cell *= 111_000
|
||||
mask = np.ma.getmaskarray(z)
|
||||
zf = z.filled(np.nan)
|
||||
lo, hi = np.nanpercentile(zf, [2, 98]) if np.isfinite(zf).any() else (0.0, 1.0)
|
||||
t = np.clip((zf - lo) / max(hi - lo, 1e-9), 0, 1)
|
||||
# Green -> yellow -> brown -> white elevation ramp.
|
||||
stops = np.array([[0.0, 64, 140, 80], [0.35, 190, 200, 110], [0.7, 160, 110, 60], [1.0, 245, 245, 240]])
|
||||
rgb = np.stack([np.interp(np.nan_to_num(t), stops[:, 0], stops[:, c]) for c in (1, 2, 3)])
|
||||
gy, gx = np.gradient(np.nan_to_num(zf, nan=float(np.nanmean(zf)) if np.isfinite(zf).any() else 0.0), cell)
|
||||
slope = np.arctan(np.hypot(gx, gy))
|
||||
aspect = np.arctan2(-gx, gy)
|
||||
az, alt = np.radians(315), np.radians(45)
|
||||
shade = np.clip(np.sin(alt) * np.cos(slope) + np.cos(alt) * np.sin(slope) * np.cos(az - aspect), 0, 1)
|
||||
rgb = rgb * (0.55 + 0.45 * shade)
|
||||
alpha = np.where(mask | ~np.isfinite(zf), 0, 255)
|
||||
img = np.concatenate([rgb, alpha[None]]).clip(0, 255).astype("uint8")
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", rasterio.errors.NotGeoreferencedWarning) # a plain image
|
||||
with MemoryFile() as mem:
|
||||
with mem.open(driver="PNG", width=w, height=h, count=4, dtype="uint8") as dst:
|
||||
dst.write(img)
|
||||
return mem.read()
|
||||
|
||||
|
||||
def sample(dem_id: str, points: list, points_crs: str | None) -> list:
|
||||
"""Bilinear DEM values at [x, y] points given in `points_crs` (None outside / no data)."""
|
||||
if not points:
|
||||
return []
|
||||
pts = np.asarray(points, dtype="float64").reshape(-1, 2)
|
||||
with rasterio.open(_path(dem_id)) as src:
|
||||
tr = Transformer.from_crs(_crs_of(points_crs), src.crs, always_xy=True)
|
||||
xs, ys = tr.transform(pts[:, 0], pts[:, 1])
|
||||
inv = ~src.transform
|
||||
xs, ys = np.asarray(xs), np.asarray(ys)
|
||||
cols, rows = inv.a * xs + inv.b * ys + inv.c, inv.d * xs + inv.e * ys + inv.f
|
||||
# pixel centres sit at +0.5
|
||||
cols, rows = np.asarray(cols) - 0.5, np.asarray(rows) - 0.5
|
||||
ok = np.isfinite(cols) & np.isfinite(rows) & (cols >= -0.5) & (rows >= -0.5) & (cols <= src.width - 0.5) & (rows <= src.height - 0.5)
|
||||
out: list = [None] * len(pts)
|
||||
if not ok.any():
|
||||
return out
|
||||
c0 = int(max(0, np.floor(cols[ok].min()))); c1 = int(min(src.width - 1, np.floor(cols[ok].max()) + 1))
|
||||
r0 = int(max(0, np.floor(rows[ok].min()))); r1 = int(min(src.height - 1, np.floor(rows[ok].max()) + 1))
|
||||
win = rasterio.windows.Window(c0, r0, c1 - c0 + 1, r1 - r0 + 1)
|
||||
if win.width * win.height > 400_000_000:
|
||||
raise BatchError("지점들이 너무 넓게 퍼져 있습니다. 나눠서 실행하세요.")
|
||||
z = src.read(1, window=win, masked=True).astype("float64").filled(np.nan)
|
||||
for k in np.where(ok)[0]:
|
||||
c, r = cols[k] - c0, rows[k] - r0
|
||||
ci, ri = int(np.clip(np.floor(c), 0, z.shape[1] - 1)), int(np.clip(np.floor(r), 0, z.shape[0] - 1))
|
||||
cj, rj = min(ci + 1, z.shape[1] - 1), min(ri + 1, z.shape[0] - 1)
|
||||
fc, fr = float(np.clip(c - ci, 0, 1)), float(np.clip(r - ri, 0, 1))
|
||||
q = np.array([z[ri, ci], z[ri, cj], z[rj, ci], z[rj, cj]])
|
||||
wts = np.array([(1 - fc) * (1 - fr), fc * (1 - fr), (1 - fc) * fr, fc * fr])
|
||||
good = np.isfinite(q) & (wts > 0)
|
||||
if good.any():
|
||||
out[k] = round(float(np.sum(q[good] * wts[good]) / np.sum(wts[good])), 3)
|
||||
elif np.isfinite(q[0]):
|
||||
out[k] = round(float(q[0]), 3)
|
||||
return out
|
||||
|
||||
|
||||
def from_points(points: list, points_crs: str | None, cell: float, name: str = "점 표고 DEM", target_crs: str | None = None) -> dict:
|
||||
"""TIN-style DEM: linear interpolation over the Delaunay triangulation of [x, y, z] points."""
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
pts = np.asarray(points, dtype="float64").reshape(-1, 3)
|
||||
pts = pts[np.all(np.isfinite(pts), axis=1)]
|
||||
if len(pts) < 3:
|
||||
raise BatchError("표고 점이 3개 이상 필요합니다.")
|
||||
crs = _crs_of(points_crs)
|
||||
if target_crs:
|
||||
# e.g. GIS points arrive in WGS84; grid them in the model's plane CRS.
|
||||
dst = _crs_of(target_crs)
|
||||
x, y = Transformer.from_crs(crs, dst, always_xy=True).transform(pts[:, 0], pts[:, 1])
|
||||
pts = np.column_stack([x, y, pts[:, 2]])
|
||||
crs = dst
|
||||
if crs.is_geographic:
|
||||
raise BatchError("경위도 좌표계 점으로는 만들 수 없습니다. 평면 좌표계를 쓰세요.")
|
||||
if not (cell > 0):
|
||||
raise BatchError("격자 크기는 0보다 커야 합니다.")
|
||||
x0, y0 = pts[:, 0].min(), pts[:, 1].min()
|
||||
x1, y1 = pts[:, 0].max(), pts[:, 1].max()
|
||||
w, h = int(np.ceil((x1 - x0) / cell)) + 1, int(np.ceil((y1 - y0) / cell)) + 1
|
||||
if w * h > MAX_CELLS_FROM_POINTS:
|
||||
raise BatchError(f"격자가 너무 큽니다 ({w}x{h}). 격자 크기를 키우세요.")
|
||||
try:
|
||||
interp = LinearNDInterpolator(pts[:, :2], pts[:, 2])
|
||||
except Exception as e: # Qhull errors on collinear input
|
||||
raise BatchError("점들이 한 줄 위에 있어 삼각망을 만들 수 없습니다.") from e
|
||||
gx = x0 + (np.arange(w) + 0.5) * cell
|
||||
gy = y1 - (np.arange(h) + 0.5) * cell
|
||||
xx, yy = np.meshgrid(gx, gy)
|
||||
z = interp(xx, yy).astype("float32")
|
||||
nodata = -9999.0
|
||||
z[~np.isfinite(z)] = nodata
|
||||
DEM_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = DEM_DIR / f"tmp_{uuid.uuid4().hex}.tif"
|
||||
with rasterio.open(tmp, "w", driver="GTiff", width=w, height=h, count=1, dtype="float32", crs=crs.to_wkt(),
|
||||
transform=from_origin(x0, y1, cell, cell), nodata=nodata, compress="deflate") as dst:
|
||||
dst.write(z, 1)
|
||||
try:
|
||||
return _store(str(tmp), name)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def terrain_tile(dem_id: str, z: int, x: int, y: int) -> bytes:
|
||||
"""Web-mercator XYZ tile of the DEM in Mapbox/MapLibre 'terrarium' encoding
|
||||
(elevation = R*256 + G + B/256 - 32768)."""
|
||||
from rasterio.warp import reproject
|
||||
|
||||
if not (0 <= z <= 18 and 0 <= x < 2 ** z and 0 <= y < 2 ** z):
|
||||
raise BatchError("타일 번호가 올바르지 않습니다.")
|
||||
size = 256
|
||||
world = 20037508.342789244
|
||||
span = 2 * world / 2 ** z
|
||||
x0, y1 = -world + x * span, world - y * span
|
||||
dst = np.full((size, size), np.nan, dtype="float32")
|
||||
with rasterio.open(_path(dem_id)) as src:
|
||||
reproject(
|
||||
source=rasterio.band(src, 1), destination=dst,
|
||||
dst_transform=from_origin(x0, y1, span / size, span / size), dst_crs="EPSG:3857",
|
||||
dst_nodata=np.nan, resampling=Resampling.bilinear,
|
||||
)
|
||||
# Outside the DEM: its lowest value, so the terrain does not drop to sea level in a cliff.
|
||||
fill = float(np.nanmin(dst)) if np.isfinite(dst).any() else float(dem_info(dem_id).get("min") or 0.0)
|
||||
v = np.nan_to_num(dst, nan=fill).astype("float64") + 32768.0
|
||||
r = np.floor(v / 256)
|
||||
g = np.floor(v - r * 256)
|
||||
b = np.floor((v - r * 256 - g) * 256)
|
||||
img = np.stack([r, g, b]).clip(0, 255).astype("uint8")
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", rasterio.errors.NotGeoreferencedWarning)
|
||||
with MemoryFile() as mem:
|
||||
with mem.open(driver="PNG", width=size, height=size, count=3, dtype="uint8") as d:
|
||||
d.write(img)
|
||||
return mem.read()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- hydrology (유역·하천)
|
||||
|
||||
MAX_HYDRO_CELLS = 1_000_000
|
||||
_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
|
||||
|
||||
|
||||
def _hydro_grid(dem_id: str):
|
||||
"""DEM (downsampled to <= MAX_HYDRO_CELLS) -> D8 receivers and flow accumulation
|
||||
on a priority-flood filled surface (pits and flats drain)."""
|
||||
import heapq
|
||||
|
||||
with rasterio.open(_path(dem_id)) as src:
|
||||
k = max(1, int(np.ceil(np.sqrt(src.width * src.height / MAX_HYDRO_CELLS))))
|
||||
h, w = max(2, src.height // k), max(2, src.width // k)
|
||||
z = src.read(1, masked=True, out_shape=(h, w), resampling=Resampling.average).astype("float64")
|
||||
transform = src.transform @ src.transform.scale(src.width / w, src.height / h)
|
||||
crs = src.crs
|
||||
if crs.is_geographic:
|
||||
raise BatchError("유역 분석에는 평면 좌표계 DEM이 필요합니다.")
|
||||
valid = ~np.ma.getmaskarray(z)
|
||||
filled = np.where(valid, z.filled(np.nan), np.inf)
|
||||
done = ~valid.copy()
|
||||
heap: list = []
|
||||
edge = np.zeros_like(valid)
|
||||
edge[0, :] = edge[-1, :] = edge[:, 0] = edge[:, -1] = True
|
||||
pad = np.pad(~valid, 1, constant_values=True)
|
||||
near_nodata = np.zeros_like(valid)
|
||||
for dr, dc in _D8:
|
||||
near_nodata |= pad[1 + dr:1 + dr + h, 1 + dc:1 + dc + w]
|
||||
for r, c in zip(*np.where(valid & (edge | near_nodata))):
|
||||
heapq.heappush(heap, (filled[r, c], int(r), int(c)))
|
||||
done[r, c] = True
|
||||
eps = 1e-4
|
||||
order = []
|
||||
while heap:
|
||||
e, r, c = heapq.heappop(heap)
|
||||
order.append(r * w + c)
|
||||
for dr, dc in _D8:
|
||||
rr, cc = r + dr, c + dc
|
||||
if 0 <= rr < h and 0 <= cc < w and not done[rr, cc]:
|
||||
done[rr, cc] = True
|
||||
filled[rr, cc] = max(filled[rr, cc], e + eps)
|
||||
heapq.heappush(heap, (filled[rr, cc], rr, cc))
|
||||
n = h * w
|
||||
flat = filled.ravel()
|
||||
recv = np.full(n, -1, dtype=np.int64)
|
||||
best = np.zeros(n)
|
||||
cell = abs(transform.a)
|
||||
rows, cols = np.divmod(np.arange(n), w)
|
||||
for dr, dc in _D8:
|
||||
rr, cc = rows + dr, cols + dc
|
||||
ok = (rr >= 0) & (rr < h) & (cc >= 0) & (cc < w)
|
||||
nb = np.where(ok, rr * w + cc, 0)
|
||||
with np.errstate(invalid="ignore"):
|
||||
drop = np.where(ok, (flat - flat[nb]) / (cell * np.hypot(dr, dc)), -np.inf)
|
||||
drop[~np.isfinite(drop)] = -np.inf
|
||||
take = drop > best
|
||||
best[take] = drop[take]
|
||||
recv[take] = nb[take]
|
||||
acc = valid.ravel().astype("float64")
|
||||
for i in reversed(order): # from the highest cell down
|
||||
j = recv[i]
|
||||
if j >= 0:
|
||||
acc[j] += acc[i]
|
||||
return {"h": h, "w": w, "recv": recv, "acc": acc, "transform": transform, "crs": crs, "cell_area": abs(transform.a * transform.e)}
|
||||
|
||||
|
||||
def streams(dem_id: str, min_area_km2: float) -> dict:
|
||||
"""Stream lines (WGS84 GeoJSON): cells draining at least `min_area_km2`."""
|
||||
g = _hydro_grid(dem_id)
|
||||
thr = max(2.0, min_area_km2 * 1e6 / g["cell_area"])
|
||||
w, tr = g["w"], g["transform"]
|
||||
idx = np.where((g["acc"] >= thr) & (g["recv"] >= 0))[0]
|
||||
if len(idx) > 300_000:
|
||||
raise BatchError("하천 셀이 너무 많습니다. 최소 유역 면적을 키우세요.")
|
||||
if not len(idx):
|
||||
return {"type": "FeatureCollection", "features": []}
|
||||
|
||||
def xy(i):
|
||||
r, c = divmod(int(i), w)
|
||||
return tr @ (c + 0.5, r + 0.5)
|
||||
|
||||
segs = np.array([shapely.LineString([xy(i), xy(g["recv"][i])]) for i in idx], dtype=object)
|
||||
merged = shapely.get_parts(shapely.line_merge(shapely.union_all(segs)))
|
||||
to_wgs = Transformer.from_crs(g["crs"], "EPSG:4326", always_xy=True)
|
||||
pts = shapely.points([xy(i) for i in idx])
|
||||
tree = shapely.STRtree(pts)
|
||||
acc_at = g["acc"][idx]
|
||||
feats = []
|
||||
for line in merged:
|
||||
near = tree.query(line, predicate="dwithin", distance=abs(tr.a) * 0.6)
|
||||
area = float(acc_at[near].max() * g["cell_area"] / 1e6) if len(near) else 0.0
|
||||
coords = np.asarray(line.coords)
|
||||
x, y = to_wgs.transform(coords[:, 0], coords[:, 1])
|
||||
feats.append({"type": "Feature", "properties": {"area_km2": round(area, 3), "length_m": round(float(line.length), 1)},
|
||||
"geometry": {"type": "LineString", "coordinates": [[float(a), float(b)] for a, b in zip(x, y)]}})
|
||||
return {"type": "FeatureCollection", "features": feats}
|
||||
|
||||
|
||||
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0) -> dict:
|
||||
"""Catchment polygon upstream of each pour point (snapped to the largest flow within snap_m)."""
|
||||
from rasterio import features as rfeatures
|
||||
|
||||
g = _hydro_grid(dem_id)
|
||||
h, w, tr = g["h"], g["w"], g["transform"]
|
||||
to_dem = Transformer.from_crs(_crs_of(points_crs), g["crs"], always_xy=True)
|
||||
to_wgs = Transformer.from_crs(g["crs"], "EPSG:4326", always_xy=True)
|
||||
order = np.argsort(g["recv"], kind="stable")
|
||||
recv_sorted = g["recv"][order]
|
||||
inv = ~tr
|
||||
rad = max(1, int(round(snap_m / abs(tr.a))))
|
||||
acc2 = g["acc"].reshape(h, w)
|
||||
feats = []
|
||||
for k, p in enumerate(points):
|
||||
x, y = to_dem.transform(float(p[0]), float(p[1]))
|
||||
c = inv.a * x + inv.b * y + inv.c
|
||||
r = inv.d * x + inv.e * y + inv.f
|
||||
r, c = int(r), int(c)
|
||||
if not (0 <= r < h and 0 <= c < w):
|
||||
continue
|
||||
r0, r1, c0, c1 = max(0, r - rad), min(h, r + rad + 1), max(0, c - rad), min(w, c + rad + 1)
|
||||
win = acc2[r0:r1, c0:c1]
|
||||
rr, cc = np.unravel_index(int(np.argmax(win)), win.shape)
|
||||
start = (r0 + rr) * w + (c0 + cc)
|
||||
mask = np.zeros(h * w, dtype=bool)
|
||||
mask[start] = True
|
||||
stack = [start]
|
||||
while stack:
|
||||
j = stack.pop()
|
||||
lo = np.searchsorted(recv_sorted, j, "left")
|
||||
hi = np.searchsorted(recv_sorted, j, "right")
|
||||
for i in order[lo:hi]:
|
||||
if not mask[i]:
|
||||
mask[i] = True
|
||||
stack.append(int(i))
|
||||
m2 = mask.reshape(h, w)
|
||||
polys = [shapely.geometry.shape(geom) for geom, val in rfeatures.shapes(m2.astype("uint8"), mask=m2, transform=tr) if val == 1]
|
||||
if not polys:
|
||||
continue
|
||||
poly = shapely.union_all(np.array(polys, dtype=object))
|
||||
poly = shapely.transform(poly, lambda a: np.column_stack(to_wgs.transform(a[:, 0], a[:, 1])))
|
||||
feats.append({"type": "Feature", "properties": {"point": k, "area_km2": round(float(mask.sum() * g["cell_area"] / 1e6), 4)},
|
||||
"geometry": shapely.geometry.mapping(poly)})
|
||||
return {"type": "FeatureCollection", "features": feats}
|
||||
Reference in new issue
Block a user