From 08281bd7128758697c9050a4712b018c41901800 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 16:03:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(766-2):=20=EC=99=B8=EA=B3=BD=20=EC=95=88?= =?UTF-8?q?=20=EA=B5=AC=EB=A9=8D=EC=9D=84=20=EB=91=98=EB=A0=88=20=EC=A7=80?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=A7=A4=EB=81=88=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=A9=94=EC=9B=80=20=E2=80=94=20=EB=8B=A4=EB=93=AC?= =?UTF-8?q?=EA=B8=B0=20=ED=8C=90=20=EC=99=B8=EA=B3=BD=20=3D=20=EB=B0=94?= =?UTF-8?q?=ED=83=95=20=ED=95=84=ED=84=B0=20=EC=99=B8=EA=B3=BD=20=C2=B7=20?= =?UTF-8?q?=EC=8C=8D=EC=A1=B0=ED=99=94=20=EB=A9=94=EC=9B=80=20=C2=B7=20imp?= =?UTF-8?q?licit=20=EA=B8=B8=EC=9E=A1=EC=9D=B4=20=C2=B7=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=20=EC=A7=80=ED=91=9C=20=E3=80=8C=EB=A9=94=EC=9A=B4=20?= =?UTF-8?q?=EB=84=93=EC=9D=B4=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다듬기 판(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 --- B04_PreProcess/B04_PreProcess_Engine.py | 11 ++- .../B04_PreProcess_Engine_ModelBuild.py | 5 +- .../B04_PreProcess_Engine_ModelContext.py | 62 ++++++++++++++++- .../B04_PreProcess_Engine_Pipeline.py | 5 +- .../B04_PreProcess_Engine_Quality.py | 18 ++++- B04_PreProcess/B04_PreProcess_UI_Filter.ts | 12 +++- config/config_system_terrain.py | 2 + resources/tester/test_766_2_hole_fill.py | 68 +++++++++++++++++++ 8 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 resources/tester/test_766_2_hole_fill.py diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 37f9b0126..ceacb5df7 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -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)", diff --git a/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py b/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py index e7574be19..bc911b3dc 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py +++ b/B04_PreProcess/B04_PreProcess_Engine_ModelBuild.py @@ -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: diff --git a/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py b/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py index a5efb1ca3..79c1d0d1e 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py +++ b/B04_PreProcess/B04_PreProcess_Engine_ModelContext.py @@ -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 diff --git a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py index f9bb5f3c4..eab668b44 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Pipeline.py @@ -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": {}} ) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Quality.py b/B04_PreProcess/B04_PreProcess_Engine_Quality.py index ef1ec756d..5e45284fa 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Quality.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Quality.py @@ -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 diff --git a/B04_PreProcess/B04_PreProcess_UI_Filter.ts b/B04_PreProcess/B04_PreProcess_UI_Filter.ts index ca2741cf0..8df521a81 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Filter.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Filter.ts @@ -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 { { 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; diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 2d91375ec..cc9d4b260 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -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, diff --git a/resources/tester/test_766_2_hole_fill.py b/resources/tester/test_766_2_hole_fill.py new file mode 100644 index 000000000..2c9275c6c --- /dev/null +++ b/resources/tester/test_766_2_hole_fill.py @@ -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()