Files
Aislo/0_old/utils/terrain_model_converter.py
T

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)