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