tkadkghdlf commited on
Commit
1c703bf
ยท
verified ยท
1 Parent(s): 95cf558

Sync DemoService 347d491b75d7ea205a93c78d594520ed50d1d31a

Browse files
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
- candidate_pool = min(8, max(route_count, route_count + 1))
 
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[:route_count]:
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
- outline = list(template or [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)])
70
- if outline[0] != outline[-1]:
71
- outline.append(outline[0])
72
- # Keep public requests small. Every segment is routed by the pedestrian
73
- # engine; these waypoints are never returned as a fabricated road line.
74
- # The public pedestrian endpoint accepts at most ten locations, including
75
- # both endpoints. Longer drawing outlines must be sampled before routing.
76
- if len(outline) > 10:
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: abs(route["length_km"]-target_distance_km))
 
 
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
- for _ in range(guided_attempts):
 
 
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)