Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions src/main/java/server/gooroomi/domain/bus/api/BusRestController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<List<BusArrivalResponse>> getBusArrivals(@RequestParam Long userId) {
return busService.getBusArrivals(userId);
}

@Operation(summary = "OCR 결과와 버스 번호 매칭", description = "OCR로 인식된 버스 번호와 도착 예정 버스 목록을 비교합니다.")
@PostMapping("/ocr-process")
public BaseResponse<OcrProcessResponse> processOcrResult(@RequestBody OcrProcessRequest request) {
return busOcrMatchingService.processOcrResult(request);
}
}
Original file line number Diff line number Diff line change
@@ -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<OcrProcessResponse> processOcrResult(OcrProcessRequest request) {
// 사용자 및 버스 정류장 조회
BusStation busStation = getUserBusStation(request.getUserId());
List<BusArrival> busArrivals = busStation.getBusArrivals();

// 정확히 일치하는 버스 번호 찾기
Optional<OcrProcessResponse> exactMatchResponse = findExactMatchResponse(busArrivals, request.getOcrText());
if (exactMatchResponse.isPresent()) {
return BaseResponse.success(exactMatchResponse.get());
}

// 유사한 버스 번호 찾기
Optional<OcrProcessResponse> 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<OcrProcessResponse> findExactMatchResponse(List<BusArrival> 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<OcrProcessResponse> findSimilarMatchResponse(List<BusArrival> busArrivals, String ocrText) {
Optional<BusArrival> 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<BusArrival> findMostSimilarBus(List<BusArrival> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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에서 자동으로 추가됨
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package server.gooroomi.domain.bus.entity;

public enum MatchType {
EXACT, // 정확히 일치
SIMILAR, // 유사도 기반 매칭 성공
NONE // 매칭 실패
}
1 change: 0 additions & 1 deletion src/main/resources/logback-spring.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

<appender name="GOOROOMI_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>ACCEPT</onMismatch>
Expand Down
Loading