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
@@ -4,6 +4,7 @@
② 1 m 등고선 꺾임 /km — 2 m 마다 다시 찍어 30° 넘게 꺾인 곳
③ 땅 점 빈 곳 % — 2 m 안 지면점 없는 칸 · 그중 기울기 > 15° 칸
④ 필터 차 % — 같은 표현 · 짝 필터 모델과 |차| > 0.5 m 넓이
⑤ 메운 넓이 m² · % — 외곽 안 구멍을 막 펴기로 메운 칸(766-2 · 같은 필터 dtm `filled_mask`)
저장 = `models/quality_{필터}_{표현}.json`(파생 저장본 — 모델 · 짝 모델 지문이 다를 때만 다시 셈).
"""
@@ -21,7 +22,7 @@ from common_util.common_util_json import atomic_write_json
logger = logging.getLogger(__name__)
QUALITY_VERSION = 1
QUALITY_VERSION = 2
SPIKE_M = 0.5
SPIKE_WINDOW = 5
CONTOUR_INTERVAL_M = 1.0
@@ -132,6 +133,20 @@ def diff_pct(z: np.ndarray, valid: np.ndarray, other_path: Path) -> float | None
return round(100.0 * int(((np.abs(z - other_z) > DIFF_M) & both).sum()) / area, 2)
def filled_stats(
models_dir: Path, filter_key: str, valid: np.ndarray, cell: float
) -> dict[str, Any]:
with np.load(models_dir / f"dtm_{filter_key}.npz") as data:
filled = np.asarray(data["filled_mask"], dtype=bool) if "filled_mask" in data else None
if filled is None or filled.shape != valid.shape:
return {"filled_m2": None, "filled_pct": None}
count, area = int((filled & valid).sum()), int(valid.sum())
return {
"filled_m2": round(count * cell * cell, 1),
"filled_pct": round(100.0 * count / area, 2) if area else None,
}
def _pair(models_dir: Path, filter_key: str, method: str) -> Path | None:
for other in _PAIRS.get(filter_key, ()):
path = _grid_path(models_dir, other, method)
@@ -181,6 +196,7 @@ def ensure_quality(
result.update({k: stored.get(k) for k in ("gap_pct", "gap_steep_pct")})
result["diff_against"] = pair.name if pair else None
result["diff_pct"] = diff_pct(z, valid, pair) if pair else None
result.update(filled_stats(models_dir, filter_key, valid, cell))
atomic_write_json(path, result)
return result