diff --git a/src/main/java/server/gooroomi/domain/bus/api/BusRestController.java b/src/main/java/server/gooroomi/domain/bus/api/BusRestController.java index 711754a..ccc8919 100644 --- a/src/main/java/server/gooroomi/domain/bus/api/BusRestController.java +++ b/src/main/java/server/gooroomi/domain/bus/api/BusRestController.java @@ -5,12 +5,12 @@ import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import server.gooroomi.domain.bus.application.BusOcrMatchingService; import server.gooroomi.domain.bus.application.BusService; import server.gooroomi.domain.bus.dto.BusArrivalResponse; +import server.gooroomi.domain.bus.dto.OcrProcessRequest; +import server.gooroomi.domain.bus.dto.OcrProcessResponse; import server.gooroomi.global.handler.response.BaseResponse; import java.util.List; @@ -22,17 +22,19 @@ public class BusRestController { private final BusService busService; + private final BusOcrMatchingService busOcrMatchingService; - @Operation( - summary = "곧 도착 버스 목록 조회", - description = "사용자의 위치 기준으로 도착 예정인 버스 목록을 조회합니다. " + - "사용자가 등록한 버스가 포함된 경우 code: 20002로 응답합니다." - ) - @Parameters({ - @Parameter(name = "userId", description = "사용자 ID", required = true) - }) + @Operation(summary = "곧 도착 버스 목록 조회", description = "사용자의 위치 기준으로 도착 예정인 버스 목록을 조회합니다. " + + "사용자가 등록한 버스가 포함된 경우 code: 20002로 응답합니다.") + @Parameters({ @Parameter(name = "userId", description = "사용자 ID", required = true) }) @GetMapping("/arrivals") public BaseResponse> getBusArrivals(@RequestParam Long userId) { return busService.getBusArrivals(userId); } + + @Operation(summary = "OCR 결과와 버스 번호 매칭", description = "OCR로 인식된 버스 번호와 도착 예정 버스 목록을 비교합니다.") + @PostMapping("/ocr-process") + public BaseResponse processOcrResult(@RequestBody OcrProcessRequest request) { + return busOcrMatchingService.processOcrResult(request); + } } diff --git a/src/main/java/server/gooroomi/domain/bus/application/BusOcrMatchingService.java b/src/main/java/server/gooroomi/domain/bus/application/BusOcrMatchingService.java new file mode 100644 index 0000000..1c0b4da --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/application/BusOcrMatchingService.java @@ -0,0 +1,124 @@ +package server.gooroomi.domain.bus.application; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import server.gooroomi.domain.bus.converter.BusConverter; +import server.gooroomi.domain.bus.dto.OcrProcessRequest; +import server.gooroomi.domain.bus.dto.OcrProcessResponse; +import server.gooroomi.domain.bus.entity.BusArrival; +import server.gooroomi.domain.bus.entity.BusStation; +import server.gooroomi.domain.bus.entity.MatchType; +import server.gooroomi.domain.user.entity.User; +import server.gooroomi.domain.user.repository.UserRepository; +import server.gooroomi.global.handler.response.BaseException; +import server.gooroomi.global.handler.response.BaseResponse; +import server.gooroomi.global.handler.response.BaseResponseStatus; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BusOcrMatchingService { + + private final UserRepository userRepository; + private final StringSimilarityService similarityService; + private static final double SIMILARITY_THRESHOLD = 0.8; // 유사도 임계값 + + /** + * OCR 결과를 처리하여 버스 번호와 매칭 + */ + @Transactional + public BaseResponse processOcrResult(OcrProcessRequest request) { + // 사용자 및 버스 정류장 조회 + BusStation busStation = getUserBusStation(request.getUserId()); + List busArrivals = busStation.getBusArrivals(); + + // 정확히 일치하는 버스 번호 찾기 + Optional exactMatchResponse = findExactMatchResponse(busArrivals, request.getOcrText()); + if (exactMatchResponse.isPresent()) { + return BaseResponse.success(exactMatchResponse.get()); + } + + // 유사한 버스 번호 찾기 + Optional similarMatchResponse = findSimilarMatchResponse(busArrivals, request.getOcrText()); + if (similarMatchResponse.isPresent()) { + return BaseResponse.success(similarMatchResponse.get()); + } + + /** + * 일치하는 버스가 없는 경우 (정확히 일치하지도 않고, 유사하지도 않은 경우) + * 다음 경우가 포함됨 + * 1. 버스 목록이 비어있는 경우 + * 2. 유사도가 임계값보다 낮은 경우 + */ + OcrProcessResponse response = BusConverter.toOCRProcessResponse(request.getOcrText(), request.getOcrText(), + MatchType.NONE); + return BaseResponse.success(response); + } + + /** + * 사용자 ID로 버스 정류장 정보 조회 + */ + private BusStation getUserBusStation(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BaseException(BaseResponseStatus.NOT_FOUND_USER)); + BusStation busStation = user.getBusStation(); + if (busStation == null) { + throw new BaseException(BaseResponseStatus.NOT_FOUND_STATION); + } + return busStation; + } + + /** + * 정확히 일치하는 버스 번호 찾기 + */ + private Optional findExactMatchResponse(List busArrivals, String ocrText) { + return busArrivals.stream().filter(arrival -> arrival.getBusNumber().equals(ocrText)).findFirst() + .map(arrival -> { + String busNumber = arrival.getBusNumber(); + return BusConverter.toOCRProcessResponse(busNumber, ocrText, MatchType.EXACT); + }); + } + + /** + * 유사한 버스 번호 찾기 유사도가 임계값 이상인 경우에만 결과를 반환하고, 그렇지 않은 경우에는 Optional.empty()를 반환 + */ + private Optional findSimilarMatchResponse(List busArrivals, String ocrText) { + Optional mostSimilarBus = findMostSimilarBus(busArrivals, ocrText); + + if (mostSimilarBus.isPresent()) { + String mostSimilarBusNumber = mostSimilarBus.get().getBusNumber(); + double similarity = similarityService.calculateJaroWinklerSimilarity(mostSimilarBusNumber, ocrText); + + logSimilarityInfo(ocrText, mostSimilarBusNumber, similarity); + + // 유사도가 임계값 이상인 경우에만 유사한 버스 번호로 응답 생성 + if (similarity >= SIMILARITY_THRESHOLD) { + return Optional.of(BusConverter.toOCRProcessResponse(mostSimilarBusNumber, ocrText, MatchType.SIMILAR)); + } + } + + // 유사도가 임계값 미만이거나 버스가 없는 경우 + return Optional.empty(); + } + + /** + * 가장 유사한 버스 찾기 + */ + private Optional findMostSimilarBus(List busArrivals, String ocrText) { + return busArrivals.stream().max(Comparator.comparingDouble( + arrival -> similarityService.calculateJaroWinklerSimilarity(arrival.getBusNumber(), ocrText))); + } + + /** + * 유사도 정보 로깅 + */ + private void logSimilarityInfo(String ocrText, String mostSimilarBusNumber, double similarity) { + log.info("OCR 결과: {}, 가장 유사한 버스 번호: {}, 유사도: {}", ocrText, mostSimilarBusNumber, similarity); + } +} diff --git a/src/main/java/server/gooroomi/domain/bus/application/StringSimilarityService.java b/src/main/java/server/gooroomi/domain/bus/application/StringSimilarityService.java new file mode 100644 index 0000000..85b29f3 --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/application/StringSimilarityService.java @@ -0,0 +1,118 @@ +package server.gooroomi.domain.bus.application; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 문자열 유사도 계산을 위한 Service 클래스 + * Jaro-Winkler 알고리즘을 사용하여 문자열 간의 유사도를 계산 + */ +@Service +@Slf4j +public class StringSimilarityService { + + private static final double JARO_WINKLER_PREFIX_WEIGHT = 0.1; // Jaro-Winkler 접두사 가중치 + + /** + * Jaro-Winkler 유사도 계산 알고리즘 구현 문자열 간의 유사성을 측정하며, 특히 시작 부분이 일치할 때 더 높은 점수를 부여함 + * + * @param s1 첫 번째 문자열 + * @param s2 두 번째 문자열 + * @return 두 문자열 간의 Jaro-Winkler 유사도 (0.0 ~ 1.0) + */ + public double calculateJaroWinklerSimilarity(String s1, String s2) { + // 두 문자열이 같으면 유사도는 1.0 + if (s1.equals(s2)) { + return 1.0; + } + + // 두 문자열 중 하나라도 비어있으면 유사도는 0.0 + if (s1 == null || s2 == null || s1.isEmpty() || s2.isEmpty()) { + return 0.0; + } + + // Jaro 유사도 계산 + double jaroSimilarity = calculateJaroSimilarity(s1, s2); + + // 공통 접두사 길이 계산 (최대 4자까지) + int prefixLength = 0; + int maxPrefixLength = Math.min(4, Math.min(s1.length(), s2.length())); + + for (int i = 0; i < maxPrefixLength; i++) { + if (s1.charAt(i) == s2.charAt(i)) { + prefixLength++; + } else { + break; + } + } + + // Jaro-Winkler 유사도 계산: Jaro 유사도 + (접두사 길이 * 가중치 * (1 - Jaro 유사도)) + return jaroSimilarity + (prefixLength * JARO_WINKLER_PREFIX_WEIGHT * (1.0 - jaroSimilarity)); + } + + /** + * Jaro 유사도 계산 알고리즘 구현 + * + * @param s1 첫 번째 문자열 + * @param s2 두 번째 문자열 + * @return 두 문자열 간의 Jaro 유사도 (0.0 ~ 1.0) + */ + private double calculateJaroSimilarity(String s1, String s2) { + // 두 문자열의 길이 + int len1 = s1.length(); + int len2 = s2.length(); + + // 최대 일치 거리 계산 (두 문자열 길이 중 큰 값 / 2 - 1) + int maxDistance = Math.max(0, Math.max(len1, len2) / 2 - 1); + + // 일치하는 문자 찾기 + boolean[] matched1 = new boolean[len1]; + boolean[] matched2 = new boolean[len2]; + + int matchCount = 0; // 일치하는 문자 수 + + for (int i = 0; i < len1; i++) { + int start = Math.max(0, i - maxDistance); + int end = Math.min(len2 - 1, i + maxDistance); + + for (int j = start; j <= end; j++) { + if (!matched2[j] && s1.charAt(i) == s2.charAt(j)) { + matched1[i] = true; + matched2[j] = true; + matchCount++; + break; + } + } + } + + // 일치하는 문자가 없으면 유사도는 0.0 + if (matchCount == 0) { + return 0.0; + } + + // 전환된 문자 수 계산 + int transpositions = 0; + int k = 0; + + for (int i = 0; i < len1; i++) { + if (matched1[i]) { + while (!matched2[k]) { + k++; + } + + if (s1.charAt(i) != s2.charAt(k)) { + transpositions++; + } + + k++; + } + } + + // 전환은 쌍으로 계산하므로 2로 나눔 + transpositions /= 2; + + // Jaro 유사도 계산: (일치 문자 비율 + 일치 문자 비율 + (일치 문자 - 전환) / 일치 문자) / 3 + double m = matchCount; + return (m / len1 + m / len2 + (m - transpositions) / m) / 3.0; + } +} \ No newline at end of file diff --git a/src/main/java/server/gooroomi/domain/bus/converter/BusConverter.java b/src/main/java/server/gooroomi/domain/bus/converter/BusConverter.java index 6823dd5..5c8a3c5 100644 --- a/src/main/java/server/gooroomi/domain/bus/converter/BusConverter.java +++ b/src/main/java/server/gooroomi/domain/bus/converter/BusConverter.java @@ -1,8 +1,10 @@ package server.gooroomi.domain.bus.converter; import server.gooroomi.domain.bus.dto.BusArrivalResponse; +import server.gooroomi.domain.bus.dto.OcrProcessResponse; import server.gooroomi.domain.bus.entity.BusArrival; import server.gooroomi.domain.bus.entity.BusStation; +import server.gooroomi.domain.bus.entity.MatchType; public class BusConverter { public static BusStation toBusStation(String arsId, String stationNm) { @@ -24,4 +26,12 @@ public static BusArrivalResponse toBusArrivalResponse(BusArrival busArrival) { .busNumber(busArrival.getBusNumber()) .build(); } + + public static OcrProcessResponse toOCRProcessResponse(String proccessedBusNumber, String rawOcrText, MatchType type) { + return OcrProcessResponse.builder() + .proccessedBusNumber(proccessedBusNumber) + .rawOcrText(rawOcrText) + .matchType(type) + .build(); + } } diff --git a/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessRequest.java b/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessRequest.java new file mode 100644 index 0000000..73858b4 --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessRequest.java @@ -0,0 +1,11 @@ +package server.gooroomi.domain.bus.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class OcrProcessRequest { + private String ocrText; // OCR로 추출된 텍스트 + private Long userId; // process.ts에서 자동으로 추가됨 +} diff --git a/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessResponse.java b/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessResponse.java new file mode 100644 index 0000000..0a8b4bc --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessResponse.java @@ -0,0 +1,22 @@ +package server.gooroomi.domain.bus.dto; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import server.gooroomi.domain.bus.entity.MatchType; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OcrProcessResponse { + private String proccessedBusNumber; // 가공된 버스 번호(가공 후) + private String rawOcrText; // OCR로 추출된 텍스트(가공 전) + private MatchType matchType; //OCR 텍스트와 버스 번호 간의 매칭 결과 유형 + + @Builder + public OcrProcessResponse(String proccessedBusNumber, String rawOcrText, MatchType matchType) { + this.proccessedBusNumber = proccessedBusNumber; + this.rawOcrText = rawOcrText; + this.matchType = matchType; + } +} \ No newline at end of file diff --git a/src/main/java/server/gooroomi/domain/bus/entity/MatchType.java b/src/main/java/server/gooroomi/domain/bus/entity/MatchType.java new file mode 100644 index 0000000..cad73db --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/entity/MatchType.java @@ -0,0 +1,7 @@ +package server.gooroomi.domain.bus.entity; + +public enum MatchType { + EXACT, // 정확히 일치 + SIMILAR, // 유사도 기반 매칭 성공 + NONE // 매칭 실패 +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 7cb84e5..687c34d 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -10,7 +10,6 @@ - INFO INFO ACCEPT ACCEPT