feat(B04): 사이 등고선을 2D 가상 등고라인으로 뽑아 반복 이분한다

앞서는 등거리 '점'을 5셀 간격으로 흩뿌려 점 사이가 벌어진 곳에 평탄
삼각형이 남았고 한 단계(+2.5m)만 넣어 그 안에서 계단이 남았다.
사용자 지시(2D에서 사이 등고선을 먼저 만들고 3D화)대로 등거리선을
연속 셀 선으로 뽑아 새 레벨로 등록하고, 그 선들 사이를 다시 이분한다
(SHEET_SURFACE_MIDLINE_ROUNDS=2 → 5m 주곡선이 2.5m·1.25m 가상 등고로).
정점 간격도 5셀에서 2m로 좁혔다. 거리장은 라운드 간 재사용한다.

실측(c1bb453f): 평탄 셀 3.96%→2.48%(최초 9.35%), 경사 2% 미만 셀
27163→17145, 노선 |Δz| 2.56m 유지. 생성 16.4s→36.5s — 라운드 수는
config로 조절 가능.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 15:22:30 +09:00
co-authored by Claude Opus 5
parent 0dae8e3016
commit cb1c5261e2
2 changed files with 73 additions and 37 deletions
@@ -35,6 +35,7 @@ from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
SHEET_SURFACE_MIDLINE_MAX_M,
SHEET_SURFACE_MIDLINE_ROUNDS,
SURFACE_MAX_PREVIEW_VERTICES,
)
@@ -176,13 +177,17 @@ def _stream_breakline_vertices(
def _densify_between_contours(
spec: Any, burned: np.ndarray, present: list[float], cloud: Any
) -> Any:
"""인접 표고 등고선 쌍의 등거리 중간선을 중간 표고 정점으로 추가한다.
"""등고선 사이에 2D 가상 등고라인을 반복 이분으로 만들어 정점으로 추가한다.
등고선 정점만으로 Delaunay TIN을 치면 같은 표고 정점 3개짜리 평탄 삼각형이
굴곡부·능선에서 계단(terrace)을 만든다. 두 등고선의 "사이점"(등거리선)에
평균 표고 정점을 넣어 삼각형이 반드시 서로 다른 표고를 잇게 한다
(2026-08-30 사용자 지적). 최고 등고선 안쪽 봉우리·같은 표고 사이 골짜기
바닥은 짝이 없어 평탄으로 남는다 — 원천 데이터 한계.
등고선 정점만 Delaunay TIN 같은 표고 정점 3개짜리 평탄 삼각형이 굴곡부에
계단(terrace)을 만든다. 인접 표고 라인 쌍의 등거리선(사이 등고선)을 **연속 셀
선**으로 뽑아 새 레벨로 등록하고, 그 선들 사이를 다시 이분하는 식으로
`SHEET_SURFACE_MIDLINE_ROUNDS`회 반복한다 — 5m 주곡선이면 2.5m, 1.25m 가상
등고가 생겨 TIN이 어디서든 서로 다른 표고를 잇는다(2026-08-30 사용자 지시:
2D에서 사이 등고선을 먼저 만들고 3D화).
등거리 조건은 멀리 있는 다른 표고 쌍에도 우연히 성립하므로(예: 525~530
골짜기에 555/560 중간점), "그 지점의 최근접 라인이 바로 그 쌍"일 때만 인정한다.
"""
from scipy.ndimage import distance_transform_edt
@@ -191,43 +196,71 @@ def _densify_between_contours(
if len(present) < 2:
return cloud
def _distance(level: float) -> np.ndarray:
return distance_transform_edt(burned != level, sampling=spec.cell_m).astype(np.float32)
# 1차: 셀별 최근접 등고선 레벨 — 등거리 조건은 멀리 있는 다른 표고 쌍에도 우연히
# 성립하므로(예: 525~530 골짜기에 555/560 중간점이 박힘), "그 지점의 최근접
# 등고선이 바로 그 쌍"일 때만 사이점으로 인정한다.
best_distance = np.full(burned.shape, np.inf, dtype=np.float32)
best_index = np.full(burned.shape, -1, dtype=np.int16)
for index, level in enumerate(present):
distance = _distance(level)
take = distance < best_distance
best_distance[take] = distance[take]
best_index[take] = index
# 라운드마다 거리장을 다시 구하되, 직전 라운드 것을 재사용한다 — 새 라인 사이의
# 이분은 (직전 레벨 거리장, 새 중간선 거리장)만 있으면 되므로 전량 재계산이 필요없다.
levels = list(present)
masks = [burned == level for level in levels]
distances = [
distance_transform_edt(~mask, sampling=spec.cell_m).astype(np.float32) for mask in masks
]
virtual: dict[float, np.ndarray] = {}
for _ in range(max(SHEET_SURFACE_MIDLINE_ROUNDS, 0)):
if len(levels) < 2:
break
best_distance = np.full(burned.shape, np.inf, dtype=np.float32)
best_index = np.full(burned.shape, -1, dtype=np.int16)
for index, distance in enumerate(distances):
take = distance < best_distance
best_distance[take] = distance[take]
best_index[take] = index
next_levels: list[float] = []
next_masks: list[np.ndarray] = []
next_distances: list[np.ndarray] = []
added_any = False
for index in range(len(levels)):
next_levels.append(levels[index])
next_masks.append(masks[index])
next_distances.append(distances[index])
if index == 0:
continue
lower, upper = distances[index - 1], distances[index]
near = (lower < SHEET_SURFACE_MIDLINE_MAX_M) & (upper < SHEET_SURFACE_MIDLINE_MAX_M)
pair_is_nearest = (best_index == index - 1) | (best_index == index)
midline = near & pair_is_nearest & (np.abs(lower - upper) <= spec.cell_m)
midline &= ~masks[index - 1] & ~masks[index]
if not midline.any():
continue
mid_level = (levels[index - 1] + levels[index]) / 2.0
virtual[mid_level] = midline
# 새 라인은 마지막(=자기 레벨) 자리 앞에 끼워 정렬을 유지한다.
next_levels.insert(-1, mid_level)
next_masks.insert(-1, midline)
next_distances.insert(
-1, distance_transform_edt(~midline, sampling=spec.cell_m).astype(np.float32)
)
added_any = True
levels, masks, distances = next_levels, next_masks, next_distances
if not added_any:
break
if not virtual:
return cloud
xs = spec.cell_centers_x()
ys = spec.cell_centers_y()
extra_xy: list[np.ndarray] = []
extra_z: list[np.ndarray] = []
previous_distance = _distance(present[0])
for index in range(1, len(present)):
distance = _distance(present[index])
near = (previous_distance < SHEET_SURFACE_MIDLINE_MAX_M) & (
distance < SHEET_SURFACE_MIDLINE_MAX_M
)
pair_is_nearest = (best_index == index - 1) | (best_index == index)
midline = near & pair_is_nearest & (np.abs(previous_distance - distance) <= spec.cell_m)
rows, cols = np.nonzero(midline)
if len(rows):
# 등고선 정점 재샘플 밀도에 맞춰 성기게 담는다 — TIN 비용 절제.
rows, cols = rows[::5], cols[::5]
extra_xy.append(np.column_stack([xs[cols], ys[rows]]))
extra_z.append(np.full(len(rows), (present[index - 1] + present[index]) / 2.0))
previous_distance = distance
if not extra_xy:
return cloud
for level, mask in virtual.items():
rows, cols = np.nonzero(mask)
rows, cols = rows[::2], cols[::2] # 1m 셀 → 2m 간격 정점 (TIN 비용 절제)
extra_xy.append(np.column_stack([xs[cols], ys[rows]]))
extra_z.append(np.full(len(rows), level))
added = int(sum(len(a) for a in extra_z))
logger.info("도엽 서피스: 중간 보간선 정점 %d개 추가 (등고 %d단)", added, len(present))
logger.info(
"도엽 서피스: 가상 등고라인 %d단, 정점 %d개 추가 (원 등고 %d단)",
len(virtual),
added,
len(present),
)
return ContourCloud(
xy=np.vstack([cloud.xy, *extra_xy]),
z=np.concatenate([cloud.z, *extra_z]),
+3
View File
@@ -191,6 +191,9 @@ SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0"))
# 중간 보간선(인접 등고선 쌍의 등거리선) 탐색 최대 거리(m). 등고선 간격이 이보다 넓은
# 완경사면은 보간선을 넣지 않는다. 5m 주곡선 기준 경사 2.5%까지 커버.
SHEET_SURFACE_MIDLINE_MAX_M = float(os.getenv("SHEET_SURFACE_MIDLINE_MAX_M", "200.0"))
# 가상 등고라인 반복 이분 횟수. 2회 = 5m 주곡선 → 2.5m → 1.25m 가상 등고 생성
# (2026-08-30 사용자 지시 — 2D 사이 등고선을 먼저 만들고 3D화).
SHEET_SURFACE_MIDLINE_ROUNDS = int(os.getenv("SHEET_SURFACE_MIDLINE_ROUNDS", "2"))
# 일반 사용자 WF1 자동 확정 기본값
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf")