From 00decc812d65c2cfacceb1d3824e80d3c64e0282 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 00:05:48 +0900 Subject: [PATCH] =?UTF-8?q?perf(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=85=B8=EB=93=9C=20=EB=BD=91=EA=B8=B0=20=E2=80=94?= =?UTF-8?q?=20=EC=99=84=EB=A7=8C=ED=95=9C=20=EA=B8=B4=20=EA=B5=B4=EA=B3=A1?= =?UTF-8?q?=EC=9D=B4=20=EC=82=BC=EC=BC=9C=EC=A7=80=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- common_util/common_util_route_polyline.py | 81 +++++++++++++++++++++-- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/common_util/common_util_route_polyline.py b/common_util/common_util_route_polyline.py index 8a819b91..292bc93a 100644 --- a/common_util/common_util_route_polyline.py +++ b/common_util/common_util_route_polyline.py @@ -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: