feat(640-2): implicit 점 수 = 지면점 비율 · 격자 칸 뽑기 · 설정 지문 바뀌면 다시 지음

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 21:25:08 +09:00
co-authored by Claude Opus 5.5
parent 366fd7a700
commit 437333c7af
5 changed files with 105 additions and 23 deletions
@@ -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:
@@ -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
@@ -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()
+7 -4
View File
@@ -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,
@@ -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