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
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ RUN chmod +x ./gradlew && ./gradlew --no-daemon dependencies > /dev/null 2>&1 ||
COPY src ./src
RUN ./gradlew --no-daemon bootJar

FROM eclipse-temurin:21-jre-alpine AS runtime
FROM eclipse-temurin:21-jre AS runtime

WORKDIR /app

RUN addgroup -S spring && adduser -S spring -G spring
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system spring \
&& useradd --system --gid spring spring

COPY --from=build /app/build/libs/*.jar app.jar

Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'
implementation platform('software.amazon.awssdk:bom:2.28.16')
implementation 'software.amazon.awssdk:s3'
// 로컬 얼굴 파싱 모델을 JVM 안에서 실행한다. 외부 AI API 로 사진을 보내지 않는다.
implementation 'com.microsoft.onnxruntime:onnxruntime:1.29.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'

runtimeOnly 'org.flywaydb:flyway-database-postgresql'
Expand Down
9 changes: 8 additions & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,17 @@ services:
# 사진 조회 Presigned GET URL 의 만료(초). 짧을수록 URL 유출 창이 좁지만,
# 화면 체류 시간보다 짧으면 이미지가 403 으로 깨진다.
DOWNLOAD_URL_TTL_SECONDS: ${DOWNLOAD_URL_TTL_SECONDS:-300}
# 로컬 ONNX 얼굴 파싱 모델. 호스트의 읽기 전용 파일을 컨테이너에 마운트한다.
HAIR_ANALYSIS_ENABLED: ${HAIR_ANALYSIS_ENABLED:-false}
HAIR_ANALYSIS_MODEL_PATH: ${HAIR_ANALYSIS_MODEL_PATH:-/opt/heddy/models/face-parsing.onnx}
HAIR_ANALYSIS_MODEL_VERSION: ${HAIR_ANALYSIS_MODEL_VERSION:-segformer-face-parsing-cv-v1}
HAIR_ANALYSIS_WORKER_THREADS: ${HAIR_ANALYSIS_WORKER_THREADS:-1}
ports:
- "8080:8080"
volumes:
- ${HAIR_ANALYSIS_MODEL_HOST_DIR:-/home/ubuntu/heddy-models}:/opt/heddy/models:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"]
interval: 15s
timeout: 5s
retries: 10
Expand Down
22 changes: 22 additions & 0 deletions docs/deploy-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ SHARE_PUBLIC_BASE_URL=https://heddy.site/s # 선택: 미지정 시에도 이
기능별 선택 변수(소셜 로그인 `KAKAO_APP_ID` · `GOOGLE_CLIENT_ID` · `APPLE_CLIENT_ID`,
SMS `SOLAPI_*` 등)는 해당 기능을 켤 때 같은 파일에 추가하면 `.env` 째로 컨테이너에 전달된다.

헤어 분석을 켤 때는 아래 값도 추가한다. 모델 파일은 이미지에 넣지 않고 EC2의
`/home/ubuntu/heddy-models`에서 컨테이너로 읽기 전용 마운트한다.

```dotenv
HAIR_ANALYSIS_ENABLED=true
HAIR_ANALYSIS_MODEL_HOST_DIR=/home/ubuntu/heddy-models
HAIR_ANALYSIS_MODEL_PATH=/opt/heddy/models/face-parsing.onnx
HAIR_ANALYSIS_MODEL_VERSION=<학습 데이터와 가중치를 식별하는 버전>
HAIR_ANALYSIS_WORKER_THREADS=1
```

- ONNX 입력은 RGB `float32 [1,3,512,512]`, 출력은 19개 이상의 클래스 logits
`[1,C,H,W]`여야 한다. 사용하는 클래스 번호는 skin=1, nose=2, left-eye=4,
right-eye=5, hair=13이다.
- 모델 파일이 없거나 열리지 않으면 앱을 분석 활성 상태로 기동하지 않는다. 기능을 끈 환경의
분석 요청은 더미 점수 대신 `503 ANALYSIS_ENGINE_UNAVAILABLE`을 반환한다.
- 참고용 공개 `jonathandinu/face-parsing`/CelebAMask-HQ 가중치는 비상업 용도 제한이 있으므로
운영 서비스에 그대로 배포하지 않는다. 실제 운영에는 사용 권리를 확보한 가중치를 같은 ONNX
계약으로 내보내 배치한다.
- 인스턴스 역할에는 기존 업로드 권한과 함께 시술 사진 객체에 대한 `s3:GetObject`가 필요하다.
서버가 분석 시 S3 원본을 직접 읽으며 Presigned URL을 외부 AI 서비스에 전달하지 않는다.

## 3. 첫 배포

1. 위 1·2번이 끝났으면 `main` 에 푸시하거나 Actions 에서 `Deploy` 를 수동 실행한다.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.heddy.adapter.in.web.analysis;

import com.heddy.adapter.in.web.analysis.dto.AnalysisResponse;
import com.heddy.adapter.in.web.analysis.dto.AnalysisJobAcceptedResponse;
import com.heddy.adapter.in.web.analysis.dto.AnalysisJobResponse;
import com.heddy.adapter.in.web.analysis.dto.RequestAnalysisRequest;
import com.heddy.domain.analysis.port.in.GetAnalysisJobUseCase;
import com.heddy.domain.analysis.port.in.GetAnalysisUseCase;
import com.heddy.domain.analysis.port.in.GetLatestAnalysisUseCase;
import com.heddy.domain.analysis.port.in.RequestAnalysisUseCase;
import com.heddy.domain.analysis.port.in.RetryAnalysisUseCase;
import com.heddy.global.docs.ApiDocs;
import com.heddy.global.filter.RequestIdFilter;
import com.heddy.global.response.ApiResponse;
Expand All @@ -11,9 +18,13 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;
Expand All @@ -25,6 +36,76 @@
public class AnalysisController {

private final GetLatestAnalysisUseCase getLatestAnalysisUseCase;
private final RequestAnalysisUseCase requestAnalysisUseCase;
private final GetAnalysisJobUseCase getAnalysisJobUseCase;
private final RetryAnalysisUseCase retryAnalysisUseCase;
private final GetAnalysisUseCase getAnalysisUseCase;

@PostMapping("/treatment-records/{recordId}/analyses")
@ApiDocs.Accepted
@ApiDocs.Authenticated
@ApiDocs.OwnedResource
@Operation(summary = "머리 분석 작업 요청",
description = "S3의 READY 시술 사진을 실제 SegFormer ONNX 모델로 비동기 분석한다. "
+ "모델을 사용할 수 없으면 임의 점수를 만들지 않고 503으로 답한다.")
public ResponseEntity<ApiResponse<AnalysisJobAcceptedResponse>> request(
@AuthenticationPrincipal UUID userId,
@PathVariable UUID recordId,
@RequestBody(required = false) RequestAnalysisRequest request,
HttpServletRequest servletRequest
) {
UUID photoId = request == null ? null : request.photoId();
var job = requestAnalysisUseCase.request(
new RequestAnalysisUseCase.Command(userId, recordId, photoId));
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(
AnalysisJobAcceptedResponse.from(job), RequestIdFilter.get(servletRequest)));
}

@GetMapping("/analysis-jobs/{jobId}")
@ApiDocs.Ok
@ApiDocs.Authenticated
@ApiDocs.OwnedResource
@Operation(summary = "분석 작업 상태 조회")
public ApiResponse<AnalysisJobResponse> getJob(
@AuthenticationPrincipal UUID userId,
@PathVariable UUID jobId,
HttpServletRequest servletRequest
) {
return ApiResponse.success(AnalysisJobResponse.from(getAnalysisJobUseCase.get(
new GetAnalysisJobUseCase.Query(userId, jobId))),
RequestIdFilter.get(servletRequest));
}

@PostMapping("/analysis-jobs/{jobId}/retry")
@ApiDocs.Accepted
@ApiDocs.Authenticated
@ApiDocs.OwnedResource
@Operation(summary = "실패한 분석 작업 재시도",
description = "FAILED 작업만 새 작업 ID로 재접수한다. UNAVAILABLE은 재촬영 대상이다.")
public ResponseEntity<ApiResponse<AnalysisJobAcceptedResponse>> retry(
@AuthenticationPrincipal UUID userId,
@PathVariable UUID jobId,
HttpServletRequest servletRequest
) {
var job = retryAnalysisUseCase.retry(new RetryAnalysisUseCase.Command(userId, jobId));
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(
AnalysisJobAcceptedResponse.from(job), RequestIdFilter.get(servletRequest)));
}

@GetMapping("/analyses/{analysisId}")
@ApiDocs.Ok
@ApiDocs.Authenticated
@ApiDocs.OwnedResource
@Operation(summary = "분석 결과 조회")
public ApiResponse<AnalysisResponse> getAnalysis(
@AuthenticationPrincipal UUID userId,
@PathVariable UUID analysisId,
HttpServletRequest servletRequest
) {
return ApiResponse.success(AnalysisResponse.from(getAnalysisUseCase.get(
new GetAnalysisUseCase.Query(userId, analysisId))),
RequestIdFilter.get(servletRequest));
}

@GetMapping("/treatment-records/{recordId}/analyses/latest")
@ApiDocs.Ok
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.heddy.adapter.in.web.analysis.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.heddy.domain.analysis.model.AnalysisJob;
import io.swagger.v3.oas.annotations.media.Schema;

import java.time.Instant;
import java.util.UUID;

@Schema(description = "접수된 비동기 분석 작업")
public record AnalysisJobAcceptedResponse(
@JsonProperty("job_id") UUID jobId,
@JsonProperty("record_id") UUID recordId,
@JsonProperty("photo_id") UUID photoId,
String status,
@JsonProperty("created_at") Instant createdAt
) {
public static AnalysisJobAcceptedResponse from(AnalysisJob job) {
return new AnalysisJobAcceptedResponse(job.jobId(), job.recordId(), job.photoId(),
job.status().name(), job.createdAt());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.heddy.adapter.in.web.analysis.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.heddy.domain.analysis.port.in.GetAnalysisJobUseCase;
import io.swagger.v3.oas.annotations.media.Schema;

import java.time.Instant;
import java.util.UUID;

@Schema(description = "비동기 분석 작업 상태")
public record AnalysisJobResponse(
@JsonProperty("job_id") UUID jobId,
String status,
int progress,
@JsonProperty("attempt_count") int attemptCount,
@JsonProperty("analysis_id") UUID analysisId,
Failure failure,
@JsonProperty("created_at") Instant createdAt,
@JsonProperty("updated_at") Instant updatedAt
) {
public record Failure(String code, String message) {
}

public static AnalysisJobResponse from(GetAnalysisJobUseCase.Result result) {
var job = result.job();
Failure failure = job.failureCode() == null
? null : new Failure(job.failureCode(), job.failureMessage());
Instant updatedAt = job.finishedAt() != null ? job.finishedAt()
: job.startedAt() != null ? job.startedAt() : job.createdAt();
return new AnalysisJobResponse(job.jobId(), job.status().name(), job.progress(),
job.attemptCount(), result.analysisId(), failure, job.createdAt(), updatedAt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.heddy.domain.analysis.model.AnalysisOverlay;
import com.heddy.domain.analysis.model.MetricType;
import com.heddy.domain.analysis.port.in.GetLatestAnalysisUseCase;
import com.heddy.domain.analysis.port.in.GetAnalysisUseCase;
import io.swagger.v3.oas.annotations.media.Schema;

import java.math.BigDecimal;
Expand Down Expand Up @@ -80,27 +81,42 @@ public record Overlay(

public static AnalysisResponse from(GetLatestAnalysisUseCase.Result result) {
var analysis = result.analysis();
return new AnalysisResponse(
analysis.analysisId(), analysis.recordId(), analysis.photoId(), analysis.jobId(),
result.status().name(),
metrics(result), confidence(result), analysis.modelVersion(), analysis.summary(),
analysis.analyzedAt(), overlays(result.overlays()));
return from(analysis, result.status().name(), result.overlays());
}

private static List<Metric> metrics(GetLatestAnalysisUseCase.Result result) {
public static AnalysisResponse from(GetAnalysisUseCase.Result result) {
return from(result.analysis(), result.status().name(), result.overlays());
}

private static AnalysisResponse from(
com.heddy.domain.analysis.model.AnalysisResult analysis,
String status,
List<AnalysisOverlay> overlays
) {
return new AnalysisResponse(analysis.analysisId(), analysis.recordId(), analysis.photoId(),
analysis.jobId(), status, metrics(analysis), confidence(analysis),
analysis.modelVersion(), analysis.summary(), analysis.analyzedAt(),
overlays(overlays));
}

private static List<Metric> metrics(
com.heddy.domain.analysis.model.AnalysisResult analysis
) {
// 지표 순서를 열거형 선언 순서로 고정한다. map 순회 순서에 맡기면 화면의 목록 순서가
// 요청마다 달라진다.
return java.util.Arrays.stream(MetricType.values())
.map(type -> {
var metric = result.analysis().metric(type);
var metric = analysis.metric(type);
return new Metric(type.name(), metric.score(), metric.grade().name(),
type.higherIsBetter());
})
.toList();
}

private static Metric confidence(GetLatestAnalysisUseCase.Result result) {
var confidence = result.analysis().confidence();
private static Metric confidence(
com.heddy.domain.analysis.model.AnalysisResult analysis
) {
var confidence = analysis.confidence();
return new Metric(null, confidence.score(), confidence.grade().name(), true);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.heddy.adapter.in.web.analysis.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;

import java.util.UUID;

/** 분석할 사진을 선택한다. 비우면 기록의 첫 번째 AFTER 사진을 사용한다. */
public record RequestAnalysisRequest(
@Schema(description = "분석할 시술 사진 식별자. 생략하면 대표 AFTER 사진")
@JsonProperty("photo_id") UUID photoId
) {
}
64 changes: 64 additions & 0 deletions src/main/java/com/heddy/adapter/out/ai/FaceParsingOutput.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.heddy.adapter.out.ai;

