Spaces:
Running
Running
Sync DemoService 347d491b75d7ea205a93c78d594520ed50d1d31a
Browse files- api/ai-service/app/api/v1/endpoints/route_generation.py +13 -2
- api/ai-service/app/services/gpx_shape_similarity.py +59 -0
- api/ai-service/app/services/walking_route_fallback.py +28 -16
- api/ai-service/tests/test_gpx_shape_similarity.py +26 -0
- api/ai-service/tests/test_route_generation_recovery.py +20 -0
- api/ai-service/tests/test_walking_route_fallback.py +24 -0
- router/ai/scripts/clip_osm_region_test.py +6 -2
api/ai-service/app/api/v1/endpoints/route_generation.py
CHANGED
|
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, status
|
|
| 11 |
from pydantic import BaseModel, Field, field_validator
|
| 12 |
|
| 13 |
from app.core.config import settings
|
|
|
|
| 14 |
from app.services.walking_route_fallback import generate_walking_fallback
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/ai", tags=["ai"])
|
|
@@ -422,7 +423,8 @@ def _run_fast_road_pipeline(
|
|
| 422 |
# A small safety margin is enough for the live service. Generating a
|
| 423 |
# larger pool on the CPU-only Space makes a five-route request look like a
|
| 424 |
# failed request before it can return any usable road loop.
|
| 425 |
-
|
|
|
|
| 426 |
candidates = build_candidates(
|
| 427 |
graph=graph,
|
| 428 |
route_count=candidate_pool,
|
|
@@ -456,13 +458,14 @@ def _run_fast_road_pipeline(
|
|
| 456 |
raise RuntimeError("No walkable road routes were generated near this location.")
|
| 457 |
|
| 458 |
routes: list[dict[str, Any]] = []
|
| 459 |
-
for route in candidates
|
| 460 |
# Simplified OSM edges can bend between intersections. Preserve those
|
| 461 |
# bends rather than drawing straight lines across blocks or water.
|
| 462 |
coords = _route_nodes_to_coords(graph, route["nodes"])
|
| 463 |
if len(coords) < 2:
|
| 464 |
continue
|
| 465 |
shape_quality = float(route.get("shape_quality", 0.0))
|
|
|
|
| 466 |
route_label = selected_label or "nearby_route"
|
| 467 |
maneuvers = maneuvers_for_route(graph, route["nodes"]) if callable(maneuvers_for_route) else []
|
| 468 |
routes.append(
|
|
@@ -478,6 +481,7 @@ def _run_fast_road_pipeline(
|
|
| 478 |
"is_loop": bool(route.get("is_loop")),
|
| 479 |
"route_quality": {
|
| 480 |
"art_ready": generation_mode == "fast_walk_roads" and bool(selected_label),
|
|
|
|
| 481 |
"target_shape": {
|
| 482 |
"score": shape_quality,
|
| 483 |
"route_coverage": shape_quality,
|
|
@@ -492,6 +496,12 @@ def _run_fast_road_pipeline(
|
|
| 492 |
}
|
| 493 |
)
|
| 494 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
if not routes:
|
| 496 |
raise RuntimeError("Walkable road routes did not contain usable map coordinates.")
|
| 497 |
|
|
@@ -723,6 +733,7 @@ def generate_routes(payload: RouteGenerateRequest) -> dict[str, Any]:
|
|
| 723 |
route_count=route_count, target_distance_km=target_distance_km,
|
| 724 |
selected_label=selected_label, place=payload.place,
|
| 725 |
template=template_fn(selected_label) if callable(template_fn) else None,
|
|
|
|
| 726 |
generation_id=payload.generationId,
|
| 727 |
)
|
| 728 |
if real_roads:
|
|
|
|
| 11 |
from pydantic import BaseModel, Field, field_validator
|
| 12 |
|
| 13 |
from app.core.config import settings
|
| 14 |
+
from app.services.gpx_shape_similarity import best_gpx_shape_match
|
| 15 |
from app.services.walking_route_fallback import generate_walking_fallback
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/ai", tags=["ai"])
|
|
|
|
| 423 |
# A small safety margin is enough for the live service. Generating a
|
| 424 |
# larger pool on the CPU-only Space makes a five-route request look like a
|
| 425 |
# failed request before it can return any usable road loop.
|
| 426 |
+
examples = getattr(module, "GPX_SHAPE_TEMPLATES", {}).get(selected_label, [])
|
| 427 |
+
candidate_pool = min(16 if examples else 8, max(route_count, route_count + (11 if examples else 1)))
|
| 428 |
candidates = build_candidates(
|
| 429 |
graph=graph,
|
| 430 |
route_count=candidate_pool,
|
|
|
|
| 458 |
raise RuntimeError("No walkable road routes were generated near this location.")
|
| 459 |
|
| 460 |
routes: list[dict[str, Any]] = []
|
| 461 |
+
for route in candidates:
|
| 462 |
# Simplified OSM edges can bend between intersections. Preserve those
|
| 463 |
# bends rather than drawing straight lines across blocks or water.
|
| 464 |
coords = _route_nodes_to_coords(graph, route["nodes"])
|
| 465 |
if len(coords) < 2:
|
| 466 |
continue
|
| 467 |
shape_quality = float(route.get("shape_quality", 0.0))
|
| 468 |
+
similarity, example_index = best_gpx_shape_match(coords, examples) if examples else (0.0, None)
|
| 469 |
route_label = selected_label or "nearby_route"
|
| 470 |
maneuvers = maneuvers_for_route(graph, route["nodes"]) if callable(maneuvers_for_route) else []
|
| 471 |
routes.append(
|
|
|
|
| 481 |
"is_loop": bool(route.get("is_loop")),
|
| 482 |
"route_quality": {
|
| 483 |
"art_ready": generation_mode == "fast_walk_roads" and bool(selected_label),
|
| 484 |
+
**({"gpx_similarity": similarity, "gpx_example_index": example_index} if examples else {}),
|
| 485 |
"target_shape": {
|
| 486 |
"score": shape_quality,
|
| 487 |
"route_coverage": shape_quality,
|
|
|
|
| 496 |
}
|
| 497 |
)
|
| 498 |
|
| 499 |
+
if examples:
|
| 500 |
+
routes.sort(key=lambda route: (
|
| 501 |
+
-route["route_quality"]["gpx_similarity"],
|
| 502 |
+
abs(route["length_km"] - target_distance_km),
|
| 503 |
+
))
|
| 504 |
+
routes = routes[:route_count]
|
| 505 |
if not routes:
|
| 506 |
raise RuntimeError("Walkable road routes did not contain usable map coordinates.")
|
| 507 |
|
|
|
|
| 733 |
route_count=route_count, target_distance_km=target_distance_km,
|
| 734 |
selected_label=selected_label, place=payload.place,
|
| 735 |
template=template_fn(selected_label) if callable(template_fn) else None,
|
| 736 |
+
templates=getattr(module, "GPX_SHAPE_TEMPLATES", {}).get(selected_label, []),
|
| 737 |
generation_id=payload.generationId,
|
| 738 |
)
|
| 739 |
if real_roads:
|
api/ai-service/app/services/gpx_shape_similarity.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compare a real road trace with location-free GPX outline examples."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _sample_outline(points: list[tuple[float, float]], count: int = 24) -> list[complex]:
|
| 9 |
+
if len(points) < 3:
|
| 10 |
+
return []
|
| 11 |
+
stroke = [complex(x, y) for x, y in points]
|
| 12 |
+
if abs(stroke[0] - stroke[-1]) > 1e-9:
|
| 13 |
+
stroke.append(stroke[0])
|
| 14 |
+
lengths = [abs(end - start) for start, end in zip(stroke, stroke[1:])]
|
| 15 |
+
perimeter = sum(lengths)
|
| 16 |
+
if perimeter <= 1e-9:
|
| 17 |
+
return []
|
| 18 |
+
sampled = []
|
| 19 |
+
segment = 0
|
| 20 |
+
passed = 0.0
|
| 21 |
+
for index in range(count):
|
| 22 |
+
target = perimeter * index / count
|
| 23 |
+
while segment < len(lengths) - 1 and passed + lengths[segment] < target:
|
| 24 |
+
passed += lengths[segment]
|
| 25 |
+
segment += 1
|
| 26 |
+
fraction = (target - passed) / lengths[segment] if lengths[segment] else 0.0
|
| 27 |
+
sampled.append(stroke[segment] + (stroke[segment + 1] - stroke[segment]) * fraction)
|
| 28 |
+
center = sum(sampled) / count
|
| 29 |
+
sampled = [point - center for point in sampled]
|
| 30 |
+
size = math.sqrt(sum(abs(point) ** 2 for point in sampled))
|
| 31 |
+
return [point / size for point in sampled] if size > 1e-9 else []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def best_gpx_shape_match(
|
| 35 |
+
route_coords: list[tuple[float, float]] | list[list[float]],
|
| 36 |
+
templates: list[list[tuple[float, float]]],
|
| 37 |
+
) -> tuple[float, int | None]:
|
| 38 |
+
"""Return rotation/scale/start-point invariant similarity and example index."""
|
| 39 |
+
if len(route_coords) < 4 or not templates:
|
| 40 |
+
return 0.0, None
|
| 41 |
+
center_lat = sum(point[0] for point in route_coords) / len(route_coords)
|
| 42 |
+
longitude_scale = math.cos(math.radians(center_lat))
|
| 43 |
+
road = _sample_outline([(point[1] * longitude_scale, -point[0]) for point in route_coords])
|
| 44 |
+
if not road:
|
| 45 |
+
return 0.0, None
|
| 46 |
+
best_score, best_index = 0.0, None
|
| 47 |
+
for index, template in enumerate(templates):
|
| 48 |
+
example = _sample_outline(template)
|
| 49 |
+
if not example:
|
| 50 |
+
continue
|
| 51 |
+
for direction in (example, list(reversed(example))):
|
| 52 |
+
for shift in range(len(road)):
|
| 53 |
+
# Magnitude of the complex dot product is the best score after
|
| 54 |
+
# rotating either outline. Both traces have unit RMS size.
|
| 55 |
+
score = abs(sum(road[i] * direction[(i + shift) % len(road)].conjugate()
|
| 56 |
+
for i in range(len(road))))
|
| 57 |
+
if score > best_score:
|
| 58 |
+
best_score, best_index = score, index
|
| 59 |
+
return min(1.0, best_score), best_index
|
api/ai-service/app/services/walking_route_fallback.py
CHANGED
|
@@ -11,6 +11,8 @@ from typing import Any
|
|
| 11 |
|
| 12 |
import requests
|
| 13 |
|
|
|
|
|
|
|
| 14 |
PROVIDER_URL = os.getenv("WALK_ROUTE_PROVIDER_URL", "https://valhalla1.openstreetmap.de/route")
|
| 15 |
HEADERS = {"User-Agent": "RunTheShape/1.0 (+https://github.com/hwangtutu/Tranimal_Run)"}
|
| 16 |
_CACHE: dict[tuple, tuple[float, dict]] = {}
|
|
@@ -58,6 +60,7 @@ def generate_walking_fallback(
|
|
| 58 |
*, center_lat: float, center_lon: float, route_count: int,
|
| 59 |
target_distance_km: float, selected_label: str | None, place: str | None,
|
| 60 |
template: list[tuple[float, float]] | None = None,
|
|
|
|
| 61 |
generation_id: str | None = None,
|
| 62 |
) -> dict[str, Any] | None:
|
| 63 |
key = (round(center_lat, 5), round(center_lon, 5), route_count, target_distance_km, selected_label, generation_id)
|
|
@@ -66,24 +69,29 @@ def generate_walking_fallback(
|
|
| 66 |
if cached and time.monotonic() - cached[0] < 6 * 3600:
|
| 67 |
return {**cached[1], "place": place or cached[1]["place"]}
|
| 68 |
|
| 69 |
-
|
| 70 |
-
if
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
outline = [outline[round(i * (len(outline)-1) / 9)] for i in range(10)]
|
| 78 |
-
perimeter = sum(math.hypot(b[0]-a[0], b[1]-a[1]) for a, b in zip(outline, outline[1:]))
|
| 79 |
-
if perimeter <= 0:
|
| 80 |
-
return None
|
| 81 |
-
scale = target_distance_km * 1000 / perimeter / 1.2
|
| 82 |
deadline = time.monotonic() + 16
|
| 83 |
routes, seen = [], set()
|
| 84 |
for attempt in range(min(route_count + 3, 8)):
|
| 85 |
-
if time.monotonic() >= deadline or len(routes) >= route_count:
|
| 86 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
angle = generation_angle(generation_id) + attempt * math.pi * (3 - math.sqrt(5))
|
| 88 |
# Shorter retries also work near a coast or on a small island.
|
| 89 |
size = scale * (1 if attempt < 3 else 0.5 if attempt < 5 else 0.2)
|
|
@@ -123,11 +131,13 @@ def generate_walking_fallback(
|
|
| 123 |
continue
|
| 124 |
seen.add(signature)
|
| 125 |
label = selected_label or "nearby_route"
|
|
|
|
| 126 |
routes.append({"image_path":"","top1":label,"top1_score":0.0,"top1_shape":0.0,
|
| 127 |
"selected_label":selected_label,"selected_label_score":None,"is_loop":True,
|
| 128 |
"length_km":length,"route_coords":coords,"center":coords[0],"maneuvers":[],
|
| 129 |
"estimated_route":False,"results":[{"label":label,"score":0.0,"shape":0.0}],
|
| 130 |
-
"route_quality":{"art_ready":False
|
|
|
|
| 131 |
except requests.RequestException as exc:
|
| 132 |
if not isinstance(exc, requests.HTTPError) or (exc.response is not None and exc.response.status_code in {429,502,503,504}):
|
| 133 |
break
|
|
@@ -135,7 +145,9 @@ def generate_walking_fallback(
|
|
| 135 |
continue
|
| 136 |
if not routes:
|
| 137 |
return None
|
| 138 |
-
routes.sort(key=lambda route:
|
|
|
|
|
|
|
| 139 |
result = {"place":place or f"{center_lat:.6f},{center_lon:.6f}","source":"point",
|
| 140 |
"candidate_count":len(routes),"requested_count":route_count,"generated_count":len(routes),
|
| 141 |
"target_distance_km":target_distance_km,"labels":[selected_label] if selected_label else [],
|
|
|
|
| 11 |
|
| 12 |
import requests
|
| 13 |
|
| 14 |
+
from app.services.gpx_shape_similarity import best_gpx_shape_match
|
| 15 |
+
|
| 16 |
PROVIDER_URL = os.getenv("WALK_ROUTE_PROVIDER_URL", "https://valhalla1.openstreetmap.de/route")
|
| 17 |
HEADERS = {"User-Agent": "RunTheShape/1.0 (+https://github.com/hwangtutu/Tranimal_Run)"}
|
| 18 |
_CACHE: dict[tuple, tuple[float, dict]] = {}
|
|
|
|
| 60 |
*, center_lat: float, center_lon: float, route_count: int,
|
| 61 |
target_distance_km: float, selected_label: str | None, place: str | None,
|
| 62 |
template: list[tuple[float, float]] | None = None,
|
| 63 |
+
templates: list[list[tuple[float, float]]] | None = None,
|
| 64 |
generation_id: str | None = None,
|
| 65 |
) -> dict[str, Any] | None:
|
| 66 |
key = (round(center_lat, 5), round(center_lon, 5), route_count, target_distance_km, selected_label, generation_id)
|
|
|
|
| 69 |
if cached and time.monotonic() - cached[0] < 6 * 3600:
|
| 70 |
return {**cached[1], "place": place or cached[1]["place"]}
|
| 71 |
|
| 72 |
+
examples = list(templates or [])
|
| 73 |
+
if examples:
|
| 74 |
+
# Each live request explores different GPX examples before the road
|
| 75 |
+
# provider's strict time budget expires.
|
| 76 |
+
offset = int(generation_angle(generation_id) / math.tau * len(examples))
|
| 77 |
+
examples = examples[offset:] + examples[:offset]
|
| 78 |
+
else:
|
| 79 |
+
examples = [template or [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
deadline = time.monotonic() + 16
|
| 81 |
routes, seen = [], set()
|
| 82 |
for attempt in range(min(route_count + 3, 8)):
|
| 83 |
+
if time.monotonic() >= deadline or (not templates and len(routes) >= route_count):
|
| 84 |
break
|
| 85 |
+
outline = list(examples[attempt % len(examples)])
|
| 86 |
+
if outline[0] != outline[-1]:
|
| 87 |
+
outline.append(outline[0])
|
| 88 |
+
# The pedestrian provider accepts at most ten locations.
|
| 89 |
+
if len(outline) > 10:
|
| 90 |
+
outline = [outline[round(i * (len(outline)-1) / 9)] for i in range(10)]
|
| 91 |
+
perimeter = sum(math.hypot(b[0]-a[0], b[1]-a[1]) for a, b in zip(outline, outline[1:]))
|
| 92 |
+
if perimeter <= 0:
|
| 93 |
+
continue
|
| 94 |
+
scale = target_distance_km * 1000 / perimeter / 1.2
|
| 95 |
angle = generation_angle(generation_id) + attempt * math.pi * (3 - math.sqrt(5))
|
| 96 |
# Shorter retries also work near a coast or on a small island.
|
| 97 |
size = scale * (1 if attempt < 3 else 0.5 if attempt < 5 else 0.2)
|
|
|
|
| 131 |
continue
|
| 132 |
seen.add(signature)
|
| 133 |
label = selected_label or "nearby_route"
|
| 134 |
+
similarity, example_index = best_gpx_shape_match(coords, templates) if templates else (0.0, None)
|
| 135 |
routes.append({"image_path":"","top1":label,"top1_score":0.0,"top1_shape":0.0,
|
| 136 |
"selected_label":selected_label,"selected_label_score":None,"is_loop":True,
|
| 137 |
"length_km":length,"route_coords":coords,"center":coords[0],"maneuvers":[],
|
| 138 |
"estimated_route":False,"results":[{"label":label,"score":0.0,"shape":0.0}],
|
| 139 |
+
"route_quality":{"art_ready":False,
|
| 140 |
+
**({"gpx_similarity":similarity,"gpx_example_index":example_index} if templates else {})}})
|
| 141 |
except requests.RequestException as exc:
|
| 142 |
if not isinstance(exc, requests.HTTPError) or (exc.response is not None and exc.response.status_code in {429,502,503,504}):
|
| 143 |
break
|
|
|
|
| 145 |
continue
|
| 146 |
if not routes:
|
| 147 |
return None
|
| 148 |
+
routes.sort(key=lambda route: (-route["route_quality"].get("gpx_similarity", 0.0),
|
| 149 |
+
abs(route["length_km"]-target_distance_km)))
|
| 150 |
+
routes = routes[:route_count]
|
| 151 |
result = {"place":place or f"{center_lat:.6f},{center_lon:.6f}","source":"point",
|
| 152 |
"candidate_count":len(routes),"requested_count":route_count,"generated_count":len(routes),
|
| 153 |
"target_distance_km":target_distance_km,"labels":[selected_label] if selected_label else [],
|
api/ai-service/tests/test_gpx_shape_similarity.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 6 |
+
|
| 7 |
+
from app.services.gpx_shape_similarity import best_gpx_shape_match
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_finds_closest_gpx_outline_independent_of_rotation_and_location():
|
| 11 |
+
star = [(math.cos(i * math.tau * 2 / 5), math.sin(i * math.tau * 2 / 5)) for i in range(5)]
|
| 12 |
+
star.append(star[0])
|
| 13 |
+
square = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)]
|
| 14 |
+
angle = 0.7
|
| 15 |
+
route = []
|
| 16 |
+
for x, y in star:
|
| 17 |
+
east = x * math.cos(angle) - y * math.sin(angle)
|
| 18 |
+
south = x * math.sin(angle) + y * math.cos(angle)
|
| 19 |
+
route.append([37.5 - south / 111320, 127.0 + east / (111320 * math.cos(math.radians(37.5)))])
|
| 20 |
+
score, index = best_gpx_shape_match(route, [square, star])
|
| 21 |
+
assert index == 1
|
| 22 |
+
assert score > 0.99
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_empty_or_short_route_has_no_match():
|
| 26 |
+
assert best_gpx_shape_match([[37.5, 127.0]], [[(0, 0), (1, 0), (0, 1)]]) == (0.0, None)
|
api/ai-service/tests/test_route_generation_recovery.py
CHANGED
|
@@ -113,6 +113,26 @@ def test_real_independent_roads_are_preferred_to_estimated_preview(pipeline):
|
|
| 113 |
assert result == expected
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def test_place_request_returns_all_100_routes(pipeline):
|
| 117 |
pipeline.setattr(routes, "_run_fast_road_pipeline", Mock(side_effect=RuntimeError("All Overpass endpoints failed")))
|
| 118 |
result = routes.generate_routes(routes.RouteGenerateRequest(place="์๊ทํฌ์", fallbackLatitude=33.25, fallbackLongitude=126.56, routeCount=100))
|
|
|
|
| 113 |
assert result == expected
|
| 114 |
|
| 115 |
|
| 116 |
+
def test_gpx_guided_fast_routes_are_ranked_by_actual_road_similarity(monkeypatch):
|
| 117 |
+
candidates = [
|
| 118 |
+
{"nodes": [1, 2], "length_m": 5900, "shape_quality": 0.8, "is_loop": True},
|
| 119 |
+
{"nodes": [3, 4], "length_m": 6100, "shape_quality": 0.5, "is_loop": True},
|
| 120 |
+
]
|
| 121 |
+
module = SimpleNamespace(
|
| 122 |
+
GPX_SHAPE_TEMPLATES={"dog": [[(0, 0), (1, 0), (0, 1)]]},
|
| 123 |
+
_build_or_get_walk_graph=lambda *_: (object(), "test"),
|
| 124 |
+
generate_candidate_routes=lambda **_: candidates,
|
| 125 |
+
route_points=lambda *_: [],
|
| 126 |
+
simplify_coords=lambda points: points,
|
| 127 |
+
)
|
| 128 |
+
monkeypatch.setattr(routes, "_route_nodes_to_coords", lambda _graph, nodes: [[nodes[0], 0], [nodes[1], 1]])
|
| 129 |
+
monkeypatch.setattr(routes, "best_gpx_shape_match", lambda coords, _examples: (0.9 if coords[0][0] == 3 else 0.2, 0))
|
| 130 |
+
result = routes._run_fast_road_pipeline(module, place=None, center_lat=37, center_lon=127,
|
| 131 |
+
radius_km=2, route_count=2, target_distance_km=6, selected_label="dog")
|
| 132 |
+
assert [route["route_quality"]["gpx_similarity"] for route in result["routes"]] == [0.9, 0.2]
|
| 133 |
+
assert result["best_route"] == result["routes"][0]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
def test_place_request_returns_all_100_routes(pipeline):
|
| 137 |
pipeline.setattr(routes, "_run_fast_road_pipeline", Mock(side_effect=RuntimeError("All Overpass endpoints failed")))
|
| 138 |
result = routes.generate_routes(routes.RouteGenerateRequest(place="์๊ทํฌ์", fallbackLatitude=33.25, fallbackLongitude=126.56, routeCount=100))
|
api/ai-service/tests/test_walking_route_fallback.py
CHANGED
|
@@ -105,3 +105,27 @@ def test_new_generation_queries_new_waypoints_but_network_retry_reuses_result(mo
|
|
| 105 |
next_locations = json.loads(get.call_args.kwargs['params']['json'])['locations']
|
| 106 |
assert next_locations[0] == first_locations[0], 'Keep the selected starting point'
|
| 107 |
assert next_locations[1:] != first_locations[1:], 'Actually explore different roads'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
next_locations = json.loads(get.call_args.kwargs['params']['json'])['locations']
|
| 106 |
assert next_locations[0] == first_locations[0], 'Keep the selected starting point'
|
| 107 |
assert next_locations[1:] != first_locations[1:], 'Actually explore different roads'
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_gpx_fallback_checks_multiple_examples_and_returns_best_match(monkeypatch):
|
| 111 |
+
import json
|
| 112 |
+
def different_route(*_args, **_kwargs):
|
| 113 |
+
index = different_route.calls
|
| 114 |
+
different_route.calls += 1
|
| 115 |
+
shape = encode([[37.5, 127.0], [37.501 + index * 0.001, 127.002], [37.5, 127.0]])
|
| 116 |
+
return SimpleNamespace(raise_for_status=lambda: None,
|
| 117 |
+
json=lambda: {'trip': {'summary': {'length': 0.5, 'has_ferry': False},
|
| 118 |
+
'legs': [{'shape': shape}]}})
|
| 119 |
+
different_route.calls = 0
|
| 120 |
+
get = Mock(side_effect=different_route)
|
| 121 |
+
monkeypatch.setattr(requests, 'get', get)
|
| 122 |
+
scores = iter([(0.2, 0), (0.9, 1), (0.3, 0), (0.4, 1)])
|
| 123 |
+
monkeypatch.setattr(walking, 'best_gpx_shape_match', lambda *_: next(scores))
|
| 124 |
+
examples = [[(0, 0), (1, 0), (1, 1), (0, 0)],
|
| 125 |
+
[(0, 0), (0, 1), (1, 1), (0, 0)]]
|
| 126 |
+
result = walking.generate_walking_fallback(**options(), templates=examples, generation_id='check')
|
| 127 |
+
assert get.call_count == 4
|
| 128 |
+
locations = [json.loads(call.kwargs['params']['json'])['locations'] for call in get.call_args_list]
|
| 129 |
+
assert locations[0] != locations[1]
|
| 130 |
+
assert result['best_route']['route_quality']['gpx_similarity'] == 0.9
|
| 131 |
+
assert result['generated_count'] == 1
|
router/ai/scripts/clip_osm_region_test.py
CHANGED
|
@@ -786,8 +786,9 @@ def guided_geo_waypoints(
|
|
| 786 |
graph: nx.MultiDiGraph,
|
| 787 |
label: str,
|
| 788 |
target_distance_m: float,
|
|
|
|
| 789 |
) -> list[tuple[float, float]]:
|
| 790 |
-
template = guided_waypoints_for_label(label)
|
| 791 |
if not template:
|
| 792 |
return []
|
| 793 |
template = diversify_guided_waypoints(template)
|
|
@@ -2408,12 +2409,15 @@ def generate_candidate_routes(
|
|
| 2408 |
guided_template = guided_waypoints_for_label(selected_label)
|
| 2409 |
if guided_template:
|
| 2410 |
guided_attempts = min(max(route_count * LABEL_GUIDED_ATTEMPT_MULTIPLIER, 36), 180)
|
| 2411 |
-
|
|
|
|
|
|
|
| 2412 |
try:
|
| 2413 |
geo_points = guided_geo_waypoints(
|
| 2414 |
graph=graph,
|
| 2415 |
label=str(selected_label),
|
| 2416 |
target_distance_m=target_distance_m,
|
|
|
|
| 2417 |
)
|
| 2418 |
waypoint_nodes = nearest_nodes_for_geo_points(graph, geo_points)
|
| 2419 |
route_nodes = build_guided_route_nodes(graph, waypoint_nodes)
|
|
|
|
| 786 |
graph: nx.MultiDiGraph,
|
| 787 |
label: str,
|
| 788 |
target_distance_m: float,
|
| 789 |
+
template: list[tuple[float, float]] | None = None,
|
| 790 |
) -> list[tuple[float, float]]:
|
| 791 |
+
template = template if template is not None else guided_waypoints_for_label(label)
|
| 792 |
if not template:
|
| 793 |
return []
|
| 794 |
template = diversify_guided_waypoints(template)
|
|
|
|
| 2409 |
guided_template = guided_waypoints_for_label(selected_label)
|
| 2410 |
if guided_template:
|
| 2411 |
guided_attempts = min(max(route_count * LABEL_GUIDED_ATTEMPT_MULTIPLIER, 36), 180)
|
| 2412 |
+
examples = GPX_SHAPE_TEMPLATES.get(selected_label, [])
|
| 2413 |
+
shuffled_examples = random.sample(examples, len(examples)) if examples else []
|
| 2414 |
+
for attempt in range(guided_attempts):
|
| 2415 |
try:
|
| 2416 |
geo_points = guided_geo_waypoints(
|
| 2417 |
graph=graph,
|
| 2418 |
label=str(selected_label),
|
| 2419 |
target_distance_m=target_distance_m,
|
| 2420 |
+
template=shuffled_examples[attempt % len(shuffled_examples)] if shuffled_examples else None,
|
| 2421 |
)
|
| 2422 |
waypoint_nodes = nearest_nodes_for_geo_points(graph, geo_points)
|
| 2423 |
route_nodes = build_guided_route_nodes(graph, waypoint_nodes)
|