From b0551c0851b718152f85c30229f088fd5777813e Mon Sep 17 00:00:00 2001 From: 23MinL Date: Thu, 22 May 2025 22:16:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20ImageValidator=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=20=EB=B0=8F=20VisionController=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vision/adapter/in/VisionController.java | 41 +++++- .../petty/vision/helper/ImageValidator.java | 139 ++++++++++++++++++ 2 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 src/main/java/io/github/petty/vision/helper/ImageValidator.java diff --git a/src/main/java/io/github/petty/vision/adapter/in/VisionController.java b/src/main/java/io/github/petty/vision/adapter/in/VisionController.java index 1bba173..3a67d57 100644 --- a/src/main/java/io/github/petty/vision/adapter/in/VisionController.java +++ b/src/main/java/io/github/petty/vision/adapter/in/VisionController.java @@ -1,31 +1,58 @@ package io.github.petty.vision.adapter.in; -import java.io.IOException; +import io.github.petty.vision.helper.ImageValidator; +import io.github.petty.vision.helper.ImageValidator.ValidationResult; import io.github.petty.vision.port.in.VisionUseCase; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.io.IOException; @Controller @RequestMapping("/vision") @RequiredArgsConstructor public class VisionController { private final VisionUseCase vision; + private final ImageValidator imageValidator; + @GetMapping("/upload") - public String page(){ return "visionUpload"; } + public String page() { + return "visionUpload"; + } + @PostMapping("/species") @ResponseBody - public String getSpeciesInterim(@RequestParam("file") MultipartFile file, - @RequestParam("petName") String petName) throws IOException { + public String getSpeciesInterim( + @RequestParam("file") MultipartFile file, + @RequestParam("petName") String petName + ) throws IOException { + // 파일 유효성 검사 + ValidationResult vr = imageValidator.validate(file); + if (!vr.isValid()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage()); + } + + // 기존 서비스 호출 return vision.interim(file.getBytes(), petName); } @PostMapping("/analyze") @ResponseBody - public String analyze(@RequestParam("file") MultipartFile file, - @RequestParam("petName") String petName) { + public String analyze( + @RequestParam("file") MultipartFile file, + @RequestParam("petName") String petName + ) { + // 파일 유효성 검사 + ValidationResult vr = imageValidator.validate(file); + if (!vr.isValid()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage()); + } + + // 기존 서비스 호출 return vision.analyze(file, petName); } -} \ No newline at end of file +} diff --git a/src/main/java/io/github/petty/vision/helper/ImageValidator.java b/src/main/java/io/github/petty/vision/helper/ImageValidator.java new file mode 100644 index 0000000..0d3f2a8 --- /dev/null +++ b/src/main/java/io/github/petty/vision/helper/ImageValidator.java @@ -0,0 +1,139 @@ +package io.github.petty.vision.helper; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.rekognition.RekognitionClient; +import software.amazon.awssdk.services.rekognition.model.DetectLabelsRequest; +import software.amazon.awssdk.services.rekognition.model.DetectLabelsResponse; +import software.amazon.awssdk.services.rekognition.model.Image; +import software.amazon.awssdk.services.rekognition.model.Label; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ImageValidator { + + private final RekognitionClient rekognitionClient; + + private static final Set VALID_EXTENSIONS = new HashSet<>( + Arrays.asList("jpg", "jpeg", "png", "bmp") + ); + private static final long MIN_FILE_SIZE = 10 * 1024; // 10KB + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + private static final int MIN_WIDTH = 200; + private static final int MIN_HEIGHT = 200; + private static final float MIN_ANIMAL_CONFIDENCE = 70.0f; + private static final Set ANIMAL_LABELS = new HashSet<>( + Arrays.asList("Animal", "Pet", "Dog", "Cat", "Mammal", "Canine", "Feline") + ); + + public ValidationResult validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + return ValidationResult.invalid("이미지 파일이 없습니다."); + } + if (file.getSize() < MIN_FILE_SIZE) { + return ValidationResult.invalid("이미지 파일이 너무 작습니다. 최소 10KB 이상이어야 합니다."); + } + if (file.getSize() > MAX_FILE_SIZE) { + return ValidationResult.invalid("이미지 파일이 너무 큽니다. 최대 5MB 이하여야 합니다."); + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || !originalFilename.contains(".")) { + return ValidationResult.invalid("파일 형식을 확인할 수 없습니다."); + } + String extension = originalFilename.substring(originalFilename.lastIndexOf('.') + 1).toLowerCase(); + if (!VALID_EXTENSIONS.contains(extension)) { + return ValidationResult.invalid("지원하지 않는 이미지 형식입니다. JPG, PNG, BMP 형식만 지원합니다."); + } + + try { + byte[] bytes = file.getBytes(); + if (!hasValidSignature(bytes, extension)) { + return ValidationResult.invalid("파일 시그니처가 유효하지 않습니다."); + } + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + if (image == null) { + return ValidationResult.invalid("유효한 이미지 파일이 아닙니다."); + } + if (image.getWidth() < MIN_WIDTH || image.getHeight() < MIN_HEIGHT) { + return ValidationResult.invalid("이미지 해상도가 너무 낮습니다. 최소 200×200 이상이어야 합니다."); + } + return validateAnimalContent(bytes); + } catch (IOException e) { + log.error("이미지 처리 중 오류 발생", e); + return ValidationResult.invalid("이미지 파일을 처리하는 중 오류가 발생했습니다."); + } + } + + private ValidationResult validateAnimalContent(byte[] bytes) { + try { + DetectLabelsRequest request = DetectLabelsRequest.builder() + .image(Image.builder().bytes(SdkBytes.fromByteArray(bytes)).build()) + .maxLabels(10) + .minConfidence(50.0f) + .build(); + DetectLabelsResponse response = rekognitionClient.detectLabels(request); + + for (Label label : response.labels()) { + if (ANIMAL_LABELS.contains(label.name()) && label.confidence() >= MIN_ANIMAL_CONFIDENCE) { + log.info("동물 감지됨: {}, 신뢰도: {}", label.name(), label.confidence()); + return ValidationResult.valid(); + } + } + return ValidationResult.invalid("반려동물이 감지되지 않았습니다."); + } catch (Exception e) { + log.error("Rekognition 오류", e); + return ValidationResult.invalid("이미지 분석 중 오류가 발생했습니다."); + } + } + + // Magic Number Signatures + private static final byte[] JPG_SIG = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; + private static final byte[] PNG_SIG = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + private static final byte[] BMP_SIG = new byte[]{0x42, 0x4D}; + + private boolean hasValidSignature(byte[] data, String ext) { + if (data.length < 8) return false; + return switch (ext) { + case "jpg", "jpeg" -> startsWith(data, JPG_SIG); + case "png" -> startsWith(data, PNG_SIG); + case "bmp" -> startsWith(data, BMP_SIG); + default -> false; + }; + } + + private boolean startsWith(byte[] data, byte[] sig) { + for (int i = 0; i < sig.length; i++) { + if (data[i] != sig[i]) return false; + } + return true; + } + + @Getter + @RequiredArgsConstructor + public static class ValidationResult { + private final boolean valid; + private final String message; + + public static ValidationResult valid() { + return new ValidationResult(true, "유효한 이미지입니다."); + } + + public static ValidationResult invalid(String message) { + return new ValidationResult(false, message); + } + } +} From 7f53a1d147e97f53d90a689ba5b4502ac09e18eb Mon Sep 17 00:00:00 2001 From: 23MinL Date: Thu, 22 May 2025 22:49:19 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=BA=90=EC=8B=9C=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20VisionServiceImpl=20=EC=BA=90=EC=8B=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../petty/vision/config/CacheConfig.java | 18 +++++ .../vision/service/VisionServiceImpl.java | 74 +++++++++++++++---- 2 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 src/main/java/io/github/petty/vision/config/CacheConfig.java diff --git a/src/main/java/io/github/petty/vision/config/CacheConfig.java b/src/main/java/io/github/petty/vision/config/CacheConfig.java new file mode 100644 index 0000000..9d5820a --- /dev/null +++ b/src/main/java/io/github/petty/vision/config/CacheConfig.java @@ -0,0 +1,18 @@ +package io.github.petty.vision.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + // 기본 메모리 캐시: speciesResults, visionResults 두 개의 캐시 영역만 생성 + return new ConcurrentMapCacheManager("speciesResults", "visionResults"); + } +} diff --git a/src/main/java/io/github/petty/vision/service/VisionServiceImpl.java b/src/main/java/io/github/petty/vision/service/VisionServiceImpl.java index d39b5cd..cd0476e 100644 --- a/src/main/java/io/github/petty/vision/service/VisionServiceImpl.java +++ b/src/main/java/io/github/petty/vision/service/VisionServiceImpl.java @@ -1,59 +1,103 @@ package io.github.petty.vision.service; -import io.github.petty.vision.config.VisionProperties; -import io.github.petty.vision.helper.*; +import io.github.petty.vision.helper.PromptFactory; +import io.github.petty.vision.helper.SpeciesDetector; import io.github.petty.vision.port.in.VisionUseCase; -import io.github.petty.vision.port.out.*; +import io.github.petty.vision.port.out.GeminiPort; +import io.github.petty.vision.port.out.TogetherPort; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + @Service @RequiredArgsConstructor @Slf4j public class VisionServiceImpl implements VisionUseCase { - private final VisionProperties prop; private final SpeciesDetector detector; private final PromptFactory prompt; private final GeminiPort gemini; private final TogetherPort together; - // private final RestTemplate rest; + /** + * 중간 결과 캐시: 이미지 바이트와 petName 조합으로 키 생성 + */ + @Override + @Cacheable(value = "speciesResults", + key = "#petName + '_' + T(io.github.petty.vision.service.VisionServiceImpl).generateCacheKey(#image)") public String interim(byte[] image, String petName) { String species = detector.detect(image); return prompt.interimMsg(petName, species); } + /** + * 상세 분석 캐시: MultipartFile과 petName 조합으로 키 생성 + */ @Override - public String analyze(MultipartFile file, String pet) { + @Cacheable(value = "visionResults", + key = "#petName + '_' + T(io.github.petty.vision.service.VisionServiceImpl).generateCacheKey(#file)") + public String analyze(MultipartFile file, String petName) { byte[] img; try { img = file.getBytes(); - } catch (Exception e) { - throw new IllegalStateException("이미지를 읽을 수 없습니다", e); + } catch (IOException e) { + throw new IllegalStateException("이미지 읽기 실패", e); } String species = detector.detect(img); - String interim = prompt.interimMsg(pet, species); // (원하면 프론트에 먼저 보내기) + String interim = prompt.interimMsg(petName, species); - /* ---------------- Gemini ---------------- */ try { return gemini.generate( - prompt.toGeminiReq(img, pet, species) + prompt.toGeminiReq(img, petName, species) ).plainText(); } catch (Exception gex) { log.warn("Gemini 실패 → Together fallback", gex); } - /* ---------------- Together -------------- */ try { return together.generate( - prompt.toTogetherReq(img, pet) + prompt.toTogetherReq(img, petName) ).plainText(); } catch (Exception tex) { log.error("Together 실패", tex); - return interim + "\n\n최종 분석 보고서 생성에 실패했습니다."; + return interim + "\n\n최종 분석 실패"; + } + } + + /** + * SHA-256 해시(Base64 URL-safe)로 캐시 키 생성 + * @param file MultipartFile + */ + @SuppressWarnings("unused") + public static String generateCacheKey(MultipartFile file) { + try { + byte[] data = file.getBytes(); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(data); + return Base64.getUrlEncoder().encodeToString(hash); + } catch (IOException | NoSuchAlgorithmException e) { + return file.getOriginalFilename() + "_" + file.getSize(); + } + } + + /** + * byte[]용 캐시 키 오버로드 + */ + @SuppressWarnings("unused") + public static String generateCacheKey(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(data); + return Base64.getUrlEncoder().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + return String.valueOf(java.util.Arrays.hashCode(data)); } } -} \ No newline at end of file +} From a4f5f11344d61f520428d06ea10f4bbb2b30c63b Mon Sep 17 00:00:00 2001 From: 23MinL Date: Fri, 23 May 2025 00:14:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20PromptFactory=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=ED=94=84=EB=A1=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../petty/vision/helper/PromptFactory.java | 74 ++++++++++++++----- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/src/main/java/io/github/petty/vision/helper/PromptFactory.java b/src/main/java/io/github/petty/vision/helper/PromptFactory.java index 85d50e6..96554bb 100644 --- a/src/main/java/io/github/petty/vision/helper/PromptFactory.java +++ b/src/main/java/io/github/petty/vision/helper/PromptFactory.java @@ -8,44 +8,80 @@ import java.util.*; import java.util.Base64; +/** + * 기존 PromptFactory에 최적화된 프롬프트 기능을 통합한 클래스 + */ @Component @RequiredArgsConstructor public class PromptFactory { private final VisionProperties prop; /* ---------- 공통 ---------- */ - public String interimMsg(String pet, String sp){ - return "알 수 없음".equals(sp)? - String.format("'%s'에 대해서 알아볼게요! \n잠시만 기다려 주세요. 보고서를 작성 중입니다...", pet): - String.format("오 '%s'는 '%s'이군요!\n잠시만 기다려 주세요. 보고서를 작성 중입니다...", pet, sp); + /** + * 중간 분석 결과 메시지 생성 + * @param pet 반려동물 이름 + * @param sp 감지된 종 (한글 또는 "알 수 없음") + */ + public String interimMsg(String pet, String sp) { + if ("알 수 없음".equals(sp)) { + return String.format("'%s'에 대해서 알아볼게요! \n잠시만 기다려 주세요. 보고서를 작성 중입니다...", pet); + } else { + return String.format("오 '%s'는 '%s'이군요!\n잠시만 기다려 주세요. 보고서를 작성 중입니다...", pet, sp); + } } /* ---------- Gemini ---------- */ - public GeminiRequest toGeminiReq(byte[] img, String pet, String sp){ + /** + * Gemini API용 요청 생성 + */ + public GeminiRequest toGeminiReq(byte[] img, String pet, String sp) { String base64 = Base64.getEncoder().encodeToString(img); - String prompt = detailedPrompt(pet, sp); - Map part1 = Map.of("text", prompt); - Map part2 = Map.of("inline_data", Map.of("mime_type","image/jpeg","data", base64)); - Map content = Map.of("parts", List.of(part1, part2)); + String promptText = detailedPrompt(pet, sp); + Map part1 = Map.of("text", promptText); + Map part2 = Map.of("inline_data", Map.of("mime_type", "image/jpeg", "data", base64)); + Map content = Map.of("parts", List.of(part1, part2)); return new GeminiRequest(List.of(content)); } /* ---------- Together ---------- */ - public TogetherRequest toTogetherReq(byte[] img, String pet){ + /** + * Together API용 요청 생성 + */ + public TogetherRequest toTogetherReq(byte[] img, String pet) { String base64 = Base64.getEncoder().encodeToString(img); - Map imageData = Map.of("format","jpeg","data", base64); - Map message = Map.of( - "role","user", + String promptText = detailedPrompt(pet, ""); + Map imageData = Map.of("format", "jpeg", "data", base64); + Map message = Map.of( + "role", "user", "content", List.of( - Map.of("type","text","text", detailedPrompt(pet, "")), - Map.of("type","image_data","image_data", imageData)) + Map.of("type", "text", "text", promptText), + Map.of("type", "image_data", "image_data", imageData) + ) ); return new TogetherRequest(prop.getLlamaModel(), List.of(message)); } /* ---------- 내부 ---------- */ - private String detailedPrompt(String pet, String species){ - return String.format(""" -반려동물 '%s'(종류: %s)에 대한 분석 보고서를 작성해줘.\n\n- 종류\n- 품종(믹스견은 추정 근거)\n- 외형(크기·털 색·특징)\n- 무게(1~40kg)\n- 맹수 여부\n- 감정·행동\n- 기타 특이사항(목줄·배경 등)\n\n보호자가 이해하기 쉬운 문장으로 요약해줘.""", pet, species); + /** + * 상세 분석 보고서용 프롬프트 템플릿 + */ + private String detailedPrompt(String pet, String species) { + return String.format( + """ + 반려동물 '%s'(종류: %s)에 대한 분석 보고서를 작성해줘. + + - 종류 + - 품종(믹스견은 추정 근거) + - 외형(크기·털 색·특징) + - 무게(1~40kg) + - 맹수 여부 + - 감정·행동 + - 기타 특이사항(목줄·배경 등) + + 보호자가 이해하기 쉬운 문장으로 요약해줘. + """, + pet, + species == null || species.isBlank() ? "알 수 없음" : species + ); } -} \ No newline at end of file +}