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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user