도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, DXF(AutoCAD 2018)
- 데모 v2 이후 개발 PC의 변경을 옮김 (이 저장소의 이름 변경·README 정리는 유지) - 서버 기본 DEM, DEM 등고선, 속성 테이블 범위 선택, WNTR 교차 검증 시험 포함 - 새 라이브러리: ezdxf(Python), maplibre-contour(npm) - 변경 내역: docs/SPipe_패치내역_도면출력_2026-11-06.txt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
14f07181db
commit
e3f386addf
33 files changed
+3454
-482
No files matched your search
+76
-15
@@ -27,12 +27,24 @@ 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"))
|
||||
# Server-provided DEMs (e.g. the national 90 m DEM), registered once with
|
||||
# `python -m backend.dem register <raster|zip> <name> <label>`; never trimmed.
|
||||
BASE_DIR = Path(os.environ.get("SPIPE_BASE_DEM_DIR", Path(__file__).resolve().parents[1] / "data" / "base_dem"))
|
||||
_NAME_OK = set("abcdefghijklmnopqrstuvwxyz0123456789_-")
|
||||
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 dem_id.startswith("base-"):
|
||||
name = dem_id[5:]
|
||||
if not name or not set(name) <= _NAME_OK:
|
||||
raise BatchError("DEM id가 올바르지 않습니다.")
|
||||
p = BASE_DIR / f"{name}.tif"
|
||||
if not p.exists():
|
||||
raise BatchError("서버 기본 DEM이 없습니다.")
|
||||
return p
|
||||
if len(dem_id) != 32 or not set(dem_id) <= _ID_OK:
|
||||
raise BatchError("DEM id가 올바르지 않습니다.")
|
||||
p = DEM_DIR / f"{dem_id}.tif"
|
||||
@@ -79,10 +91,12 @@ def _info(dem_id: str, src, name: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
def _store(src_path: str, name: str, base: str | None = None) -> dict:
|
||||
"""Copy a raster into the store as a tiled GeoTIFF (base: a permanent server DEM)."""
|
||||
folder = BASE_DIR if base else DEM_DIR
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
dem_id = f"base-{base}" if base else uuid.uuid4().hex
|
||||
out = folder / f"{base if base else dem_id}.tif"
|
||||
try:
|
||||
with rasterio.open(src_path) as src:
|
||||
if src.count < 1:
|
||||
@@ -105,10 +119,36 @@ def _store(src_path: str, name: str) -> dict:
|
||||
out.unlink(missing_ok=True)
|
||||
raise
|
||||
out.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False), encoding="utf-8")
|
||||
_trim()
|
||||
if not base:
|
||||
_trim()
|
||||
return info
|
||||
|
||||
|
||||
def register_base(src: str, name: str, label: str) -> dict:
|
||||
"""Register a raster (or a zip holding one) as a permanent server DEM."""
|
||||
if not name or not set(name) <= _NAME_OK:
|
||||
raise BatchError("이름은 영문 소문자·숫자·-·_ 만 쓸 수 있습니다.")
|
||||
if src.lower().endswith(".zip"):
|
||||
with zipfile.ZipFile(src) as z:
|
||||
names = [n for n in z.namelist() if os.path.splitext(n.lower())[1] in RASTER_EXTENSIONS]
|
||||
if not names:
|
||||
raise BatchError("zip 안에 래스터가 없습니다.")
|
||||
src = "/vsizip/" + os.path.abspath(src).replace("\\", "/") + "/" + names[0]
|
||||
return _store(src, label, base=name)
|
||||
|
||||
|
||||
def list_base() -> list[dict]:
|
||||
if not BASE_DIR.exists():
|
||||
return []
|
||||
out = []
|
||||
for j in sorted(BASE_DIR.glob("*.json")):
|
||||
try:
|
||||
out.append(json.loads(j.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
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]
|
||||
@@ -292,16 +332,27 @@ 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):
|
||||
def _hydro_grid(dem_id: str, bbox: list | None = None, bbox_crs: str | None = None):
|
||||
"""DEM (downsampled to <= MAX_HYDRO_CELLS) -> D8 receivers and flow accumulation
|
||||
on a priority-flood filled surface (pits and flats drain)."""
|
||||
on a priority-flood filled surface (pits and flats drain). `bbox` [x0, y0, x1, y1]
|
||||
in `bbox_crs` limits the area (needed for country-wide DEMs)."""
|
||||
import heapq
|
||||
from rasterio.windows import Window, from_bounds
|
||||
|
||||
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)
|
||||
win = Window(0, 0, src.width, src.height)
|
||||
if bbox:
|
||||
tr = Transformer.from_crs(_crs_of(bbox_crs), src.crs, always_xy=True)
|
||||
xs, ys = tr.transform([bbox[0], bbox[2], bbox[0], bbox[2]], [bbox[1], bbox[1], bbox[3], bbox[3]])
|
||||
win = from_bounds(min(xs), min(ys), max(xs), max(ys), src.transform).round_offsets().round_lengths()
|
||||
win = win.intersection(Window(0, 0, src.width, src.height))
|
||||
elif src.width * src.height > 16 * MAX_HYDRO_CELLS:
|
||||
raise BatchError("DEM이 너무 넓습니다. 관망 범위로 잘라서 계산하세요.")
|
||||
k = max(1, int(np.ceil(np.sqrt(win.width * win.height / MAX_HYDRO_CELLS))))
|
||||
h, w = max(2, int(win.height) // k), max(2, int(win.width) // k)
|
||||
z = src.read(1, window=win, masked=True, out_shape=(h, w), resampling=Resampling.average).astype("float64")
|
||||
wt = src.window_transform(win)
|
||||
transform = wt @ wt.scale(win.width / w, win.height / h)
|
||||
crs = src.crs
|
||||
if crs.is_geographic:
|
||||
raise BatchError("유역 분석에는 평면 좌표계 DEM이 필요합니다.")
|
||||
@@ -353,9 +404,9 @@ def _hydro_grid(dem_id: str):
|
||||
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:
|
||||
def streams(dem_id: str, min_area_km2: float, bbox: list | None = None, bbox_crs: str | None = None) -> dict:
|
||||
"""Stream lines (WGS84 GeoJSON): cells draining at least `min_area_km2`."""
|
||||
g = _hydro_grid(dem_id)
|
||||
g = _hydro_grid(dem_id, bbox, bbox_crs)
|
||||
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]
|
||||
@@ -385,11 +436,11 @@ def streams(dem_id: str, min_area_km2: float) -> dict:
|
||||
return {"type": "FeatureCollection", "features": feats}
|
||||
|
||||
|
||||
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0) -> dict:
|
||||
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0, bbox: list | None = None) -> 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)
|
||||
g = _hydro_grid(dem_id, bbox, points_crs)
|
||||
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)
|
||||
@@ -430,3 +481,13 @@ def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float =
|
||||
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}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) == 5 and sys.argv[1] == "register":
|
||||
info = register_base(sys.argv[2], sys.argv[3], sys.argv[4])
|
||||
print(f"registered {info['id']}: {info['name']} {info['width']}x{info['height']} res {info['resolution']} z {info['min']}~{info['max']}")
|
||||
else:
|
||||
print("usage: python -m backend.dem register <raster|zip> <name> <label>")
|
||||
Reference in new issue
Block a user