Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -14,6 +14,7 @@ from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import (
|
||||
base_filter,
|
||||
build_ground_masks,
|
||||
resolve_auto_source_filters,
|
||||
run_ground_filter,
|
||||
@@ -282,24 +283,29 @@ def run_surface_analysis(
|
||||
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:
|
||||
|
||||
def _mask_for(filter_key: str) -> np.ndarray:
|
||||
mask_path = processed_dir / f"mask_{filter_key}.npy"
|
||||
mask: np.ndarray | None = None
|
||||
if not rebuild and mask_path.is_file():
|
||||
stored_mask = np.load(mask_path)
|
||||
if len(stored_mask) == total_points:
|
||||
mask = np.asarray(stored_mask, dtype=bool)
|
||||
logger.info("B04 지면 필터 캐시 재사용: %s", mask_path.name)
|
||||
if mask is None:
|
||||
step_started = time.monotonic()
|
||||
mask = np.asarray(run_ground_filter(filter_key, data), dtype=bool)
|
||||
np.save(mask_path, mask)
|
||||
logger.info(
|
||||
"B04 지면 필터 계산 완료: %s (%.1fs)",
|
||||
filter_key,
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
masks[filter_key] = mask
|
||||
return np.asarray(stored_mask, dtype=bool)
|
||||
# 다듬기 판(762)은 바탕 필터 마스크(저장본)를 다듬음 — 점진 TIN 을 두 번 돌리지 않음
|
||||
base = base_filter(filter_key)
|
||||
base_mask = (masks.get(base) if base in masks else _mask_for(base)) if base else None
|
||||
step_started = time.monotonic()
|
||||
mask = np.asarray(run_ground_filter(filter_key, data, base_mask), dtype=bool)
|
||||
np.save(mask_path, mask)
|
||||
logger.info(
|
||||
"B04 지면 필터 계산 완료: %s (%.1fs)",
|
||||
filter_key,
|
||||
time.monotonic() - step_started,
|
||||
)
|
||||
return mask
|
||||
|
||||
for filter_key in source_filters:
|
||||
masks[filter_key] = _mask_for(filter_key)
|
||||
ground_summary = summarize_masks(data, masks)
|
||||
# 지면점 비율은 필터가 조용히 실패해도 유일하게 드러나는 신호다 — 반드시 남긴다.
|
||||
for filter_key, entry in ground_summary.items():
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""B04 지면점 다듬기 — 필터가 고른 지면점에서 덤불 · 풀 혹과 낮은 잡점을 한 번 더 거름(762-1).
|
||||
|
||||
SMRF 꼴(Pingel 2013 · PDAL filters.smrf) — 입력 = 필터 지면 마스크 · 출력 = 줄어든 마스크.
|
||||
① 1 m 칸 최저면 → 뒤집어 열림(낮은 잡점 칸)
|
||||
② 창 반지름 1 칸부터 최대까지 키우며 열림 · 문턱 = 기울기 × 칸 × 반지름(물체 칸)
|
||||
③ 걸린 칸 빼고 남은 칸으로 임시 지면 다시 그림(줄여 메우고 늘려 붓는 다층 보간)
|
||||
④ 점마다 |z − 임시 지면| ≤ 문턱 + 배수 × 그 자리 기울기 이면 지면.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy import ndimage
|
||||
|
||||
from config.config_system import (
|
||||
SURFACE_REFINE_CELL_M,
|
||||
SURFACE_REFINE_LOW_SLOPE,
|
||||
SURFACE_REFINE_SCALAR,
|
||||
SURFACE_REFINE_SLOPE,
|
||||
SURFACE_REFINE_THRESHOLD_M,
|
||||
SURFACE_REFINE_WINDOW_M,
|
||||
)
|
||||
|
||||
_RELAX_STEPS = 30
|
||||
|
||||
|
||||
def _fill_nearest(grid: np.ndarray, missing: np.ndarray) -> np.ndarray:
|
||||
if not missing.any():
|
||||
return grid
|
||||
index = ndimage.distance_transform_edt(missing, return_distances=False, return_indices=True)
|
||||
return grid[tuple(index)]
|
||||
|
||||
|
||||
def _progressive(surface: np.ndarray, cell: float, slope: float, max_radius: int) -> np.ndarray:
|
||||
"""창을 한 칸씩 키운 열림이 한 단계에 문턱(기울기 × 칸 × 반지름)보다 깎은 칸."""
|
||||
flagged = np.zeros(surface.shape, dtype=bool)
|
||||
last = surface
|
||||
for radius in range(1, max_radius + 1):
|
||||
# ponytail: 정사각 창(분리형 최소 · 최대 거름) — 원판보다 빠름 · 모서리 방향만 조금 넓음
|
||||
# (네 방향 선분 열림은 능선을 더 지키나 덤불 판을 3 배 남겨 버림 — 762 훑기)
|
||||
current = ndimage.grey_opening(last, size=(2 * radius + 1, 2 * radius + 1))
|
||||
flagged |= (last - current) > slope * cell * radius
|
||||
last = current
|
||||
return flagged
|
||||
|
||||
|
||||
def _inpaint(values: np.ndarray, known: np.ndarray) -> np.ndarray:
|
||||
"""빈 칸을 아는 칸으로 메움 — 2 배씩 줄여 메운 뒤 늘려 붓고 이웃 평균으로 폄."""
|
||||
if known.all():
|
||||
return values
|
||||
if not known.any():
|
||||
return np.zeros_like(values)
|
||||
rows, cols = values.shape
|
||||
pad = ((0, rows % 2), (0, cols % 2))
|
||||
weight = np.pad(known.astype(np.float64), pad)
|
||||
total = np.pad(np.where(known, values, 0.0), pad)
|
||||
shape = (weight.shape[0] // 2, 2, weight.shape[1] // 2, 2)
|
||||
coarse_weight = weight.reshape(shape).sum(axis=(1, 3))
|
||||
coarse_known = coarse_weight > 0
|
||||
coarse = total.reshape(shape).sum(axis=(1, 3)) / np.maximum(coarse_weight, 1.0)
|
||||
coarse = _inpaint(coarse, coarse_known)
|
||||
up = ndimage.zoom(coarse, 2, order=1, mode="nearest", grid_mode=True)[:rows, :cols]
|
||||
result = np.where(known, values, up)
|
||||
missing = ~known
|
||||
for _ in range(_RELAX_STEPS):
|
||||
padded = np.pad(result, 1, mode="edge")
|
||||
mean = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:]) / 4
|
||||
result[missing] = mean[missing]
|
||||
return result
|
||||
|
||||
|
||||
def refine_ground_mask(
|
||||
xyz: Any,
|
||||
mask: np.ndarray,
|
||||
*,
|
||||
cell: float = SURFACE_REFINE_CELL_M,
|
||||
slope: float = SURFACE_REFINE_SLOPE,
|
||||
window_m: float = SURFACE_REFINE_WINDOW_M,
|
||||
threshold: float = SURFACE_REFINE_THRESHOLD_M,
|
||||
scalar: float = SURFACE_REFINE_SCALAR,
|
||||
low_slope: float = SURFACE_REFINE_LOW_SLOPE,
|
||||
) -> np.ndarray:
|
||||
"""필터 지면 마스크를 다듬어 돌려줌 — 지면이던 점만 지면으로 남음(늘지 않음)."""
|
||||
mask = np.asarray(mask, dtype=bool)
|
||||
index = np.flatnonzero(mask)
|
||||
refined = np.zeros(mask.shape, dtype=bool)
|
||||
if index.size < 3:
|
||||
refined[index] = True
|
||||
return refined
|
||||
points = np.asarray(xyz[index], dtype=np.float64)
|
||||
fx = (points[:, 0] - points[:, 0].min()) / cell
|
||||
fy = (points[:, 1] - points[:, 1].min()) / cell
|
||||
gx = fx.astype(np.int64)
|
||||
gy = fy.astype(np.int64)
|
||||
shape = (int(gy.max()) + 1, int(gx.max()) + 1)
|
||||
zmin = np.full(shape, np.inf)
|
||||
np.minimum.at(zmin, (gy, gx), points[:, 2])
|
||||
empty = ~np.isfinite(zmin)
|
||||
filled = _fill_nearest(np.where(empty, 0.0, zmin), empty)
|
||||
|
||||
# ① 낮은 잡점 — 뒤집은 면에서 한 칸 창 열림이 문턱 넘게 깎는 칸(PDAL 기본 기울기 5)
|
||||
low = _progressive(-filled, cell, low_slope, 1) & ~empty
|
||||
surface = _fill_nearest(filled, empty | low)
|
||||
# ② 물체 — 창을 최대 반지름까지 키우며
|
||||
objects = _progressive(surface, cell, slope, max(1, int(round(window_m / cell))))
|
||||
# ③ 임시 지면 · 그 자리 기울기(rise / run)
|
||||
provisional = _inpaint(filled, ~empty & ~low & ~objects)
|
||||
grad_y, grad_x = np.gradient(provisional, cell)
|
||||
gradient = np.hypot(grad_x, grad_y)
|
||||
# ④ 점마다 판정 — 칸 가운데 기준 쌍선형
|
||||
coords = [fy - 0.5, fx - 0.5]
|
||||
ground_z = ndimage.map_coordinates(provisional, coords, order=1, mode="nearest")
|
||||
local_slope = ndimage.map_coordinates(gradient, coords, order=1, mode="nearest")
|
||||
keep = np.abs(points[:, 2] - ground_z) <= threshold + scalar * local_slope
|
||||
refined[index[keep]] = True
|
||||
return refined
|
||||
@@ -1,6 +1,7 @@
|
||||
"""B04 지면 필터 오케스트레이션.
|
||||
|
||||
구조화된 포인트클라우드(structured.npz)에 대해 grid_min_z/csf/pmf/ransac
|
||||
구조화된 포인트클라우드(structured.npz)에 대해 grid_min_z/csf/pmf/ransac/ptd/classification
|
||||
(+ `_refine` 다듬기 판)
|
||||
필터를 실행하여 지면 마스크 딕셔너리를 만든다. 필터 선택은 config의
|
||||
source_filters를 따른다.
|
||||
"""
|
||||
@@ -20,7 +21,12 @@ from B04_PreProcess.B04_PreProcess_Engine_Filter_Grid import filter_grid_min_z
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_PMF import filter_pmf
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_PTD import filter_ptd
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_RANSAC import filter_ransac
|
||||
from config.config_system import SURFACE_AUTO_FALLBACK_FILTER
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Refine import refine_ground_mask
|
||||
from config.config_system import SURFACE_AUTO_FALLBACK_FILTER, SURFACE_REFINE_FLOW
|
||||
|
||||
# 다듬기 판(762) — `{기본 필터}_refine` = 기본 필터 지면점을 SMRF 꼴로 한 번 더 거름
|
||||
REFINE_SUFFIX = "_refine"
|
||||
_REFINABLE = ("classification", "ptd")
|
||||
|
||||
# 필터 키 → 함수 매핑
|
||||
_FILTERS = {
|
||||
@@ -30,10 +36,17 @@ _FILTERS = {
|
||||
"ransac": filter_ransac,
|
||||
"ptd": filter_ptd,
|
||||
"classification": filter_classification,
|
||||
"classification_refine": lambda data: refine_ground_mask(
|
||||
data["xyz"], filter_classification(data)
|
||||
),
|
||||
"ptd_refine": lambda data: refine_ground_mask(data["xyz"], filter_ptd(data)),
|
||||
}
|
||||
|
||||
# LAS에 지면분류가 실제로 들어 있을 때만 쓰는 필터 (미분류 LAS에서는 마스크가 빈다)
|
||||
_CONDITIONAL_FILTERS = {"classification": has_classified_ground}
|
||||
_CONDITIONAL_FILTERS = {
|
||||
"classification": has_classified_ground,
|
||||
"classification_refine": has_classified_ground,
|
||||
}
|
||||
|
||||
|
||||
def available_filters() -> tuple[str, ...]:
|
||||
@@ -61,15 +74,29 @@ def resolve_auto_source_filters(
|
||||
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]
|
||||
filters = [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
|
||||
# 762 제안 흐름(스위치 켬) — 같은 기본 필터에 다듬기를 붙인 판을 지음
|
||||
if SURFACE_REFINE_FLOW:
|
||||
filters = [f"{key}{REFINE_SUFFIX}" if key in _REFINABLE else key for key in filters]
|
||||
return filters
|
||||
|
||||
|
||||
def base_filter(filter_key: str) -> str | None:
|
||||
"""다듬기 판이면 바탕 필터 이름 — 엔진이 바탕 마스크 저장본을 다시 쓰게."""
|
||||
base = filter_key.removesuffix(REFINE_SUFFIX)
|
||||
return base if base != filter_key and base in _REFINABLE else None
|
||||
|
||||
|
||||
def run_ground_filter(
|
||||
filter_key: str, structured_data: dict[str, Any] | np.lib.npyio.NpzFile
|
||||
filter_key: str,
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
base_mask: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""단일 지면 필터를 실행해 불리언 마스크를 반환한다."""
|
||||
"""단일 지면 필터를 실행해 불리언 마스크를 반환한다 — 다듬기 판은 `base_mask` 를 다듬음."""
|
||||
if filter_key not in _FILTERS:
|
||||
raise ValueError(f"알 수 없는 지면 필터입니다: {filter_key}")
|
||||
if base_mask is not None and base_filter(filter_key):
|
||||
return refine_ground_mask(structured_data["xyz"], base_mask)
|
||||
return np.asarray(_FILTERS[filter_key](structured_data), dtype=bool)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
|
||||
bounds_dict,
|
||||
config_signature,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Quality import quality_after_build
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Smooth import (
|
||||
compute_smoothing_signature,
|
||||
run_smoothing,
|
||||
@@ -262,6 +263,7 @@ def _build_all_terrain_models(
|
||||
manifest.get("bounds", {}),
|
||||
entry,
|
||||
)
|
||||
quality_after_build(output_dir, filter_key, method, context, fresh=False)
|
||||
done_units += 1
|
||||
_report(f"{filter_key}-{method} 캐시 재사용")
|
||||
continue
|
||||
@@ -320,6 +322,7 @@ def _build_all_terrain_models(
|
||||
manifest.get("bounds", {}),
|
||||
metadata,
|
||||
)
|
||||
quality_after_build(output_dir, filter_key, method, context, fresh=True)
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
filter_entry["methods"][method] = {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""B04 서피스 품질 지표 넷 — 기준 없이 재는 값(762-2 · _분석_599 정의 · _분석_601-1 3-3).
|
||||
|
||||
① 튐 넓이 % — 5 × 5 칸 중간값 대비 0.5 m 넘는 칸(8 방향 묶음 = 곳 수)
|
||||
② 1 m 등고선 꺾임 /km — 2 m 마다 다시 찍어 30° 넘게 꺾인 곳
|
||||
③ 땅 점 빈 곳 % — 2 m 안 지면점 없는 칸 · 그중 기울기 > 15° 칸
|
||||
④ 필터 차 % — 같은 표현 · 짝 필터 모델과 |차| > 0.5 m 넓이
|
||||
저장 = `models/quality_{필터}_{표현}.json`(파생 저장본 — 모델 · 짝 모델 지문이 다를 때만 다시 셈).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy import ndimage
|
||||
from skimage.measure import find_contours
|
||||
|
||||
from common_util.common_util_derived_store import file_stamp
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUALITY_VERSION = 1
|
||||
SPIKE_M = 0.5
|
||||
SPIKE_WINDOW = 5
|
||||
CONTOUR_INTERVAL_M = 1.0
|
||||
KINK_STEP_M = 2.0
|
||||
KINK_DEG = 30.0
|
||||
GAP_M = 2.0
|
||||
GAP_STEEP_DEG = 15.0
|
||||
DIFF_M = 0.5
|
||||
GRID_METHODS = ("dtm", "implicit")
|
||||
# 필터 차 짝 — 업체 분류 ↔ 점진 TIN(다듬기 판끼리) · 없으면 다듬기 전 ↔ 뒤
|
||||
_PAIRS = {
|
||||
"classification": ("ptd", "classification_refine"),
|
||||
"ptd": ("classification", "ptd_refine"),
|
||||
"classification_refine": ("ptd_refine", "classification"),
|
||||
"ptd_refine": ("classification_refine", "ptd"),
|
||||
}
|
||||
|
||||
|
||||
def _grid_path(models_dir: Path, filter_key: str, method: str) -> Path:
|
||||
suffix = "_grid" if method == "implicit" else ""
|
||||
return models_dir / f"{method}_{filter_key}{suffix}.npz"
|
||||
|
||||
|
||||
def _load_grid(path: Path) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
with np.load(path) as data:
|
||||
z = np.asarray(data["z"], dtype=np.float64)
|
||||
valid = np.asarray(data["valid_mask"], dtype=bool) & np.isfinite(z)
|
||||
cell = float(np.asarray(data["resolution"]).ravel()[0]) if "resolution" in data else 1.0
|
||||
return np.where(valid, z, 0.0), valid, np.asarray(cell)
|
||||
|
||||
|
||||
def spike_stats(z: np.ndarray, valid: np.ndarray) -> dict[str, Any]:
|
||||
residual = z - ndimage.median_filter(z, size=SPIKE_WINDOW, mode="nearest")
|
||||
inner = ndimage.binary_erosion(valid, iterations=SPIKE_WINDOW // 2)
|
||||
spike = (np.abs(residual) > SPIKE_M) & inner
|
||||
_, count = ndimage.label(spike, structure=np.ones((3, 3), dtype=bool))
|
||||
area = int(inner.sum())
|
||||
return {
|
||||
"spike_pct": round(100.0 * int(spike.sum()) / area, 2) if area else None,
|
||||
"spike_count": int(count),
|
||||
}
|
||||
|
||||
|
||||
def contour_kinks(z: np.ndarray, valid: np.ndarray, cell: float) -> dict[str, Any]:
|
||||
if not valid.any():
|
||||
return {"contour_km": 0.0, "kinks_per_km": None, "bubbles": 0}
|
||||
low, high = float(z[valid].min()), float(z[valid].max())
|
||||
total, kinks, bubbles = 0.0, 0, 0
|
||||
step = KINK_STEP_M / cell
|
||||
for level in np.arange(np.ceil(low / CONTOUR_INTERVAL_M) * CONTOUR_INTERVAL_M, high, 1.0):
|
||||
for line in find_contours(z, level, mask=valid):
|
||||
seg = np.hypot(*np.diff(line, axis=0).T)
|
||||
length = float(seg.sum())
|
||||
if length < step * 2:
|
||||
continue
|
||||
total += length
|
||||
if np.allclose(line[0], line[-1]) and length * cell < 30.0:
|
||||
bubbles += 1
|
||||
along = np.r_[0.0, np.cumsum(seg)]
|
||||
t = np.arange(0.0, length, step)
|
||||
rows, cols = np.interp(t, along, line[:, 0]), np.interp(t, along, line[:, 1])
|
||||
angle = np.arctan2(np.diff(rows), np.diff(cols))
|
||||
turn = np.abs((np.diff(angle) + np.pi) % (2 * np.pi) - np.pi)
|
||||
kinks += int((np.degrees(turn) > KINK_DEG).sum())
|
||||
km = total * cell / 1000.0
|
||||
return {
|
||||
"contour_km": round(km, 2),
|
||||
"kinks_per_km": round(kinks / km, 1) if km else None,
|
||||
"bubbles": bubbles,
|
||||
}
|
||||
|
||||
|
||||
def gap_stats(
|
||||
z: np.ndarray, valid: np.ndarray, occupied: np.ndarray, cell: float
|
||||
) -> dict[str, Any]:
|
||||
gap = (ndimage.distance_transform_edt(~occupied) * cell > GAP_M) & valid
|
||||
grad_y, grad_x = np.gradient(z, cell)
|
||||
steep = np.degrees(np.arctan(np.hypot(grad_x, grad_y))) > GAP_STEEP_DEG
|
||||
area = int(valid.sum())
|
||||
return {
|
||||
"gap_pct": round(100.0 * int(gap.sum()) / area, 2) if area else None,
|
||||
"gap_steep_pct": round(100.0 * int((gap & steep).sum()) / area, 2) if area else None,
|
||||
}
|
||||
|
||||
|
||||
def ground_occupancy(xyz: Any, indices: np.ndarray, bounds: np.ndarray, shape: tuple) -> np.ndarray:
|
||||
"""지면점이 하나라도 든 칸 — dtm 격자와 같은 칸 나눔(`TerrainContext.grid`)."""
|
||||
rows, cols = shape
|
||||
cell_x = (bounds[0, 1] - bounds[0, 0]) / max(cols - 1, 1)
|
||||
cell_y = (bounds[1, 1] - bounds[1, 0]) / max(rows - 1, 1)
|
||||
occupied = np.zeros(shape, dtype=bool)
|
||||
for start in range(0, len(indices), 2_000_000):
|
||||
points = np.asarray(xyz[indices[start : start + 2_000_000]], dtype=np.float64)
|
||||
gx = np.clip(((points[:, 0] - bounds[0, 0]) / cell_x).astype(np.int64), 0, cols - 1)
|
||||
gy = np.clip(((points[:, 1] - bounds[1, 0]) / cell_y).astype(np.int64), 0, rows - 1)
|
||||
occupied[gy, gx] = True
|
||||
return occupied
|
||||
|
||||
|
||||
def diff_pct(z: np.ndarray, valid: np.ndarray, other_path: Path) -> float | None:
|
||||
other_z, other_valid, _ = _load_grid(other_path)
|
||||
if other_z.shape != z.shape:
|
||||
return None
|
||||
both = valid & other_valid
|
||||
area = int(both.sum())
|
||||
if not area:
|
||||
return None
|
||||
return round(100.0 * int(((np.abs(z - other_z) > DIFF_M) & both).sum()) / area, 2)
|
||||
|
||||
|
||||
def _pair(models_dir: Path, filter_key: str, method: str) -> Path | None:
|
||||
for other in _PAIRS.get(filter_key, ()):
|
||||
path = _grid_path(models_dir, other, method)
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def ensure_quality(
|
||||
models_dir: Path,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
occupied: np.ndarray | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""지표 넷을 돌려줌 — 저장본 지문이 맞으면 그대로 · ③ 은 지을 때 넘긴 지면점 칸만."""
|
||||
if method not in GRID_METHODS:
|
||||
return None
|
||||
grid_path = _grid_path(models_dir, filter_key, method)
|
||||
if not grid_path.is_file():
|
||||
return None
|
||||
pair = _pair(models_dir, filter_key, method)
|
||||
key = {
|
||||
"version": QUALITY_VERSION,
|
||||
"model": file_stamp(grid_path),
|
||||
"pair": [pair.name, file_stamp(pair)] if pair else None,
|
||||
}
|
||||
path = models_dir / f"quality_{filter_key}_{method}.json"
|
||||
try:
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
stored = {}
|
||||
if stored.get("key") == key and occupied is None:
|
||||
return stored
|
||||
z, valid, cell = _load_grid(grid_path)
|
||||
cell = float(cell)
|
||||
same_model = (stored.get("key") or {}).get("model") == key["model"]
|
||||
result: dict[str, Any] = {"key": key, "filter": filter_key, "method": method}
|
||||
if same_model and occupied is None:
|
||||
result.update({k: stored.get(k) for k in ("spike_pct", "spike_count", "contour_km")})
|
||||
result.update({k: stored.get(k) for k in ("kinks_per_km", "bubbles")})
|
||||
else:
|
||||
result.update(spike_stats(z, valid))
|
||||
result.update(contour_kinks(z, valid, cell))
|
||||
if occupied is not None and occupied.shape == z.shape:
|
||||
result.update(gap_stats(z, valid, occupied, cell))
|
||||
elif same_model:
|
||||
result.update({k: stored.get(k) for k in ("gap_pct", "gap_steep_pct")})
|
||||
result["diff_against"] = pair.name if pair else None
|
||||
result["diff_pct"] = diff_pct(z, valid, pair) if pair else None
|
||||
atomic_write_json(path, result)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_quality_safely(models_dir: Path, filter_key: str, method: str, **kwargs: Any) -> None:
|
||||
"""지을 때 부름 — 실패가 모델 짓기를 막지 않음."""
|
||||
try:
|
||||
ensure_quality(models_dir, filter_key, method, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("서피스 품질 지표 실패: %s-%s %s", filter_key, method, exc)
|
||||
|
||||
|
||||
def quality_after_build(
|
||||
models_dir: Path, filter_key: str, method: str, context: Any, fresh: bool
|
||||
) -> None:
|
||||
"""파이프라인이 표현 하나를 짓거나 다시 쓴 뒤 부름 — ③ 은 지면점이 손에 있을 때만 셈."""
|
||||
if method not in GRID_METHODS:
|
||||
return
|
||||
occupied = None
|
||||
if fresh or not (models_dir / f"quality_{filter_key}_{method}.json").is_file():
|
||||
try:
|
||||
resolution = float(context.config.get("dtm_grid_resolution_meters", 1.0))
|
||||
shape = context.grid(resolution)[2].shape
|
||||
occupied = ground_occupancy(context.xyz, context.indices(), context.bounds, shape)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("서피스 품질 지면점 칸 실패: %s %s", filter_key, exc)
|
||||
ensure_quality_safely(models_dir, filter_key, method, occupied=occupied)
|
||||
@@ -287,19 +287,6 @@ async def list_surface_models(
|
||||
return models
|
||||
|
||||
|
||||
async def clear_confirmed_surface_models(connection: aiomysql.Connection, project_id: UUID) -> None:
|
||||
"""재분석 시 기존 확정을 해제하여 새 분석 결과의 재확정을 요구한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE surface_models
|
||||
SET status = 'COMPLETE'
|
||||
WHERE project_id = %s AND status = 'CONFIRMED'
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
|
||||
|
||||
async def confirm_surface_model(
|
||||
connection: aiomysql.Connection, project_id: UUID, model_id: int
|
||||
) -> None:
|
||||
@@ -384,7 +371,7 @@ async def _upsert_surface_model(
|
||||
processed_cloud_id: int | None,
|
||||
crs_epsg: int | None,
|
||||
) -> int:
|
||||
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결이 그대로.
|
||||
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결 · 확정이 그대로(763-3).
|
||||
|
||||
지문이 다르거나 없으면 옛 행을 지우고 새로 넣는다(새 지표면 = 노선 연결도 끊김).
|
||||
"""
|
||||
@@ -397,7 +384,8 @@ async def _upsert_surface_model(
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE surface_models
|
||||
SET source_file_id = %s, processed_cloud_id = %s, status = 'COMPLETE',
|
||||
SET source_file_id = %s, processed_cloud_id = %s,
|
||||
status = IF(status = 'CONFIRMED', 'CONFIRMED', 'COMPLETE'),
|
||||
crs_epsg = %s, resolution_m = %s, generation_params = %s,
|
||||
completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
|
||||
@@ -24,7 +24,6 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
|
||||
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
|
||||
from B04_PreProcess.B04_PreProcess_Repository import (
|
||||
clear_confirmed_surface_models,
|
||||
get_input_file,
|
||||
list_project_point_cloud_inputs,
|
||||
list_project_point_cloud_paths,
|
||||
@@ -67,13 +66,11 @@ from common_util.common_util_auth import require_system_admin
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
confirmed_surface_params,
|
||||
get_surface_confirmation_params,
|
||||
surface_confirmation_defaults,
|
||||
)
|
||||
from common_util.common_util_workflow_state import (
|
||||
fail_stage,
|
||||
start_stage,
|
||||
update_stage_progress,
|
||||
terrain_changes,
|
||||
update_contour_interval_param,
|
||||
)
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
@@ -89,7 +86,10 @@ router.include_router(status_router)
|
||||
async def analyze_surface(
|
||||
project_id: UUID, request: SurfaceAnalyzeRequest
|
||||
) -> SurfaceAnalyzeResponse | JSONResponse:
|
||||
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다."""
|
||||
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다 — 보기만(763-3 · 요구 605).
|
||||
|
||||
확정 · 단계 상태 · 1 단계 값(`las_selection` 포함)을 안 바꿈 · 미리 셈도 없음. 서피스를 바꾸는
|
||||
길 = 다른 모델 [확정](전처리 체인 다시)."""
|
||||
try:
|
||||
source_filters = request.resolved_filters()
|
||||
methods = request.resolved_methods()
|
||||
@@ -97,19 +97,9 @@ async def analyze_surface(
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
|
||||
pool = get_db_pool()
|
||||
project_root: Path | None = None
|
||||
try:
|
||||
params = {
|
||||
"input_file_id": request.input_file_id,
|
||||
"source_filters": source_filters,
|
||||
"methods": methods,
|
||||
"force": request.force,
|
||||
}
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 1, params)
|
||||
await clear_confirmed_surface_models(connection, project_id)
|
||||
await connection.commit()
|
||||
|
||||
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)
|
||||
@@ -151,19 +141,23 @@ async def analyze_surface(
|
||||
analysis_result=result,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await update_stage_progress(cursor, str(project_id), 1, 100)
|
||||
await connection.commit()
|
||||
confirmed = await confirmed_surface_params(connection, str(project_id))
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
write_surface_progress(
|
||||
project_root,
|
||||
100,
|
||||
"awaiting_confirmation",
|
||||
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
||||
)
|
||||
if confirmed is not None:
|
||||
write_surface_progress(
|
||||
project_root, 100, "completed", "조합을 지었습니다 — 확정 그대로."
|
||||
)
|
||||
else:
|
||||
write_surface_progress(
|
||||
project_root,
|
||||
100,
|
||||
"awaiting_confirmation",
|
||||
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
||||
)
|
||||
|
||||
return SurfaceAnalyzeResponse(
|
||||
project_id=str(project_id),
|
||||
@@ -173,16 +167,14 @@ async def analyze_surface(
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 1, str(exc))
|
||||
await connection.commit()
|
||||
except (OSError, ValueError) as exc: # 보기만 — 단계 상태 그대로 · 진행 파일만 끝(763-3)
|
||||
if project_root is not None:
|
||||
write_surface_progress(project_root, 100, "failed", str(exc))
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception as exc:
|
||||
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 1, str(exc))
|
||||
await connection.commit()
|
||||
if project_root is not None:
|
||||
write_surface_progress(project_root, 100, "failed", str(exc))
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
|
||||
@@ -214,7 +206,10 @@ async def confirm_surface(
|
||||
request: SurfaceConfirmRequest,
|
||||
_session: dict[str, Any] = Depends(require_system_admin),
|
||||
) -> SurfaceConfirmResponse | JSONResponse:
|
||||
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다."""
|
||||
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다.
|
||||
|
||||
같은 서피스(필터 · 표현 · 평활) 다시 [확정] = 등고선 간격만 · 체인 없음. 다른 서피스 = 업로드와
|
||||
같은 전처리 체인(B05~B09 초기화 → 자동 설계 · 추천 미리 셈 · 763-3 · 요구 605)."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
@@ -247,24 +242,32 @@ async def confirm_surface(
|
||||
),
|
||||
}
|
||||
)
|
||||
await confirm_surface_selection(
|
||||
connection,
|
||||
project_id,
|
||||
request.model_id,
|
||||
selection,
|
||||
)
|
||||
current = await confirmed_surface_params(connection, str(project_id))
|
||||
same = current is not None and not terrain_changes(current, selection)
|
||||
if same: # 같은 서피스 — 아무것도 안 다시 셈(등고선 간격만 · 763-3)
|
||||
await update_contour_interval_param(
|
||||
connection, str(project_id), float(selection["contour_interval_m"])
|
||||
)
|
||||
else:
|
||||
await confirm_surface_selection(
|
||||
connection,
|
||||
project_id,
|
||||
request.model_id,
|
||||
selection,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
# 재확정 체인(2026-08-04 사용자 확정) — 새 지표면 기준으로 B05·B06을 백그라운드
|
||||
# 재계산·저장한다. 사용자 저장 입력(제어점·경사 옵션·측점별 설계)은 유지·이월된다.
|
||||
if same:
|
||||
return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id)
|
||||
# 서피스가 바뀜 — 업로드와 같은 전처리 체인 하나(B05~B09 초기화 · 763-3 · 요구 605).
|
||||
# 응답을 막지 않도록 백그라운드로 돌리고, 체인은 실패를 스스로 격리한다.
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import run_redesign_chain
|
||||
from B03_FileInput.B03_FileInput_Service_SurfaceChange import run_surface_change_chain
|
||||
|
||||
redesign_task = asyncio.create_task(
|
||||
run_redesign_chain(project_id, request.model_id, dict(selection)),
|
||||
name=f"redesign-chain-{project_id}",
|
||||
run_surface_change_chain(project_id, request.model_id),
|
||||
name=f"surface-change-chain-{project_id}",
|
||||
)
|
||||
redesign_task.add_done_callback(
|
||||
lambda task: task.exception() # 체인 내부에서 이미 로깅 — 미회수 예외 경고만 방지
|
||||
|
||||
@@ -190,3 +190,34 @@ async def get_surface_model_contour(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/quality")
|
||||
async def get_surface_model_quality(project_id: UUID, model_id: int) -> JSONResponse:
|
||||
"""서피스 품질 지표 넷(762-2) — 저장본 지문이 다르면 다시 셈 · 격자 표현 밖이면 null."""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Quality import ensure_quality
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT model_file_path FROM surface_models WHERE id = %s AND project_id = %s",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row[0]:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||
)
|
||||
model_path = Path(resolve_stored_project_path(stored_path)) / row[0]
|
||||
method, _, filter_key = model_path.stem.partition("_")
|
||||
try:
|
||||
quality = await asyncio.to_thread(ensure_quality, model_path.parent, filter_key, method)
|
||||
except Exception:
|
||||
logger.exception("서피스 품질 지표 실패: project_id=%s, model_id=%s", project_id, model_id)
|
||||
quality = None
|
||||
if quality:
|
||||
quality = {key: value for key, value in quality.items() if key != "key"}
|
||||
return JSONResponse(content={"status": "success", "quality": quality})
|
||||
|
||||
@@ -11,7 +11,12 @@ from config.config_system import (
|
||||
|
||||
# classification은 LAS가 지면분류를 싣고 있을 때 엔진이 자동으로 붙이지만,
|
||||
# 재계산 요청에서 이름으로 지정하는 것도 허용한다.
|
||||
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {"ransac", "classification"}
|
||||
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {
|
||||
"ransac",
|
||||
"classification",
|
||||
"classification_refine",
|
||||
"ptd_refine",
|
||||
}
|
||||
_ALLOWED_METHODS = set(SURFACE_MODEL_PRECOMPUTE)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* =============================================================================
|
||||
* B04_PreProcess_UI_Filter.ts
|
||||
* B04 지면 필터 드롭다운(고를 갈래 · 이름) + 고른 모델의 서피스 품질 지표 넷(762-2).
|
||||
* 지표 = GET /api/projects/{id}/surface/models/{model}/quality — 서버가 지을 때 셈해 둔 값
|
||||
* (튐 넓이 % · 1 m 등고선 꺾임 /km · 땅 점 빈 곳 % · 필터 차 %) · 문턱 판정은 안 함(사용자 협의).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createSelectField } from "@ui/ui_template_elements";
|
||||
import { createKv } from "@ui/ui_template_kv";
|
||||
import type { SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
||||
|
||||
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
|
||||
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
|
||||
// `_refine` = 다듬기 판(762) — 바탕 필터 바로 뒤에 둠(이름 찾기는 가장 긴 것이 이김).
|
||||
export const SOURCE_FILTERS = [
|
||||
"classification",
|
||||
"classification_refine",
|
||||
"ptd",
|
||||
"ptd_refine",
|
||||
"grid_min_z",
|
||||
"csf",
|
||||
"pmf",
|
||||
] as const;
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
ptd: "점진 TIN",
|
||||
classification_refine: "업체 분류 + 다듬기",
|
||||
ptd_refine: "점진 TIN + 다듬기",
|
||||
};
|
||||
|
||||
function optionLabel(value: string): string {
|
||||
return FILTER_LABELS[value] ?? value.replaceAll("_", " ").toUpperCase();
|
||||
}
|
||||
|
||||
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
|
||||
export function buildSelectField(
|
||||
label: string,
|
||||
values: readonly string[],
|
||||
defaultValue: string,
|
||||
): { root: HTMLElement; select: HTMLSelectElement } {
|
||||
const { root, select } = createSelectField({
|
||||
label,
|
||||
options: values.map((value) => ({ value, text: optionLabel(value) })),
|
||||
value: defaultValue,
|
||||
size: 36,
|
||||
});
|
||||
root.classList.add("b04-surface__field");
|
||||
return { root, select };
|
||||
}
|
||||
|
||||
interface SurfaceQuality {
|
||||
spike_pct?: number | null;
|
||||
spike_count?: number | null;
|
||||
kinks_per_km?: number | null;
|
||||
gap_pct?: number | null;
|
||||
gap_steep_pct?: number | null;
|
||||
diff_pct?: number | null;
|
||||
diff_against?: string | null;
|
||||
}
|
||||
|
||||
function fmt(value: number | null | undefined, unit: string): string | null {
|
||||
return value == null ? null : `${value.toLocaleString()} ${unit}`;
|
||||
}
|
||||
|
||||
function rows(quality: SurfaceQuality | null): ReturnType<typeof createKv> {
|
||||
const against = quality?.diff_against
|
||||
?.replace(/^(dtm|implicit)_/, "")
|
||||
.replace(/(_grid)?\.npz$/, "");
|
||||
const list = createKv([
|
||||
{
|
||||
label: "튐 넓이",
|
||||
value:
|
||||
quality?.spike_pct == null
|
||||
? null
|
||||
: `${quality.spike_pct} % · ${quality.spike_count ?? 0} 곳`,
|
||||
},
|
||||
{ label: "등고선 꺾임", value: fmt(quality?.kinks_per_km, "/km") },
|
||||
{ label: "땅 점 빈 곳", value: fmt(quality?.gap_pct, "%") },
|
||||
{ label: "그중 급경사", value: fmt(quality?.gap_steep_pct, "%") },
|
||||
// 대상 이름은 길어 줄을 깨뜨림 — 값은 % 만 · 대상은 마우스 올림 글
|
||||
{ label: "필터 차", value: fmt(quality?.diff_pct, "%") },
|
||||
]);
|
||||
if (against) list.title = `필터 차 = ${optionLabel(against)} 모델과 0.5 m 넘게 다른 넓이`;
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 고른 모델의 품질 지표 넷 — 늦게 온 옛 응답은 버림. */
|
||||
export function createQualityPanel(): {
|
||||
root: HTMLElement;
|
||||
show: (projectId: string, model: SurfaceModelSummary | undefined) => void;
|
||||
} {
|
||||
const root = document.createElement("div");
|
||||
root.append(rows(null));
|
||||
let ticket = 0;
|
||||
function show(projectId: string, model: SurfaceModelSummary | undefined): void {
|
||||
const mine = ++ticket;
|
||||
root.replaceChildren(rows(null));
|
||||
if (!model) return;
|
||||
void fetch(`${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/quality`, {
|
||||
credentials: "include",
|
||||
})
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload: { quality?: SurfaceQuality | null } | null) => {
|
||||
if (mine === ticket) root.replaceChildren(rows(payload?.quality ?? null));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return { root, show };
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type SurfacePointCloudSampleResponse,
|
||||
type SurfaceStatusResponse,
|
||||
} from "./B04_PreProcess_Api_Fetch";
|
||||
import { buildSelectField, createQualityPanel, SOURCE_FILTERS } from "./B04_PreProcess_UI_Filter";
|
||||
import { buildSurfaceGroup } from "./B04_PreProcess_UI_Group";
|
||||
import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer";
|
||||
import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer";
|
||||
@@ -49,9 +50,6 @@ import "./B04_PreProcess_UI_Style.css";
|
||||
import "./B04_PreProcess_UI_Style_Map.css";
|
||||
import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch";
|
||||
|
||||
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
|
||||
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
|
||||
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 = "ptd";
|
||||
const DEFAULT_METHOD = "implicit"; // 확정 기본 표현(요구 558) — 확정본이 있으면 그것이 먼저
|
||||
@@ -67,30 +65,11 @@ function infoRow(label: string, value: unknown): KvRow {
|
||||
return { label, value: value == null || value === "" ? "-" : String(value) };
|
||||
}
|
||||
|
||||
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
|
||||
function buildSelectField(
|
||||
label: string,
|
||||
values: readonly string[],
|
||||
defaultValue: string,
|
||||
): { root: HTMLElement; select: HTMLSelectElement } {
|
||||
const { root, select } = createSelectField({
|
||||
label,
|
||||
options: values.map((value) => ({
|
||||
value,
|
||||
text: value === "ptd" ? "점진 TIN" : value.replaceAll("_", " ").toUpperCase(),
|
||||
})),
|
||||
value: defaultValue,
|
||||
size: 36,
|
||||
});
|
||||
root.classList.add("b04-surface__field");
|
||||
return { root, select };
|
||||
}
|
||||
|
||||
function getModelFilter(model: SurfaceModelSummary): string {
|
||||
const configured = model.generation_params?.source_filter;
|
||||
if (typeof configured === "string") return configured.toLowerCase();
|
||||
const path = model.model_file_path?.toLowerCase() ?? "";
|
||||
return SOURCE_FILTERS.find((filter) => path.includes(filter)) ?? "";
|
||||
return SOURCE_FILTERS.filter((filter) => path.includes(filter)).pop() ?? "";
|
||||
}
|
||||
|
||||
export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
@@ -138,6 +117,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
let appliedFilter = DEFAULT_FILTER;
|
||||
let appliedMethod = DEFAULT_METHOD;
|
||||
const viewer = createSurfacePointCloudViewer();
|
||||
const quality = createQualityPanel(); // 762-2 고른 모델의 서피스 품질 지표 넷
|
||||
const terrainViewer = createSurfaceTerrainViewer();
|
||||
const mapViewer = createSurfaceMapViewer();
|
||||
// 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천,
|
||||
@@ -268,6 +248,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
filterGroup.root,
|
||||
methodGroup.root,
|
||||
terrainViewer.smoothingField,
|
||||
quality.root,
|
||||
]);
|
||||
// 표시 옵션은 3D 모델 토글과 포인트 슬라이더를 한 칸에 둔다(슬라이더가 맨 아래).
|
||||
const displayGroup = buildSurfaceGroup(L("B04_Surface_Group_Display"), [
|
||||
@@ -460,6 +441,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
if (!projectId) return;
|
||||
terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value);
|
||||
terrainViewer.render(projectId, models);
|
||||
quality.show(projectId, findSelectedModel());
|
||||
confirmButton.disabled = !findSelectedModel();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user