Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-11-01 23:05:18 +09:00
86 changed files with 4143 additions and 957 deletions
+1 -1
View File
@@ -279,7 +279,7 @@ def run_surface_analysis(
_report(40, "ground_filter", "지면 필터 적용 중")
# 자동 전처리는 목록을 넘기지 않는다 — 기본 필터는 입력 LAS를 보고 여기서 정한다.
if not source_filters:
source_filters = resolve_auto_source_filters(data)
source_filters = resolve_auto_source_filters(data, processed_dir)
logger.info("B04 자동 전처리 기본 필터: %s", ", ".join(source_filters))
masks: dict[str, np.ndarray] = {}
for filter_key in source_filters:
@@ -428,6 +428,20 @@ def extract_contours(
)
if representation == "local_rbf_height_field":
# 굳힌 격자(641-1)가 있으면 그 면 그대로 — 등고선 = 서버 표본 · 종횡단과 같은 면(643)
filter_key = model_npz_path.stem.removeprefix("implicit_")
if (model_npz_path.parent / f"dtm_{filter_key}.npz").is_file():
from common_util.common_util_surface_sampler import implicit_grid_npz
with np.load(implicit_grid_npz(model_npz_path.parent, filter_key)) as grid:
return extract_contours_from_grid(
grid["x"],
grid["y"],
grid["z"],
grid["valid_mask"],
interval,
scene_center=scene_center,
)
centers_xy, center_z = data["centers_xy"], data["center_z"]
smoothing = float(data["smoothing"][0])
interpolator = RBFInterpolator(
@@ -5,11 +5,18 @@
쓸 수 있는지는 has_classified_ground()로 먼저 판정한다.
"""
import json
from pathlib import Path
from typing import Any
import numpy as np
from config.config_system import SURFACE_CLASSIFIED_GROUND_MIN_RATIO
from common_util.common_util_json import atomic_write_json
from config.config_system import (
SURFACE_CLASSIFIED_COVERAGE_CELL_M,
SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO,
SURFACE_CLASSIFIED_GROUND_MIN_RATIO,
)
# ASPRS 표준 지면 클래스 코드
GROUND_CLASS_CODE = 2
@@ -38,3 +45,51 @@ def has_classified_ground(
return False
ground = int(np.count_nonzero(classification == GROUND_CLASS_CODE))
return ground / total >= minimum_ratio
def classified_coverage(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
cell_meters: float = SURFACE_CLASSIFIED_COVERAGE_CELL_M,
) -> float:
"""점 있는 칸 중 class 2 가 있는 칸의 비율 — 일부만 분류된 LAS 를 가려냄."""
if "classification" not in structured_data:
return 0.0
classification = np.asarray(structured_data["classification"])
xyz = structured_data["xyz"]
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
cell = max(float(cell_meters), 0.1)
cols = int((bounds[0, 1] - bounds[0, 0]) // cell) + 1
rows = int((bounds[1, 1] - bounds[1, 0]) // cell) + 1
occupied = np.zeros((rows, cols), dtype=bool)
ground = np.zeros((rows, cols), dtype=bool)
for start in range(0, len(classification), 5_000_000):
points = np.asarray(xyz[start : start + 5_000_000, :2], dtype=np.float64)
gx = np.clip(((points[:, 0] - bounds[0, 0]) // cell).astype(np.int32), 0, cols - 1)
gy = np.clip(((points[:, 1] - bounds[1, 0]) // cell).astype(np.int32), 0, rows - 1)
occupied[gy, gx] = True
is_ground = classification[start : start + len(points)] == GROUND_CLASS_CODE
ground[gy[is_ground], gx[is_ground]] = True
total = int(np.count_nonzero(occupied))
return float(np.count_nonzero(ground)) / total if total else 0.0
def has_classified_coverage(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
processed_dir: Path | None = None,
minimum_ratio: float = SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO,
) -> bool:
"""분류 덮임 문턱 판정 — `processed_dir` 면 structured.npz 지문으로 저장본 재사용."""
if processed_dir is None:
return classified_coverage(structured_data) >= minimum_ratio
stat = (processed_dir / "structured.npz").stat()
stamp = [int(stat.st_size), int(stat.st_mtime_ns)]
path = processed_dir / "classification_coverage.json"
try:
stored = json.loads(path.read_text(encoding="utf-8"))
coverage = float(stored["coverage"]) if stored.get("structured") == stamp else None
except (OSError, ValueError, KeyError, TypeError, AttributeError):
coverage = None
if coverage is None:
coverage = classified_coverage(structured_data)
atomic_write_json(path, {"structured": stamp, "coverage": coverage})
return coverage >= minimum_ratio
@@ -5,12 +5,14 @@
source_filters를 따른다.
"""
from pathlib import Path
from typing import Any
import numpy as np
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
filter_classification,
has_classified_coverage,
has_classified_ground,
)
from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf
@@ -47,14 +49,18 @@ def usable_conditional_filters(
def resolve_auto_source_filters(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
processed_dir: Path | None = None,
) -> list[str]:
"""자동 전처리가 만들 필터를 입력 LAS를 보고 정한다. 첫 항목이 기본 확정값이다.
업체가 이미 지면을 분류해 놨으면 그것을 기본으로 쓴다 — 계산이 없고 품질도 가장
안정적이다. **csf는 분류가 있어도 함께 만든다** — csf 처리 결과를 눈으로 검증할 수
있어야 하기 때문이다 (2026-09-01 사용자 확정). 분류가 없으면 csf 하나만 남는다.
「분류 있음」 = 비율 문턱 + 덮임(일부만 분류된 LAS 거름) — 자동 고름에만(요구 558).
"""
usable = usable_conditional_filters(structured_data)
if usable and not has_classified_coverage(structured_data, processed_dir):
usable = []
return [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
@@ -4,6 +4,7 @@ TerrainContext에서 파생 격자·샘플을 받아 각 표현의 모델(npz)
(GLB/PLY)를 저장하고 메타데이터를 반환한다.
"""
from contextlib import suppress
from pathlib import Path
from typing import Any
@@ -25,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
write_binary_ply,
write_glb,
)
from common_util.common_util_surface_sampler import implicit_grid_npz
def build_tin(
@@ -168,11 +170,19 @@ def build_nurbs(
)
def implicit_point_count(ground_count: int, config: dict[str, Any]) -> int:
"""implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부."""
minimum = int(config["implicit_min_points"])
if ground_count <= minimum:
return int(ground_count)
ratio = round(ground_count * float(config["implicit_point_ratio"]))
return int(min(max(ratio, minimum), int(config["implicit_max_points"])))
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)
points = context.grid_sample(implicit_point_count(context.source_count, context.config))
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
points = points[unique_indices]
if len(points) < 4:
@@ -209,6 +219,8 @@ def build_implicit(
smoothing=np.array([float(context.config["implicit_smoothing"])], np.float32),
)
write_glb(preview_path, vertices, faces, context.bounds)
with suppress(OSError, ValueError): # 641-1 굳힌 격자 — 못 지으면 처음 읽을 때 지음
implicit_grid_npz(output_dir, stem.removeprefix("implicit_"))
progress(100)
return with_footprint(
context,
@@ -29,6 +29,8 @@ ProgressCallback = Callable[[int], None]
# 대용량 포인트 배치 처리 크기
_BATCH_SIZE = 500_000
# implicit 격자 칸 뽑기 씨앗 — 고정이라 다시 지어도 같은 중심점
IMPLICIT_SAMPLE_SEED = 558
# NURBS 평활 계수 — 제어점당 허용 잔차제곱(m²).
# 0.01(RMS 0.1m)은 요구가 너무 빡빡해 FITPACK이 제어점 수(122x113)보다 많은
@@ -262,6 +264,30 @@ class TerrainContext:
self._samples[maximum] = points
return points
def grid_sample(self, count: int, seed: int = IMPLICIT_SAMPLE_SEED) -> np.ndarray:
"""implicit 전용 — 격자 칸(√(외곽 넓이 / 점 수))마다 하나 · 모자라면 무작위로 채움.
파일 차례 뽑기는 점 많은 트인 곳에 몰려 숲 밑 성긴 곳이 빔(요구 558). 씨앗 고정.
"""
indices = self.indices()
if len(indices) <= count:
return np.asarray(self.xyz[indices], dtype=np.float32)
_, _, resolution, footprint = self.footprint()
cell = max(math.sqrt(float(footprint.sum()) * resolution * resolution / count), 0.01)
xy = np.asarray(self.xyz[indices][:, :2], dtype=np.float64)
rng = np.random.default_rng(seed)
order = rng.permutation(len(indices))
gx = ((xy[order, 0] - self.bounds[0, 0]) // cell).astype(np.int64)
gy = ((xy[order, 1] - self.bounds[1, 0]) // cell).astype(np.int64)
_, first = np.unique(gy * (int(gx.max()) + 1) + gx, return_index=True)
chosen = order[first]
if len(chosen) > count:
chosen = rng.choice(chosen, count, replace=False)
elif len(chosen) < count:
rest = np.setdiff1d(np.arange(len(indices)), chosen, assume_unique=True)
chosen = np.concatenate([chosen, rng.choice(rest, count - len(chosen), replace=False)])
return np.asarray(self.xyz[indices[np.sort(chosen)]], dtype=np.float32)
def footprint(self) -> tuple[float, float, float, np.ndarray]:
if self._footprint is not None:
return self._footprint
@@ -151,7 +151,13 @@ _REPRESENTATIONS = {
def _cache_is_valid(
output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any]
) -> bool:
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다."""
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다.
manifest 항목이 완료여야 함 — 설정 지문이 바뀌면 manifest 가 비므로 파일만 남은
옛 결과(평활 없는 implicit · nurbs · meshfree)를 다시 짓는다(요구 558).
"""
if entry.get("status") != "completed":
return False
ext = "ply" if method == "meshfree" else "glb"
files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and (
output_dir / f"{stem}.npz"
@@ -190,6 +196,8 @@ def _build_all_terrain_models(
methods_list.remove("dtm")
methods_list.insert(0, "dtm")
methods = tuple(methods_list)
# 무거운 implicit 은 기본 필터(첫 필터)에만 — 분류 LAS 의 csf 까지 두 번 짓지 않음(요구 558).
later_methods = tuple(method for method in methods if method != "implicit")
signature = config_signature(config)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
xyz = structured_data["xyz"]
@@ -211,7 +219,7 @@ def _build_all_terrain_models(
}
started_at = time.monotonic()
timeout = max(0, int(config.get("sync_timeout_seconds", 0)))
total_units = max(1, len(filters) * len(methods))
total_units = max(1, len(methods) + (len(filters) - 1) * len(later_methods))
done_units = 0
failures = 0
@@ -229,27 +237,11 @@ def _build_all_terrain_models(
)
filter_entry["source_point_count"] = context.source_count
for method in methods:
for method in later_methods if filter_index else 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)
interval = float(config.get("contour_interval_meters", 1.0))
contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json"
needs_contours = not contour_path.exists()
+2 -2
View File
@@ -54,7 +54,7 @@ import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_swi
const SOURCE_FILTERS = ["classification", "ptd", "grid_min_z", "csf", "pmf"] as const;
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
const DEFAULT_FILTER = "csf";
const DEFAULT_METHOD = "dtm";
const DEFAULT_METHOD = "implicit"; // 확정 기본 표현(요구 558) — 확정본이 있으면 그것이 먼저
const ROUTE_STAGE = ROUTES.B05_PROFILE;
// 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다.
const SHEET_METHOD_ORDER = ["tin_sheet", "tin", "biharmonic", "anudem", "multires", "laplace"];
@@ -494,7 +494,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
]);
models = modelResponse.models;
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
// 확정 이력이 없을 때만 개발 기본값(csf·implicit)으로 둔다.
if (confirmed.model_id) {
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
if (confirmed.method) methodGroup.select.value = confirmed.method;