Skip to content

Commit 13e5497

Browse files
authored
Merge branch 'feature/25-mapbox-map-matching'
feat: Mapbox Map Matching API 연동으로 경로 품질 개선
2 parents ad746b0 + ce21b88 commit 13e5497

File tree

5 files changed

+152
-13
lines changed

5 files changed

+152
-13
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ REDIS_HOST=localhost
77
REDIS_PORT=6379
88
SPRING_PROFILES_ACTIVE=local
99
GEMINI_API_KEY=your-gemini-api-key-here
10+
MAPBOX_API_KEY=your-mapbox-api-key-here
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package com.artrun.server.service;
2+
3+
import com.artrun.server.common.BusinessException;
4+
import com.artrun.server.common.ErrorCode;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.locationtech.jts.geom.Coordinate;
7+
import org.locationtech.jts.geom.GeometryFactory;
8+
import org.locationtech.jts.geom.LineString;
9+
import org.locationtech.jts.geom.PrecisionModel;
10+
import org.springframework.beans.factory.annotation.Value;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.web.client.RestClient;
13+
import tools.jackson.databind.JsonNode;
14+
import tools.jackson.databind.ObjectMapper;
15+
16+
import java.util.ArrayList;
17+
import java.util.List;
18+
19+
@Slf4j
20+
@Service
21+
public class MapboxMapMatchingService {
22+
23+
private static final GeometryFactory GF = new GeometryFactory(new PrecisionModel(), 4326);
24+
private static final int MAX_COORDINATES_PER_REQUEST = 100;
25+
26+
private final String apiKey;
27+
private final ObjectMapper objectMapper;
28+
private final RestClient restClient;
29+
30+
public MapboxMapMatchingService(
31+
@Value("${mapbox.api-key:}") String apiKey,
32+
ObjectMapper objectMapper) {
33+
this.apiKey = apiKey;
34+
this.objectMapper = objectMapper;
35+
this.restClient = RestClient.create();
36+
}
37+
38+
public boolean isAvailable() {
39+
return apiKey != null && !apiKey.isBlank();
40+
}
41+
42+
/**
43+
* 좌표열을 Mapbox Map Matching API로 도로에 매칭하여 경로를 반환한다.
44+
* 입력: 도형 윤곽을 따르는 촘촘한 좌표열 (lat/lng)
45+
* 출력: 도로를 따라가는 LineString
46+
*/
47+
public LineString matchToRoads(Coordinate[] coordinates) {
48+
log.info("Mapbox Map Matching: {} coordinates", coordinates.length);
49+
50+
List<Coordinate> allMatched = new ArrayList<>();
51+
52+
// Mapbox는 한 번에 최대 100좌표 → 청크로 분할
53+
for (int start = 0; start < coordinates.length; start += MAX_COORDINATES_PER_REQUEST - 1) {
54+
int end = Math.min(start + MAX_COORDINATES_PER_REQUEST, coordinates.length);
55+
Coordinate[] chunk = new Coordinate[end - start];
56+
System.arraycopy(coordinates, start, chunk, 0, end - start);
57+
58+
List<Coordinate> matched = callMapMatchingApi(chunk);
59+
60+
if (!allMatched.isEmpty() && !matched.isEmpty()) {
61+
matched = matched.subList(1, matched.size());
62+
}
63+
allMatched.addAll(matched);
64+
}
65+
66+
if (allMatched.size() < 2) {
67+
throw new BusinessException(ErrorCode.ROUTING_FAILED, "Mapbox Map Matching 실패: 매칭된 좌표가 부족합니다.");
68+
}
69+
70+
log.info("Mapbox matched: {} -> {} coordinates", coordinates.length, allMatched.size());
71+
return GF.createLineString(allMatched.toArray(new Coordinate[0]));
72+
}
73+
74+
private List<Coordinate> callMapMatchingApi(Coordinate[] coordinates) {
75+
// coordinates는 JTS 형식 (x=lng, y=lat)
76+
StringBuilder coordStr = new StringBuilder();
77+
StringBuilder radiuses = new StringBuilder();
78+
for (int i = 0; i < coordinates.length; i++) {
79+
if (i > 0) {
80+
coordStr.append(";");
81+
radiuses.append(";");
82+
}
83+
coordStr.append(coordinates[i].x).append(",").append(coordinates[i].y);
84+
radiuses.append("25"); // 25m 반경 내 도로 매칭
85+
}
86+
87+
String url = "https://api.mapbox.com/matching/v5/mapbox/walking/%s?access_token=%s&geometries=geojson&radiuses=%s&overview=full"
88+
.formatted(coordStr, apiKey, radiuses);
89+
90+
try {
91+
String response = restClient.get()
92+
.uri(url)
93+
.retrieve()
94+
.body(String.class);
95+
96+
JsonNode root = objectMapper.readTree(response);
97+
String code = root.path("code").asText();
98+
99+
if (!"Ok".equals(code)) {
100+
log.warn("Mapbox Map Matching returned: {}", code);
101+
return List.of();
102+
}
103+
104+
JsonNode matchings = root.path("matchings");
105+
if (matchings.isEmpty()) {
106+
return List.of();
107+
}
108+
109+
// 첫 번째 매칭 결과의 geometry 좌표 추출
110+
JsonNode coords = matchings.path(0).path("geometry").path("coordinates");
111+
List<Coordinate> result = new ArrayList<>();
112+
for (JsonNode point : coords) {
113+
double lng = point.path(0).asDouble();
114+
double lat = point.path(1).asDouble();
115+
result.add(new Coordinate(lng, lat));
116+
}
117+
118+
return result;
119+
} catch (Exception e) {
120+
log.error("Mapbox API call failed: {}", e.getMessage());
121+
return List.of();
122+
}
123+
}
124+
}

