from __future__ import annotations import hashlib import json import math import os import sys import threading import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable import numpy as np from scipy import ndimage from scipy.interpolate import RBFInterpolator, RectBivariateSpline from scipy.spatial import Delaunay import trimesh 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] # 같은 서버 프로세스에서 동일 프로젝트 계산 요청이 겹치면 두 번째 요청을 # 대기시키지 않고 즉시 취소하기 위한 실행 상태입니다. _ACTIVE_TERRAIN_BUILDS: set[str] = set() _ACTIVE_TERRAIN_BUILDS_GUARD = threading.Lock() def _atomic_bytes(path: Path, payload: bytes) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_bytes(payload) os.replace(temporary, path) def _atomic_json(path: Path, value: dict[str, Any]) -> None: _atomic_bytes(path, json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8")) def _atomic_npz(path: Path, **arrays: np.ndarray) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") with temporary.open("wb") as handle: np.savez_compressed(handle, **arrays) os.replace(temporary, path) def _config_signature(config: dict[str, Any]) -> str: # 등고선(contour_*) 및 스무딩(smoothing_*) 설정은 지표면 지오메트리 원본과 무관하게 이후 단계나 토글로 파생되므로 # 캐시 서명에서 제외한다. (스무딩 강도나 등고선 간격 변경이 지표면 15종 재빌드를 유발하지 않도록) 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: 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: 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_bytes(path, payload) def _write_binary_ply(path: Path, vertices: np.ndarray, normals: np.ndarray, bounds: np.ndarray) -> None: scene_vertices = _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", " 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) @dataclass class TerrainContext: 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), 500_000): points = np.asarray(self.xyz[indices[start:start + 500_000]], 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), 500_000): points = np.asarray(self.xyz[indices[start:start + 500_000]], 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 _artifact_size(*paths: Path) -> int: return int(sum(path.stat().st_size for path in paths if path.exists())) def _with_footprint(context: TerrainContext, metadata: dict[str, Any]) -> dict[str, Any]: metadata.update(context.footprint_metadata()) return metadata 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} class OneLineTerrainProgress: def __init__(self, filters: tuple[str, ...], methods: tuple[str, ...]) -> None: self.filters, self.methods = filters, methods self.values: dict[str, int | str] = {method: 0 for method in methods} self.filter_index = 0 self.filter_key = filters[0] if filters else "-" def begin_filter(self, filter_index: int, filter_key: str) -> None: self.filter_index, self.filter_key = filter_index, filter_key self.values = {method: 0 for method in self.methods} self.render() def update(self, method: str, value: int | str) -> None: self.values[method] = value self.render() def finish_method(self, method: str, success: bool) -> None: self.values[method] = 100 if success else "실패" self.render() def render(self) -> None: pieces, current_fraction = [], 0.0 for method in self.methods: value = self.values[method] pieces.append(f"{method.upper()}:{value}{'%' if isinstance(value, int) else ''}") if isinstance(value, int): current_fraction += value / 100.0 else: current_fraction += 1.0 total = max(1, len(self.filters) * len(self.methods)) completed_filters = self.filter_index * len(self.methods) overall = int(100 * min(completed_filters + current_fraction, total) / total) label = SOURCE_FILTER_LABELS.get(self.filter_key, self.filter_key) sys.stdout.write(f"\r[지표면 {self.filter_index + 1}/{len(self.filters)} {label}] " + " | ".join(pieces) + f" | 전체:{overall}%") sys.stdout.flush() def finish(self, started_at: float, failures: int) -> None: sys.stdout.write("\n") success = len(self.filters) * len(self.methods) - failures print(f"[지표면] 계산 완료: 성공 {success}개, 실패 {failures}개, {time.monotonic() - started_at:.2f}초") 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, ) -> 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))) progress = OneLineTerrainProgress(filters, methods) failures = 0 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 progress.begin_filter(filter_index, filter_key) for method in methods: stem = f"{method}_{filter_key}" entry = filter_entry["methods"].get(method, {}) # 실제 파일 확장자 결정 (meshfree는 ply, 나머지는 glb) ext = "ply" if method == "meshfree" else "glb" expected_preview = f"{stem}_preview.{ext}" expected_model = f"{stem}.npz" # manifest 기록여부와 무관하게, 실제 디스크에 결과 파일이 정상 존재하는지 검사 physical_files_exist = (output_dir / expected_preview).exists() and (output_dir / expected_model).exists() # 스무딩 메타데이터 및 실제 스무딩 파일 존재 유효성 검사 추가 smooth_valid = True if method in config.get("smoothing_methods", ("dtm", "tin")): smooth_entry = entry.get("smooth", {}) expected_smooth_model = f"{stem}_smooth.npz" expected_smooth_preview = f"{stem}_smooth_preview.glb" physical_smooth_exist = (output_dir / expected_smooth_model).exists() and (output_dir / expected_smooth_preview).exists() from utils.surface_smoother import compute_smoothing_signature current_sig = compute_smoothing_signature(config) if (not physical_smooth_exist or smooth_entry.get("status") != "completed" or smooth_entry.get("smoothing_signature") != current_sig): smooth_valid = False if not force and physical_files_exist and smooth_valid: # manifest 엔트리가 손상되었거나 빈 경우 대비하여 복구/보정 if entry.get("status") != "completed": entry.update({ "status": "completed", "representation": "meshfree_surfels" if method == "meshfree" else ("regular_grid" if method == "dtm" else ("triangular_mesh" if method == "tin" else ("bspline_surface" if method == "nurbs" else "local_rbf_height_field"))), "model_file": expected_model, "preview_file": expected_preview, "preview_media_type": "application/octet-stream" if method == "meshfree" else "model/gltf-binary", "error": None }) filter_entry["methods"][method] = entry _atomic_json(manifest_path, manifest) progress.finish_method(method, True) continue if timeout and time.monotonic() - started_at >= timeout: failures += 1 filter_entry["methods"][method] = { "status": "failed", "error": f"동기 계산 제한시간 {timeout}초를 초과했습니다."} _atomic_json(manifest_path, manifest) progress.finish_method(method, False) continue method_started = time.monotonic() filter_entry["methods"][method] = {"status": "running", "error": None} _atomic_json(manifest_path, manifest) try: metadata = BUILDERS[method](context, output_dir, stem, lambda value, current=method: progress.update(current, max(0, min(100, int(value))))) metadata.update({"status": "completed", "duration_seconds": round(time.monotonic() - method_started, 3), "error": None}) # 지표면 스무딩 기능 연동 (TIN, DTM 방식 지원) if method in config.get("smoothing_methods", ("dtm", "tin")): try: from utils.surface_smoother import run_smoothing original_model_path = output_dir / f"{stem}.npz" if original_model_path.exists(): 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) } print(f"\n[Warning] 지표면 스무딩 실패 ({filter_key}-{method}): {smooth_exc}") filter_entry["methods"][method] = metadata progress.finish_method(method, True) # 빌드 완료 직후 기본 5m 등고선 사전 추출 및 캐싱 try: from utils.contour_extractor import CONTOUR_EXTRACTOR_VERSION, extract_contours default_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" bounds_info = manifest.get("bounds", {}) # 1) 원본 등고선 캐싱 if model_path.exists(): contours = extract_contours( model_path, representation=metadata.get("representation", "regular_grid"), interval=default_interval, target_grid_m=target_grid_m, scene_center=None ) contour_data = { "extractor_version": CONTOUR_EXTRACTOR_VERSION, "project_id": output_dir.parent.name, "source_filter": filter_key, "method": method, "interval": default_interval, "bounds": bounds_info, "contours": contours } cache_filename = f"contour_{filter_key}_{method}_{default_interval}m.json" cache_path = output_dir / cache_filename cache_path.write_text(json.dumps(contour_data, ensure_ascii=False), encoding="utf-8") # 2) 스무딩 등고선 캐싱 (스무딩 모델이 완성된 경우) smooth_model_path = output_dir / f"{stem}_smooth.npz" if smooth_model_path.exists() and "smooth" in metadata and metadata["smooth"].get("status") == "completed": smooth_rep = "regular_grid" if method == "dtm" else "triangular_mesh" smooth_contours = extract_contours( smooth_model_path, representation=smooth_rep, interval=default_interval, target_grid_m=target_grid_m, scene_center=None ) smooth_contour_data = { "extractor_version": CONTOUR_EXTRACTOR_VERSION, "project_id": output_dir.parent.name, "source_filter": filter_key, "method": method, "interval": default_interval, "bounds": bounds_info, "contours": smooth_contours } smooth_cache_filename = f"contour_{filter_key}_{method}_smooth_{default_interval}m.json" smooth_cache_path = output_dir / smooth_cache_filename smooth_cache_path.write_text(json.dumps(smooth_contour_data, ensure_ascii=False), encoding="utf-8") except Exception as cache_exc: print(f"\n[Warning] 등고선 사전 캐시 생성 실패 ({filter_key}-{method}): {cache_exc}") except Exception as exc: failures += 1 filter_entry["methods"][method] = { "status": "failed", "duration_seconds": round(time.monotonic() - method_started, 3), "error": str(exc)} progress.finish_method(method, False) _atomic_json(manifest_path, manifest) context._samples.clear() context._grids.clear() context._indices = None 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 _atomic_json(manifest_path, manifest) progress.finish(started_at, failures) 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, ) -> 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": {}} # 디스크 manifest는 건드리지 않고 이번 요청의 처리 결과만 표시합니다. response = dict(current) response["request_status"] = "cancelled_already_running" response["message"] = "동일 프로젝트의 지표면 모델 계산이 이미 진행 중이어서 요청을 취소했습니다." print(f"[지표면] 중복 요청 취소: {output_dir}") return response _ACTIVE_TERRAIN_BUILDS.add(build_key) try: return _build_all_terrain_models( structured_data, ground_masks, output_dir, config, force=force, ) finally: with _ACTIVE_TERRAIN_BUILDS_GUARD: _ACTIVE_TERRAIN_BUILDS.discard(build_key)