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:
@@ -306,6 +306,10 @@ def run_surface_analysis(
|
||||
|
||||
for filter_key in source_filters:
|
||||
masks[filter_key] = _mask_for(filter_key)
|
||||
# 다듬기 판의 바탕 마스크 — 모델 외곽용(766-2) · 지면점 저장본은 안 지음
|
||||
outer_masks = {
|
||||
b: _mask_for(b) for k in source_filters if (b := base_filter(k)) not in (None, *masks)
|
||||
}
|
||||
ground_summary = summarize_masks(data, masks)
|
||||
# 지면점 비율은 필터가 조용히 실패해도 유일하게 드러나는 신호다 — 반드시 남긴다.
|
||||
for filter_key, entry in ground_summary.items():
|
||||
@@ -349,7 +353,12 @@ def run_surface_analysis(
|
||||
_report(70 + int(percent * 0.2), "surface_model", detail)
|
||||
|
||||
manifest = build_all_terrain_models(
|
||||
data, masks, models_dir, config, force=rebuild, progress=_model_progress
|
||||
data,
|
||||
{**outer_masks, **masks},
|
||||
models_dir,
|
||||
config,
|
||||
force=rebuild,
|
||||
progress=_model_progress,
|
||||
)
|
||||
logger.info(
|
||||
"B04 지표면 모델 빌드 완료: status=%s (%.1fs)",
|
||||
|
||||
@@ -98,6 +98,7 @@ def build_dtm(
|
||||
y=y_coords,
|
||||
z=z_grid,
|
||||
valid_mask=valid_grid,
|
||||
filled_mask=context.filled(resolution) & valid_grid,
|
||||
resolution=np.array([resolution], np.float32),
|
||||
)
|
||||
progress(75)
|
||||
@@ -182,7 +183,9 @@ def implicit_point_count(ground_count: int, config: dict[str, Any]) -> int:
|
||||
def build_implicit(
|
||||
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
|
||||
) -> dict[str, Any]:
|
||||
points = context.grid_sample(implicit_point_count(context.source_count, context.config))
|
||||
count = implicit_point_count(context.source_count, context.config)
|
||||
# 구멍(766-2)은 메운 dtm 칸을 중심점으로 더해 dtm 과 같은 면으로 이음
|
||||
points = np.concatenate([context.grid_sample(count), context.hole_guides()])
|
||||
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
|
||||
points = points[unique_indices]
|
||||
if len(points) < 4:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Contour import (
|
||||
contour_cache_is_stale,
|
||||
extract_contours,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import base_filter
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import BUILDERS
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
|
||||
MODEL_VERSION,
|
||||
@@ -231,7 +232,9 @@ def _build_all_terrain_models(
|
||||
mask = np.asarray(ground_masks[filter_key], dtype=bool)
|
||||
if len(mask) != len(xyz):
|
||||
raise ValueError(f"{filter_key} 마스크 길이가 XYZ 데이터와 다릅니다.")
|
||||
context = TerrainContext(xyz=xyz, mask=mask, bounds=bounds, config=config)
|
||||
# 다듬기 판은 바탕 필터 지면점으로 외곽을 그림 — 다듬기가 지운 자리 = 외곽 안 구멍(766-2)
|
||||
outer = ground_masks.get(base_filter(filter_key) or "")
|
||||
context = TerrainContext(xyz=xyz, mask=mask, bounds=bounds, config=config, outer_mask=outer)
|
||||
filter_entry = manifest["source_filters"].setdefault(
|
||||
filter_key, {"source_point_count": context.source_count, "methods": {}}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ interface SurfaceQuality {
|
||||
gap_steep_pct?: number | null;
|
||||
diff_pct?: number | null;
|
||||
diff_against?: string | null;
|
||||
filled_m2?: number | null;
|
||||
filled_pct?: number | null;
|
||||
}
|
||||
|
||||
function fmt(value: number | null | undefined, unit: string): string | null {
|
||||
@@ -80,12 +82,20 @@ function rows(quality: SurfaceQuality | null): ReturnType<typeof createKv> {
|
||||
{ label: "그중 급경사", value: fmt(quality?.gap_steep_pct, "%") },
|
||||
// 대상 이름은 길어 줄을 깨뜨림 — 값은 % 만 · 대상은 마우스 올림 글
|
||||
{ label: "필터 차", value: fmt(quality?.diff_pct, "%") },
|
||||
// 외곽 안 구멍을 둘레 지면에서 이어 메운 자리(766-2)
|
||||
{
|
||||
label: "메운 넓이",
|
||||
value:
|
||||
quality?.filled_m2 == null
|
||||
? null
|
||||
: `${fmt(quality.filled_m2, "m²")} · ${quality.filled_pct ?? 0} %`,
|
||||
},
|
||||
]);
|
||||
if (against) list.title = `필터 차 = ${optionLabel(against)} 모델과 0.5 m 넘게 다른 넓이`;
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 고른 모델의 품질 지표 넷 — 늦게 온 옛 응답은 버림. */
|
||||
/** 고른 모델의 품질 지표(넷 + 메운 넓이) — 늦게 온 옛 응답은 버림. */
|
||||
export function createQualityPanel(): {
|
||||
root: HTMLElement;
|
||||
show: (projectId: string, model: SurfaceModelSummary | undefined) => void;
|
||||
|
||||
@@ -205,6 +205,8 @@ def build_surface_model_config() -> dict:
|
||||
"footprint_gap_close_meters": SURFACE_FOOTPRINT_GAP_CLOSE_M,
|
||||
"boundary_inset_meters": SURFACE_BOUNDARY_INSET_M,
|
||||
"keep_largest_footprint": SURFACE_KEEP_LARGEST_FOOTPRINT,
|
||||
# 외곽 안 큰 구멍 메움 방식(766-2) — 바뀌면 모델 지문이 달라져 다시 지음
|
||||
"hole_fill": "laplace",
|
||||
"tile_size_meters": SURFACE_TILE_SIZE_M,
|
||||
"max_preview_vertices": SURFACE_MAX_PREVIEW_VERTICES,
|
||||
"tin_max_input_points": SURFACE_TIN_MAX_INPUT_POINTS,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""766-2 외곽 안 구멍 메움 지킴 시험 — 다듬기 판은 바탕 필터 외곽 · 구멍은 막 펴기로 매끈하게.
|
||||
|
||||
합성 비탈(평면) 120 × 120 m · 가운데 30 × 30 m 땅 점이 다 걸러짐(다듬기가 지운 자리 흉내).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import build_dtm, build_implicit
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import TerrainContext, fill_holes_smooth
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Quality import filled_stats
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
RNG = np.random.default_rng(7662)
|
||||
|
||||
|
||||
def _scene() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
count = 120 * 120 * 3
|
||||
x, y = RNG.uniform(0, 120, count), RNG.uniform(0, 120, count)
|
||||
xyz = np.column_stack((x, y, 0.3 * x + 0.1 * y + 100.0)).astype(np.float32)
|
||||
base = np.ones(count, dtype=bool)
|
||||
hole = (np.abs(x - 60) < 15) & (np.abs(y - 60) < 15)
|
||||
bounds = np.array([[0, 120], [0, 120], [90, 150]], dtype=np.float64)
|
||||
return xyz, base, base & ~hole, bounds
|
||||
|
||||
|
||||
def test_fill_keeps_plane_and_curved_slope() -> None:
|
||||
yy, xx = np.mgrid[0:60, 0:60].astype(float)
|
||||
known = np.ones(xx.shape, dtype=bool)
|
||||
known[20:40, 20:40] = False
|
||||
inside = np.ones(xx.shape, dtype=bool)
|
||||
for truth in (0.3 * xx + 0.1 * yy, 0.004 * (xx - 25) ** 2 + 0.002 * (yy - 35) ** 2):
|
||||
work = np.where(known, truth, 0.0)
|
||||
filled = fill_holes_smooth(work, known, inside, 3.0)
|
||||
assert filled.sum() == 14 * 14 # 땅 칸에서 3 칸 넘게 먼 자리만 메운 넓이로 셈
|
||||
assert np.abs(work - truth).max() < 0.1 # 둘레 높이 · 기울기 그대로 이어짐(계단 · 혹 없음)
|
||||
# 작은 틈(3 칸 안)은 메움 대상이 아님 — 지금처럼 가까운 칸 값
|
||||
small = np.ones(xx.shape, dtype=bool)
|
||||
small[10:13, 10:13] = False
|
||||
assert not fill_holes_smooth(xx.copy(), small, inside, 3.0).any()
|
||||
|
||||
|
||||
def test_refine_hole_inside_outer_is_filled(tmp_path) -> None:
|
||||
xyz, base, refined, bounds = _scene()
|
||||
config = build_surface_model_config()
|
||||
plain = TerrainContext(xyz=xyz, mask=refined, bounds=bounds, config=config)
|
||||
outer = TerrainContext(xyz=xyz, mask=refined, bounds=bounds, config=config, outer_mask=base)
|
||||
# 외곽 = 바탕 필터 그대로(넓히지 않음) · 구멍 칸만 메움
|
||||
base_ctx = TerrainContext(xyz=xyz, mask=base, bounds=bounds, config=config)
|
||||
assert outer.footprint()[3].sum() == base_ctx.footprint()[3].sum()
|
||||
resolution = float(config["dtm_grid_resolution_meters"])
|
||||
assert outer.filled(resolution).sum() > 400
|
||||
assert not base_ctx.filled(resolution).any()
|
||||
build_dtm(outer, tmp_path, "dtm_ptd_refine", lambda _: None)
|
||||
build_implicit(outer, tmp_path, "implicit_ptd_refine", lambda _: None)
|
||||
xy = np.column_stack([np.linspace(50, 70, 21), np.full(21, 60.0)])
|
||||
truth = 0.3 * xy[:, 0] + 0.1 * xy[:, 1] + 100.0
|
||||
for method in ("dtm", "implicit"):
|
||||
z, valid = build_surface_sampler(tmp_path, "ptd_refine", method, False).sample_xy(xy)
|
||||
assert valid.all(), method # 구멍 0
|
||||
# 둘레 비탈과 같은 면 — dtm 둘레 칸 = 칸 최저점(0.1~0.2 m 들쭉날쭉)이라 0.5 m 안
|
||||
assert np.abs(z - truth).max() < 0.5, method
|
||||
with np.load(tmp_path / "dtm_ptd_refine.npz") as data:
|
||||
valid = np.asarray(data["valid_mask"], dtype=bool)
|
||||
stats = filled_stats(tmp_path, "ptd_refine", valid, 1.0)
|
||||
assert 400 < stats["filled_m2"] < 900 and stats["filled_pct"] > 0
|
||||
# 바탕 외곽 없이(옛 길)는 구멍 자리가 외곽 밖 — 구멍이 열린 만이면 비어 있었음
|
||||
assert plain.footprint()[3].sum() <= outer.footprint()[3].sum()
|
||||
Reference in New Issue
Block a user