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:
2026-11-03 14:51:29 +09:00
29 changed files with 1260 additions and 149 deletions
+19 -13
View File
@@ -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
+33 -6
View File
@@ -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)
+3 -15
View File
@@ -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
+48 -45
View File
@@ -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})
+6 -1
View File
@@ -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)
+110
View File
@@ -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 };
}
+5 -23
View File
@@ -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();
}