tkadkghdlf commited on
Commit
ebac040
·
verified ·
1 Parent(s): 10630e6

Sync DemoService 778e066c9915dd0ec1a6d9a620a99adbf50c2f31

Browse files
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} if examples else {}),
 
 
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 = (3, 5, 8, 12, 16, 20, 6, 10)[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,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 <= 20):
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} if templates else {})}})
 
 
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("gpx_similarity", 0.0),
 
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 = (3000.0, 5000.0, 8000.0, 12000.0, 16000.0)[attempt % 5] if auto_distance else target_distance_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 <= 20000.0):
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 <= 20000.0):
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 <= 20000.0):
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)