260705_2
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user