764 lines
38 KiB
Python
764 lines
38 KiB
Python
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", "<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"] = scene_vertices.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_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)
|
|
|
|
|
|
@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)
|