From d825354c7c1ef7c6c85017b1af56f9225cd1e104 Mon Sep 17 00:00:00 2001 From: parkmineum Date: Mon, 18 Aug 2025 17:02:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20[FEAT]=20ImagePreprocessingUtil?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OCR 이미지 전처리 유틸리티 클래스 구현 - 이미지 품질 분석 기능 (해상도, 대비 검사) - 해상도 개선, 대비 향상, 노이즈 제거 기능 - OCR 정확도 향상을 위한 전처리 파이프라인 구축 --- .../global/util/ImagePreprocessingUtil.java | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/main/java/com/mumuk/global/util/ImagePreprocessingUtil.java diff --git a/src/main/java/com/mumuk/global/util/ImagePreprocessingUtil.java b/src/main/java/com/mumuk/global/util/ImagePreprocessingUtil.java new file mode 100644 index 00000000..dfa17646 --- /dev/null +++ b/src/main/java/com/mumuk/global/util/ImagePreprocessingUtil.java @@ -0,0 +1,222 @@ +package com.mumuk.global.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.IOException; +import javax.imageio.ImageIO; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +@Slf4j +@Component +public class ImagePreprocessingUtil { + + /** + * 전처리가 필요한지 판단 + */ + public boolean needsPreprocessing(MultipartFile imageFile) { + try { + byte[] imageBytes = imageFile.getBytes(); + ImageQualityResult qualityResult = analyzeImageQuality(imageBytes); + return qualityResult.hasAnyIssue(); + } catch (IOException e) { + log.error("❌ 전처리 필요 여부 판단 실패", e); + return true; // 안전하게 전처리 필요하다고 처리 + } + } + + /** + * OCR을 위한 이미지 전처리 + */ + public byte[] preprocessForOcr(MultipartFile imageFile) { + try { + byte[] originalBytes = imageFile.getBytes(); + return preprocessImage(originalBytes); + } catch (IOException e) { + log.error("❌ OCR 전처리 실패", e); + try { + return imageFile.getBytes(); // 실패시 원본 반환 + } catch (IOException ex) { + throw new RuntimeException("이미지 처리 실패", ex); + } + } + } + + /** + * 이미지 품질 분석 + */ + public ImageQualityResult analyzeImageQuality(byte[] imageBytes) { + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes)); + + boolean sizeIssue = image.getWidth() < 300 || image.getHeight() < 300; + boolean contrastIssue = hasLowContrast(image); + + log.info("📊 이미지 품질 분석: 크기문제={}, 대비문제={}", sizeIssue, contrastIssue); + + return new ImageQualityResult(sizeIssue, contrastIssue); + + } catch (IOException e) { + log.error("❌ 이미지 품질 분석 실패", e); + return new ImageQualityResult(true, true); // 안전하게 문제 있다고 처리 + } + } + + /** + * 이미지 전처리 (품질 개선) + */ + public byte[] preprocessImage(byte[] originalImageBytes) { + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(originalImageBytes)); + + // 해상도 개선 + BufferedImage enhanced = enhanceResolution(image); + + // 대비 개선 + enhanced = enhanceContrast(enhanced); + + // 노이즈 제거 + enhanced = removeNoise(enhanced); + + // 결과를 byte array로 변환 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(enhanced, "jpg", baos); + + log.info("✅ 이미지 전처리 완료"); + return baos.toByteArray(); + + } catch (IOException e) { + log.error("❌ 이미지 전처리 실패, 원본 반환", e); + return originalImageBytes; // 실패시 원본 반환 + } + } + + private boolean hasLowContrast(BufferedImage image) { + // 간단한 대비 검사 로직 + int[] histogram = new int[256]; + + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int rgb = image.getRGB(x, y); + int gray = (int) (0.299 * ((rgb >> 16) & 0xFF) + + 0.587 * ((rgb >> 8) & 0xFF) + + 0.114 * (rgb & 0xFF)); + histogram[gray]++; + } + } + + // 히스토그램 분산을 통한 대비 판단 + double mean = 127.5; + double variance = 0; + int totalPixels = image.getWidth() * image.getHeight(); + + for (int i = 0; i < 256; i++) { + double probability = (double) histogram[i] / totalPixels; + variance += probability * Math.pow(i - mean, 2); + } + + return variance < 1000; // 임계값은 조정 가능 + } + + private BufferedImage enhanceResolution(BufferedImage image) { + // 최소 해상도 보장 + int minWidth = 800; + int minHeight = 600; + + if (image.getWidth() < minWidth || image.getHeight() < minHeight) { + double scaleX = (double) minWidth / image.getWidth(); + double scaleY = (double) minHeight / image.getHeight(); + double scale = Math.max(scaleX, scaleY); + + int newWidth = (int) (image.getWidth() * scale); + int newHeight = (int) (image.getHeight() * scale); + + BufferedImage scaledImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = scaledImage.createGraphics(); + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g2d.drawImage(image, 0, 0, newWidth, newHeight, null); + g2d.dispose(); + + return scaledImage; + } + + return image; + } + + private BufferedImage enhanceContrast(BufferedImage image) { + BufferedImage enhanced = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB); + + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int rgb = image.getRGB(x, y); + + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + + // 간단한 대비 향상 (gamma correction) + r = (int) Math.min(255, Math.pow(r / 255.0, 0.8) * 255); + g = (int) Math.min(255, Math.pow(g / 255.0, 0.8) * 255); + b = (int) Math.min(255, Math.pow(b / 255.0, 0.8) * 255); + + enhanced.setRGB(x, y, (r << 16) | (g << 8) | b); + } + } + + return enhanced; + } + + private BufferedImage removeNoise(BufferedImage image) { + // 간단한 블러 필터로 노이즈 제거 + BufferedImage denoised = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB); + + for (int y = 1; y < image.getHeight() - 1; y++) { + for (int x = 1; x < image.getWidth() - 1; x++) { + int totalR = 0, totalG = 0, totalB = 0; + + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + int rgb = image.getRGB(x + dx, y + dy); + totalR += (rgb >> 16) & 0xFF; + totalG += (rgb >> 8) & 0xFF; + totalB += rgb & 0xFF; + } + } + + int avgR = totalR / 9; + int avgG = totalG / 9; + int avgB = totalB / 9; + + denoised.setRGB(x, y, (avgR << 16) | (avgG << 8) | avgB); + } + } + + return denoised; + } + + public static class ImageQualityResult { + private final boolean sizeIssue; + private final boolean contrastIssue; + + public ImageQualityResult(boolean sizeIssue, boolean contrastIssue) { + this.sizeIssue = sizeIssue; + this.contrastIssue = contrastIssue; + } + + public boolean hasSizeIssue() { + return sizeIssue; + } + + public boolean hasContrastIssue() { + return contrastIssue; + } + + public boolean hasAnyIssue() { + return sizeIssue || contrastIssue; + } + } +} From 71bb700df643474db86f47c6de71552bda50e510 Mon Sep 17 00:00:00 2001 From: parkmineum Date: Mon, 18 Aug 2025 17:05:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[REFACTOR]=20#137=20:=20OCR=20=EC=A0=84?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=9D=B8=EC=8B=9D=EB=A5=A0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 +- .../domain/ocr/service/OcrServiceImpl.java | 315 +++++++++++------ .../mumuk/global/client/ClovaOcrClient.java | 322 +++++++++++++++++- src/main/resources/application.yml | 6 + 4 files changed, 533 insertions(+), 116 deletions(-) diff --git a/build.gradle b/build.gradle index 7395099d..a94f1e42 100644 --- a/build.gradle +++ b/build.gradle @@ -76,9 +76,13 @@ dependencies { implementation 'com.vladmihalcea:hibernate-types-60:2.21.1' implementation 'org.hibernate.orm:hibernate-core:6.5.2.Final' -//FCM + //FCM implementation 'com.google.firebase:firebase-admin:9.2.0' + // Mocking Test + testImplementation 'org.springframework.security:spring-security-test' + + } diff --git a/src/main/java/com/mumuk/domain/ocr/service/OcrServiceImpl.java b/src/main/java/com/mumuk/domain/ocr/service/OcrServiceImpl.java index 4e1232fa..563c822c 100644 --- a/src/main/java/com/mumuk/domain/ocr/service/OcrServiceImpl.java +++ b/src/main/java/com/mumuk/domain/ocr/service/OcrServiceImpl.java @@ -10,9 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -24,7 +22,80 @@ public class OcrServiceImpl implements OcrService { private final ClovaOcrClient clovaOcrClient; private final ObjectMapper objectMapper; - public OcrServiceImpl(UserHealthDataRepository userHealthDataRepository, ClovaOcrClient clovaOcrClient, ObjectMapper objectMapper) { + // 🔥 핵심 개선: 범용적인 건강 지표 패턴들 + private static final Map HEALTH_PATTERNS = new LinkedHashMap<>(); + + static { + // 체중 관련 패턴들 (다양한 형태 지원) + HEALTH_PATTERNS.put(Pattern.compile("(?i)(체중|weight|몸무게|Weight)\\s*(?:\\([^)]*\\))?\\s*(\\d{2,3}(?:\\.\\d{1,2})?)\\s*(?:kg|킬로|키로)?"), "체중"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(골격근량|근육량|skeletal muscle|muscle mass|Skeletal Muscle Mass)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,3}(?:\\.\\d{1,2})?)\\s*(?:kg|킬로)?"), "골격근량"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(체지방량|지방량|body fat mass|fat mass|Body FatMass)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,3}(?:\\.\\d{1,2})?)\\s*(?:kg|킬로)?"), "체지방량"); + + // 체성분 관련 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(체수분|수분량|body water|total.*water|Total Body Water)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,3}(?:\\.\\d{1,2})?)\\s*(?:L|리터|ℓ)?"), "체수분"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(단백질|protein|Protein)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,2}(?:\\.\\d{1,2})?)\\s*(?:kg|킬로)?"), "단백질"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(무기질|미네랄|mineral|minerals|Minerals)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,2}(?:\\.\\d{1,2})?)\\s*(?:kg|킬로)?"), "무기질"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(체지방|body fat|지방|fat)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,3}(?:\\.\\d{1,2})?)\\s*(?:%|퍼센트|percent)?"), "체지방률"); + + // BMI 및 기타 지수 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(BMI|body mass index|비만지수)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,2}(?:\\.\\d{1,2})?)"), "BMI"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(기초대사율|bmr|basal metabolic|기초대사|BMR)\\s*(?:\\([^)]*\\))?\\s*(\\d{3,4})\\s*(?:kcal|칼로리)?"), "기초대사율"); + + // 혈압 관련 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(수축기|최고혈압|systolic)\\s*(?:\\([^)]*\\))?\\s*(\\d{2,3})\\s*(?:mmHg)?"), "수축기혈압"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(이완기|최저혈압|diastolic)\\s*(?:\\([^)]*\\))?\\s*(\\d{2,3})\\s*(?:mmHg)?"), "이완기혈압"); + + // 혈당 관련 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(혈당|glucose|blood sugar|글루코스)\\s*(?:\\([^)]*\\))?\\s*(\\d{2,3})\\s*(?:mg/dL|mg/dl)?"), "혈당"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(당화혈색소|HbA1c|hba1c)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,2}(?:\\.\\d{1,2})?)\\s*(?:%)?"), "당화혈색소"); + + // 콜레스테롤 관련 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(총콜레스테롤|total cholesterol|콜레스테롤)\\s*(?:\\([^)]*\\))?\\s*(\\d{2,3})\\s*(?:mg/dL|mg/dl)?"), "총콜레스테롤"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(HDL|good cholesterol|hdl)\\s*(?:\\([^)]*\\))?\\s*(\\d{2,3})\\s*(?:mg/dL|mg/dl)?"), "HDL콜레스테롤"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(LDL|bad cholesterol|ldl)\\s*(?:\\([^)]*\\))?\\s*(\\d{2,3})\\s*(?:mg/dL|mg/dl)?"), "LDL콜레스테롤"); + + // 간기능 관련 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(ALT|SGPT|alt)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,3})\\s*(?:U/L|IU/L)?"), "ALT"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(AST|SGOT|ast)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,3})\\s*(?:U/L|IU/L)?"), "AST"); + + // 신장기능 관련 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(크레아티닌|creatinine)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,2}(?:\\.\\d{1,2})?)\\s*(?:mg/dL|mg/dl)?"), "크레아티닌"); + HEALTH_PATTERNS.put(Pattern.compile("(?i)(요소질소|BUN|urea nitrogen)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,3})\\s*(?:mg/dL|mg/dl)?"), "요소질소"); + + // 염증 지표 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(CRP|c-reactive protein|염증수치)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,2}(?:\\.\\d{1,2})?)\\s*(?:mg/L|mg/dl)?"), "CRP"); + + // 갑상선 관련 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(TSH|갑상선자극호르몬)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,2}(?:\\.\\d{1,2})?)\\s*(?:mIU/L)?"), "TSH"); + + // 비타민 관련 + HEALTH_PATTERNS.put(Pattern.compile("(?i)(비타민D|vitamin d|25-OH)\\s*(?:\\([^)]*\\))?\\s*(\\d{1,3}(?:\\.\\d{1,2})?)\\s*(?:ng/mL|ng/ml)?"), "비타민D"); + + // 💡 범용 숫자-텍스트 패턴 (위의 특정 패턴에 매칭되지 않는 경우 사용) + HEALTH_PATTERNS.put(Pattern.compile("([가-힣A-Za-z][가-힣A-Za-z\\s]{1,15})\\s*(?:\\([^)]*\\))?\\s*(\\d{1,4}(?:\\.\\d{1,3})?)\\s*(?:[가-힣A-Za-z/%]{0,10})?"), "기타지표"); + } + + // 유효성 검사를 위한 범위 정의 (더 유연하게) + private static final Map VALUE_RANGES = new HashMap() {{ + put("체중", new double[]{20.0, 300.0}); + put("골격근량", new double[]{5.0, 100.0}); + put("체지방량", new double[]{0.0, 150.0}); + put("체수분", new double[]{15.0, 100.0}); + put("단백질", new double[]{3.0, 50.0}); + put("무기질", new double[]{1.0, 15.0}); + put("체지방률", new double[]{0.0, 70.0}); + put("BMI", new double[]{10.0, 60.0}); + put("기초대사율", new double[]{800.0, 4000.0}); + put("수축기혈압", new double[]{60.0, 300.0}); + put("이완기혈압", new double[]{30.0, 200.0}); + put("혈당", new double[]{30.0, 600.0}); + put("총콜레스테롤", new double[]{50.0, 500.0}); + put("HDL콜레스테롤", new double[]{10.0, 150.0}); + put("LDL콜레스테롤", new double[]{20.0, 400.0}); + }}; + + public OcrServiceImpl(UserHealthDataRepository userHealthDataRepository, + ClovaOcrClient clovaOcrClient, ObjectMapper objectMapper) { this.userHealthDataRepository = userHealthDataRepository; this.clovaOcrClient = clovaOcrClient; this.objectMapper = objectMapper; @@ -32,34 +103,9 @@ public OcrServiceImpl(UserHealthDataRepository userHealthDataRepository, ClovaOc @Override public Map extractText(MultipartFile imageFile) { - String ocrJson = clovaOcrClient.callClovaOcr(imageFile); Map result = new LinkedHashMap<>(); - Map keyNameMap = Map.of( - "체수분", "체수분 (L)", - "단백질", "단백질 (kg)", - "무기질", "무기질", - "체지방", "체지방 (kg)", - "체중", "체중 (kg)", - "골격근량", "골격근량", - "체지방량", "체지방량 (kg)", - "BMI", "BMI", - "체지방률", "체지방률" - ); - - Map overrideKeys = Map.of( - "Weight", "체중 (kg)", - "Skeletal Muscle Mass", "골격근량", - "Body FatMass", "체지방량 (kg)", - "Protein", "단백질 (kg)", - "Body Fat", "체지방 (kg)", - "Total Body Water", "체수분 (L)", - "Minerals", "무기질" - ); - - Set allowedKeys = keyNameMap.keySet(); - try { JsonNode fields = objectMapper .readTree(ocrJson) @@ -69,93 +115,166 @@ public Map extractText(MultipartFile imageFile) { if (fields.size() == 0) return result; String rawText = fields.get(0).path("inferText").asText(); - String[] lines = rawText.split("\n"); - - String pendingKey = null; + log.info("🔍 원본 OCR 텍스트:\n{}", rawText); + + // 🔥 개선된 범용 파싱 로직 + result = parseHealthDataUniversally(rawText); + + // 데이터 검증 및 정제 + result = validateAndCleanResults(result); - for (String line : lines) { - line = line.trim(); - log.info("🔍 Line: {}", line); + } catch (JsonProcessingException e) { + log.error("❌ OCR JSON 파싱 실패", e); + throw new RuntimeException("OCR JSON 파싱 실패", e); + } - if (line.matches(".*표준[이하|이상|정도]?.*")) continue; + log.info("✅ 최종 추출 결과: {}", result); + return result; + } - // 1. pendingKey 우선 처리 - if (pendingKey != null && line.matches(".*\\d+.*")) { - String value = extractFirstDecimalOutsideParentheses(line); - if (value != null) { - result.putIfAbsent(keyNameMap.get(pendingKey), value); - pendingKey = null; - continue; - } + // 🔥 핵심 개선: 범용적인 건강 데이터 파싱 + private Map parseHealthDataUniversally(String rawText) { + Map result = new LinkedHashMap<>(); + Set usedValues = new HashSet<>(); // 중복 값 방지 + + log.info("🔍 범용 건강 데이터 파싱 시작"); + + // 텍스트 정제 + String cleanedText = preprocessText(rawText); + + // 각 패턴에 대해 매칭 시도 + for (Map.Entry entry : HEALTH_PATTERNS.entrySet()) { + Pattern pattern = entry.getKey(); + String categoryPrefix = entry.getValue(); + + Matcher matcher = pattern.matcher(cleanedText); + + while (matcher.find()) { + String keyMatch = matcher.group(1); // 키워드 부분 + String valueMatch = matcher.group(2); // 숫자 부분 + + // 중복 값 체크 + if (usedValues.contains(valueMatch)) { + continue; } - - // 2. override 키 대응 (영문) - for (Map.Entry entry : overrideKeys.entrySet()) { - if (line.contains(entry.getKey()) && line.matches(".*\\d+.*")) { - String value = extractFirstDecimalOutsideParentheses(line); - if (value != null && !result.containsKey(entry.getValue())) { - result.put(entry.getValue(), value); - } + + // 유효성 검사 + if (isValidHealthValue(categoryPrefix, keyMatch, valueMatch)) { + String finalKey; + + if ("기타지표".equals(categoryPrefix)) { + // 기타 지표의 경우 원본 키워드 사용 + finalKey = normalizeKeyName(keyMatch); + } else { + // 정의된 카테고리의 경우 표준화된 이름 사용 + finalKey = categoryPrefix; } + + result.putIfAbsent(finalKey, valueMatch); + usedValues.add(valueMatch); + + log.info("✅ 패턴 매칭 성공: {} = {} (패턴: {})", finalKey, valueMatch, categoryPrefix); + + break; // 같은 패턴에서 첫 번째 매칭만 사용 } + } + } + + log.info("🔍 파싱 완료, 추출된 항목 수: {}", result.size()); + return result; + } - // 3. 한글 키 + 숫자 포함된 라인 - if (line.matches(".*[가-힣]+.*\\d+.*")) { - String rawKey = extractKey(line); - String normKey = normalizeKey(rawKey); - - if (allowedKeys.contains(normKey)) { - String stdKey = keyNameMap.get(normKey); - String value = extractFirstDecimalOutsideParentheses(line); - if (value != null && !result.containsKey(stdKey)) { - result.put(stdKey, value); - continue; - } - } - } + // 텍스트 전처리 + private String preprocessText(String rawText) { + return rawText + .replaceAll("(?i)inbody|검사|결과|report", "") // 브랜드명/불필요한 단어 제거 + .replaceAll("표준\\s*[이하|이상|정도|범위]", "") // 표준 관련 텍스트 제거 + .replaceAll("권장\\s*[범위|수치]", "") // 권장 관련 텍스트 제거 + .replaceAll("정상\\s*[범위|수치]", "") // 정상 관련 텍스트 제거 + .replaceAll("\\s+", " ") // 다중 공백 정리 + .trim(); + } - // 4. 키만 있는 경우 → 다음 줄에서 값 받기 - if (line.matches(".*[가-힣]+.*") && !line.matches(".*\\d+.*")) { - String rawKey = extractKey(line); - String normKey = normalizeKey(rawKey); + // 키 이름 정규화 + private String normalizeKeyName(String rawKey) { + return rawKey.trim() + .replaceAll("\\s+", "_") // 공백을 언더스코어로 + .replaceAll("[^가-힣A-Za-z0-9_]", "") // 특수문자 제거 + .toLowerCase(); + } - if (allowedKeys.contains(normKey)) { - pendingKey = normKey; - } + // 건강 수치 유효성 검사 (더 유연하게) + private boolean isValidHealthValue(String category, String key, String value) { + try { + double val = Double.parseDouble(value); + + // 범위가 정의된 경우 범위 체크 + double[] range = VALUE_RANGES.get(category); + if (range != null) { + boolean valid = val >= range[0] && val <= range[1]; + if (!valid) { + log.debug("⚠️ 범위 벗어남: {} = {} (범위: {}-{})", key, val, range[0], range[1]); + return false; + } + } else { + // 범위가 정의되지 않은 경우 기본 검사 + if (val <= 0 || val > 10000) { + log.debug("⚠️ 기본 범위 벗어남: {} = {}", key, val); + return false; } } - - } catch (JsonProcessingException e) { - throw new RuntimeException("OCR JSON 파싱 실패", e); + + // 소수점 자릿수 체크 (너무 많은 소수점은 오인식일 가능성) + String[] parts = value.split("\\."); + if (parts.length > 1 && parts[1].length() > 3) { + log.debug("⚠️ 소수점 자릿수 초과: {} = {}", key, value); + return false; + } + + return true; + + } catch (NumberFormatException e) { + log.debug("⚠️ 숫자 형식 오류: {} = {}", key, value); + return false; } + } - log.info("추출된 키-값 : {}", result); - return result; + // 결과 검증 및 정제 + private Map validateAndCleanResults(Map result) { + Map cleaned = new LinkedHashMap<>(); + + for (Map.Entry entry : result.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue().trim().replaceAll("[^\\d\\.]", ""); + + // 빈 값 체크 + if (value.isEmpty()) { + log.warn("⚠️ 빈 값 제외: {}", key); + continue; + } + + // 최종 유효성 재검사 + try { + double val = Double.parseDouble(value); + if (val > 0 && val <= 10000) { // 기본 범위 + cleaned.put(key, value); + log.debug("✅ 최종 검증 통과: {} = {}", key, value); + } else { + log.warn("⚠️ 최종 검증 실패: {} = {}", key, value); + } + } catch (NumberFormatException e) { + log.warn("⚠️ 최종 숫자 변환 실패: {} = {}", key, value); + } + } + + return cleaned; } @Override public void saveOcrResult(Long userId, Map ocrResult) { UserHealthData entity = new UserHealthData(userId, ocrResult); userHealthDataRepository.save(entity); - } - - - private String extractKey(String line) { - Matcher matcher = Pattern.compile("([가-힣A-Za-z·\\s\\(\\)]+)").matcher(line); - return matcher.find() ? matcher.group(1).trim() : null; - } - - private String normalizeKey(String key) { - if (key == null) return null; - return key.replaceAll("\\s*\\(.*?\\)", "") // 괄호 제거 - .trim() - .replace(" ", ""); - } - - - private String extractFirstDecimalOutsideParentheses(String line) { - String cleaned = line.replaceAll("\\([^\\)]*\\)", " "); - Matcher matcher = Pattern.compile("\\d+\\.\\d+").matcher(cleaned); - return matcher.find() ? matcher.group() : null; + + log.info("💾 사용자 {}의 건강 데이터 저장 완료: {} 항목", userId, ocrResult.size()); } } diff --git a/src/main/java/com/mumuk/global/client/ClovaOcrClient.java b/src/main/java/com/mumuk/global/client/ClovaOcrClient.java index e1ea0282..ad31bec1 100644 --- a/src/main/java/com/mumuk/global/client/ClovaOcrClient.java +++ b/src/main/java/com/mumuk/global/client/ClovaOcrClient.java @@ -1,6 +1,10 @@ package com.mumuk.global.client; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.mumuk.global.util.FileResourceUtil; +import com.mumuk.global.util.ImagePreprocessingUtil; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpEntity; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; @@ -14,8 +18,8 @@ import java.io.IOException; import java.util.UUID; - @Component +@Slf4j public class ClovaOcrClient { @Value("${naver.clova.ocr.invoke-url}") @@ -25,34 +29,185 @@ public class ClovaOcrClient { private String secretKey; private final RestTemplate restTemplate; + private final ImagePreprocessingUtil imagePreprocessingUtil; + private final ObjectMapper objectMapper; - public ClovaOcrClient(RestTemplate restTemplate) { + public ClovaOcrClient(RestTemplate restTemplate, + ImagePreprocessingUtil imagePreprocessingUtil) { this.restTemplate = restTemplate; + this.imagePreprocessingUtil = imagePreprocessingUtil; + this.objectMapper = new ObjectMapper(); } + /** + * 기본 OCR 호출 + */ public String callClovaOcr(MultipartFile imageFile) { + return callClovaOcrWithOptimization(imageFile, false); + } + + /** + * 최적화된 OCR 호출 (이미지 전처리 포함) + */ + public String callClovaOcrWithOptimization(MultipartFile imageFile, boolean enablePreprocessing) { try { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-OCR-SECRET", secretKey); + log.info("🔄 Clova OCR 요청 시작: {}", imageFile.getOriginalFilename()); + + MultipartFile processedImage = imageFile; + + // 이미지 전처리 수행 (옵션) + if (enablePreprocessing && imagePreprocessingUtil.needsPreprocessing(imageFile)) { + log.info("이미지 전처리 수행"); + byte[] preprocessedBytes = imagePreprocessingUtil.preprocessForOcr(imageFile); + processedImage = createMultipartFileFromBytes(preprocessedBytes, imageFile.getOriginalFilename()); + } + + // OCR 요청 수행 + String result = performOcrRequest(processedImage); + + // 결과 품질 검증 + if (!isValidOcrResult(result) && !enablePreprocessing) { + log.warn("⚠️ OCR 결과 품질 불량, 전처리 후 재시도"); + return callClovaOcrWithOptimization(imageFile, true); + } + + log.info("✅ Clova OCR 응답 수신 완료"); + return result; + + } catch (IOException e) { + log.error("❌ CLOVA OCR 요청 실패: {}", e.getMessage()); + throw new RuntimeException("CLOVA OCR 요청 실패", e); + } + } - String messageJson = buildClovaRequestMessage(imageFile); + /** + * 다중 템플릿을 사용한 OCR (더 높은 정확도) + */ + public String callClovaOcrWithMultipleTemplates(MultipartFile imageFile) { + try { + log.info("다중 템플릿 OCR 시작"); + + // 인바디 관련 여러 템플릿 시도 + int[] templateIds = {38491, 0}; // 38491: 기본 템플릿, 0: 범용 템플릿 + + String bestResult = null; + int maxFieldCount = 0; + + for (int templateId : templateIds) { + log.info("템플릿 {} 시도", templateId); + String result = callClovaOcrWithTemplate(imageFile, templateId); + int fieldCount = countExtractedFields(result); + + log.info("템플릿 {} 결과: {} 필드 추출", templateId, fieldCount); + + if (fieldCount > maxFieldCount) { + maxFieldCount = fieldCount; + bestResult = result; + } + } + + log.info("최적 결과 선택: {} 필드", maxFieldCount); + return bestResult; + + } catch (Exception e) { + log.warn("다중 템플릿 실패, 기본 템플릿 사용"); + return callClovaOcr(imageFile); + } + } + /** + * 재시도 로직이 포함된 OCR 호출 + */ + public String callClovaOcrWithRetry(MultipartFile imageFile, int maxRetries) { + Exception lastException = null; + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + log.info("OCR 시도 {}/{}", attempt, maxRetries); + + // 첫 번째 시도는 기본, 이후는 전처리 적용 + boolean enablePreprocessing = attempt > 1; + String result = callClovaOcrWithOptimization(imageFile, enablePreprocessing); + + // 결과 품질 검증 + if (isValidOcrResult(result)) { + log.info("OCR 성공 (시도 {})", attempt); + return result; + } else { + log.warn("OCR 결과 품질 불량 (시도 {})", attempt); + if (attempt < maxRetries) { + Thread.sleep(1000 * attempt); // 점진적 대기 + } + } + + } catch (Exception e) { + lastException = e; + log.warn("OCR 실패 (시도 {}): {}", attempt, e.getMessage()); + + if (attempt < maxRetries) { + try { + Thread.sleep(2000 * attempt); // 점진적 대기 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + + throw new RuntimeException("OCR 최대 재시도 횟수 초과", lastException); + } - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", FileResourceUtil.toResource(imageFile)); - body.add("message", messageJson); + /** + * 실제 OCR 요청 수행 + */ + private String performOcrRequest(MultipartFile imageFile) throws IOException { + HttpHeaders headers = createOptimizedHeaders(); + String messageJson = buildOptimizedClovaRequestMessage(imageFile); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", FileResourceUtil.toResource(imageFile)); + body.add("message", messageJson); - HttpEntity> requestEntity = new HttpEntity<>(body, headers); + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(invokeUrl, requestEntity, String.class); + + return response.getBody(); + } - ResponseEntity response = restTemplate.postForEntity(invokeUrl, requestEntity, String.class); + /** + * 특정 템플릿으로 OCR 요청 + */ + private String callClovaOcrWithTemplate(MultipartFile imageFile, int templateId) throws IOException { + HttpHeaders headers = createOptimizedHeaders(); + String messageJson = buildTemplateSpecificMessage(imageFile, templateId); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", FileResourceUtil.toResource(imageFile)); + body.add("message", messageJson); - return response.getBody(); - } catch (IOException e) { - throw new RuntimeException("CLOVA OCR 요청 실패", e); - } + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(invokeUrl, requestEntity, String.class); + + return response.getBody(); } - private String buildClovaRequestMessage(MultipartFile imageFile) { + /** + * 최적화된 헤더 생성 + */ + private HttpHeaders createOptimizedHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-OCR-SECRET", secretKey); + headers.set("Content-Type", "multipart/form-data"); + headers.set("Connection", "keep-alive"); + headers.set("Accept", "application/json"); + return headers; + } + + /** + * 최적화된 요청 메시지 생성 + */ + private String buildOptimizedClovaRequestMessage(MultipartFile imageFile) { return """ { "version": "V2", @@ -64,19 +219,152 @@ private String buildClovaRequestMessage(MultipartFile imageFile) { "name": "%s", "templateIds": [38491] } - ] + ], + "lang": "ko", + "resultType": "string", + "enableTableDetection": false } """.formatted( UUID.randomUUID(), System.currentTimeMillis(), - getFileExtension(imageFile.getOriginalFilename()), // "jpg", "png" 등 + getFileExtension(imageFile.getOriginalFilename()), imageFile.getOriginalFilename() ); } + /** + * 템플릿별 요청 메시지 생성 + */ + private String buildTemplateSpecificMessage(MultipartFile imageFile, int templateId) { + return """ + { + "version": "V2", + "requestId": "%s", + "timestamp": %d, + "images": [ + { + "format": "%s", + "name": "%s", + "templateIds": [%d] + } + ], + "lang": "ko", + "resultType": "string" + } + """.formatted( + UUID.randomUUID(), + System.currentTimeMillis(), + getFileExtension(imageFile.getOriginalFilename()), + imageFile.getOriginalFilename(), + templateId + ); + } + + /** + * OCR 결과에서 추출된 필드 개수 계산 + */ + private int countExtractedFields(String ocrResult) { + try { + JsonNode root = objectMapper.readTree(ocrResult); + JsonNode fields = root.path("images").get(0).path("fields"); + + if (fields.size() > 0) { + String text = fields.get(0).path("inferText").asText(); + // 인바디 관련 키워드 개수 계산 + String[] keywords = {"체중", "체지방", "골격근", "BMI", "단백질", "체수분", "무기질"}; + int count = 0; + for (String keyword : keywords) { + if (text.contains(keyword)) count++; + } + return count; + } + return 0; + } catch (Exception e) { + log.warn("필드 개수 계산 실패: {}", e.getMessage()); + return 0; + } + } + + /** + * OCR 결과 품질 검증 + */ + private boolean isValidOcrResult(String result) { + try { + JsonNode root = objectMapper.readTree(result); + JsonNode fields = root.path("images").get(0).path("fields"); + + if (fields.size() > 0) { + String text = fields.get(0).path("inferText").asText(); + // 최소한의 텍스트가 있고, 숫자가 포함되어 있는지 확인 + boolean hasMinLength = text.length() > 10; + boolean hasNumbers = text.matches(".*\\d+.*"); + boolean hasInBodyKeywords = text.contains("체중") || text.contains("BMI") || + text.contains("Weight") || text.contains("체지방"); + + return hasMinLength && hasNumbers && hasInBodyKeywords; + } + return false; + } catch (Exception e) { + log.warn("OCR 결과 검증 실패: {}", e.getMessage()); + return false; + } + } + + /** + * 파일 확장자 추출 + */ private String getFileExtension(String filename) { if (filename == null) return "jpg"; int dotIndex = filename.lastIndexOf('.'); return (dotIndex != -1) ? filename.substring(dotIndex + 1).toLowerCase() : "jpg"; } + + /** + * byte[]를 MultipartFile로 변환 + */ + private MultipartFile createMultipartFileFromBytes(byte[] bytes, String originalFilename) { + return new MultipartFile() { + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return "image/jpeg"; + } + + @Override + public boolean isEmpty() { + return bytes == null || bytes.length == 0; + } + + @Override + public long getSize() { + return bytes.length; + } + + @Override + public byte[] getBytes() throws IOException { + return bytes; + } + + @Override + public java.io.InputStream getInputStream() throws IOException { + return new java.io.ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(java.io.File dest) throws IOException, IllegalStateException { + try (java.io.FileOutputStream fos = new java.io.FileOutputStream(dest)) { + fos.write(bytes); + } + } + }; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b997038b..c876a5e6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,6 +59,12 @@ jwt: app: env: local + ocr: + max-retries: 3 + retry-delay-ms: 2000 + enable-image-preprocessing: true + enable-multi-template: true + confidence-threshold: 0.7 kakao: redirect-uri: http://localhost:8080/login/oauth2/code/kakao # 인가 코드