import java.awt.image.BufferedImage;

/** ONNX logits를 입력 해상도로 확대한 얼굴 파싱 결과. */
record FaceParsingOutput(
int width,
int height,
int[] rgb,
byte[] labels,
float[] hairProbability
) {
static FaceParsingOutput from(BufferedImage image, float[][][] logits) {
int classes = logits.length;
int outputHeight = logits[0].length;
int outputWidth = logits[0][0].length;
int width = image.getWidth();
int height = image.getHeight();
int[] rgb = new int[width * height];
byte[] labels = new byte[width * height];
float[] hairProbability = new float[width * height];

for (int y = 0; y < height; y++) {
int outputY = Math.min(outputHeight - 1, y * outputHeight / height);
for (int x = 0; x < width; x++) {
int outputX = Math.min(outputWidth - 1, x * outputWidth / width);
int index = y * width + x;
rgb[index] = image.getRGB(x, y) & 0x00ffffff;

int bestClass = 0;
float maximum = logits[0][outputY][outputX];
for (int label = 1; label < classes; label++) {
float value = logits[label][outputY][outputX];
if (value > maximum) {
maximum = value;
bestClass = label;
}
}
labels[index] = (byte) bestClass;

double denominator = 0;
for (int label = 0; label < classes; label++) {
denominator += Math.exp(logits[label][outputY][outputX] - maximum);
}
hairProbability[index] = (float) (Math.exp(
logits[HairMetricCalculator.HAIR_LABEL][outputY][outputX] - maximum)
/ denominator);
}
}
return new FaceParsingOutput(width, height, rgb, labels, hairProbability);
}

int label(int x, int y) {
return Byte.toUnsignedInt(labels[y * width + x]);
}

boolean isHair(int x, int y) {
return label(x, y) == HairMetricCalculator.HAIR_LABEL;
}

int rgb(int x, int y) {
return rgb[y * width + x];
}
}
Loading
Loading