diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index f70826ae7..34a4eceed 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -347,12 +347,6 @@ async def run_auto_design_chain( logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") - # 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도 - # 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다. - from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute - - schedule_precompute(project_id) - return { "route_id": route_id, "length_m": float(solve_result.total_length_m or 0.0), @@ -367,6 +361,10 @@ async def run_auto_design_chain( # 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다. if project_root is not None: clear_designing(project_root) + # 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀 + from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute + + schedule_precompute(project_id) async def _ensure_initial_polyline( @@ -670,10 +668,6 @@ async def run_redesign_chain( return f"종횡단 확정 실패({sections_result.status_code})" marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter())) _log_steps("재확정 체인", marks) - # 지형이 바뀌면 추천 키도 바뀜 — 첫 누름이 저장본이게 미리 셈(351-1) - from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute - - schedule_precompute(project_id) logger.info( "재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)", project_id, @@ -688,3 +682,7 @@ async def run_redesign_chain( finally: if project_root is not None: clear_designing(project_root) + # 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀 + from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute + + schedule_precompute(project_id) diff --git a/B03_FileInput/B03_FileInput_Service_SurfaceChange.py b/B03_FileInput/B03_FileInput_Service_SurfaceChange.py new file mode 100644 index 000000000..099f44d13 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Service_SurfaceChange.py @@ -0,0 +1,48 @@ +"""서피스가 바뀐 뒤 전처리 체인 다시(763-3 · 요구 605) — 업로드와 같은 길 하나. + +부르는 곳 = B04 다른 서피스 [확정]. B05~B09 초기화 = 같은 지형 재업로드 길 +(`purge_project_outputs(keep_preprocess=True)` · 단계 2 이후 처음 · 초기값 스냅샷 버림) → +`run_auto_design_chain`(끝에 추천 미리 셈 · 실패 길에도). 전처리 결과(B04) · 확정은 그대로. +""" + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +logger = logging.getLogger(__name__) + + +async def run_surface_change_chain(project_id: UUID, surface_model_id: int | None) -> None: + """B05~B09 를 지우고 자동 설계 체인을 새 확정 서피스로 다시 탐 — 실패는 로그만.""" + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain + from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute + from common_util.common_util_initial_snapshot import discard_initial_snapshot + from common_util.common_util_project_reset import purge_project_outputs + from common_util.common_util_storage import resolve_stored_project_path + from common_util.common_util_workflow_state import reset_stages_after_input_change + from config.config_db import get_db_pool + + try: + async with get_db_pool().acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if not stored_path: + return + root = Path(resolve_stored_project_path(stored_path)) + await asyncio.to_thread(discard_initial_snapshot, root) + await connection.begin() + try: + await purge_project_outputs(connection, str(project_id), root, keep_preprocess=True) + async with connection.cursor() as cursor: + await reset_stages_after_input_change(cursor, str(project_id), 2) + await connection.commit() + except Exception: + await connection.rollback() + raise + except Exception: # noqa: BLE001 — 체인은 응답 뒤 · 추천은 새 서피스로 셈 + logger.exception("서피스 바꿈 체인 — B05~B09 초기화 실패: project_id=%s", project_id) + schedule_precompute(project_id) + return + logger.info("서피스 바꿈 체인 — B05~B09 초기화 뒤 자동 설계: project_id=%s", project_id) + await run_auto_design_chain(project_id, surface_model_id=surface_model_id) diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index becc04c5e..37f9b0126 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -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(): diff --git a/B04_PreProcess/B04_PreProcess_Engine_Filter_Refine.py b/B04_PreProcess/B04_PreProcess_Engine_Filter_Refine.py new file mode 100644 index 000000000..048da42d8 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_Filter_Refine.py @@ -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 diff --git a/B04_PreProcess/B04_PreProcess_Engine_Ground.py b/B04_PreProcess/B04_PreProcess_Engine_Ground.py index 790805f5a..76393f353 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Ground.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Ground.py @@ -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) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py index 3fab1e129..f9bb5f3c4 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py @@ -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] = { diff --git a/B04_PreProcess/B04_PreProcess_Engine_Quality.py b/B04_PreProcess/B04_PreProcess_Engine_Quality.py new file mode 100644 index 000000000..ef1ec756d --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_Quality.py @@ -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) diff --git a/B04_PreProcess/B04_PreProcess_Repository.py b/B04_PreProcess/B04_PreProcess_Repository.py index 241d6cc46..0340878d7 100644 --- a/B04_PreProcess/B04_PreProcess_Repository.py +++ b/B04_PreProcess/B04_PreProcess_Repository.py @@ -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 diff --git a/B04_PreProcess/B04_PreProcess_Router.py b/B04_PreProcess/B04_PreProcess_Router.py index bca58a726..ec3fb54c3 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -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() # 체인 내부에서 이미 로깅 — 미회수 예외 경고만 방지 diff --git a/B04_PreProcess/B04_PreProcess_Router_Contour.py b/B04_PreProcess/B04_PreProcess_Router_Contour.py index 50e1d4bea..051ce537d 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Contour.py +++ b/B04_PreProcess/B04_PreProcess_Router_Contour.py @@ -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}) diff --git a/B04_PreProcess/B04_PreProcess_Schema.py b/B04_PreProcess/B04_PreProcess_Schema.py index b13522af4..9225853d1 100644 --- a/B04_PreProcess/B04_PreProcess_Schema.py +++ b/B04_PreProcess/B04_PreProcess_Schema.py @@ -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) diff --git a/B04_PreProcess/B04_PreProcess_UI_Filter.ts b/B04_PreProcess/B04_PreProcess_UI_Filter.ts new file mode 100644 index 000000000..ca2741cf0 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_Filter.ts @@ -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 = { + 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 { + 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 }; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 09ccb24bc..ad923b909 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -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 { @@ -138,6 +117,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { 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 { 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 { if (!projectId) return; terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value); terrainViewer.render(projectId, models); + quality.show(projectId, findSelectedModel()); confirmButton.disabled = !findSelectedModel(); } diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 3c076ed39..1a87b5a5d 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -81,6 +81,8 @@ export interface PrecomputeProgress { done: number; total: number; mode: RouteInitialLogic | null; + /** 기록은 「도는 중」인데 서버가 다시 켜져 끊김(763-4) — 추천 요청 409 가 이어 셈을 예약. */ + interrupted?: boolean; } /** 미리 셈 진행 — 못 읽으면 null(진행 표시만 빠짐). */ diff --git a/B05_Profile/B05_Profile_Router_Lidar.py b/B05_Profile/B05_Profile_Router_Lidar.py index 97c8f0241..283af043c 100644 --- a/B05_Profile/B05_Profile_Router_Lidar.py +++ b/B05_Profile/B05_Profile_Router_Lidar.py @@ -221,6 +221,9 @@ async def upload_route_lidar(project_id: UUID, files: list[UploadFile] = File(.. await connection.begin() await stash_las_selection(connection, str(project_id), got["meta"]["selection"]) await connection.commit() + from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute + + schedule_precompute(project_id) # 라이다 지형이 바뀜 — 추천 다시 셈(763-3) report(100, f"라이다 지형 끝 — 점 {got['meta']['point_count']:,}개") return lidar_upload.start_job(_JOBS, _TASKS, project_id, KIND, work, root / JOB, "B05") diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index acc9bfcea..1adbba3f8 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -578,7 +578,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any if request.balanced_profile and as_initial else None ), - request.terrain if as_initial else None, # 손 [저장] = 지형 그대로 · 바꿈 = 지형 칩(626-1) + None, # 설계 지형 그대로 — 추천 [적용]도 선만(763-3 · 요구 605-2) · 바꿈 = 지형 칩(626-1) (old_line, await asyncio.to_thread(_vertices_of, working_path)), as_initial, ) diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 20aeb4ca9..0b01d3878 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -26,8 +26,13 @@ logger = logging.getLogger(__name__) # 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜) _RUNNING: dict[str, dict[str, Any]] = {} -# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음 -NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다." +# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음 · 화면은 머리말로 가름 +# (`B05_Profile_UI_RouteEdit_RecPreview.ts` NOT_PREPARED_HEAD · 셈 중이면 진행 · 아니면 [지금 셈]) +NOT_PREPARED = "추천 저장본 없음 — 지표면이 바뀌어 다시 셈해야 함" +# 미리 셈 진행 기록(763-4) — 재시동 뒤 「끊김」을 알게 · 저장본 폴더 밖(저장본 glob 에 안 섞임) +JOB_FILE = Path("B05_Profile") / "route_recommend_job.json" +# ponytail: 다른 서버(같은 storage)가 도는 중인지 = 기록 나이 · 갈래 하나 최대 ~2 분 · 넘으면 끊김 +JOB_STALE_S = 600.0 @replan.router.post("/{project_id}/route/initial", response_model=None) @@ -172,6 +177,7 @@ async def compute_initial( )[0], ) if found is None: + resume_if_interrupted(project_id, project_root) # 끊긴 미리 셈 이어 셈(763-4) return JSONResponse( status_code=409, content={"status": "error", "message": NOT_PREPARED} ) @@ -296,6 +302,9 @@ async def precompute_defaults(project_id: UUID) -> None: progress = _RUNNING.get(str(project_id)) if progress is not None: progress.update(total=len(terrains) * len(variants) * len(modes)) + if paths is not None: # 진행 기록 자리 — 끝 기록은 `run_precompute`(763-4) + progress["root"] = paths[0] + _note(paths, progress) for terrain, (criteria, weights), mode in itertools.product(terrains, variants, modes): if progress is not None: progress["mode"] = mode @@ -308,17 +317,17 @@ async def precompute_defaults(project_id: UUID) -> None: terrain=terrain, ) outcome = await compute_initial(project_id, request, precompute=True) - if isinstance(outcome, JSONResponse): + if isinstance(outcome, JSONResponse): # 그 갈래만 건너뜀(763-4) logger.warning( - "추천 미리 셈 멈춤(%s %s %s): project_id=%s", + "추천 미리 셈 갈래 건너뜀(%s %s %s): project_id=%s", terrain, mode, outcome.status_code, project_id, ) - return if progress is not None: progress["done"] += 1 + _note(paths, progress) logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started) @@ -343,8 +352,23 @@ async def precompute_terrains(project_id: UUID, project_root: Path) -> list[Any] return [design, *[source for source in sources if source != design]] +def _note(paths: Any, progress: dict[str, Any], running: bool = True) -> None: + """진행 기록 쓰기(763-4) — 실패해도 셈은 계속.""" + from common_util.common_util_lidar_upload import note_job + + if paths is not None: + note_job( + Path(paths[0]) / JOB_FILE, + { + "running": running, + **{k: progress.get(k) for k in ("done", "total", "mode")}, + "updated": time.time(), + }, + ) + + async def run_precompute(project_id: UUID) -> None: - """미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음).""" + """미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음) · 기록(763-4).""" key = str(project_id) if key in _RUNNING: _RUNNING[key]["again"] = True @@ -356,20 +380,62 @@ async def run_precompute(project_id: UUID) -> None: if not _RUNNING[key]["again"]: break finally: - _RUNNING.pop(key, None) + progress = _RUNNING.pop(key, {}) + if progress.get("root"): + _note((progress["root"],), progress, running=False) -def precompute_progress(project_id: UUID) -> dict[str, Any]: - """모달 진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래.""" +def precompute_progress(project_id: UUID, project_root: Path | None = None) -> dict[str, Any]: + """진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래 · 끊겼나(763-4). + + 이 서버 메모리가 먼저 · 없으면 기록: 「도는 중」이 `JOB_STALE_S` 안 = 다른 서버가 셈 · + 넘음 = 끊김.""" progress = _RUNNING.get(str(project_id)) - if progress is None: - return {"running": False, "done": 0, "total": 0, "mode": None} - return {"running": True, **{k: progress[k] for k in ("done", "total", "mode")}} + if progress is not None: + return { + "running": True, + "interrupted": False, + **{k: progress[k] for k in ("done", "total", "mode")}, + } + idle = {"running": False, "interrupted": False, "done": 0, "total": 0, "mode": None} + if project_root is None: + return idle + try: + saved = json.loads((Path(project_root) / JOB_FILE).read_text(encoding="utf-8")) + except (OSError, ValueError): + return idle + if not isinstance(saved, dict) or not saved.get("running"): + return idle + fresh = time.time() - float(saved.get("updated") or 0) < JOB_STALE_S + return { + **idle, + **{k: saved.get(k) for k in ("done", "total", "mode")}, + "running": fresh, + "interrupted": not fresh, + } + + +def resume_if_interrupted(project_id: UUID, project_root: Path) -> bool: + """추천 요청이 409 인데 기록이 「끊김」 — 이 서버가 이어 셈 예약 한 번(돌면 안 겹침 · 763-4). + + 이어 셈 = 미리 셈 다시(있는 갈래는 저장본 꺼내기만 · 0.2 s 안팎).""" + state = precompute_progress(project_id, project_root) + if not state["interrupted"]: + return False + _note((project_root,), state) # 곧바로 「도는 중」 — 뒤 요청이 또 예약하지 않게 + schedule_precompute(project_id) + logger.info("추천 미리 셈 이어 셈 예약(끊김 기록): project_id=%s", project_id) + return True @replan.router.get("/{project_id}/route/initial/progress", response_model=None) async def initial_progress(project_id: UUID) -> dict[str, Any]: - return {"status": "success", **precompute_progress(project_id)} + try: + paths = await replan._project_paths(project_id) + except LookupError: + paths = None + root = paths[0] if paths is not None else None + return {"status": "success", **precompute_progress(project_id, root)} def schedule_precompute(project_id: UUID) -> None: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts b/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts index def720ed0..7c7c682d9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts @@ -4,20 +4,32 @@ * (영선 · 토공 최소 · 기울기 최소 · 비중 계산) · 여럿 고름(589-12 · 줄마다 색 · 하나도 안 고르면 꺼짐) · * 선만(위 캔버스 · 노선 아래 그림 · 집기 없음). * 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 · - * 저장본 없으면(409) 알림 · 끔. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김). + * 저장본 없으면(409) — 미리 셈 도는 중 = 진행 n/m(줄 마우스 글 · 알림 한 번) · 끝나면 다시 그림 · + * 아니면 확인창 「지표면이 바뀌어 다시 셈해야 함」 [지금 셈](그 갈래만 `recompute` · 763-4) · + * 지형이 바뀌면 받아 둔 선을 비움. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김). * 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞). * 메뉴 · 선 그리기 = 공용 `A00_Common/map/map_route_preview`(636-2 · 예정노선 가이드 선과 같이 씀). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; +import { askConfirm } from "@ui/ui_template_modal"; import { themeColor } from "@ui/ui_template_palette"; import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview"; import type { AnalysisTerrain } from "./B05_Profile_Api_Replan"; -import { fetchRouteInitial, type RouteInitialLogic } from "./B05_Profile_Api_RouteInitial"; +import { + fetchPrecomputeProgress, + fetchRouteInitial, + type RouteInitialLogic, +} from "./B05_Profile_Api_RouteInitial"; import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model"; type Vertex = [number, number]; +/** 서버 `NOT_PREPARED` 머리말(`B05_Profile_Router_RouteInitial.py`) — 저장본 없음 가름(763-4). */ +const NOT_PREPARED_HEAD = "추천 저장본 없음"; +/** 미리 셈 진행 묻기 간격(ms). */ +const POLL_MS = 3000; + /** 드롭다운 넷(요구 530 · 「예상노선 추적」 = 초기값이라 뺌). */ const MODES = [ "gradeline", @@ -54,6 +66,8 @@ export function createRecPreview(opts: { const lines = new Map(); const shown = new Map(); const turns = new Map(); + const waiting = new Set(); + let seenTerrain = opts.terrain(); const menu = createRoutePreviewMenu({ items: MODES.map((mode, index) => ({ value: mode, @@ -63,32 +77,67 @@ export function createRecPreview(opts: { onPick: (mode) => void pick(mode as RouteInitialLogic), }); - async function pick(mode: RouteInitialLogic): Promise { + /** 미리 셈이 도는 중이면 진행 n/m 을 보이고 끝난 뒤 다시 고름 — 돌면 true(763-4). */ + async function waitForPrecompute(mode: RouteInitialLogic, mine: number): Promise { + const progress = await fetchPrecomputeProgress(opts.projectId); + if (!progress?.running || mine !== turns.get(mode)) return false; + const note = `추천 셈 중 ${progress.done}/${progress.total}`; + menu.setItem(mode, { title: note }); + if (!waiting.has(mode)) showToast(`${note} — 끝나면 그림`, "info"); + waiting.add(mode); + window.setTimeout(() => { + if (mine === turns.get(mode) && menu.values().includes(mode)) void pick(mode); + }, POLL_MS); + return true; + } + + async function pick(mode: RouteInitialLogic, recompute = false): Promise { const mine = (turns.get(mode) ?? 0) + 1; turns.set(mode, mine); shown.delete(mode); opts.redraw(); if (!menu.values().includes(mode)) return; - const key = `${mode}|${opts.terrain()}`; + const terrain = opts.terrain(); + if (terrain !== seenTerrain) lines.clear(); // 지형 바뀜 = 옛 선 버림(763-4) + seenTerrain = terrain; + const key = `${mode}|${terrain}`; try { - if (!lines.has(key)) { + if (!lines.has(key) || recompute) { const body = (await fetchRouteInitial(opts.projectId, { mode, with_profile: true, - terrain: opts.terrain(), + terrain, + ...(recompute ? { recompute: true } : {}), })) as { planned?: number[][] }; const planned = (body.planned ?? []).map(([x, y]) => [x, y] as Vertex); if (planned.length < 2) throw new Error("추천 선이 비었음"); lines.set(key, planned); } if (mine !== turns.get(mode)) return; + waiting.delete(mode); + menu.setItem(mode, { title: "" }); shown.set(mode, lines.get(key)!); opts.redraw(); } catch (error) { if (mine !== turns.get(mode)) return; - menu.setItem(mode, { checked: false }); const text = error instanceof Error ? error.message : String(error); - showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info"); + if (!recompute && (await waitForPrecompute(mode, mine))) return; + waiting.delete(mode); + menu.setItem(mode, { checked: false, title: "" }); + const now = + text.startsWith(NOT_PREPARED_HEAD) && + (await askConfirm({ + title: "추천 노선", + message: `「${LOGIC_LABEL[mode]}」 ${text}. 지금 셀까요? 몇 분 걸릴 수 있습니다.`, + okLabel: "지금 셈", + })); + if (now) { + menu.setItem(mode, { checked: true }); + showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 셈 중 — 끝나면 그림`, "info"); + return void pick(mode, true); + } + if (!text.startsWith(NOT_PREPARED_HEAD)) + showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info"); } } diff --git a/common_util/common_util_surface_sampler.py b/common_util/common_util_surface_sampler.py index cc26565ac..eab838374 100644 --- a/common_util/common_util_surface_sampler.py +++ b/common_util/common_util_surface_sampler.py @@ -33,6 +33,8 @@ SurfaceFilter = Literal[ "pmf", "ptd", "ransac", + "classification_refine", + "ptd_refine", "sheet_tin_sheet", "sheet_tin", "sheet_biharmonic", diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 87a177e5e..02645468b 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -527,11 +527,14 @@ async def _surface_run_settled(cursor: aiomysql.DictCursor, project_id: str) -> return stage in _SETTLED_SURFACE_STAGES -async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_id: str) -> None: +async def reset_stages_after_input_change( + cursor: aiomysql.DictCursor, project_id: str, first_stage: int = 1 +) -> None: """입력 자료가 갈렸을 때 1단계(전처리) 이후를 처음 상태로 되돌린다. 새 자료로 다시 계산해 덮어쓰므로, 옛 자료 기준의 진행 표시가 남아 있으면 사용자가 이미 끝난 단계로 착각한다. 파일 입력(0단계)은 방금 끝났으니 건드리지 않는다. + `first_stage` = 2 — 서피스 바꿈 체인(전처리 결과 · 확정은 그대로 · 763-3). """ await cursor.execute( """ @@ -542,7 +545,7 @@ async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_i message = NULL, started_at = NULL, completed_at = NULL - WHERE project_id = %s AND stage_no >= 1 + WHERE project_id = %s AND stage_no >= %s """, - (project_id,), + (project_id, first_stage), ) diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 104a7e0eb..8e0b260ee 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -94,6 +94,16 @@ SURFACE_MODEL_SOURCE_FILTERS = tuple( SURFACE_AUTO_METHODS = tuple(os.getenv("SURFACE_AUTO_METHODS", "dtm").split(",")) # 기본 필터는 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 그것을, 없으면 아래 값. SURFACE_AUTO_FALLBACK_FILTER = os.getenv("SURFACE_AUTO_FALLBACK_FILTER", "ptd") +# 762 제안 흐름 스위치 — 켜면 자동 전처리 · 확정 기본 = 기본 필터 + 다듬기(`_refine`) · 기본 끔. +SURFACE_REFINE_FLOW = os.getenv("SURFACE_REFINE_FLOW", "False").lower() == "true" +# 다듬기(SMRF 꼴) — 칸 · 물체 기울기(rise/run · PDAL 0.15 는 급경사 능선을 깎아 0.3) · 최대 창 · +# 판정 문턱 · 기울기 배수 · 낮은 잡점 기울기 — 나머지는 PDAL 기본. +SURFACE_REFINE_CELL_M = float(os.getenv("SURFACE_REFINE_CELL_M", "1.0")) +SURFACE_REFINE_SLOPE = float(os.getenv("SURFACE_REFINE_SLOPE", "0.3")) +SURFACE_REFINE_WINDOW_M = float(os.getenv("SURFACE_REFINE_WINDOW_M", "18.0")) +SURFACE_REFINE_THRESHOLD_M = float(os.getenv("SURFACE_REFINE_THRESHOLD_M", "0.5")) +SURFACE_REFINE_SCALAR = float(os.getenv("SURFACE_REFINE_SCALAR", "1.25")) +SURFACE_REFINE_LOW_SLOPE = float(os.getenv("SURFACE_REFINE_LOW_SLOPE", "5.0")) # dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6) SURFACE_MODEL_PRECOMPUTE = tuple( os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",") @@ -177,7 +187,9 @@ SHEET_SURFACE_AUTO_METHODS = [ ] # 일반 사용자 WF1 자동 확정 기본값 -SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "ptd") +SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv( + "SURFACE_CONFIRM_DEFAULT_FILTER", "ptd_refine" if SURFACE_REFINE_FLOW else "ptd" +) # 확정 표현 = implicit · 평활 끔 — WF1 자동 짓기도 이 표현을 기본 필터에 지음(요구 558). SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "implicit") SURFACE_CONFIRM_DEFAULT_SMOOTH = ( diff --git a/resources/tester/test_309_3_recommend_preprocess_only.py b/resources/tester/test_309_3_recommend_preprocess_only.py index ce1a31ea5..4fb1fe14e 100644 --- a/resources/tester/test_309_3_recommend_preprocess_only.py +++ b/resources/tester/test_309_3_recommend_preprocess_only.py @@ -66,6 +66,8 @@ def test_미리_셈_부르는_곳은_체인만() -> None: "B03_FileInput_Service_Chain.py", # 업로드 체인 · B04 재확정 체인 "B01_Dashboard_Router.py", # 사용 범위 바꿈 = 예상노선 다시 씀 "B05_Profile_Router_RouteInitial.py", # 정의 + "B03_FileInput_Service_SurfaceChange.py", # 서피스 바꿈 체인 초기화 실패 길(763-3) + "B05_Profile_Router_Lidar.py", # LAS 나중 올리기 끝(763-3) } @@ -73,7 +75,7 @@ def test_화면_기본_갈래_저장본_없으면_409_셈_0(wired) -> None: # n for mode in DEFAULT_MODES: response = _ask(False, mode=mode) assert response.status_code == 409 - assert "전처리를 다시 하면 생깁니다" in json.loads(response.body)["message"] + assert json.loads(response.body)["message"] == router.NOT_PREPARED assert wired.count == 0 and not list(store_dir(wired.root).glob("*.json")) diff --git a/resources/tester/test_351_1_recommend_gaps.py b/resources/tester/test_351_1_recommend_gaps.py index a9e2a6835..f504efec2 100644 --- a/resources/tester/test_351_1_recommend_gaps.py +++ b/resources/tester/test_351_1_recommend_gaps.py @@ -92,10 +92,9 @@ def test_두_체인_끝에서_미리_셈_예약() -> None: source = Path("B03_FileInput/B03_FileInput_Service_Chain.py").read_text(encoding="utf-8") redesign = source[source.index("async def run_redesign_chain(") :] upload = source[: source.index("async def run_redesign_chain(")] - assert "schedule_precompute(project_id)" in upload - # 재확정 체인 성공 끝(완료 로그 앞) - done = redesign.index("재확정 체인 완료") - assert "schedule_precompute(project_id)" in redesign[:done] + # 두 체인 모두 finally — 실패 · 건너뜀 길에도 예약(763-3 · 실행 시험 = test_763_3) + for body in (upload, redesign): + assert "schedule_precompute(project_id)" in body.split(" finally:", 1)[1] b01 = Path("B01_Dashboard/B01_Dashboard_Router.py").read_text(encoding="utf-8") assert "refresh_expected_route(" in b01 diff --git a/resources/tester/test_364_4_precompute_on_open.py b/resources/tester/test_364_4_precompute_on_open.py index 90fc281a3..028ec6e59 100644 --- a/resources/tester/test_364_4_precompute_on_open.py +++ b/resources/tester/test_364_4_precompute_on_open.py @@ -40,9 +40,16 @@ def test_겹쳐_셈_막음_끝난_뒤_한_번_더(monkeypatch) -> None: asyncio.run(main()) assert calls == [str(pid), str(pid)] - assert seen[0] == {"running": True, "done": 2, "total": 5, "mode": "earthwork"} + assert seen[0] == { + "running": True, + "interrupted": False, # 끊김 기록(763-4) + "done": 2, + "total": 5, + "mode": "earthwork", + } assert router.precompute_progress(pid) == { "running": False, + "interrupted": False, "done": 0, "total": 0, "mode": None, diff --git a/resources/tester/test_509_5_recommend_terrain.py b/resources/tester/test_509_5_recommend_terrain.py index c7d022618..7e10c87ab 100644 --- a/resources/tester/test_509_5_recommend_terrain.py +++ b/resources/tester/test_509_5_recommend_terrain.py @@ -63,7 +63,7 @@ def test_미리_셈_없는_지형은_안내_재계산은_셈(two) -> None: missing = _ask(False, mode="gentle", terrain="sheet") assert missing.status_code == 409 message = json.loads(missing.body)["message"] - assert "전처리를 다시 하면" in message and "[재계산]" in message + assert message == router.NOT_PREPARED # 화면 = 진행 또는 [지금 셈](763-4) assert two.count == 0 assert _ask(False, mode="gentle", terrain="sheet", recompute=True)["stored"] is False assert two.count == 1 @@ -116,7 +116,7 @@ def test_고를_지형은_DB_모델_있을_때만(tmp_path, monkeypatch) -> None def test_지형_바꿈은_추천_적용만_초기값_다시_세움() -> None: replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8") save = replan.split("async def _save_route(", 1)[1] - assert "request.terrain if as_initial else None," in save # 손 [저장]은 지형 안 바꿈 + assert "request.terrain" not in save # 추천 [적용]도 지형 안 바꿈(763-3 · 요구 605-2) assert save.index("await _recompute(") < save.index("refresh_initial_snapshot(project_id") recompute = replan.split("async def _recompute(", 1)[1].split("\nasync def ")[0] assert "switch_design_terrain(connection, project_id, terrain)" in recompute diff --git a/resources/tester/test_626_1_recommend_preview.py b/resources/tester/test_626_1_recommend_preview.py index 5a72591c2..1cfe04a24 100644 --- a/resources/tester/test_626_1_recommend_preview.py +++ b/resources/tester/test_626_1_recommend_preview.py @@ -42,10 +42,11 @@ def test_미리보기_넷_하나만_저장본만() -> None: src = read(B05 / "B05_Profile_UI_RouteEdit_RecPreview.ts") modes = re.search(r"const MODES = \[(.*?)\]", src, re.S).group(1) assert re.findall(r'"(\w+)"', modes) == ["gradeline", "earthwork", "gentle", "weighted"] - assert "recompute" not in src and "createRoutePreviewMenu({" in src + # 저장본만 · 다시 셈은 저장본 없을 때 확인창 [지금 셈]만(763-4) + assert src.count("recompute: true") == 1 and "createRoutePreviewMenu({" in src shared = read(ROOT / "A00_Common" / "map" / "map_route_preview.ts") # 636-2 공용 메뉴 · 그리기 assert "createPickMenu({" in shared and "multi: true" in shared and "offLabel" not in shared - assert "terrain: opts.terrain()" in src # 지금 설계 지형 저장본(D7) + assert "const terrain = opts.terrain();" in src # 지금 설계 지형 저장본(D7) chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts") assert "recommend: preview.root" in chrome and "terrain: terrain.root" in chrome menu = read(ROOT / "ui_template" / "ui_template_context_menu.ts") diff --git a/resources/tester/test_762_1_refine.py b/resources/tester/test_762_1_refine.py new file mode 100644 index 000000000..1ddbf5d95 --- /dev/null +++ b/resources/tester/test_762_1_refine.py @@ -0,0 +1,80 @@ +"""762-1 지면점 다듬기(SMRF 꼴) · 제안 흐름 스위치 · 762-2 품질 지표 지킴 시험. + +합성 비탈(17°) + 능선(높이 6 m · 폭 약 16 m) + 덤불 혹(3 × 3 m · 1.5 m 뜸) — 필터가 +덤불까지 지면으로 고른 판 → 다듬기가 혹은 지우고 능선 · 비탈은 남김. +""" + +import numpy as np +import pytest + +import B04_PreProcess.B04_PreProcess_Engine_Ground as ground +from B04_PreProcess.B04_PreProcess_Engine_Filter_Refine import refine_ground_mask +from B04_PreProcess.B04_PreProcess_Engine_Quality import spike_stats + +RNG = np.random.default_rng(7621) + + +def _terrain(x: np.ndarray, y: np.ndarray) -> np.ndarray: + return 0.3 * x + 6.0 * np.exp(-(((x - 60.0) / 8.0) ** 2)) + 0.02 * y + + +def _scene() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + count = 120 * 120 * 4 + x = RNG.uniform(0, 120, count) + y = RNG.uniform(0, 120, count) + z = _terrain(x, y) + RNG.normal(0, 0.03, count) + bush = np.zeros(count, dtype=bool) + for cx, cy in RNG.uniform(10, 110, (15, 2)): + if abs(cx - 60) < 12: # 능선 위 덤불은 따로 가름 — 여기선 비탈 · 골 덤불 + cx += 25 + inside = (np.abs(x - cx) < 1.5) & (np.abs(y - cy) < 1.5) + z[inside] += 1.5 + bush |= inside + ridge = (np.abs(x - 60) < 4) & ~bush + return np.column_stack((x, y, z)), bush, ridge + + +def test_refine_drops_bushes_keeps_ridge() -> None: + xyz, bush, ridge = _scene() + kept = refine_ground_mask(xyz, np.ones(len(xyz), dtype=bool)) + print(kept[bush].mean(), kept[ridge].mean(), kept[~bush].mean()) + assert kept[bush].mean() < 0.1 # 덤불 혹 90 % 넘게 지움 + assert kept[ridge].mean() > 0.95 # 능선 안 깎음 + assert kept[~bush].mean() > 0.97 # 땅 점 거의 그대로 + + +def test_refine_never_adds_points() -> None: + xyz, _, _ = _scene() + mask = RNG.random(len(xyz)) < 0.5 + kept = refine_ground_mask(xyz, mask) + assert not (kept & ~mask).any() + assert refine_ground_mask(xyz, np.zeros(len(xyz), dtype=bool)).sum() == 0 + + +def test_refine_flow_switch(monkeypatch: pytest.MonkeyPatch) -> None: + xyz, _, _ = _scene() + bounds = np.array([[0, 120], [0, 120], [0, 50]], dtype=np.float64) + data = {"xyz": xyz, "bounds": bounds, "classification": np.full(len(xyz), 2, np.uint8)} + # 기본 끔 = 지금 길 그대로 + assert ground.resolve_auto_source_filters(data) == ["classification", "ptd"] + monkeypatch.setattr(ground, "SURFACE_REFINE_FLOW", True) + assert ground.resolve_auto_source_filters(data) == ["classification_refine", "ptd_refine"] + data["classification"][:] = 0 + assert ground.resolve_auto_source_filters(data) == ["ptd_refine"] + assert ground.base_filter("ptd_refine") == "ptd" + assert ground.base_filter("ptd") is None + assert {"classification_refine", "ptd_refine"} <= set(ground.available_filters()) + # 바탕 마스크를 넘기면 바탕 필터를 다시 돌리지 않고 다듬기만 + base = np.ones(len(xyz), dtype=bool) + refined = ground.run_ground_filter("ptd_refine", data, base) + assert refined.sum() < base.sum() + + +def test_spike_stat_sees_bumps() -> None: + yy, xx = np.mgrid[0:100, 0:100].astype(float) + z = 0.3 * xx + valid = np.ones(z.shape, dtype=bool) + assert spike_stats(z, valid)["spike_pct"] == 0.0 + z[40:42, 40:42] += 1.5 + stats = spike_stats(z, valid) + assert stats["spike_count"] == 1 and stats["spike_pct"] > 0 diff --git a/resources/tester/test_763_3_surface_change.py b/resources/tester/test_763_3_surface_change.py new file mode 100644 index 000000000..7c574173e --- /dev/null +++ b/resources/tester/test_763_3_surface_change.py @@ -0,0 +1,266 @@ +"""763-3 (요구 605) B04 분석 · 조합 짓기 = 보기만 · 다른 서피스 [확정] = 업로드와 같은 전처리 체인 · +같은 서피스 다시 [확정] = 다시 셈 없음 · 체인 실패에도 추천 미리 셈 · B05~B09 는 서피스를 안 바꿈. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_763_3_surface_change.py -q +""" + +import asyncio +import re +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 + +import pytest + +from B03_FileInput import B03_FileInput_Service_SurfaceChange as change +from B04_PreProcess import B04_PreProcess_Router as b04 +from B04_PreProcess.B04_PreProcess_Schema import SurfaceAnalyzeRequest, SurfaceConfirmRequest + +ROOT = Path(__file__).resolve().parents[2] +CONFIRMED = { + "source_filter": "classification", + "method": "implicit", + "smooth": False, + "contour_interval_m": 1.0, +} + + +class _Cursor: + def __init__(self, log): + self.log = log + + async def __aenter__(self): + return self + + async def __aexit__(self, *_a): + return False + + async def execute(self, sql, *_a): + self.log.append(" ".join(str(sql).split())) + + async def fetchone(self): + return None + + +class _Connection: + def __init__(self, log): + self.log = log + + def cursor(self, *_a): + return _Cursor(self.log) + + async def begin(self): + self.log.append("BEGIN") + + async def commit(self): + self.log.append("COMMIT") + + async def rollback(self): + self.log.append("ROLLBACK") + + +class _Pool: + def __init__(self, log): + self.log = log + + @asynccontextmanager + async def acquire(self): + yield _Connection(self.log) + + +def _async(value): + async def inner(*_a, **_k): + return value + + return inner + + +@pytest.fixture +def b04_world(tmp_path, monkeypatch): + log: list[str] = [] + calls: dict[str, list] = {"progress": [], "chain": [], "confirm": [], "contour": []} + las = tmp_path / "raw.las" + las.write_bytes(b"") + monkeypatch.setattr(b04, "get_db_pool", lambda: _Pool(log)) + monkeypatch.setattr(b04, "get_project_storage_relative_path", _async("x")) + monkeypatch.setattr(b04, "resolve_stored_project_path", lambda _p: str(tmp_path)) + monkeypatch.setattr(b04, "get_input_file", _async({"raw_file_path": "raw.las"})) + monkeypatch.setattr(b04, "list_project_point_cloud_paths", _async([las])) + monkeypatch.setattr( + b04, + "run_surface_analysis", + lambda *_a, **_k: {"ground_summary": {}, "manifest": {"status": "ok"}}, + ) + monkeypatch.setattr(b04, "save_surface_analysis_to_db", _async([1, 2])) + monkeypatch.setattr(b04, "confirmed_surface_params", _async(dict(CONFIRMED))) + monkeypatch.setattr( + b04, "write_surface_progress", lambda _r, p, stage, _m: calls["progress"].append(stage) + ) + monkeypatch.setattr( + b04, + "list_surface_models", + _async( + [ + { + "id": 7, + "model_type": "implicit", + "generation_params": {"source_filter": "classification"}, + }, + {"id": 8, "model_type": "implicit", "generation_params": {"source_filter": "csf"}}, + ] + ), + ) + + async def confirm(_c, _pid, model_id, selection): + calls["confirm"].append((model_id, selection["source_filter"])) + + async def contour(_c, _pid, value): + calls["contour"].append(value) + + async def chain(pid, model_id): + calls["chain"].append(model_id) + + monkeypatch.setattr(b04, "confirm_surface_selection", confirm) + monkeypatch.setattr(b04, "update_contour_interval_param", contour) + monkeypatch.setattr(change, "run_surface_change_chain", chain) + calls["log"] = log + return calls + + +def test_분석은_보기만_단계_확정_그대로(b04_world) -> None: + request = SurfaceAnalyzeRequest(input_file_id=3, source_filters=["csf"], methods=["implicit"]) + answer = asyncio.run(b04.analyze_surface(uuid4(), request)) + assert answer.surface_model_ids == [1, 2] + sql = " | ".join(b04_world["log"]) + assert "project_workflow_stages" not in sql and "surface_models" not in sql + assert b04_world["progress"][-1] == "completed" # 확정 그대로 — 「확정하세요」 아님 + source = (ROOT / "B04_PreProcess/B04_PreProcess_Router.py").read_text(encoding="utf-8") + body = source.split("async def analyze_surface(", 1)[1].split("\n@router.", 1)[0] + for name in ("start_stage", "fail_stage", "update_stage_progress", "schedule_precompute"): + assert name not in body, name + + +def test_조합_다시_적어도_확정_행_그대로() -> None: + source = (ROOT / "B04_PreProcess/B04_PreProcess_Repository.py").read_text(encoding="utf-8") + assert "status = IF(status = 'CONFIRMED', 'CONFIRMED', 'COMPLETE')" in source + assert "clear_confirmed_surface_models" not in source + + +def _confirm(model_id: int, contour: float | None = None): + request = SurfaceConfirmRequest(model_id=model_id, smooth=False, contour_interval_m=contour) + + async def run(): + answer = await b04.confirm_surface(uuid4(), request, {}) + await asyncio.sleep(0) # 백그라운드 체인 한 번 돌림 + return answer + + return asyncio.run(run()) + + +def test_같은_서피스_다시_확정은_다시_셈_없음(b04_world) -> None: + _confirm(7, 2.0) + assert b04_world["confirm"] == [] and b04_world["chain"] == [] + assert b04_world["contour"] == [2.0] + + +def test_다른_서피스_확정은_전처리_체인(b04_world) -> None: + _confirm(8) + assert b04_world["confirm"] == [(8, "csf")] + assert b04_world["chain"] == [8] + + +def test_서피스_바꿈_체인_차례(tmp_path, monkeypatch) -> None: + import B03_FileInput.B03_FileInput_Repository as repo + import B03_FileInput.B03_FileInput_Service_Chain as chain + import B05_Profile.B05_Profile_Router_RouteInitial as router + import common_util.common_util_initial_snapshot as snapshot + import common_util.common_util_project_reset as reset + import common_util.common_util_storage as storage + import common_util.common_util_workflow_state as state + import config.config_db as db + + log: list = [] + + async def purge(_c, _pid, _root, keep_preprocess=False): + log.append(("purge", keep_preprocess)) + + async def stages(_cursor, _pid, first_stage=1): + log.append(("stages", first_stage)) + + async def auto(_pid, surface_model_id=None): + log.append(("auto", surface_model_id)) + + monkeypatch.setattr(db, "get_db_pool", lambda: _Pool([])) + monkeypatch.setattr(repo, "get_project_storage_relative_path", _async("x")) + monkeypatch.setattr(storage, "resolve_stored_project_path", lambda _p: str(tmp_path)) + monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda _r: log.append("snapshot")) + monkeypatch.setattr(reset, "purge_project_outputs", purge) + monkeypatch.setattr(state, "reset_stages_after_input_change", stages) + monkeypatch.setattr(chain, "run_auto_design_chain", auto) + monkeypatch.setattr(router, "schedule_precompute", lambda pid: log.append("precompute")) + asyncio.run(change.run_surface_change_chain(uuid4(), 8)) + assert log == ["snapshot", ("purge", True), ("stages", 2), ("auto", 8)] + + async def broken(*_a, **_k): + raise RuntimeError("db") + + log.clear() + monkeypatch.setattr(reset, "purge_project_outputs", broken) + asyncio.run(change.run_surface_change_chain(uuid4(), 8)) + assert log == ["snapshot", "precompute"] # 초기화 실패 = 체인 없음 · 추천만 새 서피스로 + + +@pytest.mark.parametrize("name", ["run_auto_design_chain", "run_redesign_chain"]) +def test_체인_실패_길에도_미리_셈(monkeypatch, name) -> None: + import B03_FileInput.B03_FileInput_Service_Chain as chain + import B05_Profile.B05_Profile_Router_RouteInitial as router + import config.config_db as db + + class _Broken: + def acquire(self): + raise RuntimeError("db down") + + scheduled: list = [] + monkeypatch.setattr(db, "get_db_pool", lambda: _Broken()) + monkeypatch.setattr(router, "schedule_precompute", scheduled.append) + project = uuid4() + args = (project,) if name == "run_auto_design_chain" else (project, 1, {}) + asyncio.run(getattr(chain, name)(*args)) + assert scheduled == [project] + + +def test_LAS_나중_올리기_끝에_미리_셈() -> None: + source = (ROOT / "B05_Profile/B05_Profile_Router_Lidar.py").read_text(encoding="utf-8") + work = source.split("async def work(", 1)[1].split("return lidar_upload.start_job", 1)[0] + assert "schedule_precompute(project_id)" in work + + +#: B05~B09 에서 확정 서피스를 바꿀 수 있는 부름 — 남은 곳은 까닭 있는 둘만(요구 605-2) +_SURFACE_WRITES = re.compile( + r"\b(confirm_surface_selection|confirm_surface_model|merge_surface_confirmation_params|" + r"switch_design_terrain|stash_las_selection|run_surface_change_chain)\(" +) +_ALLOWED = { + # 지형 칩(626-1) — 사용자 답 기다림(763 ①) · 추천 [적용]은 지형을 안 넘김(아래 시험) + "B05_Profile/B05_Profile_Router_Replan.py", + # 라이다 확정값만 적어 둠 · 설계 지형 그대로(637-2) + "B05_Profile/B05_Profile_Router_Lidar.py", +} + + +def test_B05_B09_확정은_서피스를_안_바꿈() -> None: + found = set() + for folder in ( + "B05_Profile", + "B06_Section", + "B07_DesignDetail", + "B08_Quantity", + "B09_Estimation", + ): + for path in (ROOT / folder).glob("*.py"): + if _SURFACE_WRITES.search(path.read_text(encoding="utf-8")): + found.add(path.relative_to(ROOT).as_posix()) + assert found <= _ALLOWED, found - _ALLOWED + replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8") + save = replan.split("async def _save_route(", 1)[1].split("\nasync def ", 1)[0] + assert "request.terrain" not in save # 추천 [적용] = 선만 · 설계 지형 그대로(605-2) diff --git a/resources/tester/test_763_4_precompute_resume.py b/resources/tester/test_763_4_precompute_resume.py new file mode 100644 index 000000000..6cbe0e4f3 --- /dev/null +++ b/resources/tester/test_763_4_precompute_resume.py @@ -0,0 +1,92 @@ +"""763-4 추천 미리 셈이 끊기면 이어 셈 — 진행 기록 · 한 갈래 409 는 그 갈래만 건너뜀 · 화면 글 짝. + +./venv/Scripts/python.exe -m pytest resources/tester/test_763_4_precompute_resume.py -q +""" + +import asyncio +import json +import time +from pathlib import Path +from uuid import uuid4 + +from fastapi.responses import JSONResponse +from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처 + +from B05_Profile import B05_Profile_Router_RouteInitial as router +from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES + +ROOT = Path(__file__).resolve().parents[2] + + +def _record(root: Path, running: bool, age_s: float) -> None: + path = root / router.JOB_FILE + path.parent.mkdir(parents=True, exist_ok=True) + body = {"running": running, "done": 3, "total": 10, "mode": "gentle"} + path.write_text(json.dumps({**body, "updated": time.time() - age_s}), encoding="utf-8") + + +def test_진행_기록_읽기(tmp_path) -> None: + pid = uuid4() + assert router.precompute_progress(pid, tmp_path)["running"] is False + _record(tmp_path, True, 5) # 다른 서버가 도는 중 + state = router.precompute_progress(pid, tmp_path) + assert state["running"] and not state["interrupted"] and state["done"] == 3 + _record(tmp_path, True, router.JOB_STALE_S + 1) # 재시동으로 끊김 + state = router.precompute_progress(pid, tmp_path) + assert not state["running"] and state["interrupted"] + _record(tmp_path, False, 5) + assert router.precompute_progress(pid, tmp_path)["interrupted"] is False + + +def test_끊김_기록_409_이어_셈_한_번(wired, monkeypatch) -> None: # noqa: F811 + scheduled: list = [] + monkeypatch.setattr(router, "schedule_precompute", scheduled.append) + _record(wired.root, True, router.JOB_STALE_S + 1) + for _ in range(3): # 화면이 여러 번 물어도 예약 한 번(기록이 곧바로 「도는 중」) + answer = _ask(False, mode="gentle") + assert answer.status_code == 409 + assert json.loads(answer.body)["message"] == router.NOT_PREPARED + assert len(scheduled) == 1 + assert router.precompute_progress(uuid4(), wired.root)["running"] is True + + +def test_끊김_아니면_예약_없음(wired, monkeypatch) -> None: # noqa: F811 + scheduled: list = [] + monkeypatch.setattr(router, "schedule_precompute", scheduled.append) + _ask(False, mode="gentle") # 기록 없음 + _record(wired.root, True, 5) # 다른 서버가 셈 중 — 안 겹침 + _ask(False, mode="gentle") + assert scheduled == [] + + +def test_미리_셈_기록_남기고_끝(wired) -> None: # noqa: F811 + asyncio.run(router.run_precompute(uuid4())) + saved = json.loads((wired.root / router.JOB_FILE).read_text(encoding="utf-8")) + assert saved["running"] is False and saved["done"] == saved["total"] == len(DEFAULT_MODES) + + +def test_한_갈래_409_는_그_갈래만_건너뜀(wired, monkeypatch) -> None: # noqa: F811 + real = router.compute_initial + asked: list[str] = [] + + async def flaky(project_id, request, precompute=False): + asked.append(request.mode) + if request.mode == "gentle": + return JSONResponse(status_code=409, content={"message": "x"}) + return await real(project_id, request, precompute) + + monkeypatch.setattr(router, "compute_initial", flaky) + asyncio.run(router.precompute_defaults(uuid4())) + assert sorted(asked) == sorted(DEFAULT_MODES) + assert wired.count == len(DEFAULT_MODES) - 1 + + +def test_화면_머리말_서버_글과_같음() -> None: + preview = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts").read_text( + encoding="utf-8" + ) + head = preview.split('const NOT_PREPARED_HEAD = "', 1)[1].split('"', 1)[0] + assert router.NOT_PREPARED.startswith(head) + assert "fetchPrecomputeProgress(" in preview and "recompute: true" in preview + assert 'okLabel: "지금 셈"' in preview and "lines.clear()" in preview + assert "