"""B07 지표면 음영기복 그림 — 계획평면도 배경 · D03 지형 층이 같이 씀. 이미 만들어 둔 **지표면 격자(DTM)** 로 음영기복 PNG 를 서버에서 만든다 — 점구름을 그대로 그리면 수천만 점이라 느려진다(용화_LAS 실측 4,900만 점). 옛 「계획평면도(라이다)」 짓기는 걷음(608). """ import base64 import io import math import numpy as np # 음영기복 광원 — 도면 관행대로 북서(방위각 315°)에서 45° 높이로 비춘다. _AZIMUTH_DEG = 315.0 _ALTITUDE_DEG = 45.0 # 그림이 지나치게 커지지 않도록 한 변 최대 픽셀 수 (A1 에 인쇄하면 1,200 px 이면 충분하다). _MAX_PIXELS = 1600 def hillshade_png(z: np.ndarray, valid: np.ndarray, resolution_m: float) -> tuple[str, int, int]: """지표면 격자에서 음영기복 PNG(data URL)를 만든다. (data_url, 가로 px, 세로 px). 입력 `z`는 행이 남→북 순서(격자 y 오름차순)다. 그림은 위가 북이어야 하므로 뒤집는다. 빈 칸(`valid`가 False)은 흰색으로 두어 도면에서 배경과 구분되게 한다. """ from PIL import Image grid = np.asarray(z, dtype=np.float64) mask = np.asarray(valid, dtype=bool) if grid.ndim != 2 or grid.size == 0: raise ValueError("지표면 격자가 비어 있습니다.") # 큰 격자는 미리 솎는다 — A1 한 장에 1,600 px 이상은 눈으로 구분되지 않는다. rows, columns = grid.shape stride = max(1, math.ceil(max(rows, columns) / _MAX_PIXELS)) if stride > 1: grid = grid[::stride, ::stride] mask = mask[::stride, ::stride] resolution_m *= stride filled = np.where(mask, grid, np.nan) # 빈 칸이 기울기를 망치지 않도록 평균으로 메운 뒤 기울기를 잰다. mean = float(np.nanmean(filled)) if np.isfinite(filled).any() else 0.0 filled = np.nan_to_num(filled, nan=mean) dz_dy, dz_dx = np.gradient(filled, max(resolution_m, 1e-6)) slope = np.arctan(np.hypot(dz_dx, dz_dy)) aspect = np.arctan2(-dz_dx, dz_dy) azimuth = math.radians(360.0 - _AZIMUTH_DEG + 90.0) altitude = math.radians(_ALTITUDE_DEG) shade = np.sin(altitude) * np.cos(slope) + np.cos(altitude) * np.sin(slope) * np.cos( azimuth - aspect ) shade = np.clip(shade, 0.0, 1.0) # 배경이므로 완전히 검지 않게 누르되, 능선·계곡이 인쇄에서 보일 만큼은 대비를 준다 # (2026-09-04 실측: 120~255 는 너무 흐렸음). pixels = (90 + 160 * shade).astype(np.uint8) pixels[~mask] = 255 image = Image.fromarray(np.flipud(pixels), mode="L") buffer = io.BytesIO() image.save(buffer, format="PNG", optimize=True) data_url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") return (data_url, image.width, image.height)