329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""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)
|