From 437333c7af158e4859db4c234cbb54ffba3e9b16 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 21:25:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(640-2):=20implicit=20=EC=A0=90=20=EC=88=98?= =?UTF-8?q?=20=3D=20=EC=A7=80=EB=A9=B4=EC=A0=90=20=EB=B9=84=EC=9C=A8=20?= =?UTF-8?q?=C2=B7=20=EA=B2=A9=EC=9E=90=20=EC=B9=B8=20=EB=BD=91=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=84=A4=EC=A0=95=20=EC=A7=80=EB=AC=B8=20=EB=B0=94?= =?UTF-8?q?=EB=80=8C=EB=A9=B4=20=EB=8B=A4=EC=8B=9C=20=EC=A7=80=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SURFACE_IMPLICIT_POINT_RATIO 0.175 · _MIN_POINTS 5 만 · _MAX_POINTS 200 만(옛 _MAX_POINTS_PER_TILE 걷음) - TerrainContext.grid_sample — 칸 = √(외곽 넓이 / 점 수) · 씨앗 558 · 칸마다 하나 · 모자라면 무작위 채움(sample() 그대로) - 짓기 캐시 흠 — manifest 항목 완료일 때만 재사용(지문 바뀐 뒤 implicit · nurbs · meshfree 옛 파일 재사용 막음) - 지킴 시험 test_640_lidar_default 점 수 · 격자 뽑기 · 지문 다시 지음 Co-Authored-By: Claude Opus 5.5 --- .../B04_PreProcess_Engine_ModelBuild.py | 12 +++- .../B04_PreProcess_Engine_ModelContext.py | 26 +++++++++ .../B04_PreProcess_Engine_Pipeline.py | 24 +++----- config/config_system_terrain.py | 11 ++-- resources/tester/test_640_lidar_default.py | 55 +++++++++++++++++++ 5 files changed, 105 insertions(+), 23 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py b/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py index baa33c69f..f80d73b91 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py +++ b/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py @@ -168,11 +168,19 @@ def build_nurbs( ) +def implicit_point_count(ground_count: int, config: dict[str, Any]) -> int: + """implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부.""" + minimum = int(config["implicit_min_points"]) + if ground_count <= minimum: + return int(ground_count) + ratio = round(ground_count * float(config["implicit_point_ratio"])) + return int(min(max(ratio, minimum), int(config["implicit_max_points"]))) + + def build_implicit( context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback ) -> dict[str, Any]: - maximum = max(100, int(context.config["implicit_max_points_per_tile"])) - points = context.sample(maximum) + points = context.grid_sample(implicit_point_count(context.source_count, context.config)) unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True) points = points[unique_indices] if len(points) < 4: diff --git a/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py b/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py index 13300da3c..a5efb1ca3 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py +++ b/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py @@ -29,6 +29,8 @@ ProgressCallback = Callable[[int], None] # 대용량 포인트 배치 처리 크기 _BATCH_SIZE = 500_000 +# implicit 격자 칸 뽑기 씨앗 — 고정이라 다시 지어도 같은 중심점 +IMPLICIT_SAMPLE_SEED = 558 # NURBS 평활 계수 — 제어점당 허용 잔차제곱(m²). # 0.01(RMS 0.1m)은 요구가 너무 빡빡해 FITPACK이 제어점 수(122x113)보다 많은 @@ -262,6 +264,30 @@ class TerrainContext: self._samples[maximum] = points return points + def grid_sample(self, count: int, seed: int = IMPLICIT_SAMPLE_SEED) -> np.ndarray: + """implicit 전용 — 격자 칸(√(외곽 넓이 / 점 수))마다 하나 · 모자라면 무작위로 채움. + + 파일 차례 뽑기는 점 많은 트인 곳에 몰려 숲 밑 성긴 곳이 빔(요구 558). 씨앗 고정. + """ + indices = self.indices() + if len(indices) <= count: + return np.asarray(self.xyz[indices], dtype=np.float32) + _, _, resolution, footprint = self.footprint() + cell = max(math.sqrt(float(footprint.sum()) * resolution * resolution / count), 0.01) + xy = np.asarray(self.xyz[indices][:, :2], dtype=np.float64) + rng = np.random.default_rng(seed) + order = rng.permutation(len(indices)) + gx = ((xy[order, 0] - self.bounds[0, 0]) // cell).astype(np.int64) + gy = ((xy[order, 1] - self.bounds[1, 0]) // cell).astype(np.int64) + _, first = np.unique(gy * (int(gx.max()) + 1) + gx, return_index=True) + chosen = order[first] + if len(chosen) > count: + chosen = rng.choice(chosen, count, replace=False) + elif len(chosen) < count: + rest = np.setdiff1d(np.arange(len(indices)), chosen, assume_unique=True) + chosen = np.concatenate([chosen, rng.choice(rest, count - len(chosen), replace=False)]) + return np.asarray(self.xyz[indices[np.sort(chosen)]], dtype=np.float32) + def footprint(self) -> tuple[float, float, float, np.ndarray]: if self._footprint is not None: return self._footprint diff --git a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py index 98406aaa0..f2f8766bf 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py @@ -151,7 +151,13 @@ _REPRESENTATIONS = { def _cache_is_valid( output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any] ) -> bool: - """디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다.""" + """디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다. + + manifest 항목이 완료여야 함 — 설정 지문이 바뀌면 manifest 가 비므로 파일만 남은 + 옛 결과(평활 없는 implicit · nurbs · meshfree)를 다시 짓는다(요구 558). + """ + if entry.get("status") != "completed": + return False ext = "ply" if method == "meshfree" else "glb" files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and ( output_dir / f"{stem}.npz" @@ -236,22 +242,6 @@ def _build_all_terrain_models( entry = filter_entry["methods"].get(method, {}) if not force and _cache_is_valid(output_dir, stem, method, entry, config): - if entry.get("status") != "completed": - entry.update( - { - "status": "completed", - "representation": _REPRESENTATIONS[method], - "model_file": f"{stem}.npz", - "preview_file": f"{stem}_preview." - + ("ply" if method == "meshfree" else "glb"), - "preview_media_type": "application/octet-stream" - if method == "meshfree" - else "model/gltf-binary", - "error": None, - } - ) - filter_entry["methods"][method] = entry - _write_json_file(manifest_path, manifest) interval = float(config.get("contour_interval_meters", 1.0)) contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json" needs_contours = not contour_path.exists() diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 99fc5b1df..e28ab794b 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -116,9 +116,10 @@ SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0 SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") ) -SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( - os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000") -) +# implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부(요구 558). +SURFACE_IMPLICIT_POINT_RATIO = float(os.getenv("SURFACE_IMPLICIT_POINT_RATIO", "0.175")) +SURFACE_IMPLICIT_MIN_POINTS = int(os.getenv("SURFACE_IMPLICIT_MIN_POINTS", "50000")) +SURFACE_IMPLICIT_MAX_POINTS = int(os.getenv("SURFACE_IMPLICIT_MAX_POINTS", "2000000")) SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1")) SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000")) SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15")) @@ -196,7 +197,9 @@ def build_surface_model_config() -> dict: "nurbs_degree": SURFACE_NURBS_DEGREE, "nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M, "nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS, - "implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE, + "implicit_point_ratio": SURFACE_IMPLICIT_POINT_RATIO, + "implicit_min_points": SURFACE_IMPLICIT_MIN_POINTS, + "implicit_max_points": SURFACE_IMPLICIT_MAX_POINTS, "implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING, "meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS, "meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M, diff --git a/resources/tester/test_640_lidar_default.py b/resources/tester/test_640_lidar_default.py index 5e58c1042..f92ee1b75 100644 --- a/resources/tester/test_640_lidar_default.py +++ b/resources/tester/test_640_lidar_default.py @@ -12,6 +12,8 @@ from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import ( 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 @@ -79,3 +81,56 @@ def test_implicit_은_기본_필터에만(tmp_path: Path) -> None: 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