"""640 라이다 기본 고름 · implicit 점 수 · 격자 칸 뽑기 · 지문 · 틈 닫기 (요구 558). 서버 함수만 — 작은 합성 점군 · 임시 폴더. """ from pathlib import Path import numpy as np from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import ( classified_coverage, has_classified_coverage, ) from B04_PreProcess.B04_PreProcess_Engine_Ground import resolve_auto_source_filters from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import implicit_point_count from B04_PreProcess.B04_PreProcess_Engine_ModelContext import TerrainContext from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models from config import config_system as cfg def _plane(size_m: float = 60.0, spacing_m: float = 0.5) -> np.ndarray: axis = np.arange(0.0, size_m, spacing_m) x, y = np.meshgrid(axis, axis) z = 100.0 + 0.2 * x + 0.1 * np.sin(y / 5.0) return np.column_stack([x.ravel(), y.ravel(), z.ravel()]) def _structured(points: np.ndarray, classification: np.ndarray | None = None) -> dict: data = {"xyz": points, "bounds": np.array([points.min(axis=0), points.max(axis=0)]).T} if classification is not None: data["classification"] = classification return data def test_확정_기본값은_implicit_평활_끔() -> None: assert cfg.SURFACE_CONFIRM_DEFAULT_METHOD == "implicit" assert cfg.SURFACE_CONFIRM_DEFAULT_SMOOTH is False assert cfg.SURFACE_AUTO_METHODS == ("dtm",) # D03 자체 라이다는 dtm 만 그대로 def test_일부만_분류된_LAS_는_자동_기본에서_ptd() -> None: points = _plane(100.0) partial = np.ones(len(points), dtype=np.uint8) partial[(points[:, 0] < 30) & (np.arange(len(points)) % 4 == 0)] = 2 # 비율 7 % · 덮임 30 % assert classified_coverage(_structured(points, partial)) < 0.5 assert resolve_auto_source_filters(_structured(points, partial)) == ["ptd"] spread = np.ones(len(points), dtype=np.uint8) spread[::37] = 2 # 비율 2.7 % · 덮임 100 % assert classified_coverage(_structured(points, spread)) == 1.0 assert resolve_auto_source_filters(_structured(points, spread)) == ["classification", "ptd"] def test_덮임은_저장본을_다시_씀(tmp_path: Path) -> None: points = _plane(40.0) classes = np.full(len(points), 2, dtype=np.uint8) (tmp_path / "structured.npz").write_bytes(b"x") assert has_classified_coverage(_structured(points, classes), tmp_path) assert (tmp_path / "classification_coverage.json").is_file() class _NoXyz(dict): def __getitem__(self, key): # type: ignore[no-untyped-def] assert key != "xyz", "저장본이 맞으면 점군을 풀지 않아야 함" return dict.__getitem__(self, key) assert has_classified_coverage(_NoXyz(_structured(points, classes)), tmp_path) def _config(**override: object) -> dict: config = cfg.build_surface_model_config() config.update({"source_filters": ["a", "b"], "precompute": ["dtm", "implicit"]}) config.update(override) return config def test_implicit_은_기본_필터에만(tmp_path: Path) -> None: points = _plane() data = _structured(points) masks = {"a": np.ones(len(points), bool), "b": np.arange(len(points)) % 2 == 0} manifest = build_all_terrain_models(data, masks, tmp_path, _config()) assert set(manifest["source_filters"]["a"]["methods"]) == {"dtm", "implicit"} assert set(manifest["source_filters"]["b"]["methods"]) == {"dtm"} assert not (tmp_path / "implicit_b.npz").exists() def test_implicit_점_수_비율_하한_상한() -> None: config = cfg.build_surface_model_config() assert (config["implicit_point_ratio"], config["implicit_min_points"]) == (0.175, 50_000) assert config["implicit_max_points"] == 2_000_000 assert implicit_point_count(1_140_716, config) == 199_625 # 용화 분류 assert implicit_point_count(768_273, config) == 134_448 # 용화 CSF assert implicit_point_count(100_000, config) == 50_000 # 하한 assert implicit_point_count(30_000, config) == 30_000 # 하한보다 적으면 전부 assert implicit_point_count(50_000_000, config) == 2_000_000 # 상한 def test_격자_칸_뽑기는_고르고_씨앗_고정() -> None: rng = np.random.default_rng(1) dense = rng.uniform(0, 20, (200_000, 2)) # 왼쪽 아래 트인 곳 — 점 몰림 sparse = rng.uniform(0, 100, (30_000, 2)) # 전체 성긴 숲 밑 xy = np.vstack([dense, sparse]) points = np.column_stack([xy, np.zeros(len(xy))]) context = TerrainContext( xyz=points, mask=np.ones(len(points), bool), bounds=np.array([points.min(axis=0), points.max(axis=0)]).T, config=cfg.build_surface_model_config(), ) picked = context.grid_sample(5_000) assert len(picked) == 5_000 and len(np.unique(picked[:, :2], axis=0)) == 5_000 assert np.array_equal(picked, context.grid_sample(5_000)) in_dense = np.count_nonzero((picked[:, 0] < 20) & (picked[:, 1] < 20)) / len(picked) file_order = context.sample(5_000) in_dense_file = np.count_nonzero((file_order[:, 0] < 20) & (file_order[:, 1] < 20)) assert in_dense < 0.1 < 0.5 < in_dense_file / len(file_order) # 파일 차례는 몰림 · 격자는 고름 assert len(context.grid_sample(300_000)) == len(points) # 모자라면 전부 def test_설정_지문이_바뀌면_implicit_다시_지음(tmp_path: Path) -> None: points = _plane() data = _structured(points) masks = {"a": np.ones(len(points), bool)} config = _config( source_filters=["a"], precompute=["implicit"], implicit_min_points=500, implicit_max_points=1_000, ) build_all_terrain_models(data, masks, tmp_path, config) with np.load(tmp_path / "implicit_a.npz") as old: assert len(old["center_z"]) == 1_000 changed = dict(config, implicit_max_points=2_000) manifest = build_all_terrain_models(data, masks, tmp_path, changed) with np.load(tmp_path / "implicit_a.npz") as new: assert len(new["center_z"]) == 2_000 assert manifest["source_filters"]["a"]["methods"]["implicit"]["center_count"] == 2_000 def test_외곽_틈_닫기_3m_는_성긴_띠를_살림() -> None: points = _plane(60.0) gap = (points[:, 0] > 28) & (points[:, 0] < 33) # 5 m 빈 띠 — 1 m 닫기면 두 덩이 points = points[~gap] def area(close_m: float) -> float: config = dict(cfg.build_surface_model_config(), footprint_gap_close_meters=close_m) context = TerrainContext( xyz=points, mask=np.ones(len(points), bool), bounds=np.array([points.min(axis=0), points.max(axis=0)]).T, config=config, ) return context.footprint_metadata()["footprint_area_m2"] assert cfg.SURFACE_FOOTPRINT_GAP_CLOSE_M == 3.0 assert area(3.0) > 1.8 * area(1.0) # 한 덩이(띠 포함) 대 큰 덩이 하나