src/main/java/com/artrun/server/service/RouteGenerationOrchestrator.java

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public class RouteGenerationOrchestrator {
3030
private final ShapeEngineService shapeEngineService;
3131
private final GeospatialScaleService geospatialScaleService;
3232
private final MapMatchingService mapMatchingService;
33+
private final MapboxMapMatchingService mapboxService;
3334
private final RoutingEngineService routingEngineService;
3435
private final ValidationScoringService validationScoringService;
3536
private final RouteRepository routeRepository;
@@ -110,15 +111,25 @@ private CandidateResult buildCandidate(RouteTask task, List<AnchorPoint> points,
110111
points, lat, lng, task.getTargetDistanceKm());
111112
Geometry originalShape = geospatialScaleService.createGeometry(scaledCoords);
112113

113-
// 2.5. Interpolation - 앵커 포인트 사이에 100m 간격으로 중간점 삽입
114-
Coordinate[] denseCoords = geospatialScaleService.interpolate(scaledCoords, 100.0);
115-
116-
// 3. Map Matching - 촘촘한 점들을 도로에 스냅
117-
List<Long> nodeIds = mapMatchingService.snapToOsmNodes(denseCoords);
118-
119-
// 4. Routing Engine
120-
LineString routePolyline = routingEngineService.buildRoute(nodeIds, avoidMainRoad, preferPark);
121-
double distanceMeters = routingEngineService.calculateRouteDistance(routePolyline);
114+
// 2.5. Interpolation - 앵커 포인트 사이에 중간점 삽입
115+
Coordinate[] denseCoords = geospatialScaleService.interpolate(scaledCoords,
116+
mapboxService.isAvailable() ? 30.0 : 100.0);
117+
118+
LineString routePolyline;
119+
double distanceMeters;
120+
121+
if (mapboxService.isAvailable()) {
122+
// 3+4. Mapbox Map Matching - 도형 윤곽을 도로에 직접 매칭
123+
log.info("Using Mapbox Map Matching for road matching");
124+
routePolyline = mapboxService.matchToRoads(denseCoords);
125+
distanceMeters = routingEngineService.calculateRouteDistance(routePolyline);
126+
} else {
127+
// 3. OSM Node Snap + 4. pgRouting 폴백
128+
log.info("Using pgRouting fallback (Mapbox not configured)");
129+
List<Long> nodeIds = mapMatchingService.snapToOsmNodes(denseCoords);
130+
routePolyline = routingEngineService.buildRoute(nodeIds, avoidMainRoad, preferPark);
131+
distanceMeters = routingEngineService.calculateRouteDistance(routePolyline);
132+
}
122133

123134
// 5. Validation & Scoring
124135
validationScoringService.validateRoute(routePolyline);

src/main/resources/application.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ gemini:
2525
api-key: ${GEMINI_API_KEY:}
2626
model: ${GEMINI_MODEL:gemini-2.0-flash}
2727

28+
mapbox:
29+
api-key: ${MAPBOX_API_KEY:}
30+
2831
server:
2932
port: ${PORT:8080}
3033

src/test/java/com/artrun/server/service/ValidationScoringServiceTest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,16 @@ void calculateDistanceAccuracy_zeroTarget() {
7070
}
7171

7272
@Test
73-
@DisplayName("유사도 30 이상이면 최소 품질 충족")
73+
@DisplayName("유사도 10 이상이면 최소 품질 충족")
7474
void meetsMinimumQuality_above() {
75-
assertThat(service.meetsMinimumQuality(30.0)).isTrue();
75+
assertThat(service.meetsMinimumQuality(10.0)).isTrue();
7676
assertThat(service.meetsMinimumQuality(50.0)).isTrue();
7777
}
7878

7979
@Test
80-
@DisplayName("유사도 30 미만이면 최소 품질 미달")
80+
@DisplayName("유사도 10 미만이면 최소 품질 미달")
8181
void meetsMinimumQuality_below() {
82-
assertThat(service.meetsMinimumQuality(29.9)).isFalse();
82+
assertThat(service.meetsMinimumQuality(9.9)).isFalse();
8383
assertThat(service.meetsMinimumQuality(0.0)).isFalse();
8484
}
8585
}

0 commit comments

Comments
 (0)