This commit is contained in:
2026-07-05 21:27:23 +09:00
parent 23d907265a
commit 3abc2edba6
83 changed files with 10351 additions and 1217 deletions
@@ -0,0 +1,91 @@
/* =============================================================================
* B04_wf1_Surface_Api_Fetch.ts
* 1차 워크플로우(지표면 분석) API 클라이언트
*
* 백엔드 계약 (B04_wf1_Surface_Router.py):
* POST /api/projects/{project_id}/surface/analyze → 분석 실행 + DB 기록
* GET /api/projects/{project_id}/surface/models → 모델 목록 조회
*
* 규칙:
* - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지).
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend";
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
export interface SurfaceAnalyzeRequest {
input_file_id: number;
source_filters?: string[];
methods?: string[];
force?: boolean;
}
/** 지표면 분석 실행 결과 (SurfaceAnalyzeResponse) */
export interface SurfaceAnalyzeResponse {
status: string;
project_id: string;
ground_summary: Record<string, unknown>;
manifest_status: string;
surface_model_ids: number[];
}
/** 저장된 지표면 모델 요약 (SurfaceModelSummary) */
export interface SurfaceModelSummary {
id: number;
model_type: string;
status: string;
resolution_m: number | null;
model_file_path: string | null;
created_at: string | null;
}
/** 지표면 모델 목록 응답 (SurfaceModelListResponse) */
export interface SurfaceModelListResponse {
status: string;
project_id: string;
models: SurfaceModelSummary[];
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
const token = localStorage.getItem(AUTH_TOKEN_KEY);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init.headers ?? {}),
},
signal: controller.signal,
});
const payload = (await response.json()) as T & { message?: string };
if (!response.ok) {
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload;
} finally {
window.clearTimeout(timeoutId);
}
}
/** 지표면 분석을 실행한다 (LAS 구조화 → 지면 필터 → 지표면 모델 생성). */
export async function analyzeSurface(
projectId: string,
request: SurfaceAnalyzeRequest,
): Promise<SurfaceAnalyzeResponse> {
return requestJson<SurfaceAnalyzeResponse>(`/projects/${projectId}/surface/analyze`, {
method: "POST",
body: JSON.stringify(request),
});
}
/** 프로젝트의 지표면 모델 목록을 조회한다. */
export async function listSurfaceModels(projectId: string): Promise<SurfaceModelListResponse> {
return requestJson<SurfaceModelListResponse>(`/projects/${projectId}/surface/models`, {
method: "GET",
});
}
+125
View File
@@ -0,0 +1,125 @@
"""B04 지표면 분석 엔진 오케스트레이터.
원본 LAS를 구조화하고 지면 필터를 실행한 뒤 지표면 5종 모델을 빌드하는
동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다.
"""
from pathlib import Path
from typing import Any
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import build_ground_masks, summarize_masks
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
from config.config_system import build_surface_model_config
def _relative_to_project(project_root: Path, path: Path) -> str:
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
return path.relative_to(project_root).as_posix()
def run_surface_analysis(
project_root: Path,
las_path: Path,
*,
source_filters: list[str],
methods: list[str],
force: bool = False,
) -> dict[str, Any]:
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
반환 dict:
- processed: {processed_file_path, converted_file_path, point_count, bounds, statistics}
- ground_summary: 필터별 지면 포인트 요약
- manifest: 지표면 모델 파이프라인 manifest
- models: [{model_type, model_file_path, resolution_m, generation_params, layers}]
"""
stage_root = project_root / "B04_wf1_Surface"
processed_dir = stage_root / "processed"
models_dir = stage_root / "models"
processed_dir.mkdir(parents=True, exist_ok=True)
models_dir.mkdir(parents=True, exist_ok=True)
# 1. LAS 구조화 (structured.npz)
structured_path = structurize_las(las_path, processed_dir)
with np.load(structured_path) as structured:
xyz = structured["xyz"]
bounds = structured["bounds"]
total_points = int(len(xyz))
stats = {
"min_z": float(bounds[2, 0]),
"max_z": float(bounds[2, 1]),
"mean_z": float(np.mean(xyz[:, 2])) if total_points else None,
}
bounds_dict = {
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
}
data = {"xyz": xyz, "bounds": bounds}
# 2. 지면 필터 실행
masks = build_ground_masks(data, source_filters)
ground_summary = summarize_masks(data, masks)
# 3. 지표면 5종 모델 빌드
config = build_surface_model_config()
config["source_filters"] = list(source_filters)
config["precompute"] = list(methods)
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
processed = {
"processed_file_path": _relative_to_project(project_root, structured_path),
"converted_file_path": None,
"point_count": total_points,
"bounds": bounds_dict,
"statistics": stats,
}
# manifest에서 저장된 모델별 정보 추출 (필터별 대표 모델)
models: list[dict[str, Any]] = []
for filter_key, filter_entry in manifest.get("source_filters", {}).items():
for method, meta in filter_entry.get("methods", {}).items():
if meta.get("status") != "completed":
continue
model_file = meta.get("model_file")
model_path = (models_dir / model_file) if model_file else None
layers: list[dict[str, Any]] = []
if meta.get("preview_file"):
layers.append(
{
"layer_name": f"{method}_{filter_key}_preview",
"geometry_type": "MESH" if method != "meshfree" else "POINTCLOUD",
"file_path": _relative_to_project(
project_root, models_dir / meta["preview_file"]
),
"file_format": "glb" if method != "meshfree" else "ply",
}
)
models.append(
{
"model_type": method,
"source_filter": filter_key,
"representation": meta.get("representation"),
"model_file_path": _relative_to_project(project_root, model_path)
if model_path
else None,
"resolution_m": meta.get("grid_resolution_meters"),
"generation_params": {
"source_filter": filter_key,
"representation": meta.get("representation"),
"footprint_area_m2": meta.get("footprint_area_m2"),
},
"layers": layers,
}
)
return {
"processed": processed,
"ground_summary": ground_summary,
"manifest": manifest,
"models": models,
}
@@ -0,0 +1,335 @@
"""B04 등고선 추출 엔진.
5종 표현(regular_grid/triangular_mesh/bspline_surface/local_rbf_height_field/
meshfree_surfels)의 npz 모델에서 표고 격자를 환원하고, marching squares로
지정 간격 등고선 라인을 추출한다. DTM valid_mask를 footprint로 사용해
경계 누출을 차단한다.
"""
from pathlib import Path
from typing import Any
import numpy as np
from scipy.interpolate import RBFInterpolator, RectBivariateSpline
from skimage import measure
# 등고선 캐시 형식/추출 규칙이 바뀔 때 증가시킨다.
CONTOUR_EXTRACTOR_VERSION = 3
def extract_contours_from_grid(
x_coords: np.ndarray,
y_coords: np.ndarray,
z_grid: np.ndarray,
valid_mask: np.ndarray | None,
interval: float,
min_interval: float = 0.5,
scene_center: tuple[float, float, float] | None = None,
) -> list[dict[str, Any]]:
"""정규 표고 격자로부터 등고선 라인을 추출한다."""
interval = max(interval, min_interval)
finite_mask = np.isfinite(z_grid)
if valid_mask is not None:
finite_mask &= valid_mask
if not finite_mask.any():
return []
z_min = float(np.min(z_grid[finite_mask]))
z_max = float(np.max(z_grid[finite_mask]))
start_level = np.ceil(z_min / interval) * interval
levels = np.arange(start_level, z_max, interval)
if len(levels) == 0:
return []
if len(levels) > 500:
new_interval = (z_max - z_min) / 100.0
levels = np.arange(np.ceil(z_min / new_interval) * new_interval, z_max, new_interval)
interval = new_interval
contours_geojson_list: list[dict[str, Any]] = []
# marching squares의 NaN 문제 예방: 무효 영역을 sentinel(z_min-1000)로 채운다.
z_grid_masked = z_grid.copy()
if valid_mask is not None:
z_grid_masked[~valid_mask] = z_min - 1000.0
invalid_mask = ~np.isfinite(z_grid_masked)
if invalid_mask.any():
z_grid_masked[invalid_mask] = z_min - 1000.0
cx, cy, cz = scene_center if scene_center is not None else (0.0, 0.0, 0.0)
for level in levels:
for contour in measure.find_contours(z_grid_masked, level):
current_segment: list[list[float]] = []
for y_idx, x_idx in contour:
x_idx_c = np.clip(x_idx, 0, len(x_coords) - 1)
y_idx_c = np.clip(y_idx, 0, len(y_coords) - 1)
x_0, x_1 = int(np.floor(x_idx_c)), int(np.ceil(x_idx_c))
y_0, y_1 = int(np.floor(y_idx_c)), int(np.ceil(y_idx_c))
is_valid = True
if valid_mask is not None and not (
valid_mask[y_0, x_0]
and valid_mask[y_0, x_1]
and valid_mask[y_1, x_0]
and valid_mask[y_1, x_1]
):
is_valid = False
if not is_valid:
if len(current_segment) >= 2:
mid_idx = len(current_segment) // 2
contours_geojson_list.append(
{
"level": float(level),
"coordinates": current_segment,
"label_position": current_segment[mid_idx],
}
)
current_segment = []
continue
tx = x_idx_c - x_0
ty = y_idx_c - y_0
x_val = (1.0 - tx) * x_coords[x_0] + tx * x_coords[x_1]
y_val = (1.0 - ty) * y_coords[y_0] + ty * y_coords[y_1]
if scene_center is not None:
current_segment.append(
[
round(float(x_val - cx), 3),
round(float(level - cz), 3),
round(float(-(y_val - cy)), 3),
]
)
else:
current_segment.append(
[round(float(x_val), 3), round(float(y_val), 3), round(float(level), 3)]
)
if len(current_segment) >= 2:
mid_idx = len(current_segment) // 2
contours_geojson_list.append(
{
"level": float(level),
"coordinates": current_segment,
"label_position": current_segment[mid_idx],
}
)
return contours_geojson_list
def _load_footprint_mask(
model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray
) -> np.ndarray | None:
"""같은 source filter의 DTM valid_mask를 현재 격자에 최근접 리샘플한다."""
stem = Path(model_npz_path).stem
if stem.endswith("_smooth"):
stem = stem[:-7]
parts = stem.split("_", 1)
if len(parts) < 2:
return None
filter_key = parts[1]
dtm_path = Path(model_npz_path).parent / f"dtm_{filter_key}.npz"
if not dtm_path.exists():
return None
try:
d = np.load(dtm_path)
dtm_x = np.asarray(d["x"]).ravel()
dtm_y = np.asarray(d["y"]).ravel()
dtm_mask = np.asarray(d["valid_mask"], dtype=bool)
except Exception:
return None
if len(dtm_x) < 2 or len(dtm_y) < 2:
return None
def _nearest_idx(axis: np.ndarray, coords: np.ndarray) -> np.ndarray:
ascending = bool(axis[0] <= axis[-1])
a = axis if ascending else axis[::-1]
idx = np.clip(np.searchsorted(a, coords), 1, len(a) - 1)
idx = np.where(np.abs(a[idx - 1] - coords) <= np.abs(a[idx] - coords), idx - 1, idx)
return idx if ascending else (len(axis) - 1 - idx)
xi = _nearest_idx(dtm_x, np.asarray(x_coords, dtype=np.float64))
yi = _nearest_idx(dtm_y, np.asarray(y_coords, dtype=np.float64))
return dtm_mask[np.ix_(yi, xi)]
def _apply_footprint(
model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray, valid_mask: np.ndarray
) -> np.ndarray:
"""valid_mask에 DTM footprint를 교집합으로 적용한다 (형상 다르면 최근접 리샘플)."""
fp = _load_footprint_mask(model_npz_path, x_coords, y_coords)
if fp is not None:
if fp.shape == valid_mask.shape:
return valid_mask & fp
from scipy.ndimage import zoom
zoom_y = valid_mask.shape[0] / fp.shape[0]
zoom_x = valid_mask.shape[1] / fp.shape[1]
fp_resized = zoom(fp.astype(float), (zoom_y, zoom_x), order=0) > 0.5
if fp_resized.shape == valid_mask.shape:
return valid_mask & fp_resized
return valid_mask
def _tin_face_coverage_mask(
vertices: np.ndarray, faces: np.ndarray, xx: np.ndarray, yy: np.ndarray
) -> np.ndarray:
"""저장된 TIN 면이 실제로 덮는 XY 영역만 True로 반환한다."""
vertices = np.asarray(vertices)
faces = np.asarray(faces, dtype=np.int64)
if vertices.ndim != 2 or vertices.shape[1] < 2 or not len(faces):
return np.zeros(xx.shape, dtype=bool)
edges = np.vstack((faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]))
edges = np.sort(edges, axis=1)
unique_edges, counts = np.unique(edges, axis=0, return_counts=True)
boundary_edges = unique_edges[counts == 1]
if not len(boundary_edges):
return np.zeros(xx.shape, dtype=bool)
from shapely import get_parts, intersects_xy, linestrings, polygonize, union_all
boundary_lines = linestrings(vertices[boundary_edges, :2])
polygons = list(get_parts(polygonize(boundary_lines)))
if not polygons:
return np.zeros(xx.shape, dtype=bool)
coverage = union_all(polygons)
xx_flat = np.asarray(xx, dtype=np.float64).ravel()
yy_flat = np.asarray(yy, dtype=np.float64).ravel()
res_flat = np.asarray(intersects_xy(coverage, xx_flat, yy_flat), dtype=bool)
return res_flat.reshape(xx.shape)
def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float):
cols = max(2, int(np.ceil((x_max - x_min) / target_grid_m)) + 1)
rows = max(2, int(np.ceil((y_max - y_min) / target_grid_m)) + 1)
x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32)
y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32)
return x_coords, y_coords
def extract_contours(
model_npz_path: Path,
representation: str,
interval: float,
target_grid_m: float = 1.0,
scene_center: tuple[float, float, float] | None = None,
) -> list[dict[str, Any]]:
"""표현별 npz 모델에서 표고 격자를 환원한 뒤 등고선 리스트를 추출한다."""
model_npz_path = Path(model_npz_path)
if not model_npz_path.exists():
raise FileNotFoundError(f"모델 파일이 존재하지 않습니다: {model_npz_path}")
data = np.load(model_npz_path)
if representation == "regular_grid":
x_coords, y_coords, z_grid, valid_mask = (
data["x"],
data["y"],
data["z"],
data["valid_mask"],
)
current_res = (x_coords[-1] - x_coords[0]) / (len(x_coords) - 1)
step = max(1, int(round(target_grid_m / current_res)))
if step > 1:
return extract_contours_from_grid(
x_coords[::step],
y_coords[::step],
z_grid[::step, ::step],
valid_mask[::step, ::step],
interval,
scene_center=scene_center,
)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
if representation == "triangular_mesh":
from scipy.interpolate import griddata
vertices, faces = data["vertices"], data["faces"]
x_min, x_max = float(np.min(vertices[:, 0])), float(np.max(vertices[:, 0]))
y_min, y_max = float(np.min(vertices[:, 1])), float(np.max(vertices[:, 1]))
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
xx, yy = np.meshgrid(x_coords, y_coords)
z_grid = griddata(vertices[:, :2], vertices[:, 2], (xx, yy), method="linear")
face_mask = _tin_face_coverage_mask(vertices, faces, xx, yy)
valid_mask = np.isfinite(z_grid) & face_mask
valid_mask = _apply_footprint(model_npz_path, x_coords, y_coords, valid_mask)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
if representation == "bspline_surface":
control_x, control_y, control_z = data["control_x"], data["control_y"], data["control_z"]
degree = int(data["degree"][0])
spline = RectBivariateSpline(
control_y,
control_x,
control_z,
kx=min(degree, len(control_y) - 1),
ky=min(degree, len(control_x) - 1),
s=float(len(control_x) * len(control_y)) * 0.01,
)
x_coords, y_coords = _grid_axes(
float(control_x[0]),
float(control_x[-1]),
float(control_y[0]),
float(control_y[-1]),
target_grid_m,
)
z_grid = np.asarray(spline(y_coords, x_coords), dtype=np.float32)
valid_mask = _apply_footprint(
model_npz_path, x_coords, y_coords, np.ones_like(z_grid, dtype=bool)
)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
if representation == "local_rbf_height_field":
centers_xy, center_z = data["centers_xy"], data["center_z"]
smoothing = float(data["smoothing"][0])
interpolator = RBFInterpolator(
centers_xy.astype(np.float64),
center_z.astype(np.float64),
neighbors=min(64, len(centers_xy)),
smoothing=smoothing,
kernel="thin_plate_spline",
)
x_min, x_max = float(np.min(centers_xy[:, 0])), float(np.max(centers_xy[:, 0]))
y_min, y_max = float(np.min(centers_xy[:, 1])), float(np.max(centers_xy[:, 1]))
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
xx, yy = np.meshgrid(x_coords, y_coords)
z_values = interpolator(np.column_stack([xx.ravel(), yy.ravel()])).astype(np.float32)
z_grid = z_values.reshape(len(y_coords), len(x_coords))
valid_mask = _apply_footprint(
model_npz_path, x_coords, y_coords, np.ones_like(z_grid, dtype=bool)
)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
if representation == "meshfree_surfels":
from scipy.interpolate import griddata
from scipy.spatial import Delaunay
points = data["points"]
x_min, x_max = float(np.min(points[:, 0])), float(np.max(points[:, 0]))
y_min, y_max = float(np.min(points[:, 1])), float(np.max(points[:, 1]))
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
xx, yy = np.meshgrid(x_coords, y_coords)
z_grid = griddata(points[:, :2], points[:, 2], (xx, yy), method="linear")
valid_mask = np.isfinite(z_grid)
try:
tri = Delaunay(points[:, :2])
hull_inside = tri.find_simplex(np.column_stack([xx.ravel(), yy.ravel()])) >= 0
valid_mask = valid_mask & hull_inside.reshape(xx.shape)
except Exception:
pass
valid_mask = _apply_footprint(model_npz_path, x_coords, y_coords, valid_mask)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
raise ValueError(f"지원하지 않는 표현 방식입니다: {representation}")
@@ -0,0 +1,110 @@
"""B04 CSF(Cloth Simulation Filter) 지면 필터.
Pure NumPy 기반 CSF. 지형을 반전시킨 뒤 가상의 천을 중력으로 낙하시켜
원 지면(반전 최하단)에 밀착시키고, 천과의 오차가 임계값 이내인 포인트를
지면으로 분류한다.
"""
import math
from typing import Any
import numpy as np
from config.config_system import (
SURFACE_CSF_CLASS_THRESHOLD_M,
SURFACE_CSF_CLOTH_RESOLUTION_M,
SURFACE_CSF_ITERATIONS,
SURFACE_CSF_RIGIDNESS,
SURFACE_CSF_SLOPE_SMOOTH,
SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M,
SURFACE_CSF_TIME_STEP,
)
# rigidness 단계별 스프링 완화 계수 (1: 산악 밀착 ~ 3: 부드럽게 덮음)
_RIGIDNESS_SPRING_COEFF = {1: 0.25, 2: 0.45, 3: 0.65}
# 중력 하강 계수 (time_step에 곱해 반복당 낙하량 산출)
_GRAVITY_BASE = 9.8 * 0.05
def filter_csf(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
cloth_resolution: float = SURFACE_CSF_CLOTH_RESOLUTION_M,
rigidness: int = SURFACE_CSF_RIGIDNESS,
time_step: float = SURFACE_CSF_TIME_STEP,
class_threshold: float = SURFACE_CSF_CLASS_THRESHOLD_M,
iterations: int = SURFACE_CSF_ITERATIONS,
slope_smooth: bool = SURFACE_CSF_SLOPE_SMOOTH,
slope_smooth_threshold: float = SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M,
) -> np.ndarray:
"""CSF로 지면 포인트의 불리언 마스크를 반환한다."""
if not math.isfinite(cloth_resolution) or cloth_resolution <= 0:
raise ValueError("천 해상도는 0보다 큰 유한한 값이어야 합니다.")
if rigidness not in _RIGIDNESS_SPRING_COEFF:
raise ValueError("rigidness는 1, 2, 3 중 하나여야 합니다.")
if not math.isfinite(time_step) or time_step <= 0:
raise ValueError("time_step은 0보다 큰 유한한 값이어야 합니다.")
if not math.isfinite(class_threshold) or class_threshold < 0:
raise ValueError("분류 임계값은 0 이상의 유한한 값이어야 합니다.")
if iterations <= 0:
raise ValueError("반복 횟수는 1 이상이어야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if xyz.shape[0] == 0:
return np.zeros(0, dtype=bool)
xs, ys, zs = xyz[:, 0], xyz[:, 1], xyz[:, 2]
# 1. 지형 반전 — 지표면 추출을 위해 높이를 뒤집는다.
z_max = float(np.max(zs))
inverted_zs = (z_max - zs).astype(np.float32)
# 2. 2D 가상 천 격자 설정 (바운더리 밀착 매핑)
x_min, x_max = float(np.min(xs)), float(np.max(xs))
y_min, y_max = float(np.min(ys)), float(np.max(ys))
cols = int(np.ceil((x_max - x_min) / cloth_resolution)) + 1
rows = int(np.ceil((y_max - y_min) / cloth_resolution)) + 1
# 천 노드 초기 높이 — 반전 지형 최고점보다 약간 높은 곳에서 낙하 시작
start_height = float(np.max(inverted_zs)) + 1.0
cloth_z = np.full((rows, cols), start_height, dtype=np.float32)
# 3. 격자 충돌 타겟 구성 (Drape Target)
collision_grid = np.full((rows, cols), -np.inf, dtype=np.float32)
gx = np.clip(((xs - x_min) / cloth_resolution).astype(np.int32), 0, cols - 1)
gy = np.clip(((ys - y_min) / cloth_resolution).astype(np.int32), 0, rows - 1)
np.maximum.at(collision_grid, (gy, gx), inverted_zs)
collision_grid[collision_grid == -np.inf] = 0.0
# 4. 천 시뮬레이션 반복 루프 (물리 하강)
gravity = _GRAVITY_BASE * time_step
spring_coeff = _RIGIDNESS_SPRING_COEFF[rigidness]
for _ in range(iterations):
cloth_z -= gravity
cloth_z = np.maximum(cloth_z, collision_grid)
# 노드 간 스프링 제약 완화 (가로/세로 인접 교정)
diff_h = cloth_z[:, 1:] - cloth_z[:, :-1]
correction_h = diff_h * spring_coeff * 0.5
cloth_z[:, :-1] += correction_h
cloth_z[:, 1:] -= correction_h
diff_v = cloth_z[1:, :] - cloth_z[:-1, :]
correction_v = diff_v * spring_coeff * 0.5
cloth_z[:-1, :] += correction_v
cloth_z[1:, :] -= correction_v
cloth_z = np.maximum(cloth_z, collision_grid)
# 5. 시뮬레이션 천 높이와 원본 대조 → 오차 이내면 지면
simulated_inverted_z = cloth_z[gy, gx]
height_diff = np.abs(inverted_zs - simulated_inverted_z)
mask = height_diff <= class_threshold
# 6. 수목 노이즈 2차 필터 보정
if slope_smooth:
local_min_z = collision_grid[gy, gx]
mask = mask & ((inverted_zs - local_min_z) < slope_smooth_threshold)
return np.asarray(mask, dtype=bool)
@@ -0,0 +1,53 @@
"""B04 grid minimum-Z 지면 필터."""
import math
from typing import Any
import numpy as np
from config.config_system import SURFACE_GRID_CELL_SIZE_M, SURFACE_GRID_HEIGHT_THRESHOLD_M
def filter_grid_min_z(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
cell_size: float = SURFACE_GRID_CELL_SIZE_M,
height_threshold: float = SURFACE_GRID_HEIGHT_THRESHOLD_M,
) -> np.ndarray:
"""격자 최저 표면에서 높이 임계값 이내인 포인트의 불리언 마스크를 반환한다."""
if not math.isfinite(cell_size) or cell_size <= 0:
raise ValueError("격자 크기는 0보다 큰 유한한 값이어야 합니다.")
if not math.isfinite(height_threshold) or height_threshold < 0:
raise ValueError("높이 임계값은 0 이상의 유한한 값이어야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if bounds.shape != (3, 2):
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
if xyz.shape[0] == 0:
return np.zeros(0, dtype=bool)
x_min, y_min, z_min = bounds[0, 0], bounds[1, 0], bounds[2, 0]
x_max, y_max = bounds[0, 1], bounds[1, 1]
grid_width = int(np.ceil((x_max - x_min) / cell_size)) + 2
grid_height = int(np.ceil((y_max - y_min) / cell_size)) + 2
minimum_z = np.full((grid_height, grid_width), np.inf, dtype=np.float32)
grid_x = np.clip(((xyz[:, 0] - x_min) / cell_size).astype(np.int64), 0, grid_width - 1)
grid_y = np.clip(((xyz[:, 1] - y_min) / cell_size).astype(np.int64), 0, grid_height - 1)
np.minimum.at(minimum_z, (grid_y, grid_x), xyz[:, 2])
minimum_z[np.isinf(minimum_z)] = z_min
try:
from scipy.ndimage import minimum_filter
minimum_z = minimum_filter(minimum_z, size=3).astype(np.float32)
except ImportError:
pass
height_above = xyz[:, 2] - minimum_z[grid_y, grid_x]
return np.asarray(
(height_above >= 0.0) & (height_above <= height_threshold),
dtype=bool,
)
@@ -0,0 +1,91 @@
"""B04 PMF(Progressive Morphological Filter) 지면 필터.
XY 평면을 격자로 투영해 Z-min 지형 맵을 만든 뒤, 윈도우 폭을 단계적으로
키워가며 형태학적 열림(Opening) 연산으로 수목·구조물을 제거하고, 최종 지면
대비 높이차가 임계값 이내인 포인트를 지면으로 분류한다. Pure NumPy 구현.
"""
import math
from typing import Any
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from config.config_system import (
SURFACE_PMF_CELL_SIZE_M,
SURFACE_PMF_INITIAL_WINDOW_SIZE,
SURFACE_PMF_MAX_DISTANCE_M,
SURFACE_PMF_MAX_WINDOW_SIZE,
SURFACE_PMF_SLOPE,
)
def _min_max_window_filter(grid: np.ndarray, w_size: int, mode: str = "min") -> np.ndarray:
"""순수 NumPy 2D 이동 윈도우 최솟값/최댓값 필터 (경계는 edge 패딩)."""
pad_val = w_size // 2
padded = np.pad(grid, pad_val, mode="edge")
windows = sliding_window_view(padded, (w_size, w_size))
if mode == "min":
return np.min(windows, axis=(2, 3))
return np.max(windows, axis=(2, 3))
def filter_pmf(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
cell_size: float = SURFACE_PMF_CELL_SIZE_M,
max_window_size: int = SURFACE_PMF_MAX_WINDOW_SIZE,
slope: float = SURFACE_PMF_SLOPE,
initial_window_size: int = SURFACE_PMF_INITIAL_WINDOW_SIZE,
max_distance: float = SURFACE_PMF_MAX_DISTANCE_M,
) -> np.ndarray:
"""PMF로 지면 포인트의 불리언 마스크를 반환한다."""
if not math.isfinite(cell_size) or cell_size <= 0:
raise ValueError("격자 크기는 0보다 큰 유한한 값이어야 합니다.")
if initial_window_size < 1 or max_window_size < initial_window_size:
raise ValueError("윈도우 크기는 1 이상이고 최대가 초기값 이상이어야 합니다.")
if not math.isfinite(max_distance) or max_distance < 0:
raise ValueError("최대 거리 임계값은 0 이상의 유한한 값이어야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if bounds.shape != (3, 2):
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
if xyz.shape[0] == 0:
return np.zeros(0, dtype=bool)
xs, ys, zs = xyz[:, 0], xyz[:, 1], xyz[:, 2]
x_min, y_min, z_min = bounds[0, 0], bounds[1, 0], bounds[2, 0]
x_max, y_max = bounds[0, 1], bounds[1, 1]
grid_w = int(np.ceil((x_max - x_min) / cell_size)) + 2
grid_h = int(np.ceil((y_max - y_min) / cell_size)) + 2
z_grid = np.full((grid_h, grid_w), np.inf, dtype=np.float32)
gx = np.clip(((xs - x_min) / cell_size).astype(np.int32), 0, grid_w - 1)
gy = np.clip(((ys - y_min) / cell_size).astype(np.int32), 0, grid_h - 1)
# 1. Z-min 지형 구성
np.minimum.at(z_grid, (gy, gx), zs.astype(np.float32))
z_grid[z_grid == np.inf] = z_min
# 2. 점진적 형태학 필터 (Opening = Dilation of Erosion)
current_grid = z_grid.copy()
w_sizes = []
w = initial_window_size
while w <= max_window_size:
w_sizes.append(w)
w = w * 2 + 1
for w_size in w_sizes:
eroded = _min_max_window_filter(current_grid, w_size, mode="min")
opened = _min_max_window_filter(eroded, w_size, mode="max")
t_dist = min(slope * w_size * cell_size * 0.15 + 0.5, max_distance)
mask_elev = (current_grid - opened) > t_dist
current_grid[mask_elev] = opened[mask_elev]
# 3. 마스크 매핑 및 원본 비교
simulated_z = current_grid[gy, gx]
mask = (zs >= simulated_z - 0.4) & (zs <= simulated_z + max_distance)
return np.asarray(mask, dtype=bool)
@@ -0,0 +1,116 @@
"""B04 RANSAC 지면 필터 (Local plane fitting).
공간을 로컬 격자로 분할하고, 각 격자에서 RANSAC 평면 피팅을 수행해
평면과의 거리가 임계값 이내인 인라이어(지면)를 취합한다. Pure NumPy 구현.
"""
import math
from collections.abc import Callable
from typing import Any
import numpy as np
from config.config_system import (
SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
SURFACE_RANSAC_ITERATIONS,
SURFACE_RANSAC_LOCAL_GRID_SIZE_M,
SURFACE_RANSAC_N,
SURFACE_RANSAC_SEED,
)
def fit_plane_ransac(
points: np.ndarray,
distance_threshold: float = SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
ransac_n: int = SURFACE_RANSAC_N,
num_iterations: int = SURFACE_RANSAC_ITERATIONS,
seed: int = SURFACE_RANSAC_SEED,
) -> np.ndarray:
"""평면 ax+by+cz+d=0을 RANSAC으로 피팅하고 인라이어 마스크를 반환한다."""
n_points = len(points)
if n_points < ransac_n:
return np.ones(n_points, dtype=bool)
best_inliers = np.zeros(n_points, dtype=bool)
max_inlier_count = -1
rng = np.random.default_rng(seed)
for _ in range(num_iterations):
idx = rng.choice(n_points, ransac_n, replace=False)
p0, p1, p2 = points[idx]
normal = np.cross(p1 - p0, p2 - p0)
norm = np.linalg.norm(normal)
if norm < 1e-6:
continue # 세 점이 일직선상 → 스킵
normal = normal / norm
d = -np.dot(normal, p0)
distances = np.abs(np.dot(points, normal) + d)
inliers = distances < distance_threshold
inlier_count = int(np.sum(inliers))
if inlier_count > max_inlier_count:
max_inlier_count = inlier_count
best_inliers = inliers
if max_inlier_count > 0:
return best_inliers
return np.ones(n_points, dtype=bool)
def filter_ransac(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
distance_threshold: float = SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
ransac_n: int = SURFACE_RANSAC_N,
num_iterations: int = SURFACE_RANSAC_ITERATIONS,
local_grid_size: float = SURFACE_RANSAC_LOCAL_GRID_SIZE_M,
seed: int = SURFACE_RANSAC_SEED,
progress_callback: Callable[[int], None] | None = None,
) -> np.ndarray:
"""로컬 격자별 RANSAC 평면 분할로 지면 포인트 마스크를 반환한다."""
if not math.isfinite(local_grid_size) or local_grid_size <= 0:
raise ValueError("로컬 격자 크기는 0보다 큰 유한한 값이어야 합니다.")
if ransac_n < 3:
raise ValueError("RANSAC 샘플 수는 3 이상이어야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if bounds.shape != (3, 2):
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
n_points = len(xyz)
mask = np.zeros(n_points, dtype=bool)
if n_points == 0:
return mask
x_min, y_min = bounds[0, 0], bounds[1, 0]
x_max, y_max = bounds[0, 1], bounds[1, 1]
grid_w = int(np.ceil((x_max - x_min) / local_grid_size)) + 1
grid_h = int(np.ceil((y_max - y_min) / local_grid_size)) + 1
xs, ys = xyz[:, 0], xyz[:, 1]
gx = np.clip(((xs - x_min) / local_grid_size).astype(np.int32), 0, grid_w - 1)
gy = np.clip(((ys - y_min) / local_grid_size).astype(np.int32), 0, grid_h - 1)
grid_indices = gy * grid_w + gx
unique_grids = np.unique(grid_indices)
total_grids = len(unique_grids)
for i, grid_id in enumerate(unique_grids):
cell_points_idx = np.where(grid_indices == grid_id)[0]
if len(cell_points_idx) < ransac_n:
mask[cell_points_idx] = True
continue
cell_inliers = fit_plane_ransac(
xyz[cell_points_idx],
distance_threshold=distance_threshold,
ransac_n=ransac_n,
num_iterations=num_iterations,
seed=seed,
)
mask[cell_points_idx[cell_inliers]] = True
if progress_callback:
progress_callback(int(((i + 1) / total_grids) * 100))
if progress_callback:
progress_callback(100) # 모든 격자 처리 완료 보장
return mask
@@ -0,0 +1,64 @@
"""B04 지면 필터 오케스트레이션.
구조화된 포인트클라우드(structured.npz)에 대해 grid_min_z/csf/pmf/ransac
필터를 실행하여 지면 마스크 딕셔너리를 만든다. 필터 선택은 config의
source_filters를 따른다.
"""
from typing import Any
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_CSF import filter_csf
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_Grid import filter_grid_min_z
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_PMF import filter_pmf
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_RANSAC import filter_ransac
# 필터 키 → 함수 매핑
_FILTERS = {
"grid_min_z": filter_grid_min_z,
"csf": filter_csf,
"pmf": filter_pmf,
"ransac": filter_ransac,
}
def available_filters() -> tuple[str, ...]:
return tuple(_FILTERS.keys())
def run_ground_filter(
filter_key: str, structured_data: dict[str, Any] | np.lib.npyio.NpzFile
) -> np.ndarray:
"""단일 지면 필터를 실행해 불리언 마스크를 반환한다."""
if filter_key not in _FILTERS:
raise ValueError(f"알 수 없는 지면 필터입니다: {filter_key}")
return np.asarray(_FILTERS[filter_key](structured_data), dtype=bool)
def build_ground_masks(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
filter_keys: tuple[str, ...] | list[str],
) -> dict[str, np.ndarray]:
"""지정한 필터들을 실행해 {filter_key: mask} 딕셔너리를 만든다."""
masks: dict[str, np.ndarray] = {}
for filter_key in filter_keys:
masks[filter_key] = run_ground_filter(filter_key, structured_data)
return masks
def summarize_masks(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
masks: dict[str, np.ndarray],
) -> dict[str, dict[str, Any]]:
"""각 필터 마스크의 지면 포인트 수·비율 요약을 만든다."""
total = int(len(structured_data["xyz"]))
summary: dict[str, dict[str, Any]] = {}
for filter_key, mask in masks.items():
ground = int(np.count_nonzero(mask))
summary[filter_key] = {
"ground_point_count": ground,
"total_point_count": total,
"ground_ratio": round(ground / total, 4) if total else 0.0,
}
return summary
@@ -0,0 +1,284 @@
"""B04 지표면 5종 표현 빌더 (TIN/DTM/NURBS/implicit/meshfree).
TerrainContext에서 파생 격자·샘플을 받아 각 표현의 모델(npz)과 프리뷰
(GLB/PLY)를 저장하고 메타데이터를 반환한다.
"""
from pathlib import Path
from typing import Any
import numpy as np
from scipy.interpolate import RBFInterpolator, RectBivariateSpline
from scipy.spatial import Delaunay
from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import (
ProgressCallback,
TerrainContext,
artifact_size,
atomic_npz,
clip_and_compact_mesh,
grid_faces,
grid_vertices,
with_footprint,
write_binary_ply,
write_glb,
)
def build_tin(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
progress(5)
points = context.sample(int(context.config["tin_max_input_points"]))
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
points = points[unique_indices]
if len(points) < 3:
raise ValueError("TIN 생성에 필요한 포인트가 부족합니다.")
progress(25)
faces = np.asarray(Delaunay(unique_xy).simplices, dtype=np.uint32)
if len(faces):
triangle_xy = points[faces, :2]
edges = np.stack(
[
np.linalg.norm(triangle_xy[:, 0] - triangle_xy[:, 1], axis=1),
np.linalg.norm(triangle_xy[:, 1] - triangle_xy[:, 2], axis=1),
np.linalg.norm(triangle_xy[:, 2] - triangle_xy[:, 0], axis=1),
],
axis=1,
)
faces = faces[np.max(edges, axis=1) <= float(context.config["tile_size_meters"]) * 2]
valid_vertices = context.contains_xy(points[:, 0], points[:, 1])
points, faces = clip_and_compact_mesh(points, faces, valid_vertices)
if not len(faces):
raise ValueError("외곽 안쪽 기준 적용 후 TIN 면이 남지 않았습니다.")
progress(65)
model_path = output_dir / f"{stem}.npz"
preview_path = output_dir / f"{stem}_preview.glb"
atomic_npz(model_path, vertices=points, faces=faces)
write_glb(preview_path, points, faces, context.bounds)
progress(100)
return with_footprint(
context,
{
"representation": "triangular_mesh",
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"vertex_count": int(len(points)),
"face_count": int(len(faces)),
"artifact_bytes": artifact_size(model_path, preview_path),
},
)
def build_dtm(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
progress(10)
resolution = float(context.config["dtm_grid_resolution_meters"])
x_coords, y_coords, z_grid = context.grid(resolution)
progress(55)
preview_x, preview_y, preview_z = context.preview_grid(resolution)
vertices = grid_vertices(preview_x, preview_y, preview_z)
faces = grid_faces(len(preview_y), len(preview_x))
valid_grid = context.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape(
len(y_coords), len(x_coords)
)
preview_valid = context.contains_xy(vertices[:, 0], vertices[:, 1])
vertices, faces = clip_and_compact_mesh(vertices, faces, preview_valid)
model_path = output_dir / f"{stem}.npz"
preview_path = output_dir / f"{stem}_preview.glb"
atomic_npz(
model_path,
x=x_coords,
y=y_coords,
z=z_grid,
valid_mask=valid_grid,
resolution=np.array([resolution], np.float32),
)
progress(75)
write_glb(preview_path, vertices, faces, context.bounds)
progress(100)
return with_footprint(
context,
{
"representation": "regular_grid",
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"grid_rows": int(len(y_coords)),
"grid_columns": int(len(x_coords)),
"grid_resolution_meters": resolution,
"vertex_count": int(len(vertices)),
"face_count": int(len(faces)),
"artifact_bytes": artifact_size(model_path, preview_path),
},
)
def build_nurbs(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
degree = max(1, min(5, int(context.config["nurbs_degree"])))
patch_size = float(context.config["nurbs_patch_size_meters"])
controls = max(degree + 1, int(context.config["nurbs_control_points_per_axis"]))
control_resolution = max(patch_size / max(controls - 1, 1), 0.25)
x_control, y_control, z_control = context.grid(control_resolution)
progress(30)
spline = RectBivariateSpline(
y_control,
x_control,
z_control,
kx=min(degree, len(y_control) - 1),
ky=min(degree, len(x_control) - 1),
s=float(len(x_control) * len(y_control)) * 0.01,
)
x_preview, y_preview, _ = context.preview_grid(
float(context.config["dtm_grid_resolution_meters"])
)
z_preview = np.asarray(spline(y_preview, x_preview), dtype=np.float32)
progress(65)
vertices = grid_vertices(x_preview, y_preview, z_preview)
faces = grid_faces(len(y_preview), len(x_preview))
valid_preview = context.contains_xy(vertices[:, 0], vertices[:, 1])
vertices, faces = clip_and_compact_mesh(vertices, faces, valid_preview)
model_path = output_dir / f"{stem}.npz"
preview_path = output_dir / f"{stem}_preview.glb"
atomic_npz(
model_path,
control_x=x_control,
control_y=y_control,
control_z=z_control,
degree=np.array([degree], np.int16),
patch_size_meters=np.array([patch_size], np.float32),
)
write_glb(preview_path, vertices, faces, context.bounds)
progress(100)
return with_footprint(
context,
{
"representation": "bspline_surface",
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"degree": degree,
"control_rows": int(len(y_control)),
"control_columns": int(len(x_control)),
"vertex_count": int(len(vertices)),
"face_count": int(len(faces)),
"artifact_bytes": artifact_size(model_path, preview_path),
},
)
def build_implicit(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
maximum = max(100, int(context.config["implicit_max_points_per_tile"]))
points = context.sample(maximum)
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
points = points[unique_indices]
if len(points) < 4:
raise ValueError("Implicit 생성에 필요한 포인트가 부족합니다.")
progress(20)
interpolator = RBFInterpolator(
unique_xy.astype(np.float64),
points[:, 2].astype(np.float64),
neighbors=min(64, len(points)),
smoothing=float(context.config["implicit_smoothing"]),
kernel="thin_plate_spline",
)
x_preview, y_preview, _ = context.preview_grid(
float(context.config["dtm_grid_resolution_meters"])
)
xx, yy = np.meshgrid(x_preview, y_preview)
query = np.column_stack([xx.ravel(), yy.ravel()])
z_values = np.empty(len(query), dtype=np.float32)
for start in range(0, len(query), 50_000):
end = min(start + 50_000, len(query))
z_values[start:end] = interpolator(query[start:end]).astype(np.float32)
progress(25 + int(45 * end / len(query)))
z_grid = z_values.reshape(len(y_preview), len(x_preview))
vertices = grid_vertices(x_preview, y_preview, z_grid)
faces = grid_faces(len(y_preview), len(x_preview))
valid_preview = context.contains_xy(vertices[:, 0], vertices[:, 1])
vertices, faces = clip_and_compact_mesh(vertices, faces, valid_preview)
model_path = output_dir / f"{stem}.npz"
preview_path = output_dir / f"{stem}_preview.glb"
atomic_npz(
model_path,
centers_xy=unique_xy.astype(np.float32),
center_z=points[:, 2].astype(np.float32),
smoothing=np.array([float(context.config["implicit_smoothing"])], np.float32),
)
write_glb(preview_path, vertices, faces, context.bounds)
progress(100)
return with_footprint(
context,
{
"representation": "local_rbf_height_field",
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"center_count": int(len(points)),
"vertex_count": int(len(vertices)),
"face_count": int(len(faces)),
"artifact_bytes": artifact_size(model_path, preview_path),
},
)
def build_meshfree(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
points = context.sample(int(context.config["meshfree_max_model_points"]))
points = points[context.contains_xy(points[:, 0], points[:, 1])]
if not len(points):
raise ValueError("외곽 안쪽 기준 적용 후 Meshfree 포인트가 남지 않았습니다.")
resolution = float(context.config["dtm_grid_resolution_meters"])
x_grid, y_grid, z_grid = context.grid(resolution)
dz_dy, dz_dx = np.gradient(z_grid, resolution, resolution)
gx = np.clip(np.searchsorted(x_grid, points[:, 0]), 0, len(x_grid) - 1)
gy = np.clip(np.searchsorted(y_grid, points[:, 1]), 0, len(y_grid) - 1)
normals = np.column_stack([-dz_dx[gy, gx], -dz_dy[gy, gx], np.ones(len(points), np.float32)])
normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-9)
progress(55)
preview_max = int(context.config["max_preview_vertices"])
if len(points) > preview_max:
selection = np.linspace(0, len(points) - 1, preview_max, dtype=np.int64)
preview_points, preview_normals = points[selection], normals[selection]
else:
preview_points, preview_normals = points, normals
model_path = output_dir / f"{stem}.npz"
preview_path = output_dir / f"{stem}_preview.ply"
radius = float(context.config["meshfree_point_radius_meters"])
atomic_npz(
model_path,
points=points,
normals=normals.astype(np.float32),
radius=np.array([radius], np.float32),
)
write_binary_ply(preview_path, preview_points, preview_normals, context.bounds)
progress(100)
return with_footprint(
context,
{
"representation": "meshfree_surfels",
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "application/octet-stream",
"point_count": int(len(points)),
"preview_point_count": int(len(preview_points)),
"point_radius_meters": radius,
"artifact_bytes": artifact_size(model_path, preview_path),
},
)
BUILDERS = {
"tin": build_tin,
"dtm": build_dtm,
"nurbs": build_nurbs,
"implicit": build_implicit,
"meshfree": build_meshfree,
}
@@ -0,0 +1,321 @@
"""B04 지표면 모델 공통 컨텍스트 및 메시 유틸리티.
지면 마스크가 적용된 포인트에서 footprint(외곽), 격자, 프리뷰 격자를 만들고,
GLB/PLY 프리뷰 및 npz 모델을 원자적으로 저장하는 공통 기능을 제공한다.
5개 표현(TIN/DTM/NURBS/implicit/meshfree) 빌더가 이 컨텍스트를 공유한다.
"""
import hashlib
import json
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
import numpy as np
import trimesh
from scipy import ndimage
from common_util.common_util_atomic import atomic_write_bytes, atomic_write_npz
MODEL_VERSION = 1
MODEL_METHODS = ("tin", "dtm", "nurbs", "implicit", "meshfree")
SOURCE_FILTER_LABELS = {"grid_min_z": "Grid Min-Z", "csf": "CSF", "pmf": "PMF"}
ProgressCallback = Callable[[int], None]
# 대용량 포인트 배치 처리 크기
_BATCH_SIZE = 500_000
def config_signature(config: dict[str, Any]) -> str:
"""지오메트리 원본과 무관한 등고선·스무딩 설정을 제외한 캐시 서명."""
sig_config = {
k: v
for k, v in config.items()
if not k.startswith("contour_") and not k.startswith("smoothing_")
}
encoded = json.dumps(sig_config, sort_keys=True, default=list).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()[:16]
def bounds_dict(bounds: np.ndarray) -> dict[str, list[float]]:
return {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
def scene_vertices(vertices: np.ndarray, bounds: np.ndarray) -> np.ndarray:
"""모델 좌표를 뷰어(Y-up) 좌표계로 변환한다."""
center = bounds.mean(axis=1)
result = np.empty((len(vertices), 3), dtype=np.float32)
result[:, 0] = vertices[:, 0] - center[0]
result[:, 1] = vertices[:, 2] - center[2]
result[:, 2] = -(vertices[:, 1] - center[1])
return result
def height_colors(vertices: np.ndarray) -> np.ndarray:
"""표고에 따른 그라디언트 정점 색상(RGBA)을 만든다."""
if not len(vertices):
return np.empty((0, 4), dtype=np.uint8)
z = vertices[:, 2]
span = max(float(np.max(z) - np.min(z)), 1e-9)
t = np.clip((z - np.min(z)) / span, 0.0, 1.0)
colors = np.empty((len(vertices), 4), dtype=np.uint8)
colors[:, 0] = np.clip(36 + 190 * t, 0, 255).astype(np.uint8)
colors[:, 1] = np.clip(86 + 95 * np.sin(t * np.pi), 0, 255).astype(np.uint8)
colors[:, 2] = np.clip(128 - 80 * t, 0, 255).astype(np.uint8)
colors[:, 3] = 255
return colors
def write_glb(path: Path, vertices: np.ndarray, faces: np.ndarray, bounds: np.ndarray) -> None:
mesh = trimesh.Trimesh(
vertices=scene_vertices(vertices, bounds),
faces=np.asarray(faces, dtype=np.int64),
vertex_colors=height_colors(vertices),
process=False,
)
payload = mesh.export(file_type="glb")
if not isinstance(payload, bytes):
raise TypeError("GLB exporter did not return bytes")
atomic_write_bytes(path, payload)
def write_binary_ply(
path: Path, vertices: np.ndarray, normals: np.ndarray, bounds: np.ndarray
) -> None:
verts = scene_vertices(vertices, bounds)
scene_normals = np.empty_like(normals, dtype=np.float32)
scene_normals[:, 0] = normals[:, 0]
scene_normals[:, 1] = normals[:, 2]
scene_normals[:, 2] = -normals[:, 1]
colors = height_colors(vertices)
dtype = np.dtype(
[
("x", "<f4"),
("y", "<f4"),
("z", "<f4"),
("nx", "<f4"),
("ny", "<f4"),
("nz", "<f4"),
("red", "u1"),
("green", "u1"),
("blue", "u1"),
("alpha", "u1"),
]
)
records = np.empty(len(vertices), dtype=dtype)
records["x"], records["y"], records["z"] = verts.T
records["nx"], records["ny"], records["nz"] = scene_normals.T
records["red"], records["green"], records["blue"], records["alpha"] = colors.T
header = (
"ply\nformat binary_little_endian 1.0\n"
f"element vertex {len(vertices)}\n"
"property float x\nproperty float y\nproperty float z\n"
"property float nx\nproperty float ny\nproperty float nz\n"
"property uchar red\nproperty uchar green\nproperty uchar blue\nproperty uchar alpha\n"
"end_header\n"
).encode("ascii")
atomic_write_bytes(path, header + records.tobytes())
def grid_faces(rows: int, cols: int) -> np.ndarray:
"""정규 격자의 삼각형 면 인덱스를 만든다."""
if rows < 2 or cols < 2:
return np.empty((0, 3), dtype=np.uint32)
base = np.arange((rows - 1) * (cols - 1), dtype=np.uint32)
row = base // (cols - 1)
col = base % (cols - 1)
top_left = row * cols + col
faces = np.empty((len(base) * 2, 3), dtype=np.uint32)
faces[0::2] = np.stack([top_left, top_left + cols, top_left + 1], axis=1)
faces[1::2] = np.stack([top_left + 1, top_left + cols, top_left + cols + 1], axis=1)
return faces
def clip_and_compact_mesh(
vertices: np.ndarray, faces: np.ndarray, valid_vertices: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""footprint 내부 정점만 사용하는 면을 남기고 미사용 정점을 제거한다."""
if not len(faces):
return np.empty((0, 3), np.float32), np.empty((0, 3), np.uint32)
kept_faces = faces[np.all(valid_vertices[faces], axis=1)]
if not len(kept_faces):
return np.empty((0, 3), np.float32), np.empty((0, 3), np.uint32)
used = np.unique(kept_faces)
remap = np.full(len(vertices), -1, dtype=np.int64)
remap[used] = np.arange(len(used))
return vertices[used], remap[kept_faces].astype(np.uint32)
def grid_vertices(x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray) -> np.ndarray:
xx, yy = np.meshgrid(x_coords, y_coords)
return np.column_stack([xx.ravel(), yy.ravel(), z_grid.ravel()]).astype(np.float32)
def artifact_size(*paths: Path) -> int:
return int(sum(path.stat().st_size for path in paths if path.exists()))
@dataclass
class TerrainContext:
"""지면 마스크가 적용된 포인트 집합에서 파생 격자·footprint를 캐싱한다."""
xyz: np.ndarray
mask: np.ndarray
bounds: np.ndarray
config: dict[str, Any]
_indices: np.ndarray | None = None
_samples: dict[int, np.ndarray] = field(default_factory=dict)
_grids: dict[float, tuple[np.ndarray, np.ndarray, np.ndarray]] = field(default_factory=dict)
_footprint: tuple[float, float, float, np.ndarray] | None = None
@property
def source_count(self) -> int:
return int(np.count_nonzero(self.mask))
def indices(self) -> np.ndarray:
if self._indices is None:
self._indices = np.flatnonzero(self.mask)
return self._indices
def sample(self, maximum: int) -> np.ndarray:
maximum = max(3, int(maximum))
if maximum in self._samples:
return self._samples[maximum]
indices = self.indices()
if len(indices) > maximum:
positions = np.linspace(0, len(indices) - 1, maximum, dtype=np.int64)
indices = indices[positions]
points = np.asarray(self.xyz[indices], dtype=np.float32)
self._samples[maximum] = points
return points
def footprint(self) -> tuple[float, float, float, np.ndarray]:
if self._footprint is not None:
return self._footprint
resolution = max(float(self.config.get("footprint_resolution_meters", 1.0)), 0.1)
x_min, x_max = self.bounds[0]
y_min, y_max = self.bounds[1]
cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1)
rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1)
occupied = np.zeros((rows, cols), dtype=bool)
indices = self.indices()
for start in range(0, len(indices), _BATCH_SIZE):
points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32)
gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1)
gy = np.clip(((points[:, 1] - y_min) / resolution).astype(np.int32), 0, rows - 1)
occupied[gy, gx] = True
if not occupied.any():
raise ValueError("기준 필터에 footprint를 만들 포인트가 없습니다.")
close_cells = max(
0,
int(math.ceil(float(self.config.get("footprint_gap_close_meters", 1.0)) / resolution)),
)
footprint = occupied
if close_cells:
padded = np.pad(footprint, close_cells, mode="constant", constant_values=False)
padded = ndimage.binary_closing(
padded, structure=np.ones((3, 3), dtype=bool), iterations=close_cells
)
footprint = padded[close_cells:-close_cells, close_cells:-close_cells]
if bool(self.config.get("keep_largest_footprint", True)):
labels, component_count = ndimage.label(
footprint, structure=np.ones((3, 3), dtype=bool)
)
if component_count:
sizes = np.bincount(labels.ravel())
sizes[0] = 0
footprint = labels == int(np.argmax(sizes))
footprint = ndimage.binary_fill_holes(footprint)
inset_cells = max(
0, int(math.ceil(float(self.config.get("boundary_inset_meters", 1.0)) / resolution))
)
if inset_cells:
footprint = ndimage.binary_erosion(
footprint,
structure=np.ones((3, 3), dtype=bool),
iterations=inset_cells,
border_value=0,
)
if not footprint.any():
raise ValueError("외곽 안쪽 기준 적용 후 유효한 footprint가 없습니다.")
self._footprint = (float(x_min), float(y_min), resolution, footprint)
return self._footprint
def contains_xy(self, x: np.ndarray, y: np.ndarray) -> np.ndarray:
x_min, y_min, resolution, footprint = self.footprint()
gx = np.floor((np.asarray(x) - x_min) / resolution).astype(np.int64)
gy = np.floor((np.asarray(y) - y_min) / resolution).astype(np.int64)
valid = (gx >= 0) & (gx < footprint.shape[1]) & (gy >= 0) & (gy < footprint.shape[0])
result = np.zeros(np.broadcast(x, y).shape, dtype=bool)
result[valid] = footprint[gy[valid], gx[valid]]
return result
def footprint_metadata(self) -> dict[str, Any]:
_, _, resolution, footprint = self.footprint()
return {
"footprint_area_m2": round(float(footprint.sum()) * resolution * resolution, 3),
"footprint_resolution_meters": resolution,
"boundary_inset_meters": float(self.config.get("boundary_inset_meters", 1.0)),
}
def grid(self, resolution: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
resolution = max(float(resolution), 0.05)
cached = self._grids.get(resolution)
if cached is not None:
return cached
x_min, x_max = self.bounds[0]
y_min, y_max = self.bounds[1]
cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1)
rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1)
grid = np.full((rows, cols), np.inf, dtype=np.float32)
indices = self.indices()
for start in range(0, len(indices), _BATCH_SIZE):
points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32)
gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1)
gy = np.clip(((points[:, 1] - y_min) / resolution).astype(np.int32), 0, rows - 1)
np.minimum.at(grid, (gy, gx), points[:, 2])
missing = ~np.isfinite(grid)
if missing.all():
raise ValueError("기준 필터에 지면 포인트가 없습니다.")
if missing.any():
nearest = ndimage.distance_transform_edt(
missing, return_distances=False, return_indices=True
)
grid = grid[tuple(nearest)]
x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32)
y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32)
result = (x_coords, y_coords, grid)
self._grids[resolution] = result
return result
def preview_grid(
self, preferred_resolution: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
x_span = max(float(self.bounds[0, 1] - self.bounds[0, 0]), preferred_resolution)
y_span = max(float(self.bounds[1, 1] - self.bounds[1, 0]), preferred_resolution)
maximum = max(4, int(self.config["max_preview_vertices"]))
predicted = (x_span / preferred_resolution + 1) * (y_span / preferred_resolution + 1)
if predicted > maximum:
preferred_resolution *= math.sqrt(predicted / maximum)
return self.grid(preferred_resolution)
def clear_caches(self) -> None:
self._samples.clear()
self._grids.clear()
self._indices = None
def with_footprint(context: TerrainContext, metadata: dict[str, Any]) -> dict[str, Any]:
metadata.update(context.footprint_metadata())
return metadata
# 모델 빌더가 사용하는 원자적 저장 래퍼 (공통 유틸 재노출)
atomic_npz = atomic_write_npz
@@ -0,0 +1,328 @@
"""B04 지표면 모델 파이프라인 오케스트레이터.
세 지면 필터(grid_min_z/csf/pmf)와 다섯 표현(TIN/DTM/NURBS/implicit/meshfree)의
캐시를 만들고 manifest.json을 관리한다. 캐시 유효성 검증, 스무딩/등고선 연동,
동일 출력 폴더의 중복 실행 취소를 포함한다.
"""
import json
import threading
import time
from pathlib import Path
from typing import Any, Callable
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
CONTOUR_EXTRACTOR_VERSION,
extract_contours,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelBuild import BUILDERS
from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import (
MODEL_VERSION,
TerrainContext,
bounds_dict,
config_signature,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Smooth import (
compute_smoothing_signature,
run_smoothing,
)
from common_util.common_util_atomic import atomic_write_bytes
from common_util.common_util_json import atomic_write_json
# 진행률 콜백: (overall_percent, detail_message)
ProgressReporter = Callable[[int, str], None]
# 같은 프로세스에서 동일 프로젝트 계산 요청이 겹치면 두 번째 요청을 즉시 취소.
_ACTIVE_TERRAIN_BUILDS: set[str] = set()
_ACTIVE_TERRAIN_BUILDS_GUARD = threading.Lock()
def _write_json_file(path: Path, value: dict[str, Any]) -> None:
atomic_write_json(path, value)
def _cache_contours(
output_dir: Path,
stem: str,
filter_key: str,
method: str,
representation: str,
config: dict[str, Any],
bounds_info: dict[str, Any],
metadata: dict[str, Any],
) -> None:
"""빌드 완료 직후 기본 간격 등고선을 사전 추출·캐싱한다 (원본 + 스무딩)."""
interval = float(config.get("contour_interval_meters", 5.0))
target_grid_m = float(config.get("contour_grid_resolution_meters", 1.0))
model_path = output_dir / f"{stem}.npz"
if model_path.exists():
contours = extract_contours(
model_path,
representation=representation,
interval=interval,
target_grid_m=target_grid_m,
scene_center=None,
)
payload = {
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
"project_id": output_dir.parent.name,
"source_filter": filter_key,
"method": method,
"interval": interval,
"bounds": bounds_info,
"contours": contours,
}
atomic_write_bytes(
output_dir / f"contour_{filter_key}_{method}_{interval}m.json",
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
smooth_model_path = output_dir / f"{stem}_smooth.npz"
smooth_meta = metadata.get("smooth", {})
if smooth_model_path.exists() and smooth_meta.get("status") == "completed":
smooth_rep = "regular_grid" if method == "dtm" else "triangular_mesh"
smooth_contours = extract_contours(
smooth_model_path,
representation=smooth_rep,
interval=interval,
target_grid_m=target_grid_m,
scene_center=None,
)
payload = {
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
"project_id": output_dir.parent.name,
"source_filter": filter_key,
"method": method,
"interval": interval,
"bounds": bounds_info,
"contours": smooth_contours,
}
atomic_write_bytes(
output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json",
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
_REPRESENTATIONS = {
"meshfree": "meshfree_surfels",
"dtm": "regular_grid",
"tin": "triangular_mesh",
"nurbs": "bspline_surface",
"implicit": "local_rbf_height_field",
}
def _cache_is_valid(
output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any]
) -> bool:
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다."""
ext = "ply" if method == "meshfree" else "glb"
files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and (
output_dir / f"{stem}.npz"
).exists()
if not files_exist:
return False
if method in config.get("smoothing_methods", ("dtm", "tin")):
smooth_entry = entry.get("smooth", {})
smooth_exist = (output_dir / f"{stem}_smooth.npz").exists() and (
output_dir / f"{stem}_smooth_preview.glb"
).exists()
if (
not smooth_exist
or smooth_entry.get("status") != "completed"
or smooth_entry.get("smoothing_signature") != compute_smoothing_signature(config)
):
return False
return True
def _build_all_terrain_models(
structured_data: dict[str, np.ndarray] | np.lib.npyio.NpzFile,
ground_masks: dict[str, np.ndarray],
output_dir: Path,
config: dict[str, Any],
*,
force: bool = False,
progress: ProgressReporter | None = None,
) -> dict[str, Any]:
"""세 지면 필터와 다섯 표현의 캐시를 만들고 manifest를 반환한다."""
output_dir.mkdir(parents=True, exist_ok=True)
manifest_path = output_dir / "manifest.json"
filters = tuple(key for key in config["source_filters"] if key in ground_masks)
methods = tuple(key for key in config["precompute"] if key in BUILDERS)
signature = config_signature(config)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
xyz = structured_data["xyz"]
existing: dict[str, Any] = {}
if manifest_path.exists() and not force:
try:
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
existing = {}
if existing.get("config_signature") != signature:
existing = {}
manifest: dict[str, Any] = existing or {
"version": MODEL_VERSION,
"config_signature": signature,
"bounds": bounds_dict(bounds),
"source_filters": {},
"started_at_unix": time.time(),
}
started_at = time.monotonic()
timeout = max(0, int(config.get("sync_timeout_seconds", 0)))
total_units = max(1, len(filters) * len(methods))
done_units = 0
failures = 0
def _report(detail: str) -> None:
if progress:
progress(int(100 * done_units / total_units), detail)
for filter_index, filter_key in enumerate(filters):
mask = np.asarray(ground_masks[filter_key], dtype=bool)
if len(mask) != len(xyz):
raise ValueError(f"{filter_key} 마스크 길이가 XYZ 데이터와 다릅니다.")
context = TerrainContext(xyz=xyz, mask=mask, bounds=bounds, config=config)
filter_entry = manifest["source_filters"].setdefault(
filter_key, {"source_point_count": context.source_count, "methods": {}}
)
filter_entry["source_point_count"] = context.source_count
for method in methods:
stem = f"{method}_{filter_key}"
entry = filter_entry["methods"].get(method, {})
if not force and _cache_is_valid(output_dir, stem, method, entry, config):
if entry.get("status") != "completed":
entry.update(
{
"status": "completed",
"representation": _REPRESENTATIONS[method],
"model_file": f"{stem}.npz",
"preview_file": f"{stem}_preview."
+ ("ply" if method == "meshfree" else "glb"),
"preview_media_type": "application/octet-stream"
if method == "meshfree"
else "model/gltf-binary",
"error": None,
}
)
filter_entry["methods"][method] = entry
_write_json_file(manifest_path, manifest)
done_units += 1
_report(f"{filter_key}-{method} 캐시 재사용")
continue
if timeout and time.monotonic() - started_at >= timeout:
failures += 1
filter_entry["methods"][method] = {
"status": "failed",
"error": f"동기 계산 제한시간 {timeout}초를 초과했습니다.",
}
_write_json_file(manifest_path, manifest)
done_units += 1
_report(f"{filter_key}-{method} 시간 초과")
continue
method_started = time.monotonic()
filter_entry["methods"][method] = {"status": "running", "error": None}
_write_json_file(manifest_path, manifest)
try:
metadata = BUILDERS[method](
context,
output_dir,
stem,
lambda value: _report(f"{filter_key}-{method} {value}%"),
)
metadata.update(
{
"status": "completed",
"duration_seconds": round(time.monotonic() - method_started, 3),
"error": None,
}
)
if method in config.get("smoothing_methods", ("dtm", "tin")):
original_model_path = output_dir / f"{stem}.npz"
if original_model_path.exists():
try:
smooth_meta = run_smoothing(
method, context, output_dir, stem, original_model_path
)
smooth_meta["status"] = "completed"
metadata["smooth"] = smooth_meta
except Exception as smooth_exc:
metadata["smooth"] = {"status": "failed", "error": str(smooth_exc)}
filter_entry["methods"][method] = metadata
try:
_cache_contours(
output_dir,
stem,
filter_key,
method,
metadata.get("representation", "regular_grid"),
config,
manifest.get("bounds", {}),
metadata,
)
except Exception:
pass # 등고선 사전 캐시는 실패해도 모델 빌드를 무효화하지 않는다.
except Exception as exc:
failures += 1
filter_entry["methods"][method] = {
"status": "failed",
"duration_seconds": round(time.monotonic() - method_started, 3),
"error": str(exc),
}
done_units += 1
_report(f"{filter_key}-{method} 완료")
_write_json_file(manifest_path, manifest)
context.clear_caches()
manifest["status"] = "completed" if failures == 0 else "completed_with_errors"
manifest["completed_at_unix"] = time.time()
manifest["duration_seconds"] = round(time.monotonic() - started_at, 3)
manifest["failure_count"] = failures
_write_json_file(manifest_path, manifest)
return manifest
def build_all_terrain_models(
structured_data: dict[str, np.ndarray] | np.lib.npyio.NpzFile,
ground_masks: dict[str, np.ndarray],
output_dir: Path,
config: dict[str, Any],
*,
force: bool = False,
progress: ProgressReporter | None = None,
) -> dict[str, Any]:
"""동일 출력 폴더의 중복 실행을 즉시 취소하고 실제 빌드를 한 번만 수행한다."""
output_dir = Path(output_dir)
build_key = str(output_dir.resolve())
with _ACTIVE_TERRAIN_BUILDS_GUARD:
if build_key in _ACTIVE_TERRAIN_BUILDS:
manifest_path = output_dir / "manifest.json"
try:
current = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
current = {"status": "running", "source_filters": {}}
response = dict(current)
response["request_status"] = "cancelled_already_running"
response["message"] = (
"동일 프로젝트의 지표면 모델 계산이 이미 진행 중이어서 요청을 취소했습니다."
)
return response
_ACTIVE_TERRAIN_BUILDS.add(build_key)
try:
return _build_all_terrain_models(
structured_data, ground_masks, output_dir, config, force=force, progress=progress
)
finally:
with _ACTIVE_TERRAIN_BUILDS_GUARD:
_ACTIVE_TERRAIN_BUILDS.discard(build_key)
@@ -0,0 +1,151 @@
"""B04 지표면 스무딩 엔진 (DTM 가우시안+B-Spline, TIN Taubin).
원본 모델(npz)을 로드해 표고 Z만 평활화한 스무딩 모델과 프리뷰(GLB)를 만든다.
XY 위치와 삼각형 연결은 원본을 유지한다.
"""
import hashlib
import json
from pathlib import Path
from typing import Any
import numpy as np
import trimesh
from scipy import ndimage
from scipy.interpolate import RectBivariateSpline
from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import (
TerrainContext,
artifact_size,
atomic_npz,
clip_and_compact_mesh,
grid_faces,
grid_vertices,
write_glb,
)
SMOOTHING_ALGORITHM_VERSION = 2
def compute_smoothing_signature(config: dict[str, Any]) -> str:
"""스무딩 관련 파라미터의 해시 서명을 계산한다."""
smooth_params = {k: v for k, v in config.items() if k.startswith("smoothing_")}
smooth_params["algorithm_version"] = SMOOTHING_ALGORITHM_VERSION
encoded = json.dumps(smooth_params, sort_keys=True, default=list).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()[:16]
def _masked_gaussian_filter(grid: np.ndarray, mask: np.ndarray, sigma: float) -> np.ndarray:
"""무효 영역(mask==False) 오염을 방지하는 정규화 가우시안 필터."""
if sigma <= 0:
return grid.copy()
values = grid.copy()
values[~mask] = 0.0
weights = np.zeros_like(grid, dtype=float)
weights[mask] = 1.0
value_blur = ndimage.gaussian_filter(values, sigma=sigma, mode="constant", cval=0.0)
weight_blur = ndimage.gaussian_filter(weights, sigma=sigma, mode="constant", cval=0.0)
valid_denom = weight_blur > 1e-10
result = grid.copy()
result[valid_denom] = value_blur[valid_denom] / weight_blur[valid_denom]
return result
def smooth_dtm(
context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path
) -> dict[str, Any]:
"""DTM 격자에 가우시안+C2 바이큐빅 보간을 적용해 스무딩 모델을 만든다."""
data = np.load(original_model_path)
x_orig, y_orig, z_orig, valid_orig = data["x"], data["y"], data["z"], data["valid_mask"]
resolution = float(context.config["dtm_grid_resolution_meters"])
sigma_meters = float(context.config.get("smoothing_dtm_sigma_meters", 0.5))
sigma_pixels = sigma_meters / resolution if resolution > 0 else 0.0
z_pre = _masked_gaussian_filter(z_orig, valid_orig, sigma_pixels)
spline = RectBivariateSpline(
y_orig,
x_orig,
z_pre,
kx=3,
ky=3,
s=float(context.config.get("smoothing_dtm_spline_smooth", 0.0)),
)
preview_res = float(context.config.get("smoothing_dtm_preview_resolution_meters", 0.5))
x_coords, y_coords, _ = context.preview_grid(preview_res)
z_smooth = np.asarray(spline(y_coords, x_coords), dtype=np.float32)
valid_grid = context.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape(
len(y_coords), len(x_coords)
)
vertices = grid_vertices(x_coords, y_coords, z_smooth)
faces = grid_faces(len(y_coords), len(x_coords))
preview_valid = context.contains_xy(vertices[:, 0], vertices[:, 1])
vertices_compact, faces_compact = clip_and_compact_mesh(vertices, faces, preview_valid)
model_path = output_dir / f"{stem}_smooth.npz"
preview_path = output_dir / f"{stem}_smooth_preview.glb"
atomic_npz(
model_path,
x=x_coords,
y=y_coords,
z=z_smooth,
valid_mask=valid_grid,
resolution=np.array([preview_res], np.float32),
)
write_glb(preview_path, vertices_compact, faces_compact, context.bounds)
return {
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"vertex_count": int(len(vertices_compact)),
"face_count": int(len(faces_compact)),
"artifact_bytes": artifact_size(model_path, preview_path),
"smoothing_signature": compute_smoothing_signature(context.config),
}
def smooth_tin(
context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path
) -> dict[str, Any]:
"""TIN 삼각망에 Taubin 저수축 스무딩을 적용한다 (Z만 평활화)."""
data = np.load(original_model_path)
vertices, faces = data["vertices"], data["faces"]
if len(vertices) < 3 or not len(faces):
raise ValueError("TIN 스무딩을 수행할 삼각망 메쉬 데이터가 올바르지 않습니다.")
iterations = int(context.config.get("smoothing_tin_taubin_iterations", 10))
lamb = float(context.config.get("smoothing_tin_taubin_lambda", 0.5))
mu = float(context.config.get("smoothing_tin_taubin_mu", -0.53))
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
if iterations > 0:
trimesh.smoothing.filter_taubin(mesh, lamb=lamb, nu=mu, iterations=iterations)
vertices_smooth = np.asarray(mesh.vertices, dtype=np.float32)
# 지표면 스무딩은 리토폴로지가 아니다: XY·연결은 원본, Z만 평활화.
vertices_smooth[:, :2] = np.asarray(vertices[:, :2], dtype=np.float32)
faces_smooth = np.asarray(mesh.faces, dtype=np.uint32)
model_path = output_dir / f"{stem}_smooth.npz"
preview_path = output_dir / f"{stem}_smooth_preview.glb"
atomic_npz(model_path, vertices=vertices_smooth, faces=faces_smooth)
write_glb(preview_path, vertices_smooth, faces_smooth, context.bounds)
return {
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"vertex_count": int(len(vertices_smooth)),
"face_count": int(len(faces_smooth)),
"artifact_bytes": artifact_size(model_path, preview_path),
"smoothing_signature": compute_smoothing_signature(context.config),
}
def run_smoothing(
method: str, context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path
) -> dict[str, Any]:
"""방식에 따라 적절한 스무딩 엔진을 수행한다."""
if method == "dtm":
return smooth_dtm(context, output_dir, stem, original_model_path)
if method == "tin":
return smooth_tin(context, output_dir, stem, original_model_path)
raise ValueError(f"스무딩이 불가능한 지형 표현 방식입니다: {method}")
@@ -0,0 +1,112 @@
"""B04 LAS/LAZ 고속 구조화 엔진."""
import os
import tempfile
from collections.abc import Callable
from pathlib import Path
import laspy
import numpy as np
from config.config_system import SURFACE_DEFAULT_RGB_VALUE, SURFACE_LAS_CHUNK_SIZE
def structurize_las(
las_path: str | Path,
output_dir: str | Path,
progress_callback: Callable[[int], None] | None = None,
) -> Path:
"""LAS/LAZ 속성을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
source = Path(las_path)
target_dir = Path(output_dir)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / "structured.npz"
with laspy.open(source) as las_file:
header = las_file.header
total_points = int(header.point_count)
point_format = header.point_format
dimensions = set(point_format.dimension_names)
has_rgb = {"red", "green", "blue"}.issubset(dimensions)
has_intensity = "intensity" in dimensions
has_returns = {"return_number", "number_of_returns"}.issubset(dimensions)
has_classification = "classification" in dimensions
bounds = np.array(
[
[float(header.mins[0]), float(header.maxs[0])],
[float(header.mins[1]), float(header.maxs[1])],
[float(header.mins[2]), float(header.maxs[2])],
],
dtype=np.float64,
)
xyz = np.empty((total_points, 3), dtype=np.float64)
intensity = np.zeros(total_points, dtype=np.uint16)
rgb = np.full((total_points, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
return_number = np.ones(total_points, dtype=np.uint8)
number_of_returns = np.ones(total_points, dtype=np.uint8)
classification = np.zeros(total_points, dtype=np.uint8)
offset = 0
for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE):
chunk_size = len(chunk)
section = slice(offset, offset + chunk_size)
xyz[section, 0] = np.asarray(chunk.x, dtype=np.float64)
xyz[section, 1] = np.asarray(chunk.y, dtype=np.float64)
xyz[section, 2] = np.asarray(chunk.z, dtype=np.float64)
if has_intensity:
intensity[section] = np.asarray(chunk.intensity, dtype=np.uint16)
if has_rgb:
colors = np.stack(
[
np.asarray(chunk.red, dtype=np.float64),
np.asarray(chunk.green, dtype=np.float64),
np.asarray(chunk.blue, dtype=np.float64),
],
axis=1,
)
if colors.size and float(colors.max()) > 255.0:
colors /= 256.0
rgb[section] = colors.clip(0, 255).astype(np.uint8)
if has_returns:
return_number[section] = np.asarray(chunk.return_number, dtype=np.uint8)
number_of_returns[section] = np.asarray(chunk.number_of_returns, dtype=np.uint8)
if has_classification:
classification[section] = np.asarray(chunk.classification, dtype=np.uint8)
offset += chunk_size
if progress_callback:
progress_callback(int(offset / total_points * 100) if total_points else 100)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=target_dir,
prefix=".structured.",
suffix=".npz.tmp",
delete=False,
) as temporary:
temporary_path = Path(temporary.name)
np.savez_compressed(
temporary,
xyz=xyz,
intensity=intensity,
rgb=rgb,
return_number=return_number,
number_of_returns=number_of_returns,
classification=classification,
bounds=bounds,
total_points=np.array([total_points], dtype=np.int64),
has_rgb=np.array([int(has_rgb)], dtype=np.int8),
)
temporary.flush()
os.fsync(temporary.fileno())
os.replace(temporary_path, target)
temporary_path = None
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
if progress_callback and total_points == 0:
progress_callback(100)
return target
@@ -0,0 +1,227 @@
"""B04 지표면 분석 결과의 aiomysql Raw SQL 접근.
processed_point_cloud(변환 포인트클라우드), surface_models(지표면 모델),
terrain_layers(지형 레이어) 테이블에 메타데이터와 상대 경로를 기록한다.
공간 데이터는 MariaDB JSON 컬럼에 GeoJSON 문자열로 저장한다.
"""
import json
from pathlib import PurePosixPath
from typing import Any
from uuid import UUID
import aiomysql
_STAGE_ROOT = "B04_wf1_Surface"
def _validate_stage_path(relative_path: str) -> str:
"""B04_wf1_Surface 아래의 안전한 상대 경로인지 검증하고 posix 문자열로 반환한다."""
normalized = PurePosixPath(relative_path.replace("\\", "/"))
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
if not normalized.parts or normalized.parts[0] != _STAGE_ROOT:
raise ValueError(f"B04 산출물 경로는 {_STAGE_ROOT} 아래여야 합니다.")
return normalized.as_posix()
async def create_processed_point_cloud(
connection: aiomysql.Connection,
*,
input_file_id: int,
project_id: UUID,
process_type: str,
processed_file_path: str | None,
converted_format: str | None,
converted_file_path: str | None,
point_count: int | None,
bounds: dict[str, Any] | None,
statistics: dict[str, Any] | None,
classification_summary: dict[str, Any] | None,
processing_params: dict[str, Any] | None,
status: str = "COMPLETE",
) -> int:
"""변환 포인트클라우드 메타데이터를 저장하고 생성된 ID를 반환한다."""
processed_rel = _validate_stage_path(processed_file_path) if processed_file_path else None
converted_rel = _validate_stage_path(converted_file_path) if converted_file_path else None
stats = statistics or {}
async with connection.cursor() as cursor:
await cursor.execute(
"""
INSERT INTO processed_point_cloud (
input_file_id, project_id, process_type,
processed_file_path, converted_format, converted_file_path,
point_count, min_z, max_z, mean_z,
x_min, x_max, y_min, y_max, density_per_sqm,
classification_summary, processing_params, status
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
input_file_id,
str(project_id),
process_type,
processed_rel,
converted_format,
converted_rel,
point_count,
stats.get("min_z"),
stats.get("max_z"),
stats.get("mean_z"),
(bounds or {}).get("x_min"),
(bounds or {}).get("x_max"),
(bounds or {}).get("y_min"),
(bounds or {}).get("y_max"),
stats.get("density_per_sqm"),
json.dumps(classification_summary, ensure_ascii=False)
if classification_summary is not None
else None,
json.dumps(processing_params, ensure_ascii=False)
if processing_params is not None
else None,
status,
),
)
new_id = cursor.lastrowid
if not new_id:
raise RuntimeError("processed_point_cloud 레코드 생성 결과에 ID가 없습니다.")
return int(new_id)
async def create_surface_model(
connection: aiomysql.Connection,
*,
project_id: UUID,
model_type: str,
source_file_id: int | None,
processed_cloud_id: int | None,
crs_epsg: int | None,
resolution_m: float | None,
model_file_path: str | None,
generation_params: dict[str, Any] | None,
status: str = "COMPLETE",
) -> int:
"""지표면 모델 메타데이터를 저장하고 생성된 ID를 반환한다."""
model_rel = _validate_stage_path(model_file_path) if model_file_path else None
async with connection.cursor() as cursor:
await cursor.execute(
"""
INSERT INTO surface_models (
project_id, model_type, source_file_id, processed_cloud_id,
status, crs_epsg, resolution_m, model_file_path,
generation_params, completed_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
""",
(
str(project_id),
model_type,
source_file_id,
processed_cloud_id,
status,
crs_epsg,
resolution_m,
model_rel,
json.dumps(generation_params, ensure_ascii=False)
if generation_params is not None
else None,
),
)
new_id = cursor.lastrowid
if not new_id:
raise RuntimeError("surface_models 레코드 생성 결과에 ID가 없습니다.")
return int(new_id)
async def create_terrain_layer(
connection: aiomysql.Connection,
*,
surface_model_id: int,
layer_name: str,
geometry_type: str,
layer_file_path: str | None,
file_format: str | None,
file_size_mb: float | None,
statistics: dict[str, Any] | None,
) -> int:
"""지형 레이어 메타데이터를 저장하고 생성된 ID를 반환한다."""
layer_rel = _validate_stage_path(layer_file_path) if layer_file_path else None
async with connection.cursor() as cursor:
await cursor.execute(
"""
INSERT INTO terrain_layers (
surface_model_id, layer_name, geometry_type,
layer_file_path, file_format, file_size_mb, statistics
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
surface_model_id,
layer_name,
geometry_type,
layer_rel,
file_format,
file_size_mb,
json.dumps(statistics, ensure_ascii=False) if statistics is not None else None,
),
)
new_id = cursor.lastrowid
if not new_id:
raise RuntimeError("terrain_layers 레코드 생성 결과에 ID가 없습니다.")
return int(new_id)
async def get_input_file(
connection: aiomysql.Connection, project_id: UUID, input_file_id: int
) -> dict[str, Any]:
"""프로젝트의 특정 입력 파일 경로·좌표계를 조회한다."""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, file_type, raw_file_path, crs_epsg
FROM input_files
WHERE id = %s AND project_id = %s
""",
(input_file_id, str(project_id)),
)
row = await cursor.fetchone()
if not row:
raise LookupError("입력 파일을 찾을 수 없습니다.")
return {
"id": int(row[0]),
"file_type": row[1],
"raw_file_path": row[2],
"crs_epsg": row[3],
}
async def list_surface_models(
connection: aiomysql.Connection, project_id: UUID
) -> list[dict[str, Any]]:
"""프로젝트의 지표면 모델 목록을 최신순으로 조회한다."""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, model_type, status, resolution_m, model_file_path, created_at
FROM surface_models
WHERE project_id = %s
ORDER BY created_at DESC
""",
(str(project_id),),
)
rows = await cursor.fetchall()
return [
{
"id": int(row[0]),
"model_type": row[1],
"status": row[2],
"resolution_m": row[3],
"model_file_path": row[4],
"created_at": row[5].isoformat() if row[5] else None,
}
for row in rows
]
+149
View File
@@ -0,0 +1,149 @@
"""B04 지표면 분석 FastAPI 라우터."""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
create_processed_point_cloud,
create_surface_model,
create_terrain_layer,
get_input_file,
list_surface_models,
)
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
SurfaceAnalyzeRequest,
SurfaceAnalyzeResponse,
SurfaceModelListResponse,
SurfaceModelSummary,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
@router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse)
async def analyze_surface(
project_id: UUID, request: SurfaceAnalyzeRequest
) -> SurfaceAnalyzeResponse | JSONResponse:
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다."""
try:
source_filters = request.resolved_filters()
methods = request.resolved_methods()
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
input_file = await get_input_file(connection, project_id, request.input_file_id)
las_path = project_root / Path(input_file["raw_file_path"])
if not las_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."},
)
# 무거운 지형 연산은 이벤트 루프를 막지 않도록 별도 스레드에서 실행.
result = await asyncio.to_thread(
run_surface_analysis,
project_root,
las_path,
source_filters=source_filters,
methods=methods,
force=request.force,
)
# DB 기록 (트랜잭션)
await connection.begin()
try:
processed = result["processed"]
processed_cloud_id = await create_processed_point_cloud(
connection,
input_file_id=request.input_file_id,
project_id=project_id,
process_type="structured",
processed_file_path=processed["processed_file_path"],
converted_format=None,
converted_file_path=processed["converted_file_path"],
point_count=processed["point_count"],
bounds=processed["bounds"],
statistics=processed["statistics"],
classification_summary=None,
processing_params={"filters": source_filters},
)
surface_model_ids: list[int] = []
for model in result["models"]:
model_id = await create_surface_model(
connection,
project_id=project_id,
model_type=model["model_type"],
source_file_id=request.input_file_id,
processed_cloud_id=processed_cloud_id,
crs_epsg=input_file["crs_epsg"],
resolution_m=model["resolution_m"],
model_file_path=model["model_file_path"],
generation_params=model["generation_params"],
)
surface_model_ids.append(model_id)
for layer in model["layers"]:
await create_terrain_layer(
connection,
surface_model_id=model_id,
layer_name=layer["layer_name"],
geometry_type=layer["geometry_type"],
layer_file_path=layer["file_path"],
file_format=layer["file_format"],
file_size_mb=None,
statistics=None,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
return SurfaceAnalyzeResponse(
project_id=str(project_id),
ground_summary=result["ground_summary"],
manifest_status=result["manifest"].get("status", "unknown"),
surface_model_ids=surface_model_ids,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/models", response_model=SurfaceModelListResponse)
async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSONResponse:
"""프로젝트의 지표면 모델 목록을 조회한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
models = await list_surface_models(connection, project_id)
return SurfaceModelListResponse(
project_id=str(project_id),
models=[SurfaceModelSummary(**model) for model in models],
)
except Exception:
logger.exception("B04 지표면 모델 목록 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "모델 목록 조회 중 오류가 발생했습니다."},
)
+71
View File
@@ -0,0 +1,71 @@
"""B04 지표면 분석 요청·응답 검증 모델."""
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from config.config_system import (
SURFACE_MODEL_PRECOMPUTE,
SURFACE_MODEL_SOURCE_FILTERS,
)
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {"ransac"}
_ALLOWED_METHODS = set(SURFACE_MODEL_PRECOMPUTE)
class SurfaceAnalyzeRequest(BaseModel):
"""지표면 분석 실행 요청."""
model_config = ConfigDict(extra="forbid")
input_file_id: int = Field(gt=0, description="구조화 대상 원본 LAS input_files.id")
source_filters: list[str] | None = Field(
default=None, description="실행할 지면 필터 (미지정 시 config 기본값)"
)
methods: list[str] | None = Field(
default=None, description="생성할 지표면 표현 (미지정 시 config 기본값)"
)
force: bool = Field(default=False, description="캐시 무시 후 강제 재계산")
def resolved_filters(self) -> list[str]:
filters = self.source_filters or list(SURFACE_MODEL_SOURCE_FILTERS)
invalid = [f for f in filters if f not in _ALLOWED_FILTERS]
if invalid:
raise ValueError(f"허용되지 않은 지면 필터입니다: {invalid}")
return filters
def resolved_methods(self) -> list[str]:
methods = self.methods or list(SURFACE_MODEL_PRECOMPUTE)
invalid = [m for m in methods if m not in _ALLOWED_METHODS]
if invalid:
raise ValueError(f"허용되지 않은 지표면 표현입니다: {invalid}")
return methods
class SurfaceModelSummary(BaseModel):
"""저장된 지표면 모델 요약."""
id: int
model_type: str
status: str
resolution_m: float | None = None
model_file_path: str | None = None
created_at: str | None = None
class SurfaceAnalyzeResponse(BaseModel):
"""지표면 분석 실행 결과."""
status: str = "success"
project_id: str
ground_summary: dict[str, Any]
manifest_status: str
surface_model_ids: list[int]
class SurfaceModelListResponse(BaseModel):
"""프로젝트 지표면 모델 목록 응답."""
status: str = "success"
project_id: str
models: list[SurfaceModelSummary]
+231 -9
View File
@@ -2,27 +2,249 @@
* B04_wf1_Surface_UI_Page.ts
* 로그인 후 04: 1차 워크플로우 (지표면 모델 분석)
*
* ⚠️ 좌측 입력 패널 / 우측 WebCAD 뷰어 본문은 준비 중 — 워크플로우 셸(헤더+
* 스텝바 = 3단 레이아웃)만 구성. 실제 본문은 0_old 참고하여 추후 구체화.
* 3단 레이아웃 (frontend.md §2):
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell)
* 좌측: 입력 파일 ID + 지면 필터/지표면 표현 선택 + 실행 옵션 폼
* 우측: 생성된 지표면 모델 목록 그리드
*
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowShell 재사용.
* 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션]
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createTag,
createWorkflowShell,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
analyzeSurface,
listSurfaceModels,
type SurfaceModelSummary,
} from "./B04_wf1_Surface_Api_Fetch";
import "./B04_wf1_Surface_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
/** 선택 가능한 지면 필터 (config_system.SURFACE_MODEL_SOURCE_FILTERS + ransac) */
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf", "ransac"] as const;
/** 선택 가능한 지표면 표현 (config_system.SURFACE_MODEL_PRECOMPUTE) */
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
/** 체크박스 그룹 하나 생성 (라벨 + 항목들). 선택 값 Set을 반환. */
function buildCheckboxGroup(
legend: string,
values: readonly string[],
defaults: readonly string[],
): { root: HTMLElement; selected: Set<string> } {
const selected = new Set<string>(defaults);
const root = document.createElement("fieldset");
root.className = "b04-surface__group";
const legendEl = document.createElement("legend");
legendEl.className = "b04-surface__group-legend";
legendEl.textContent = legend;
root.append(legendEl);
for (const value of values) {
const item = document.createElement("label");
item.className = "b04-surface__check";
const box = document.createElement("input");
box.type = "checkbox";
box.value = value;
box.checked = selected.has(value);
box.addEventListener("change", () => {
if (box.checked) selected.add(value);
else selected.delete(value);
});
const text = document.createElement("span");
text.textContent = value;
item.append(box, text);
root.append(item);
}
return { root, selected };
}
export function renderB04Surface(root: HTMLElement): void {
renderPendingWorkflow(root, {
const shell = createWorkflowShell({
title: L("B04_Surface_Title"),
steps: workflowSteps(),
activeStep: 0,
});
/* ---- 좌측 입력 패널 ---- */
const inputFileField = createInputField({
label: L("B04_Surface_Field_InputId"),
type: "number",
min: 1,
placeholder: L("B04_Surface_Field_InputId_Placeholder"),
});
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
"grid_min_z",
"csf",
"pmf",
]);
const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [
"dtm",
"tin",
]);
const forceLabel = document.createElement("label");
forceLabel.className = "b04-surface__check";
const forceBox = document.createElement("input");
forceBox.type = "checkbox";
const forceText = document.createElement("span");
forceText.textContent = L("B04_Surface_Field_Force");
forceLabel.append(forceBox, forceText);
const analyzeButton = createButton({
label: L("B04_Surface_Btn_Analyze"),
variant: "filled",
onClick: () => void onB04_Surface_Analyze_Click(),
});
const leftForm = document.createElement("div");
leftForm.className = "b04-surface__form";
leftForm.append(
inputFileField.root,
filterGroup.root,
methodGroup.root,
forceLabel,
analyzeButton,
);
shell.leftPanel.append(leftForm);
/* ---- 우측 결과 영역 ---- */
const resultHeader = document.createElement("div");
resultHeader.className = "b04-surface__result-head";
const resultTitle = document.createElement("h3");
resultTitle.textContent = L("B04_Surface_Result_Title");
const refreshButton = createButton({
label: L("B04_Surface_Btn_Refresh"),
variant: "ghost",
onClick: () => void onB04_Surface_Refresh_Click(),
});
resultHeader.append(resultTitle, refreshButton);
const modelList = document.createElement("div");
modelList.className = "b04-surface__models";
const resultArea = document.createElement("div");
resultArea.className = "b04-surface__result";
resultArea.append(resultHeader, modelList);
shell.rightArea.append(resultArea);
function renderModels(models: readonly SurfaceModelSummary[]): void {
modelList.replaceChildren();
if (models.length === 0) {
const empty = document.createElement("p");
empty.className = "b04-surface__empty";
empty.textContent = L("B04_Surface_Result_Empty");
modelList.append(empty);
return;
}
for (const model of models) {
const card = document.createElement("div");
card.className = "b04-surface__model-card";
const head = document.createElement("div");
head.className = "b04-surface__model-head";
const type = document.createElement("strong");
type.textContent = model.model_type;
const variant =
model.status === "CONFIRMED" ? "success" : model.status === "FAILED" ? "danger" : "neutral";
head.append(type, createTag(model.status, variant));
const meta = document.createElement("div");
meta.className = "b04-surface__model-meta";
const resolution = document.createElement("span");
resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${
model.resolution_m ?? "-"
}`;
const path = document.createElement("span");
path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`;
meta.append(resolution, path);
card.append(head, meta);
modelList.append(card);
}
}
function getProjectId(): string | null {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) {
inputFileField.setError(L("B04_Surface_Error_Project"));
showToast(L("B04_Surface_Error_Project"), "error");
}
return projectId;
}
async function onB04_Surface_Analyze_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
const rawId = inputFileField.input.value.trim();
const inputFileId = Number(rawId);
if (!rawId || !Number.isInteger(inputFileId) || inputFileId <= 0) {
inputFileField.setError(L("B04_Surface_Error_InputId"));
return;
}
if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) {
inputFileField.setError(L("B04_Surface_Error_Selection"));
return;
}
inputFileField.setError();
showLoadingOverlay();
try {
const response = await analyzeSurface(projectId, {
input_file_id: inputFileId,
source_filters: [...filterGroup.selected],
methods: [...methodGroup.selected],
force: forceBox.checked,
});
showToast(
`${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`,
"success",
);
await loadModels(projectId);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed");
inputFileField.setError(`${L("B04_Surface_Analyze_Failed")} ${detail}`);
showToast(L("B04_Surface_Analyze_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
async function loadModels(projectId: string): Promise<void> {
showLoadingOverlay();
try {
const response = await listSurfaceModels(projectId);
renderModels(response.models);
} catch {
showToast(L("B04_Surface_Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
async function onB04_Surface_Refresh_Click(): Promise<void> {
const projectId = getProjectId();
if (projectId) await loadModels(projectId);
}
renderModels([]);
root.replaceChildren(shell.root);
const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (initialProjectId) void loadModels(initialProjectId);
}