tkadkghdlf commited on
Commit
804f1a6
Β·
verified Β·
1 Parent(s): 9711891

Sync DemoService 71bd72e86a2e2123979cade60f569b398f021fbd

Browse files
api/ai-service/app/api/v1/endpoints/route_generation.py CHANGED
@@ -62,6 +62,11 @@ class RouteGenerateRequest(BaseModel):
62
  fallbackLongitude: float | None = Field(default=None, ge=-180, le=180)
63
 
64
 
 
 
 
 
 
65
  class RouteSnapRequest(BaseModel):
66
  centerLat: float = Field(..., ge=-90, le=90)
67
  centerLng: float = Field(..., ge=-180, le=180)
@@ -618,6 +623,64 @@ def _run_experimental_next_edge(
618
  }
619
 
620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  def _nearby_road_return_routes(
622
  graph: Any,
623
  route_count: int,
 
62
  fallbackLongitude: float | None = Field(default=None, ge=-180, le=180)
63
 
64
 
65
+ class ShapeDiscoveryRequest(BaseModel):
66
+ latitude: float = Field(..., ge=-90, le=90)
67
+ longitude: float = Field(..., ge=-180, le=180)
68
+
69
+
70
  class RouteSnapRequest(BaseModel):
71
  centerLat: float = Field(..., ge=-90, le=90)
72
  centerLng: float = Field(..., ge=-180, le=180)
 
623
  }
624
 
625
 
