From c55d34b565a10edff15616e767e3f305733d782a Mon Sep 17 00:00:00 2001 From: config25 Date: Tue, 7 Apr 2026 14:22:47 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81:=20Externa?= =?UTF-8?q?lAiClientImpl=20=EC=B1=85=EC=9E=84=EB=B3=84=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1개 클래스(293줄)에 GPT/YOLO/LAMA/S3 4개 외부 시스템이 섞여있던 구조를 시스템별 클라이언트로 분리하고, 기존 클래스는 thin composite로 슬림화. - GptVisionClient: GPT-4 Vision 분석/비교 + 응답 파싱 - YoloDetectionClient: 어지러운 영역 감지 - LamaInpaintClient: 인페인팅 + 전용 RestTemplate(multipart) - S3ImageStore: AI 파이프라인 전용 S3 헬퍼(다운로드/Base64/업로드) - ExternalAiClientImpl: 293줄 → 50줄 thin composite로 위임 도메인 인터페이스(ExternalAiClient)는 미변경 → 도메인 영향 없음. 부수 개선: - requestCompareAnalysis의 catch(Exception)이 OpenAI 429를 묵살하던 사일런트 버그 수정 → AI_RATE_LIMIT_EXCEEDED로 일관 처리 - 사용되지 않던 @Value 필드 3개 제거(openai.api.url, openai.api.url.image, openai.api.key) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../demo/external/ExternalAiClientImpl.java | 285 ++---------------- .../demo/external/ai/GptVisionClient.java | 121 ++++++++ .../demo/external/ai/LamaInpaintClient.java | 116 +++++++ .../team9/demo/external/ai/S3ImageStore.java | 85 ++++++ .../demo/external/ai/YoloDetectionClient.java | 65 ++++ 5 files changed, 409 insertions(+), 263 deletions(-) create mode 100644 demo-external/src/main/java/team9/demo/external/ai/GptVisionClient.java create mode 100644 demo-external/src/main/java/team9/demo/external/ai/LamaInpaintClient.java create mode 100644 demo-external/src/main/java/team9/demo/external/ai/S3ImageStore.java create mode 100644 demo-external/src/main/java/team9/demo/external/ai/YoloDetectionClient.java diff --git a/demo-external/src/main/java/team9/demo/external/ExternalAiClientImpl.java b/demo-external/src/main/java/team9/demo/external/ExternalAiClientImpl.java index f92b7fc..2af362b 100644 --- a/demo-external/src/main/java/team9/demo/external/ExternalAiClientImpl.java +++ b/demo-external/src/main/java/team9/demo/external/ExternalAiClientImpl.java @@ -1,292 +1,51 @@ package team9.demo.external; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.S3Object; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.*; -import org.springframework.http.converter.ByteArrayHttpMessageConverter; -import org.springframework.http.converter.FormHttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.RestTemplate; -import team9.demo.dto.ChatGPTRequest; -import team9.demo.error.AiException; -import team9.demo.error.ErrorCode; -import team9.demo.model.ai.analysis.*; +import team9.demo.external.ai.GptVisionClient; +import team9.demo.external.ai.LamaInpaintClient; +import team9.demo.external.ai.YoloDetectionClient; +import team9.demo.model.ai.analysis.ChatResponse; import team9.demo.model.ai.mask.Box; import team9.demo.model.user.UserId; import team9.demo.repository.ai.ExternalAiClient; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.util.*; - -@Slf4j +import java.util.List; + +/** + * 도메인 계층의 단일 진입점 역할을 하는 thin composite. + * 실제 호출은 책임이 분리된 3개 클라이언트에 위임한다: + * - GPT-4 Vision (분석/비교) → {@link GptVisionClient} + * - YOLO (어지러운 영역 감지) → {@link YoloDetectionClient} + * - LAMA (인페인팅) → {@link LamaInpaintClient} + * + * 도메인 계층은 본 인터페이스에만 의존하므로 내부 구현 분리/교체에 영향을 받지 않는다. + */ @Component @RequiredArgsConstructor public class ExternalAiClientImpl implements ExternalAiClient { - @Value("${openai.api.url}") - private String visionEndpoint; - - @Value("${openai.api.url2}") - private String chatEndpoint; - - @Value("${openai.api.url.image}") - private String editsEndpoint; - - @Value("${openai.model1}") - private String model; - - @Value("${cloud.aws.s3.bucket}") - private String bucket; - - @Value("${openai.api.key}") - private String openAiApiKey; + private final GptVisionClient gptVisionClient; + private final YoloDetectionClient yoloDetectionClient; + private final LamaInpaintClient lamaInpaintClient; - @Value("${ai.yolo.url:http://localhost:5000}") - private String yoloServerUrl; - - @Value("${ai.lama.url:http://localhost:7870}") - private String lamaServerUrl; - - private final RestTemplate restTemplate; - private final AmazonS3 amazonS3; - - private final RestTemplate lamaClient = new RestTemplate(List.of( - new FormHttpMessageConverter(), - new ByteArrayHttpMessageConverter(), - new MappingJackson2HttpMessageConverter() - )); - - // [1] GPT Vision 단일 이미지 분석 @Override public ChatResponse requestImageAnalysis(String imageUrl, String requestText) { - try { - String base64 = encodeImageFromS3(imageUrl); - String dataUrl = "data:image/jpeg;base64," + base64; - - ChatGPTRequest requestDto = ChatGPTRequest.of(model, requestText, dataUrl, 500); - - ResponseEntity> response = restTemplate.exchange( - chatEndpoint, HttpMethod.POST, new HttpEntity<>(requestDto), getMapType() - ); - return parseChatResponse(response); - - } catch (HttpClientErrorException.TooManyRequests e) { - log.error("OpenAI 사용량 초과: {}", e.getResponseBodyAsString()); - throw new AiException(ErrorCode.AI_RATE_LIMIT_EXCEEDED); - } catch (IOException e) { - throw new AiException(ErrorCode.AI_IMAGE_READ_FAILED); - } + return gptVisionClient.requestImageAnalysis(imageUrl, requestText); } - // [2] GPT Vision 비교 분석 (Before/After) @Override public ChatResponse requestCompareAnalysis(String beforeUrl, String afterUrl, String requestText) { - try { - String beforeBase64 = encodeImageFromS3(beforeUrl); - String afterBase64 = encodeImageFromS3(afterUrl); - - Map requestBody = Map.of( - "model", model, - "messages", List.of(Map.of( - "role", "user", - "content", List.of( - Map.of("type", "text", "text", requestText), - Map.of("type", "image_url", "image_url", Map.of("url", "data:image/jpeg;base64," + beforeBase64)), - Map.of("type", "image_url", "image_url", Map.of("url", "data:image/jpeg;base64," + afterBase64)) - ) - )), - "max_tokens", 500 - ); - - ResponseEntity> response = restTemplate.exchange( - chatEndpoint, HttpMethod.POST, new HttpEntity<>(requestBody), getMapType() - ); - return parseChatResponse(response); - - } catch (Exception e) { - log.error("비교 분석 실패", e); - throw new AiException(ErrorCode.AI_PROMPT_FAILED); - } + return gptVisionClient.requestCompareAnalysis(beforeUrl, afterUrl, requestText); } - // [3] YOLO - 어질러진 영역 감지 @Override public List detectClutterBoxes(byte[] imageBytes) { - try { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); - - ResponseEntity response = restTemplate.exchange( - yoloServerUrl + "/yolo", - HttpMethod.POST, - new HttpEntity<>(imageBytes, headers), - List.class - ); - - @SuppressWarnings("unchecked") - List> rawBoxes = (List>) response.getBody(); - if (rawBoxes == null) return Collections.emptyList(); - - return rawBoxes.stream() - .map(map -> new Box( - ((Number) map.get("x")).intValue(), - ((Number) map.get("y")).intValue(), - ((Number) map.get("width")).intValue(), - ((Number) map.get("height")).intValue() - )) - .toList(); - } catch (Exception e) { - log.error("YOLO 감지 실패: {}", e.getMessage(), e); - throw new AiException(ErrorCode.AI_DETECTION_FAILED); - } + return yoloDetectionClient.detectClutterBoxes(imageBytes); } - // [4] LAMA Inpainting - 이미지 정리 @Override public String editImageWithLama(byte[] imageBytes, byte[] maskBytes, String prompt, UserId userId) { - try { - MultiValueMap body = buildLamaRequestBody(imageBytes, maskBytes, prompt); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - - ResponseEntity response = lamaClient.exchange( - lamaServerUrl + "/inpaint", - HttpMethod.POST, - new HttpEntity<>(body, headers), - byte[].class - ); - - byte[] responseBody = response.getBody(); - if (responseBody == null || responseBody.length == 0) { - throw new AiException(ErrorCode.AI_IMAGE_LAMA_FAILED); - } - - return uploadToS3(responseBody, userId); - } catch (AiException e) { - throw e; - } catch (Exception e) { - log.error("LAMA inpainting 실패: {}", e.getMessage(), e); - throw new AiException(ErrorCode.AI_IMAGE_LAMA_FAILED); - } - } - - // S3 업로드 - public String uploadToS3(byte[] image, UserId userId) { - String key = "AI/cleaned/" + userId.getId() + "/" + UUID.randomUUID() + ".png"; - - ObjectMetadata metadata = new ObjectMetadata(); - metadata.setContentLength(image.length); - metadata.setContentType("image/png"); - - try (InputStream inputStream = new ByteArrayInputStream(image)) { - amazonS3.putObject(bucket, key, inputStream, metadata); - } catch (IOException e) { - log.error("S3 업로드 실패: {}", e.getMessage(), e); - throw new AiException(ErrorCode.AI_S3_UPLOAD_FAILED); - } - - return amazonS3.getUrl(bucket, key).toString(); - } - - // === Helper Methods === - - private byte[] downloadImageFromS3(String imageUrl) throws IOException { - URL url = new URL(imageUrl); - String host = url.getHost(); - String path = url.getPath(); - - String key; - if (path.startsWith("/" + bucket + "/")) { - key = path.substring(("/" + bucket + "/").length()); - } else if (host.startsWith(bucket + ".")) { - key = path.startsWith("/") ? path.substring(1) : path; - } else { - throw new AiException(ErrorCode.AI_IMAGE_READ_FAILED); - } - - S3Object object = amazonS3.getObject(bucket, key); - try (InputStream inputStream = object.getObjectContent()) { - return inputStream.readAllBytes(); - } - } - - private String encodeImageFromS3(String imageUrl) throws IOException { - return Base64.getEncoder().encodeToString(downloadImageFromS3(imageUrl)); - } - - @SuppressWarnings("unchecked") - private ChatResponse parseChatResponse(ResponseEntity> response) { - Map body = response.getBody(); - if (body == null || body.get("choices") == null) { - throw new AiException(ErrorCode.AI_PROMPT_FAILED); - } - - List> choices = (List>) body.get("choices"); - if (choices.isEmpty()) { - throw new AiException(ErrorCode.AI_PROMPT_FAILED); - } - - Map message = (Map) choices.get(0).get("message"); - if (message == null) { - throw new AiException(ErrorCode.AI_PROMPT_FAILED); - } - String content = (String) message.get("content"); - - return new ChatResponse(List.of(new Choice(0, new TextMessage("assistant", content)))); - } - - private MultiValueMap buildLamaRequestBody(byte[] imageBytes, byte[] maskBytes, String prompt) { - ByteArrayResource imageResource = new ByteArrayResource(imageBytes) { - @Override public String getFilename() { return "image.png"; } - }; - ByteArrayResource maskResource = new ByteArrayResource(maskBytes) { - @Override public String getFilename() { return "mask.png"; } - }; - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("image", imageResource); - body.add("mask", maskResource); - body.add("prompt", wrap(prompt)); - body.add("ldmSteps", wrap("20")); - body.add("ldmSampler", wrap("plms")); - body.add("hdStrategy", wrap("Original")); - body.add("hdStrategyCropMargin", wrap("32")); - body.add("hdStrategyCropTriggerSize", wrap("768")); - body.add("hdStrategyResizeLimit", wrap("2048")); - body.add("useCroper", wrap("false")); - body.add("zitsWireframe", wrap("false")); - body.add("negativePrompt", wrap("")); - body.add("sdScale", wrap("1.0")); - body.add("sdMaskBlur", wrap("0")); - body.add("sdStrength", wrap("0.75")); - body.add("sdSteps", wrap("20")); - body.add("sdGuidanceScale", wrap("7.5")); - body.add("sdSampler", wrap("plms")); - body.add("sdSeed", wrap("-1")); - body.add("cv2Flag", wrap("INPAINT_NS")); - body.add("cv2Radius", wrap("3")); - return body; - } - - private HttpEntity wrap(String value) { - return new HttpEntity<>(value); - } - - @SuppressWarnings("unchecked") - private static Class> getMapType() { - return (Class>) (Class) Map.class; + return lamaInpaintClient.editImage(imageBytes, maskBytes, prompt, userId); } } diff --git a/demo-external/src/main/java/team9/demo/external/ai/GptVisionClient.java b/demo-external/src/main/java/team9/demo/external/ai/GptVisionClient.java new file mode 100644 index 0000000..6463f79 --- /dev/null +++ b/demo-external/src/main/java/team9/demo/external/ai/GptVisionClient.java @@ -0,0 +1,121 @@ +package team9.demo.external.ai; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import team9.demo.dto.ChatGPTRequest; +import team9.demo.error.AiException; +import team9.demo.error.ErrorCode; +import team9.demo.model.ai.analysis.ChatResponse; +import team9.demo.model.ai.analysis.Choice; +import team9.demo.model.ai.analysis.TextMessage; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * OpenAI GPT-4 Vision 호출 전담 클라이언트. + * - 단일 이미지 분석: 정리 가이드 생성 + * - 비교 분석(before/after): 미션 자동 검증용 + * + * 비공개 S3 객체는 GPT가 직접 fetch할 수 없으므로 Base64 data URL로 변환해 전달한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GptVisionClient { + + @Value("${openai.api.url2}") + private String chatEndpoint; + + @Value("${openai.model1}") + private String model; + + private final RestTemplate restTemplate; + private final S3ImageStore s3ImageStore; + + /** 단일 이미지 분석 — 정리 가이드 텍스트 응답을 반환한다. */ + public ChatResponse requestImageAnalysis(String imageUrl, String requestText) { + try { + String dataUrl = "data:image/jpeg;base64," + s3ImageStore.encodeBase64(imageUrl); + ChatGPTRequest requestDto = ChatGPTRequest.of(model, requestText, dataUrl, 500); + + ResponseEntity> response = restTemplate.exchange( + chatEndpoint, HttpMethod.POST, new HttpEntity<>(requestDto), getMapType() + ); + return parseChatResponse(response); + + } catch (HttpClientErrorException.TooManyRequests e) { + log.error("OpenAI 사용량 초과: {}", e.getResponseBodyAsString()); + throw new AiException(ErrorCode.AI_RATE_LIMIT_EXCEEDED); + } catch (IOException e) { + throw new AiException(ErrorCode.AI_IMAGE_READ_FAILED); + } + } + + /** before/after 두 이미지를 비교 분석한다 — 미션 검증용. */ + public ChatResponse requestCompareAnalysis(String beforeUrl, String afterUrl, String requestText) { + try { + String beforeBase64 = s3ImageStore.encodeBase64(beforeUrl); + String afterBase64 = s3ImageStore.encodeBase64(afterUrl); + + Map requestBody = Map.of( + "model", model, + "messages", List.of(Map.of( + "role", "user", + "content", List.of( + Map.of("type", "text", "text", requestText), + Map.of("type", "image_url", "image_url", Map.of("url", "data:image/jpeg;base64," + beforeBase64)), + Map.of("type", "image_url", "image_url", Map.of("url", "data:image/jpeg;base64," + afterBase64)) + ) + )), + "max_tokens", 500 + ); + + ResponseEntity> response = restTemplate.exchange( + chatEndpoint, HttpMethod.POST, new HttpEntity<>(requestBody), getMapType() + ); + return parseChatResponse(response); + + } catch (HttpClientErrorException.TooManyRequests e) { + log.error("OpenAI 사용량 초과: {}", e.getResponseBodyAsString()); + throw new AiException(ErrorCode.AI_RATE_LIMIT_EXCEEDED); + } catch (Exception e) { + log.error("비교 분석 실패", e); + throw new AiException(ErrorCode.AI_PROMPT_FAILED); + } + } + + @SuppressWarnings("unchecked") + private ChatResponse parseChatResponse(ResponseEntity> response) { + Map body = response.getBody(); + if (body == null || body.get("choices") == null) { + throw new AiException(ErrorCode.AI_PROMPT_FAILED); + } + + List> choices = (List>) body.get("choices"); + if (choices.isEmpty()) { + throw new AiException(ErrorCode.AI_PROMPT_FAILED); + } + + Map message = (Map) choices.get(0).get("message"); + if (message == null) { + throw new AiException(ErrorCode.AI_PROMPT_FAILED); + } + String content = (String) message.get("content"); + + return new ChatResponse(List.of(new Choice(0, new TextMessage("assistant", content)))); + } + + @SuppressWarnings("unchecked") + private static Class> getMapType() { + return (Class>) (Class) Map.class; + } +} diff --git a/demo-external/src/main/java/team9/demo/external/ai/LamaInpaintClient.java b/demo-external/src/main/java/team9/demo/external/ai/LamaInpaintClient.java new file mode 100644 index 0000000..3b69112 --- /dev/null +++ b/demo-external/src/main/java/team9/demo/external/ai/LamaInpaintClient.java @@ -0,0 +1,116 @@ +package team9.demo.external.ai; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import team9.demo.error.AiException; +import team9.demo.error.ErrorCode; +import team9.demo.model.user.UserId; + +import java.util.List; + +/** + * 자체 호스팅 LAMA 서버를 호출해 마스크 영역을 인페인팅한다. + *

+ * 기본 RestTemplate은 multipart/form-data + binary mask + 다수 form 파라미터 동시 전송을 + * 처리하지 못하므로, 본 클래스는 FormHttpMessageConverter / ByteArrayHttpMessageConverter / + * MappingJackson2HttpMessageConverter 3종을 등록한 전용 RestTemplate 인스턴스를 사용한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LamaInpaintClient { + + @Value("${ai.lama.url:http://localhost:7870}") + private String lamaServerUrl; + + private final S3ImageStore s3ImageStore; + + private final RestTemplate lamaClient = new RestTemplate(List.of( + new FormHttpMessageConverter(), + new ByteArrayHttpMessageConverter(), + new MappingJackson2HttpMessageConverter() + )); + + /** + * 원본 이미지와 마스크를 LAMA 서버에 전송해 인페인팅을 수행하고, + * 결과 PNG를 S3에 업로드한 뒤 public URL을 반환한다. + */ + public String editImage(byte[] imageBytes, byte[] maskBytes, String prompt, UserId userId) { + try { + MultiValueMap body = buildLamaRequestBody(imageBytes, maskBytes, prompt); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + ResponseEntity response = lamaClient.exchange( + lamaServerUrl + "/inpaint", + HttpMethod.POST, + new HttpEntity<>(body, headers), + byte[].class + ); + + byte[] responseBody = response.getBody(); + if (responseBody == null || responseBody.length == 0) { + throw new AiException(ErrorCode.AI_IMAGE_LAMA_FAILED); + } + + return s3ImageStore.uploadCleanedImage(responseBody, userId); + } catch (AiException e) { + throw e; + } catch (Exception e) { + log.error("LAMA inpainting 실패: {}", e.getMessage(), e); + throw new AiException(ErrorCode.AI_IMAGE_LAMA_FAILED); + } + } + + private MultiValueMap buildLamaRequestBody(byte[] imageBytes, byte[] maskBytes, String prompt) { + ByteArrayResource imageResource = new ByteArrayResource(imageBytes) { + @Override public String getFilename() { return "image.png"; } + }; + ByteArrayResource maskResource = new ByteArrayResource(maskBytes) { + @Override public String getFilename() { return "mask.png"; } + }; + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("image", imageResource); + body.add("mask", maskResource); + body.add("prompt", wrap(prompt)); + body.add("ldmSteps", wrap("20")); + body.add("ldmSampler", wrap("plms")); + body.add("hdStrategy", wrap("Original")); + body.add("hdStrategyCropMargin", wrap("32")); + body.add("hdStrategyCropTriggerSize", wrap("768")); + body.add("hdStrategyResizeLimit", wrap("2048")); + body.add("useCroper", wrap("false")); + body.add("zitsWireframe", wrap("false")); + body.add("negativePrompt", wrap("")); + body.add("sdScale", wrap("1.0")); + body.add("sdMaskBlur", wrap("0")); + body.add("sdStrength", wrap("0.75")); + body.add("sdSteps", wrap("20")); + body.add("sdGuidanceScale", wrap("7.5")); + body.add("sdSampler", wrap("plms")); + body.add("sdSeed", wrap("-1")); + body.add("cv2Flag", wrap("INPAINT_NS")); + body.add("cv2Radius", wrap("3")); + return body; + } + + private HttpEntity wrap(String value) { + return new HttpEntity<>(value); + } +} diff --git a/demo-external/src/main/java/team9/demo/external/ai/S3ImageStore.java b/demo-external/src/main/java/team9/demo/external/ai/S3ImageStore.java new file mode 100644 index 0000000..4b9bb52 --- /dev/null +++ b/demo-external/src/main/java/team9/demo/external/ai/S3ImageStore.java @@ -0,0 +1,85 @@ +package team9.demo.external.ai; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.S3Object; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import team9.demo.error.AiException; +import team9.demo.error.ErrorCode; +import team9.demo.model.user.UserId; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Base64; +import java.util.UUID; + +/** + * AI 파이프라인 전용 S3 헬퍼. + * - GPT Vision 입력용: 비공개 버킷 객체를 다운로드 후 Base64 인코딩 + * - LAMA 결과물 업로드: inpainting 산출물 PNG 저장 후 public URL 반환 + * + * 일반 사용자 파일 업로드는 ExternalFileClientImpl이 담당하며, + * 본 클래스는 AI 파이프라인 내부 전용으로만 사용한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class S3ImageStore { + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + /** S3 오브젝트를 다운로드해 byte 배열로 반환한다. */ + public byte[] download(String imageUrl) throws IOException { + String key = extractKey(imageUrl); + S3Object object = amazonS3.getObject(bucket, key); + try (InputStream inputStream = object.getObjectContent()) { + return inputStream.readAllBytes(); + } + } + + /** GPT Vision data URL 입력용 Base64 문자열로 인코딩한다. */ + public String encodeBase64(String imageUrl) throws IOException { + return Base64.getEncoder().encodeToString(download(imageUrl)); + } + + /** AI 산출물(PNG)을 사용자 폴더에 업로드하고 public URL을 반환한다. */ + public String uploadCleanedImage(byte[] image, UserId userId) { + String key = "AI/cleaned/" + userId.getId() + "/" + UUID.randomUUID() + ".png"; + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(image.length); + metadata.setContentType("image/png"); + + try (InputStream inputStream = new ByteArrayInputStream(image)) { + amazonS3.putObject(bucket, key, inputStream, metadata); + } catch (IOException e) { + log.error("S3 업로드 실패: {}", e.getMessage(), e); + throw new AiException(ErrorCode.AI_S3_UPLOAD_FAILED); + } + + return amazonS3.getUrl(bucket, key).toString(); + } + + /** S3 URL(path-style / virtual-hosted style)에서 object key를 추출한다. */ + private String extractKey(String imageUrl) throws IOException { + URL url = new URL(imageUrl); + String host = url.getHost(); + String path = url.getPath(); + + if (path.startsWith("/" + bucket + "/")) { + return path.substring(("/" + bucket + "/").length()); + } + if (host.startsWith(bucket + ".")) { + return path.startsWith("/") ? path.substring(1) : path; + } + throw new AiException(ErrorCode.AI_IMAGE_READ_FAILED); + } +} diff --git a/demo-external/src/main/java/team9/demo/external/ai/YoloDetectionClient.java b/demo-external/src/main/java/team9/demo/external/ai/YoloDetectionClient.java new file mode 100644 index 0000000..21e9073 --- /dev/null +++ b/demo-external/src/main/java/team9/demo/external/ai/YoloDetectionClient.java @@ -0,0 +1,65 @@ +package team9.demo.external.ai; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import team9.demo.error.AiException; +import team9.demo.error.ErrorCode; +import team9.demo.model.ai.mask.Box; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 자체 호스팅 YOLO 서버 호출 전담 클라이언트. + * 방 사진에서 어질러진 객체의 bounding box를 감지하여 반환한다. + * 반환된 box들은 MaskGenerator에서 LAMA 인페인팅용 마스크로 후처리된다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class YoloDetectionClient { + + @Value("${ai.yolo.url:http://localhost:5000}") + private String yoloServerUrl; + + private final RestTemplate restTemplate; + + public List detectClutterBoxes(byte[] imageBytes) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + + ResponseEntity response = restTemplate.exchange( + yoloServerUrl + "/yolo", + HttpMethod.POST, + new HttpEntity<>(imageBytes, headers), + List.class + ); + + @SuppressWarnings("unchecked") + List> rawBoxes = (List>) response.getBody(); + if (rawBoxes == null) return Collections.emptyList(); + + return rawBoxes.stream() + .map(map -> new Box( + ((Number) map.get("x")).intValue(), + ((Number) map.get("y")).intValue(), + ((Number) map.get("width")).intValue(), + ((Number) map.get("height")).intValue() + )) + .toList(); + } catch (Exception e) { + log.error("YOLO 감지 실패: {}", e.getMessage(), e); + throw new AiException(ErrorCode.AI_DETECTION_FAILED); + } + } +}