Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-11-03 16:07:02 +09:00
8 changed files with 175 additions and 8 deletions
+10 -1
View File
@@ -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
+11 -1
View File
@@ -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;
+2
View File
@@ -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,
+68
View File
@@ -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()