Spaces:
Running
Running
Sync DemoService 778e066c9915dd0ec1a6d9a620a99adbf50c2f31
Browse files- api/ai-service/app/api/v1/endpoints/route_generation.py +7 -3
- api/ai-service/app/services/gpx_shape_similarity.py +17 -0
- api/ai-service/app/services/walking_route_fallback.py +9 -5
- api/ai-service/tests/test_gpx_shape_similarity.py +11 -1
- api/ai-service/tests/test_walking_route_fallback.py +17 -0
- router/ai/scripts/clip_osm_region_test.py +4 -4
api/ai-service/app/api/v1/endpoints/route_generation.py
CHANGED
|
@@ -11,7 +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.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"])
|
|
@@ -473,6 +473,7 @@ def _run_fast_road_pipeline(
|
|
| 473 |
continue
|
| 474 |
shape_quality = float(route.get("shape_quality", 0.0))
|
| 475 |
similarity, example_index = best_gpx_shape_match(coords, examples) if examples else (0.0, None)
|
|
|
|
| 476 |
route_label = selected_label or "nearby_route"
|
| 477 |
maneuvers = maneuvers_for_route(graph, route["nodes"]) if callable(maneuvers_for_route) else []
|
| 478 |
routes.append(
|
|
@@ -488,7 +489,9 @@ def _run_fast_road_pipeline(
|
|
| 488 |
"is_loop": bool(route.get("is_loop")),
|
| 489 |
"route_quality": {
|
| 490 |
"art_ready": generation_mode == "fast_walk_roads" and bool(selected_label),
|
| 491 |
-
**({"gpx_similarity": similarity, "gpx_example_index": example_index
|
|
|
|
|
|
|
| 492 |
"target_shape": {
|
| 493 |
"score": shape_quality,
|
| 494 |
"route_coverage": shape_quality,
|
|
@@ -505,8 +508,9 @@ def _run_fast_road_pipeline(
|
|
| 505 |
|
| 506 |
if examples:
|
| 507 |
routes.sort(key=lambda route: (
|
|
|
|
| 508 |
-route["route_quality"]["gpx_similarity"],
|
| 509 |
-
abs(route["length_km"] - target_distance_km),
|
| 510 |
))
|
| 511 |
routes = routes[:route_count]
|
| 512 |
elif auto_distance:
|
|
|
|
| 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, gpx_route_rank_score, route_footprint_km
|
| 15 |
from app.services.walking_route_fallback import generate_walking_fallback
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/ai", tags=["ai"])
|
|
|
|
| 473 |
continue
|
| 474 |
shape_quality = float(route.get("shape_quality", 0.0))
|
| 475 |
similarity, example_index = best_gpx_shape_match(coords, examples) if examples else (0.0, None)
|
| 476 |
+
footprint_km = route_footprint_km(coords) if examples else 0.0
|
| 477 |
route_label = selected_label or "nearby_route"
|
| 478 |
maneuvers = maneuvers_for_route(graph, route["nodes"]) if callable(maneuvers_for_route) else []
|
| 479 |
routes.append(
|
|
|
|
| 489 |
"is_loop": bool(route.get("is_loop")),
|
| 490 |
"route_quality": {
|
| 491 |
"art_ready": generation_mode == "fast_walk_roads" and bool(selected_label),
|
| 492 |
+
**({"gpx_similarity": similarity, "gpx_example_index": example_index,
|
| 493 |
+
"gpx_footprint_km": footprint_km,
|
| 494 |
+
"gpx_rank_score": gpx_route_rank_score(similarity, footprint_km)} if examples else {}),
|
| 495 |
"target_shape": {
|
| 496 |
"score": shape_quality,
|
| 497 |
"route_coverage": shape_quality,
|
|
|
|
| 508 |
|
| 509 |
if examples:
|
| 510 |
routes.sort(key=lambda route: (
|
| 511 |
+
-route["route_quality"]["gpx_rank_score"],
|
| 512 |
-route["route_quality"]["gpx_similarity"],
|
| 513 |
+
0 if auto_distance else abs(route["length_km"] - target_distance_km),
|
| 514 |
))
|
| 515 |
routes = routes[:route_count]
|
| 516 |
elif auto_distance:
|
api/ai-service/app/services/gpx_shape_similarity.py
CHANGED
|
@@ -5,6 +5,23 @@ from __future__ import annotations
|
|
| 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 []
|
|
|
|
| 5 |
import math
|
| 6 |
|
| 7 |
|
| 8 |
+
def route_footprint_km(coords: list[tuple[float, float]] | list[list[float]]) -> float:
|
| 9 |
+
"""Balanced geographic span; a long narrow detour should not look wide."""
|
| 10 |
+
if len(coords) < 2:
|
| 11 |
+
return 0.0
|
| 12 |
+
latitudes = [point[0] for point in coords]
|
| 13 |
+
longitudes = [point[1] for point in coords]
|
| 14 |
+
center_lat = (min(latitudes) + max(latitudes)) / 2
|
| 15 |
+
width_km = (max(longitudes) - min(longitudes)) * 111.32 * math.cos(math.radians(center_lat))
|
| 16 |
+
height_km = (max(latitudes) - min(latitudes)) * 111.32
|
| 17 |
+
return math.sqrt(max(0.0, width_km * height_km))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def gpx_route_rank_score(similarity: float, footprint_km: float) -> float:
|
| 21 |
+
"""Prefer a broad drawing when GPX fit is close, without rewarding distance alone."""
|
| 22 |
+
return similarity + 0.04 * min(max(footprint_km, 0.0) / 7.0, 1.0)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
def _sample_outline(points: list[tuple[float, float]], count: int = 24) -> list[complex]:
|
| 26 |
if len(points) < 3:
|
| 27 |
return []
|
api/ai-service/app/services/walking_route_fallback.py
CHANGED
|
@@ -11,7 +11,7 @@ from typing import Any
|
|
| 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)"}
|
|
@@ -116,7 +116,7 @@ def generate_walking_fallback(
|
|
| 116 |
continue
|
| 117 |
# Auto mode searches several sizes of the same GPX drawing. The
|
| 118 |
# result's measured length, rather than a hidden default, is shown.
|
| 119 |
-
requested_size_km = (
|
| 120 |
scale = requested_size_km * 1000 / perimeter / 1.2
|
| 121 |
angle = generation_angle(generation_id) + attempt * math.pi * (3 - math.sqrt(5))
|
| 122 |
# Shorter retries also work near a coast or on a small island.
|
|
@@ -140,7 +140,7 @@ def generate_walking_fallback(
|
|
| 140 |
trip = response.json().get("trip", {})
|
| 141 |
summary = trip.get("summary", {})
|
| 142 |
length = float(summary.get("length", 0))
|
| 143 |
-
if summary.get("has_ferry") or not math.isfinite(length) or length <= 0 or (auto_distance and not 1.5 <= length <=
|
| 144 |
continue
|
| 145 |
coords = []
|
| 146 |
leg_shapes = []
|
|
@@ -167,6 +167,7 @@ def generate_walking_fallback(
|
|
| 167 |
# Matching any unrelated dog example could give a misleadingly
|
| 168 |
# high score even when the placed contour was badly distorted.
|
| 169 |
similarity, _ = best_gpx_shape_match(coords, [source_example]) if templates else (0.0, None)
|
|
|
|
| 170 |
waypoint_error_m = None
|
| 171 |
if templates and len(leg_shapes) == len(locations) - 1:
|
| 172 |
snapped = [leg_shapes[0][0]] + [leg[-1] for leg in leg_shapes]
|
|
@@ -181,7 +182,9 @@ def generate_walking_fallback(
|
|
| 181 |
"estimated_route":False,"results":[{"label":label,"score":0.0,"shape":0.0}],
|
| 182 |
"route_quality":{"art_ready":False,
|
| 183 |
**({"gpx_similarity":similarity,"gpx_example_index":source_example_index,
|
| 184 |
-
"road_waypoint_error_m":waypoint_error_m
|
|
|
|
|
|
|
| 185 |
except requests.RequestException as exc:
|
| 186 |
if not isinstance(exc, requests.HTTPError) or (exc.response is not None and exc.response.status_code in {429,502,503,504}):
|
| 187 |
break
|
|
@@ -189,7 +192,8 @@ def generate_walking_fallback(
|
|
| 189 |
continue
|
| 190 |
if not routes:
|
| 191 |
return None
|
| 192 |
-
routes.sort(key=lambda route: (-route["route_quality"].get("
|
|
|
|
| 193 |
route["route_quality"].get("road_waypoint_error_m") or 0.0,
|
| 194 |
0 if auto_distance else abs(route["length_km"]-target_distance_km)))
|
| 195 |
routes = routes[:route_count]
|
|
|
|
| 11 |
|
| 12 |
import requests
|
| 13 |
|
| 14 |
+
from app.services.gpx_shape_similarity import best_gpx_shape_match, gpx_route_rank_score, route_footprint_km
|
| 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)"}
|
|
|
|
| 116 |
continue
|
| 117 |
# Auto mode searches several sizes of the same GPX drawing. The
|
| 118 |
# result's measured length, rather than a hidden default, is shown.
|
| 119 |
+
requested_size_km = (6, 10, 14, 18, 24, 30, 36, 42)[attempt] if auto_distance else target_distance_km
|
| 120 |
scale = requested_size_km * 1000 / perimeter / 1.2
|
| 121 |
angle = generation_angle(generation_id) + attempt * math.pi * (3 - math.sqrt(5))
|
| 122 |
# Shorter retries also work near a coast or on a small island.
|
|
|
|
| 140 |
trip = response.json().get("trip", {})
|
| 141 |
summary = trip.get("summary", {})
|
| 142 |
length = float(summary.get("length", 0))
|
| 143 |
+
if summary.get("has_ferry") or not math.isfinite(length) or length <= 0 or (auto_distance and not 1.5 <= length <= 45):
|
| 144 |
continue
|
| 145 |
coords = []
|
| 146 |
leg_shapes = []
|
|
|
|
| 167 |
# Matching any unrelated dog example could give a misleadingly
|
| 168 |
# high score even when the placed contour was badly distorted.
|
| 169 |
similarity, _ = best_gpx_shape_match(coords, [source_example]) if templates else (0.0, None)
|
| 170 |
+
footprint_km = route_footprint_km(coords) if templates else 0.0
|
| 171 |
waypoint_error_m = None
|
| 172 |
if templates and len(leg_shapes) == len(locations) - 1:
|
| 173 |
snapped = [leg_shapes[0][0]] + [leg[-1] for leg in leg_shapes]
|
|
|
|
| 182 |
"estimated_route":False,"results":[{"label":label,"score":0.0,"shape":0.0}],
|
| 183 |
"route_quality":{"art_ready":False,
|
| 184 |
**({"gpx_similarity":similarity,"gpx_example_index":source_example_index,
|
| 185 |
+
"road_waypoint_error_m":waypoint_error_m,
|
| 186 |
+
"gpx_footprint_km":footprint_km,
|
| 187 |
+
"gpx_rank_score":gpx_route_rank_score(similarity, footprint_km)} if templates else {})}})
|
| 188 |
except requests.RequestException as exc:
|
| 189 |
if not isinstance(exc, requests.HTTPError) or (exc.response is not None and exc.response.status_code in {429,502,503,504}):
|
| 190 |
break
|
|
|
|
| 192 |
continue
|
| 193 |
if not routes:
|
| 194 |
return None
|
| 195 |
+
routes.sort(key=lambda route: (-route["route_quality"].get("gpx_rank_score", 0.0),
|
| 196 |
+
-route["route_quality"].get("gpx_similarity", 0.0),
|
| 197 |
route["route_quality"].get("road_waypoint_error_m") or 0.0,
|
| 198 |
0 if auto_distance else abs(route["length_km"]-target_distance_km)))
|
| 199 |
routes = routes[:route_count]
|
api/ai-service/tests/test_gpx_shape_similarity.py
CHANGED
|
@@ -4,7 +4,7 @@ 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():
|
|
@@ -24,3 +24,13 @@ def test_finds_closest_gpx_outline_independent_of_rotation_and_location():
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, gpx_route_rank_score, route_footprint_km
|
| 8 |
|
| 9 |
|
| 10 |
def test_finds_closest_gpx_outline_independent_of_rotation_and_location():
|
|
|
|
| 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)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_wide_gpx_contour_wins_close_fit_but_not_poor_fit():
|
| 30 |
+
narrow = [[37.5, 127.0], [37.55, 127.001], [37.5, 127.0]]
|
| 31 |
+
wide = [[37.5, 127.0], [37.55, 127.06], [37.5, 127.0]]
|
| 32 |
+
narrow_span = route_footprint_km(narrow)
|
| 33 |
+
wide_span = route_footprint_km(wide)
|
| 34 |
+
assert wide_span > narrow_span
|
| 35 |
+
assert gpx_route_rank_score(0.96, wide_span) > gpx_route_rank_score(0.97, narrow_span)
|
| 36 |
+
assert gpx_route_rank_score(0.96, wide_span) < gpx_route_rank_score(0.99, narrow_span)
|
api/ai-service/tests/test_walking_route_fallback.py
CHANGED
|
@@ -185,3 +185,20 @@ def test_auto_distance_tries_multiple_sizes_and_reports_measured_length(monkeypa
|
|
| 185 |
first = json.loads(get.call_args_list[0].kwargs['params']['json'])['locations']
|
| 186 |
second = json.loads(get.call_args_list[1].kwargs['params']['json'])['locations']
|
| 187 |
assert first[1] != second[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
first = json.loads(get.call_args_list[0].kwargs['params']['json'])['locations']
|
| 186 |
second = json.loads(get.call_args_list[1].kwargs['params']['json'])['locations']
|
| 187 |
assert first[1] != second[1]
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_auto_distance_keeps_wide_measured_road_route(monkeypatch):
|
| 191 |
+
import json
|
| 192 |
+
def following_provider(_url, *, params, **_kwargs):
|
| 193 |
+
locations = json.loads(params['json'])['locations']
|
| 194 |
+
legs = [{'shape': encode([[a['lat'], a['lon']], [b['lat'], b['lon']]])}
|
| 195 |
+
for a, b in zip(locations, locations[1:])]
|
| 196 |
+
return SimpleNamespace(raise_for_status=lambda: None,
|
| 197 |
+
json=lambda: {'trip': {'summary': {'length': 30.0, 'has_ferry': False}, 'legs': legs}})
|
| 198 |
+
monkeypatch.setattr(requests, 'get', following_provider)
|
| 199 |
+
example = [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]
|
| 200 |
+
result = walking.generate_walking_fallback(**options(), templates=[example],
|
| 201 |
+
generation_id='wide-gpx', auto_distance=True)
|
| 202 |
+
assert result['best_route']['length_km'] == 30.0
|
| 203 |
+
assert result['best_route']['route_quality']['gpx_footprint_km'] > 0
|
| 204 |
+
assert result['target_distance_km'] == 30.0
|
router/ai/scripts/clip_osm_region_test.py
CHANGED
|
@@ -2416,7 +2416,7 @@ def generate_candidate_routes(
|
|
| 2416 |
shuffled_examples = random.sample(examples, len(examples)) if examples else []
|
| 2417 |
for attempt in range(guided_attempts):
|
| 2418 |
try:
|
| 2419 |
-
guided_target_m = (
|
| 2420 |
geo_points = guided_geo_waypoints(
|
| 2421 |
graph=graph,
|
| 2422 |
label=str(selected_label),
|
|
@@ -2431,7 +2431,7 @@ def generate_candidate_routes(
|
|
| 2431 |
if len(route_nodes) < 18:
|
| 2432 |
continue
|
| 2433 |
length_m = route_length_m(graph, route_nodes)
|
| 2434 |
-
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <=
|
| 2435 |
continue
|
| 2436 |
unique_ratio = len(set(route_nodes)) / len(route_nodes)
|
| 2437 |
if unique_ratio < 0.50:
|
|
@@ -2524,7 +2524,7 @@ def generate_candidate_routes(
|
|
| 2524 |
continue
|
| 2525 |
|
| 2526 |
length_m = route_length_m(graph, route_nodes)
|
| 2527 |
-
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <=
|
| 2528 |
continue
|
| 2529 |
unique_ratio = len(set(route_nodes)) / len(route_nodes)
|
| 2530 |
if unique_ratio < 0.55:
|
|
@@ -2603,7 +2603,7 @@ def generate_candidate_routes(
|
|
| 2603 |
if len(route_nodes) < 10:
|
| 2604 |
continue
|
| 2605 |
length_m = route_length_m(graph, route_nodes)
|
| 2606 |
-
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <=
|
| 2607 |
continue
|
| 2608 |
pts = route_points(graph, route_nodes)
|
| 2609 |
shape_quality, shape_metrics = route_shape_quality_with_metrics(pts, close_loop=False)
|
|
|
|
| 2416 |
shuffled_examples = random.sample(examples, len(examples)) if examples else []
|
| 2417 |
for attempt in range(guided_attempts):
|
| 2418 |
try:
|
| 2419 |
+
guided_target_m = (6000.0, 10000.0, 14000.0, 18000.0, 24000.0, 30000.0, 36000.0)[attempt % 7] if auto_distance else target_distance_m
|
| 2420 |
geo_points = guided_geo_waypoints(
|
| 2421 |
graph=graph,
|
| 2422 |
label=str(selected_label),
|
|
|
|
| 2431 |
if len(route_nodes) < 18:
|
| 2432 |
continue
|
| 2433 |
length_m = route_length_m(graph, route_nodes)
|
| 2434 |
+
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <= 45000.0):
|
| 2435 |
continue
|
| 2436 |
unique_ratio = len(set(route_nodes)) / len(route_nodes)
|
| 2437 |
if unique_ratio < 0.50:
|
|
|
|
| 2524 |
continue
|
| 2525 |
|
| 2526 |
length_m = route_length_m(graph, route_nodes)
|
| 2527 |
+
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <= 45000.0):
|
| 2528 |
continue
|
| 2529 |
unique_ratio = len(set(route_nodes)) / len(route_nodes)
|
| 2530 |
if unique_ratio < 0.55:
|
|
|
|
| 2603 |
if len(route_nodes) < 10:
|
| 2604 |
continue
|
| 2605 |
length_m = route_length_m(graph, route_nodes)
|
| 2606 |
+
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <= 45000.0):
|
| 2607 |
continue
|
| 2608 |
pts = route_points(graph, route_nodes)
|
| 2609 |
shape_quality, shape_metrics = route_shape_quality_with_metrics(pts, close_loop=False)
|