626
+ @router.post("/routes/discover-shapes")
627
+ def discover_shapes(payload: ShapeDiscoveryRequest) -> dict[str, Any]:
628
+ """Find runnable dog, star and T-shirt outlines near a picked map point."""
629
+ workspace_dir = Path(settings.ai_workspace_dir)
630
+ script_dir = workspace_dir / "scripts"
631
+ script_path = script_dir / "experimental_next_edge_service.py"
632
+ if str(script_dir) not in sys.path:
633
+ sys.path.insert(0, str(script_dir))
634
+ try:
635
+ module = _load_pipeline_module(script_dir / "clip_osm_region_test.py")
636
+ spec = importlib.util.spec_from_file_location("shape_discovery_runtime", script_path)
637
+ if spec is None or spec.loader is None:
638
+ raise RuntimeError("Shape discovery is unavailable")
639
+ experiment = importlib.util.module_from_spec(spec)
640
+ spec.loader.exec_module(experiment)
641
+ outlines = getattr(module, "GPX_SHAPE_TEMPLATES", {})
642
+ graph = experiment.fetch_small_walk_graph(payload.latitude, payload.longitude)
643
+ road, paths_by_label, snap_distance = experiment.discover_shape_paths(
644
+ graph, {label: outlines.get(label, []) for label in ("dog", "symbol_star", "t_shirt")},
645
+ payload.latitude, payload.longitude,
646
+ )
647
+ candidates = []
648
+ for label, paths in paths_by_label.items():
649
+ ranked = []
650
+ for path in paths:
651
+ coords = road.geo(path)
652
+ similarity, _ = best_gpx_shape_match(coords, outlines[label])
653
+ edges = list(zip(path[:-1], path[1:]))
654
+ retrace = 1 - len(set(edges)) / max(1, len(edges))
655
+ score = max(0., similarity - .2 * retrace)
656
+ ranked.append((score, similarity, retrace, path, coords))
657
+ if ranked:
658
+ score, similarity, retrace, path, coords = max(ranked, key=lambda row: row[0])
659
+ candidates.append({
660
+ "label": label, "score": round(score, 4),
661
+ "shape_similarity": round(similarity, 4),
662
+ "retrace_ratio": round(retrace, 4),
663
+ "available": score >= .7,
664
+ "distance_km": round(sum(road.length[a, b] for a, b in edges) / 1000, 2)
665
+ if (edges := list(zip(path[:-1], path[1:]))) else 0,
666
+ "route_coords": coords,
667
+ })
668
+ else:
669
+ candidates.append({"label": label, "score": 0., "shape_similarity": 0.,
670
+ "retrace_ratio": 0., "available": False,
671
+ "distance_km": 0., "route_coords": []})
672
+ candidates.sort(key=lambda candidate: -candidate["score"])
673
+ return {"point": [payload.latitude, payload.longitude],
674
+ "road_start": road.geo([int(road.nearest(road.project([(payload.latitude, payload.longitude)]))[0][0])])[0],
675
+ "snap_distance_m": round(snap_distance, 1),
676
+ "search_radius_km": 0.8, "candidates": candidates}
677
+ except ValueError as exc:
678
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
679
+ except Exception as exc:
680
+ logger.exception("shape discovery failed")
681
+ raise HTTPException(status_code=503, detail="이 μ§€μ μ˜ λ„λ‘œλ₯Ό μ‘°νšŒν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.") from exc
682
+
683
+
684
  def _nearby_road_return_routes(
685
  graph: Any,
686
  route_count: int,
main/app/_components/LeafletMap.tsx CHANGED
@@ -26,6 +26,7 @@ export type LeafletMapProps = {
26
  markers?: MapMarker[];
27
  selectedMarkerId?: string | null;
28
  onMarkerClick?: (marker: MapMarker) => void;
 
29
  className?: string;
30
  zoom?: number;
31
  preserveViewport?: boolean;
 
26
  markers?: MapMarker[];
27
  selectedMarkerId?: string | null;
28
  onMarkerClick?: (marker: MapMarker) => void;
29
+ onMapClick?: (point: [number, number]) => void;
30
  className?: string;
31
  zoom?: number;
32
  preserveViewport?: boolean;
main/app/_components/LeafletMapClient.tsx CHANGED
@@ -10,6 +10,7 @@ import {
10
  TileLayer,
11
  ZoomControl,
12
  useMap,
 
13
  } from "react-leaflet";
14
 
15
  type RouteLayer = {
@@ -35,6 +36,11 @@ function isMapContainerReady(map: ReturnType<typeof useMap>): boolean {
35
  }
36
  }
37
 
 
 
 
 
 
38
  function InitialRunView({ route, center, zoom }: {
39
  route: [number, number][];
40
  center: [number, number];
@@ -154,6 +160,7 @@ export default function LeafletMapClient({
154
  markers,
155
  selectedMarkerId,
156
  onMarkerClick,
 
157
  markerEmoji = "πŸƒ",
158
  className,
159
  zoom = 13,
@@ -173,6 +180,7 @@ export default function LeafletMapClient({
173
  markers?: MapMarker[];
174
  selectedMarkerId?: string | null;
175
  onMarkerClick?: (marker: MapMarker) => void;
 
176
  className?: string;
177
  zoom?: number;
178
  preserveViewport?: boolean;
@@ -263,6 +271,7 @@ export default function LeafletMapClient({
263
  fadeAnimation={false}
264
  markerZoomAnimation={false}
265
  >
 
266
  {interactive && <ZoomControl position="bottomleft" />}
267
  <TileLayer
268
  attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
 
10
  TileLayer,
11
  ZoomControl,
12
  useMap,
13
+ useMapEvents,
14
  } from "react-leaflet";
15
 
16
  type RouteLayer = {
 
36
  }
37
  }
38
 
39
+ function MapClickHandler({ onMapClick }: { onMapClick?: (point: [number, number]) => void }) {
40
+ useMapEvents({ click: (event) => onMapClick?.([event.latlng.lat, event.latlng.lng]) });
41
+ return null;
42
+ }
43
+
44
  function InitialRunView({ route, center, zoom }: {
45
  route: [number, number][];
46
  center: [number, number];
 
160
  markers,
161
  selectedMarkerId,
162
  onMarkerClick,
163
+ onMapClick,
164
  markerEmoji = "πŸƒ",
165
  className,
166
  zoom = 13,
 
180
  markers?: MapMarker[];
181
  selectedMarkerId?: string | null;
182
  onMarkerClick?: (marker: MapMarker) => void;
183
+ onMapClick?: (point: [number, number]) => void;
184
  className?: string;
185
  zoom?: number;
186
  preserveViewport?: boolean;
 
271
  fadeAnimation={false}
272
  markerZoomAnimation={false}
273
  >
274
+ {interactive && onMapClick && <MapClickHandler onMapClick={onMapClick} />}
275
  {interactive && <ZoomControl position="bottomleft" />}
276
  <TileLayer
277
  attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
main/app/route-search/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { Suspense, useEffect, useMemo, useState } from "react";
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import LeafletMap, { type MapMarker, type MapRouteLayer } from "../_components/LeafletMap";
6
  import WebLocationPermission from "../_components/WebLocationPermission";
@@ -15,6 +15,15 @@ import { prepareCoursePreview } from "../_utils/coursePreview";
15
 
16
  type Point = [number, number];
17
 
 
 
 
 
 
 
 
 
 
18
  type DemoSampleRun = {
19
  id: string;
20
  label: "dog" | "symbol_star" | "t_shirt";
@@ -440,6 +449,12 @@ function RouteSearchPageContent() {
440
  const [sampleGalleryLabel, setSampleGalleryLabel] = useState<DemoSampleRun["label"] | null>(null);
441
  const [isPostLoading, setIsPostLoading] = useState(true);
442
  const [selectedPlaceCenter, setSelectedPlaceCenter] = useState<Point | null>(null);
 
 
 
 
 
 
443
 
444
  const activeRegion = useMemo(
445
  () =>
@@ -537,8 +552,11 @@ function RouteSearchPageContent() {
537
  title: sample.title,
538
  kind: sample.label === "dog" ? "sampleDog" : sample.label === "t_shirt" ? "sampleTshirt" : "sampleStar",
539
  }));
 
540
  const selectedMapRoutes: MapRouteLayer[] =
541
- selectedSample?.route && selectedSample.route.length > 1
 
 
542
  ? [{ points: selectedSample.route, color: selectedSample.label === "dog" ? "#f97316" : selectedSample.label === "t_shirt" ? "#0d9488" : "#a855f7", weight: 5, opacity: 0.95 }]
543
  : sampleGalleryLabel
544
  ? galleryMapSamples.map((sample) => ({
@@ -785,6 +803,33 @@ function RouteSearchPageContent() {
785
  router.push(`/route-results?${params.toString()}`);
786
  };
787
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
  const handleStartSelectedPostRoute = () => {
789
  if (
790
  typeof window === "undefined" ||
@@ -848,7 +893,8 @@ function RouteSearchPageContent() {
848
  <LeafletMap
849
  center={mapCenter}
850
  routes={selectedMapRoutes}
851
- markers={[...sampleMarkers, ...postMarkers]}
 
852
  selectedMarkerId={selectedSampleId ?? (selectedPostId ? `post-${selectedPostId}` : null)}
853
  onMarkerClick={(marker) => {
854
  if (marker.kind === "sampleDog" || marker.kind === "sampleStar" || marker.kind === "sampleTshirt") {
@@ -869,6 +915,35 @@ function RouteSearchPageContent() {
869
  경둜 미리보기
870
  </div>
871
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
872
  <div className="border-t border-black/10 bg-white px-4 py-4">
873
  <div className="flex items-start justify-between gap-3">
874
  <div className="min-w-0">
 
1
  "use client";
2
 
3
+ import { Suspense, useEffect, useMemo, useRef, useState } from "react";
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import LeafletMap, { type MapMarker, type MapRouteLayer } from "../_components/LeafletMap";
6
  import WebLocationPermission from "../_components/WebLocationPermission";
 
15
 
16
  type Point = [number, number];
17
 
18
+ type ShapeDiscoveryCandidate = {
19
+ label: "dog" | "symbol_star" | "t_shirt";
20
+ score: number;
21
+ shape_similarity: number;
22
+ available: boolean;
23
+ distance_km: number;
24
+ route_coords: Point[];
25
+ };
26
+
27
  type DemoSampleRun = {
28
  id: string;
29
  label: "dog" | "symbol_star" | "t_shirt";
 
449
  const [sampleGalleryLabel, setSampleGalleryLabel] = useState<DemoSampleRun["label"] | null>(null);
450
  const [isPostLoading, setIsPostLoading] = useState(true);
451
  const [selectedPlaceCenter, setSelectedPlaceCenter] = useState<Point | null>(null);
452
+ const [discoveryPoint, setDiscoveryPoint] = useState<Point | null>(null);
453
+ const [discoveryCandidates, setDiscoveryCandidates] = useState<ShapeDiscoveryCandidate[]>([]);
454
+ const [discoveryLoading, setDiscoveryLoading] = useState(false);
455
+ const [discoveryError, setDiscoveryError] = useState<string | null>(null);
456
+ const [previewDiscoveryLabel, setPreviewDiscoveryLabel] = useState<string | null>(null);
457
+ const discoveryRequest = useRef<AbortController | null>(null);
458
 
459
  const activeRegion = useMemo(
460
  () =>
 
552
  title: sample.title,
553
  kind: sample.label === "dog" ? "sampleDog" : sample.label === "t_shirt" ? "sampleTshirt" : "sampleStar",
554
  }));
555
+ const previewDiscovery = discoveryCandidates.find((candidate) => candidate.label === previewDiscoveryLabel);
556
  const selectedMapRoutes: MapRouteLayer[] =
557
+ previewDiscovery?.route_coords && previewDiscovery.route_coords.length > 1
558
+ ? [{ points: previewDiscovery.route_coords, color: "#1E40AF", weight: 5, opacity: 0.95 }]
559
+ : selectedSample?.route && selectedSample.route.length > 1
560
  ? [{ points: selectedSample.route, color: selectedSample.label === "dog" ? "#f97316" : selectedSample.label === "t_shirt" ? "#0d9488" : "#a855f7", weight: 5, opacity: 0.95 }]
561
  : sampleGalleryLabel
562
  ? galleryMapSamples.map((sample) => ({
 
803
  router.push(`/route-results?${params.toString()}`);
804
  };
805
 
806
+ const discoverAtPoint = async (point: Point) => {
807
+ discoveryRequest.current?.abort();
808
+ const controller = new AbortController();
809
+ discoveryRequest.current = controller;
810
+ setDiscoveryPoint(point);
811
+ setDiscoveryCandidates([]);
812
+ setPreviewDiscoveryLabel(null);
813
+ setDiscoveryError(null);
814
+ setDiscoveryLoading(true);
815
+ try {
816
+ const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/api/v1/ai/routes/discover-shapes`, {
817
+ method: "POST", headers: { "Content-Type": "application/json" },
818
+ body: JSON.stringify({ latitude: point[0], longitude: point[1] }),
819
+ signal: controller.signal, cache: "no-store",
820
+ });
821
+ const data = await response.json() as { candidates?: ShapeDiscoveryCandidate[]; detail?: string };
822
+ if (!response.ok || !Array.isArray(data.candidates)) {
823
+ throw new Error(data.detail || "이 μ§€μ μ˜ λ„λ‘œλ₯Ό μ‘°νšŒν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.");
824
+ }
825
+ if (!controller.signal.aborted) setDiscoveryCandidates(data.candidates);
826
+ } catch (error) {
827
+ if (!controller.signal.aborted) setDiscoveryError(error instanceof Error ? error.message : "탐색에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.");
828
+ } finally {
829
+ if (!controller.signal.aborted) setDiscoveryLoading(false);
830
+ }
831
+ };
832
+
833
  const handleStartSelectedPostRoute = () => {
834
  if (
835
  typeof window === "undefined" ||
 
893
  <LeafletMap
894
  center={mapCenter}
895
  routes={selectedMapRoutes}
896
+ markers={[...sampleMarkers, ...postMarkers, ...(discoveryPoint ? [{ id: "discovery-point", position: discoveryPoint, title: "μ„ νƒν•œ 지점", kind: "location" as const }] : [])]}
897
+ onMapClick={(point) => { void discoverAtPoint(point); }}
898
  selectedMarkerId={selectedSampleId ?? (selectedPostId ? `post-${selectedPostId}` : null)}
899
  onMarkerClick={(marker) => {
900
  if (marker.kind === "sampleDog" || marker.kind === "sampleStar" || marker.kind === "sampleTshirt") {
 
915
  경둜 미리보기
916
  </div>
917
  </div>
918
+ <div className="border-t border-black/10 bg-[#f3f7ff] px-4 py-4">
919
+ <p className="text-sm font-bold text-[#111]">μ§€λ„μ—μ„œ ν•œ 점을 찍어 λͺ¨μ–‘ μ°ΎκΈ°</p>
920
+ <p className="mt-1 text-xs text-[#516177]">μ„ νƒν•œ 점 근처 μ•½ 0.8km λ„λ‘œμ—μ„œ 강아지·별·티셔츠 경둜λ₯Ό ν•¨κ»˜ μ°ΎμŠ΅λ‹ˆλ‹€.</p>
921
+ {discoveryLoading && <p className="mt-3 text-sm text-[#1E40AF]">μ£Όλ³€ λ„λ‘œμ™€ λͺ¨μ–‘을 ν™•μΈν•˜λŠ” 쀑...</p>}
922
+ {discoveryError && <p role="alert" className="mt-3 text-sm text-red-600">{discoveryError}</p>}
923
+ {discoveryCandidates.length > 0 && <div className="mt-3 grid grid-cols-3 gap-2">
924
+ {discoveryCandidates.map((candidate) => {
925
+ const label = candidate.label === "dog" ? "κ°•μ•„μ§€" : candidate.label === "symbol_star" ? "별" : "ν‹°μ…”μΈ ";
926
+ return <button key={candidate.label} type="button"
927
+ disabled={!candidate.available}
928
+ onClick={() => setPreviewDiscoveryLabel(candidate.label)}
929
+ className={`rounded-xl border p-2 text-left text-xs ${candidate.available ? "border-[#1E40AF]/25 bg-white" : "border-black/10 bg-[#e8ebef] text-[#667085]"}`}>
930
+ <span className="block font-bold">{label}</span>
931
+ <span className="mt-1 block">{candidate.available ? `μœ μ‚¬λ„ ${Math.round(candidate.shape_similarity * 100)}점` : "μ ν•©ν•œ 경둜 μ—†μŒ"}</span>
932
+ </button>;
933
+ })}
934
+ </div>}
935
+ {previewDiscovery?.available && discoveryPoint && <button type="button"
936
+ className="mt-3 w-full rounded-full bg-[#1E40AF] px-4 py-3 text-sm font-bold text-white"
937
+ onClick={() => router.push(prepareCoursePreview({
938
+ id: `discovered-${previewDiscovery.label}-${Date.now()}`,
939
+ title: `${previewDiscovery.label === "dog" ? "κ°•μ•„μ§€" : previewDiscovery.label === "symbol_star" ? "별" : "ν‹°μ…”μΈ "} λͺ¨μ–‘ μ½”μŠ€`,
940
+ place, distanceKm: previewDiscovery.distance_km,
941
+ center: discoveryPoint, route: previewDiscovery.route_coords,
942
+ label: previewDiscovery.label, source: "지도 지점 λͺ¨μ–‘ 탐색",
943
+ }))}>
944
+ 이 경둜 μ‚΄νŽ΄λ³΄κΈ° Β· {previewDiscovery.distance_km.toFixed(2)}km
945
+ </button>}
946
+ </div>
947
  <div className="border-t border-black/10 bg-white px-4 py-4">
948
  <div className="flex items-start justify-between gap-3">
949
  <div className="min-w-0">
router/ai/scripts/experimental_next_edge_service.py CHANGED
@@ -11,6 +11,7 @@ import torch
11
  import networkx as nx
12
  import osmnx as ox
13
  import requests
 
14
 
15
  from train_gpx_next_edge import NextEdgeGATv2, RoadGraph, rollout
16
 
@@ -85,3 +86,46 @@ def generate_experimental_paths(graph, model, outlines, count, generation_id):
85
  if len(results) >= count:
86
  break
87
  return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  import networkx as nx
12
  import osmnx as ox
13
  import requests
14
+ import numpy as np
15
 
16
  from train_gpx_next_edge import NextEdgeGATv2, RoadGraph, rollout
17
 
 
86
  if len(results) >= count:
87
  break
88
  return results
89
+
90
+
91
+ def discover_shape_paths(graph, outlines_by_label, latitude, longitude):
92
+ """Try each shape around one picked point on the same walkable graph.
93
+
94
+ The point is snapped to a nearby road and kept as the start and end of
95
+ every candidate. Scores are calculated later from the completed routes.
96
+ """
97
+ road = RoadGraph(graph)
98
+ nearest, distance = road.nearest(road.project([(latitude, longitude)]))
99
+ start = int(nearest[0])
100
+ if float(distance[0]) > 180:
101
+ raise ValueError("μ„ νƒν•œ μ μ—μ„œ 180m 이내에 보행 κ°€λŠ₯ν•œ λ„λ‘œκ°€ μ—†μŠ΅λ‹ˆλ‹€.")
102
+ result = {}
103
+ for label, outlines in outlines_by_label.items():
104
+ candidates = []
105
+ # Spread the bounded search across the full GPX outline collection.
106
+ outline_indices = np.linspace(0, len(outlines) - 1, min(3, len(outlines))).astype(int) if outlines else []
107
+ for outline_index in outline_indices:
108
+ outline = outlines[int(outline_index)]
109
+ body = np.asarray(outline[:-1], dtype=np.float32)
110
+ body = body[np.linspace(0, len(body) - 1, min(12, len(body))).astype(int)]
111
+ body -= body[0].copy()
112
+ for scale in (300., 600.):
113
+ for quarter in range(4):
114
+ angle = quarter * math.pi / 2
115
+ rotation = np.array([[math.cos(angle), -math.sin(angle)],
116
+ [math.sin(angle), math.cos(angle)]], dtype=np.float32)
117
+ nearest_nodes, snap_distance = road.nearest(body @ rotation.T * scale + road.xy[start])
118
+ if float(np.quantile(snap_distance, .8)) > 120:
119
+ continue
120
+ anchors = [start] + [int(node) for node in nearest_nodes[1:]] + [start]
121
+ path = [start]
122
+ for a, b in zip(anchors[:-1], anchors[1:]):
123
+ leg = road.route(a, b)
124
+ if not leg:
125
+ path = []
126
+ break
127
+ path.extend(leg[1:])
128
+ if len(path) >= 8:
129
+ candidates.append(path)
130
+ result[label] = candidates
131
+ return road, result, float(distance[0])
router/ai/tests/test_gpx_next_edge.py CHANGED
@@ -46,3 +46,19 @@ def test_match_train_and_holdout_rollout():
46
  report = module.evaluate(test_road, [outline], model)
47
  assert report["shortest_path"]["road_valid"] == 1
48
  assert report["gatv2"]["road_valid"] == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  report = module.evaluate(test_road, [outline], model)
47
  assert report["shortest_path"]["road_valid"] == 1
48
  assert report["gatv2"]["road_valid"] == 1
49
+
50
+
51
+ def test_point_discovery_keeps_start_and_end_on_picked_road():
52
+ from experimental_next_edge_service import discover_shape_paths
53
+
54
+ road = grid()
55
+ path = [7, 8, 9, 15, 21, 20, 19, 13, 7]
56
+ outline = module.normalized_outline_from_track([road.geo([node])[0] for node in path for _ in range(15)])
57
+ picked = road.geo([7])[0]
58
+ result_road, routes, snap_m = discover_shape_paths(
59
+ road.graph, {"dog": [outline]}, picked[0], picked[1]
60
+ )
61
+ assert snap_m < 1
62
+ assert routes["dog"]
63
+ assert all(route[0] == 7 and route[-1] == 7 for route in routes["dog"])
64
+ assert all((a, b) in result_road.length for route in routes["dog"] for a, b in zip(route[:-1], route[1:]))