perf(B05): 계획노선 노드 뽑기 — 완만한 긴 굴곡이 삼켜지던 것 보완
Douglas-Peucker 허용오차는 절대 거리라 굴곡이 완만하고 길수록 통째로 삼켜짐. 4.5km 노선에서 노드 20개(간격 238m)만 남았음(보조 창 실측 5개 표본). - 남은 노드 사이가 100m 를 넘으면 그 구간만 허용오차를 절반으로 낮춰 다시 뽑음 (최대 3겹: 4 -> 2 -> 1 -> 0.5m). 노선 길이로 허용오차를 바꾸면 짧고 급한 굴곡이 다시 뭉개지므로 벌어진 구간만 고름. - shapely 대신 색인을 돌려주는 Douglas-Peucker 를 직접 둠 — 되뽑기가 원본 구간을 다시 꺼내야 해서 색인이 필요함. 새 점은 만들지 않음(노드는 언제나 원본 자리). 반지름을 원본 굴곡에서 재어 키우는 방법도 시험했으나 되돌림 — 직각 꺾임에서 R 70m · 중앙종거 20m 가 나와 원본과 크게 어긋났음. 중앙종거 M = R(1/cos(delta/2) - 1) 이라 R 이 클수록 원호가 꺾임점에서 멀어짐. 법정 하한이 사용자가 지시한 기준값이자 원본에 가장 가까운 값이므로 그대로 둠. 곡선별 R 조정은 노드 편집 기능으로 분리(PLAN 0-10). 자체검증 — 1.1km 노선 3개: 노드 25 -> 28, 곡선 13, 위반 0, 최대 간격 82m. 4.5km 노선: 노드 20 -> 24. 그 노선의 최대 간격 1,300m 는 알고리즘 문제가 아니라 실제 직선 구간임을 확인(원본 점 간격 중앙 3.3m · 최대 3m 로 고르고, 내각 최소가 158°). 시험 2건 추가(완만한 굴곡이 살아나는지 · 되뽑기가 원본 점만 남기는지). 전체 409 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,18 @@ DUPLICATE_TOLERANCE_M = 0.5
|
||||
# 계단 모양만 걷어내고 실제 굴곡은 남긴다.
|
||||
SIMPLIFY_TOLERANCE_M = 4.0
|
||||
|
||||
# 남은 노드 사이가 이보다 멀면 그 구간만 더 촘촘히 다시 뽑는다(m).
|
||||
#
|
||||
# 왜 필요한가(2026-09-06 실측) — Douglas-Peucker 허용오차는 **절대 거리**라 굴곡이 완만하고
|
||||
# 길수록 통째로 삼켜진다. 4.5km 짜리 S자 노선에서 노드가 20개(간격 238m)·곡선 2곳만 남아
|
||||
# 「S자」가 사라졌다. 같은 4m 로 1.1km 노선은 노드 25개(간격 46m)·곡선 13곳으로 알맞았다.
|
||||
# 노선 길이로 허용오차를 바꾸면 짧고 급한 굴곡이 다시 뭉개지므로, **간격이 벌어진 구간만**
|
||||
# 골라 허용오차를 절반으로 낮춰 다시 뽑는다.
|
||||
MAX_NODE_SPACING_M = 100.0
|
||||
|
||||
# 위 되뽑기를 몇 겹까지 할지 — 겹마다 허용오차가 절반이 된다(4 → 2 → 1 → 0.5m).
|
||||
MAX_REFINE_DEPTH = 3
|
||||
|
||||
# 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다.
|
||||
ARC_STEP_DEG = 5.0
|
||||
|
||||
@@ -99,22 +111,74 @@ def dedupe_points(
|
||||
return cleaned
|
||||
|
||||
|
||||
def _perpendicular_distance(
|
||||
point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]
|
||||
) -> float:
|
||||
"""점에서 선분까지의 수직 거리(m). 선분이 한 점이면 그 점까지의 거리."""
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
if dx == 0 and dy == 0:
|
||||
return _distance(point, start)
|
||||
return abs(dy * point[0] - dx * point[1] + end[0] * start[1] - end[1] * start[0]) / math.hypot(
|
||||
dx, dy
|
||||
)
|
||||
|
||||
|
||||
def _douglas_peucker(points: list[tuple[float, float]], tolerance_m: float) -> list[int]:
|
||||
"""남길 점의 **원본 색인**을 돌려준다 — 되뽑기가 원본 구간을 다시 꺼내야 해서 색인이다."""
|
||||
if len(points) < 3:
|
||||
return list(range(len(points)))
|
||||
keep = {0, len(points) - 1}
|
||||
stack = [(0, len(points) - 1)]
|
||||
while stack:
|
||||
first, last = stack.pop()
|
||||
if last <= first + 1:
|
||||
continue
|
||||
worst, worst_index = -1.0, first
|
||||
for index in range(first + 1, last):
|
||||
gap = _perpendicular_distance(points[index], points[first], points[last])
|
||||
if gap > worst:
|
||||
worst, worst_index = gap, index
|
||||
if worst > tolerance_m:
|
||||
keep.add(worst_index)
|
||||
stack.append((first, worst_index))
|
||||
stack.append((worst_index, last))
|
||||
return sorted(keep)
|
||||
|
||||
|
||||
def simplify_to_nodes(
|
||||
points: list[tuple[float, float]], tolerance_m: float = SIMPLIFY_TOLERANCE_M
|
||||
points: list[tuple[float, float]],
|
||||
tolerance_m: float = SIMPLIFY_TOLERANCE_M,
|
||||
max_spacing_m: float = MAX_NODE_SPACING_M,
|
||||
depth: int = MAX_REFINE_DEPTH,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker.
|
||||
"""조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker + 벌어진 구간 되뽑기.
|
||||
|
||||
예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울
|
||||
접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는
|
||||
선에서 점을 걷어내므로 **모양은 그대로**다.
|
||||
|
||||
남은 노드 사이가 `max_spacing_m` 을 넘으면 **그 구간만** 허용오차를 절반으로 낮춰 다시
|
||||
뽑는다 — 완만하고 긴 굴곡이 통째로 삼켜지는 것을 막는다(2026-09-06 S자 노선 실측).
|
||||
"""
|
||||
if len(points) < 3:
|
||||
return list(points)
|
||||
from shapely.geometry import LineString
|
||||
kept = _douglas_peucker(points, tolerance_m)
|
||||
if depth <= 0 or max_spacing_m <= 0:
|
||||
return [points[index] for index in kept]
|
||||
|
||||
simplified = LineString(points).simplify(tolerance_m, preserve_topology=False)
|
||||
result = [(float(x), float(y)) for x, y in simplified.coords]
|
||||
return result if len(result) >= 2 else list(points)
|
||||
result: list[tuple[float, float]] = [points[kept[0]]]
|
||||
for previous, current in zip(kept, kept[1:]):
|
||||
if _distance(points[previous], points[current]) > max_spacing_m and current > previous + 1:
|
||||
refined = simplify_to_nodes(
|
||||
points[previous : current + 1],
|
||||
tolerance_m / 2,
|
||||
max_spacing_m,
|
||||
depth - 1,
|
||||
)
|
||||
result.extend(refined[1:])
|
||||
else:
|
||||
result.append(points[current])
|
||||
return result
|
||||
|
||||
|
||||
def _inner_angle_deg(
|
||||
@@ -207,6 +271,11 @@ def build_planned_polyline(
|
||||
|
||||
# 접선이 들어갈 자리 — 앞뒤 구간을 이웃 곡선과 나눠 쓰므로 절반까지만 쓴다.
|
||||
available = min(_distance(before, at), _distance(at, after)) / 2
|
||||
# 반지름은 **법정 하한 그대로** 쓴다 — 사용자가 「지식DB 의 R 기준값으로 선을 만들라」고
|
||||
# 한 그 값이고, 기하로도 하한이 원본에 가장 가깝다: 중앙종거 M = R(1/cos(Δ/2) − 1) 이라
|
||||
# R 이 클수록 원호가 꺾임점에서 멀어진다. 원본 굴곡에서 R 을 재어 키우는 방법도 재 봤으나
|
||||
# (2026-09-06) 직각 꺾임에서 R 70m·중앙종거 20m 가 나와 원본과 크게 어긋났다.
|
||||
# 곡선마다 R 을 다르게 주는 것은 사용자가 노드에서 고르는 별도 기능으로 둔다(PLAN 0-10).
|
||||
radius = min_radius_m
|
||||
tangent = radius * half_tan
|
||||
if tangent > available:
|
||||
|
||||
Reference in New Issue
Block a user