feat(766-2): 외곽 안 구멍을 둘레 지면에서 매끈하게 메움 — 다듬기 판 외곽 = 바탕 필터 외곽 · 쌍조화 메움 · implicit 길잡이 · 품질 지표 「메운 넓이」

- 다듬기 판(classification_refine · ptd_refine)은 바탕 필터 지면점으로 외곽을 그림 — 다듬기가 지운 자리 = 외곽 안 구멍 · 외곽 자체는 안 넓힘(엔진이 바탕 마스크를 모델 짓기에 넘김 · 지면점 저장본은 안 지음)
- dtm 격자: 땅 칸에서 틈 닫기(3 m) 넘게 먼 칸 + 그 둘레 빈 칸을 쌍조화(scikit-image inpaint_biharmonic · 이미 씀)로 메움 · 작은 틈은 지금처럼 가까운 칸 값
- 라플라스(막 펴기)는 버림 — 홀로 남은 땅 점마다 고깔이 생겨 용화 새 자리 튐 8.2 %(쌍조화 6.4 %)
- implicit: 메운 dtm 칸을 3 m 간격 길잡이 중심점으로 더함 — 구멍 칸 튐 3.1 → 0.8 %(dtm 과 같은 면) · 중심점 뽑기 칸은 자기 외곽 넓이 그대로
- 품질 지표 ⑤ 메운 넓이 m² · % — dtm `filled_mask` · B04 값 목록 한 줄 · QUALITY_VERSION 2
- 모델 지문에 hole_fill 더함 — 다음 전처리 때 모델을 다시 지음
- 지킴 시험 test_766_2_hole_fill — 평면 · 곡면 비탈 그대로 이음 · 작은 틈 안 건드림 · 바탕 외곽 그대로 · dtm · implicit 구멍 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 16:03:51 +09:00
co-authored by Claude Opus 5.5
parent 579e337892
commit 08281bd712
8 changed files with 175 additions and 8 deletions
@@ -9,7 +9,7 @@ import hashlib
import json
import logging
import math
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Callable
@@ -17,6 +17,7 @@ import numpy as np
import trimesh
from scipy import ndimage
from scipy.interpolate import RectBivariateSpline
from skimage.restoration import inpaint_biharmonic
from common_util.common_util_atomic import atomic_write_bytes, atomic_write_npz
@@ -221,6 +222,29 @@ def clip_and_compact_mesh(
return vertices[used], remap[kept_faces].astype(np.uint32)
def fill_holes_smooth(
z: np.ndarray, known: np.ndarray, inside: np.ndarray, min_gap_cells: float
) -> np.ndarray:
"""외곽 안 큰 구멍을 둘레 지면에서 매끈하게(쌍조화 · 판 굽힘) 이어 메움(766-2).
구멍 = 땅 칸에서 `min_gap_cells` 넘게 떨어진 칸 + 그 둘레 `min_gap_cells` 안 빈 칸(외곽 안) ·
작은 틈은 지금처럼 가까운 칸 값 · 둘레 높이 · 기울기를 이어 받음(평면 · 곡면 비탈 그대로) ·
외곽 밖 이웃도 같이 풀어 가까운 칸 값에 끌리지 않게 함.
돌려줌 = 땅 칸에서 `min_gap_cells` 넘게 먼 메운 칸(메운 넓이 지표 · implicit 길잡이).
"""
gap = ~known & inside
far = (ndimage.distance_transform_edt(~known) > min_gap_cells) & gap
if not far.any():
return far
square = np.ones((3, 3), dtype=bool)
reach = max(1, int(math.ceil(min_gap_cells)))
solve = ndimage.binary_dilation(far, square, iterations=reach) & gap
free = solve | (ndimage.binary_dilation(solve, square, iterations=reach) & ~inside & ~known)
# 라플라스(막 펴기)는 홀로 남은 땅 점마다 고깔이 생겨 튐이 늘어 버림(용화 실측)
z[free] = inpaint_biharmonic(z.astype(np.float64), free)[free]
return far
def grid_vertices(x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray) -> np.ndarray:
xx, yy = np.meshgrid(x_coords, y_coords)
return np.column_stack([xx.ravel(), yy.ravel(), z_grid.ravel()]).astype(np.float32)
@@ -238,10 +262,13 @@ class TerrainContext:
mask: np.ndarray
bounds: np.ndarray
config: dict[str, Any]
# 외곽을 그릴 지면점(다듬기 판 = 바탕 필터 마스크) — 다듬기가 지운 자리는 외곽 안 구멍으로 메움
outer_mask: np.ndarray | None = None
_indices: np.ndarray | None = None
_samples: dict[int, np.ndarray] = field(default_factory=dict)
_grids: dict[float, tuple[np.ndarray, np.ndarray, np.ndarray]] = field(default_factory=dict)
_footprint: tuple[float, float, float, np.ndarray] | None = None
_filled: dict[float, np.ndarray] = field(default_factory=dict)
@property
def source_count(self) -> int:
@@ -272,7 +299,13 @@ class TerrainContext:
indices = self.indices()
if len(indices) <= count:
return np.asarray(self.xyz[indices], dtype=np.float32)
_, _, resolution, footprint = self.footprint()
# 칸 = 이 필터 자기 외곽 넓이 기준 — 바탕 외곽(766-2)으로 넓혀도 원래 자리 중심점 그대로
own = (
self
if self.outer_mask is None
else replace(self, outer_mask=None, _footprint=None, _grids={}, _filled={})
)
_, _, resolution, footprint = own.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)
@@ -297,7 +330,7 @@ class TerrainContext:
cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1)
rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1)
occupied = np.zeros((rows, cols), dtype=bool)
indices = self.indices()
indices = self.indices() if self.outer_mask is None else np.flatnonzero(self.outer_mask)
for start in range(0, len(indices), _BATCH_SIZE):
points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32)
gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1)
@@ -385,10 +418,32 @@ class TerrainContext:
grid = grid[tuple(nearest)]
x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32)
y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32)
inside = self.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape(rows, cols)
gap_cells = float(self.config.get("footprint_gap_close_meters", 1.0)) / resolution
self._filled[resolution] = fill_holes_smooth(grid, ~missing, inside, gap_cells)
result = (x_coords, y_coords, grid)
self._grids[resolution] = result
return result
def filled(self, resolution: float) -> np.ndarray:
"""`grid(resolution)` 가 막 펴기로 메운 칸(766-2)."""
self.grid(resolution)
return self._filled[max(float(resolution), 0.05)]
def hole_guides(self) -> np.ndarray:
"""implicit 이 구멍도 같은 면으로 잇게 — 메운 dtm 칸을 틈 닫기 간격마다 하나씩 (N, 3)."""
resolution = float(self.config["dtm_grid_resolution_meters"])
x_coords, y_coords, z_grid = self.grid(resolution)
filled = self.filled(resolution)
spacing = float(self.config.get("footprint_gap_close_meters", 1.0))
step = max(1, int(round(spacing / resolution)))
lattice = np.zeros_like(filled)
lattice[::step, ::step] = True
rows, cols = np.nonzero(filled & lattice)
return np.column_stack([x_coords[cols], y_coords[rows], z_grid[rows, cols]]).astype(
np.float32
)
def preview_grid(
self, preferred_resolution: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
@@ -403,6 +458,7 @@ class TerrainContext:
def clear_caches(self) -> None:
self._samples.clear()
self._grids.clear()
self._filled.clear()
self._indices = None