Spaces:
Running
Running
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='© <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='© <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 |
-
|
|
|
|
|
|
|
| 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:]))
|