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
Original file line number Diff line number Diff line change
@@ -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());
}

Comment on lines +49 to +54
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

코드 중복이 있습니다.

이미지 유효성 검사 로직이 두 엔드포인트에서 동일하게 반복됩니다. 이를 추출하여 재사용 가능한 private 메소드로 만들면 코드 중복을 줄이고 유지보수성을 향상시킬 수 있습니다.

-        // 파일 유효성 검사
-        ValidationResult vr = imageValidator.validate(file);
-        if (!vr.isValid()) {
-            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage());
-        }
+        validateImage(file);

...

+    /**
+     * 이미지 파일 유효성 검사
+     * @param file 검사할 이미지 파일
+     * @throws ResponseStatusException 유효하지 않은 이미지일 경우 발생
+     */
+    private void validateImage(MultipartFile file) {
+        ValidationResult vr = imageValidator.validate(file);
+        if (!vr.isValid()) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage());
+        }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 파일 유효성 검사
ValidationResult vr = imageValidator.validate(file);
if (!vr.isValid()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage());
}
// 파일 유효성 검사
- ValidationResult vr = imageValidator.validate(file);
- if (!vr.isValid()) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage());
- }
+ validateImage(file);
...
+ /**
+ * 이미지 파일 유효성 검사
+ * @param file 검사할 이미지 파일
+ * @throws ResponseStatusException 유효하지 않은 이미지일 경우 발생
+ */
+ private void validateImage(MultipartFile file) {
+ ValidationResult vr = imageValidator.validate(file);
+ if (!vr.isValid()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, vr.getMessage());
+ }
+ }
🤖 Prompt for AI Agents
In src/main/java/io/github/petty/vision/adapter/in/VisionController.java around
lines 49 to 54, the image validation logic is duplicated across two endpoints.
Extract this validation code into a private method that accepts the file as a
parameter, performs the validation, and throws the ResponseStatusException if
invalid. Replace the duplicated code in both endpoints with calls to this new
private method to reduce redundancy and improve maintainability.

// 기존 서비스 호출
return vision.analyze(file, petName);
}
}
}
18 changes: 18 additions & 0 deletions src/main/java/io/github/petty/vision/config/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
139 changes: 139 additions & 0 deletions src/main/java/io/github/petty/vision/helper/ImageValidator.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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);
}
}
}
74 changes: 55 additions & 19 deletions src/main/java/io/github/petty/vision/helper/PromptFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String,Object> part1 = Map.of("text", prompt);
Map<String,Object> part2 = Map.of("inline_data", Map.of("mime_type","image/jpeg","data", base64));
Map<String,Object> content = Map.of("parts", List.of(part1, part2));
String promptText = detailedPrompt(pet, sp);
Map<String, Object> part1 = Map.of("text", promptText);
Map<String, Object> part2 = Map.of("inline_data", Map.of("mime_type", "image/jpeg", "data", base64));
Map<String, Object> 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<String,Object> imageData = Map.of("format","jpeg","data", base64);
Map<String,Object> message = Map.of(
"role","user",
String promptText = detailedPrompt(pet, "");
Map<String, Object> imageData = Map.of("format", "jpeg", "data", base64);
Map<String, Object> 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
);
}
}
}
Loading