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
6 changes: 6 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ jobs:
test -n "$PROD_ENV"
mkdir -p deploy-bundle
cp docker-compose.prod.yml deploy-bundle/docker-compose.yml
cp scripts/check-hair-analysis-model.sh deploy-bundle/check-hair-analysis-model.sh
printf '%s\n' "$PROD_ENV" > deploy-bundle/.env
# Non-secret analysis settings are versioned; existing secrets stay in GitHub.
cat config/hair-analysis.prod.env >> deploy-bundle/.env

- name: Upload compose and environment
env:
Expand All @@ -108,6 +111,7 @@ jobs:
run: |
ssh -i ~/.ssh/deploy_key -p "$EC2_PORT" "$EC2_USER@$EC2_HOST" "mkdir -p '$DEPLOY_PATH'"
scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/docker-compose.yml "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/docker-compose.yml"
scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/check-hair-analysis-model.sh "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/check-hair-analysis-model.sh"
scp -i ~/.ssh/deploy_key -P "$EC2_PORT" deploy-bundle/.env "$EC2_USER@$EC2_HOST:$DEPLOY_PATH/.env"

- name: Pull and restart containers
Expand All @@ -123,6 +127,8 @@ jobs:
cd "$DEPLOY_PATH"
echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
APP_IMAGE_TAG="${{ github.sha }}" docker compose pull app
# 기존 앱을 내리기 전에 후보 컨테이너의 사용자/마운트로 모델 접근을 검사한다.
APP_IMAGE_TAG="${{ github.sha }}" docker compose run --rm -T --no-deps --entrypoint sh app -s < check-hair-analysis-model.sh
APP_IMAGE_TAG="${{ github.sha }}" docker compose up -d --remove-orphans
docker image prune -f
EOF
9 changes: 9 additions & 0 deletions config/hair-analysis.prod.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Non-secret production overrides. Applied after PROD_ENV by Deploy.
# Model must be provisioned on the host before enabling analysis.
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=segformer-b5-int8-758b82e-320-v1
HAIR_ANALYSIS_INPUT_SIZE=320
HAIR_ANALYSIS_WORKER_THREADS=1
JAVA_TOOL_OPTIONS=-Xmx256m -Xms64m
20 changes: 19 additions & 1 deletion docs/deploy-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,34 @@ 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
HAIR_ANALYSIS_INPUT_SIZE=320
```

- ONNX 입력은 RGB `float32 [1,3,512,512]`, 출력은 19개 이상의 클래스 logits
- ONNX 입력은 RGB `float32 [1,3,S,S]`(기본 S=512, 소형 운영 호스트 S=320), 출력은 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
계약으로 내보내 배치한다.
- 배포 워크플로는 기존 앱을 재시작하기 전에 후보 이미지의 실제 사용자와 모델 마운트로
`scripts/check-hair-analysis-model.sh`를 실행한다. 분석이 켜졌는데 파일이 없거나 비어 있거나
읽을 수 없으면 기존 컨테이너를 유지하고 배포를 실패시킨다. 비활성화 상태는 503 경고를 남긴다.
이 검사는 파일 접근만 검증하므로 ONNX 호환성과 실제 사진 추론은 별도로 확인해야 한다.
- 작은 호스트에서 분석 실행은 엔진 단위로 직렬화하고 ONNX 내부 스레드는 1개로 제한한다.
CPU arena와 memory pattern 캐시를 끄므로 추론 버퍼를 계속 보유하지 않는다.
기본 512px와 기존 지표 계산식은 유지한다. 2GB 운영 서버는 실제 추론 검증을 거친
`HAIR_ANALYSIS_INPUT_SIZE=320`을 사용한다. 작은 입력에서는 눈 등 세부 영역을 찾지 못해
재촬영 안내가 늘 수 있으므로 입력 크기를 모델 버전에도 포함한다.
- 작은 운영 호스트의 JVM 힙은 `JAVA_TOOL_OPTIONS=-Xmx256m -Xms64m`로 제한한다.
모델 추론은 네이티브 메모리도 쓰므로 JVM 힙 외 여유가 필요하다. 호스트 swap 설정은 변경하지 않는다.
- 공개 검증 모델의 정확한 버전·체크섬·사용 제한은 `docs/hair-analysis-model-NOTICE.txt`에 기록한다.
모델을 배치하는 경우 해당 NOTICE도 모델 파일과 함께 보관한다.
- 현재 운영 분석 설정은 `config/hair-analysis.prod.env`에서 관리하며 배포 시 `PROD_ENV` 뒤에
적용한다(동일 키는 이 파일의 값이 우선). 기존 시크릿은 변경하거나 다시 읽어 올 필요가 없다.
분석을 끄거나 입력 크기를 바꿀 때는 이 파일을 수정하고 Actions로 배포한다.
서버 `.env`만 수정하면 다음 배포에서 다시 덮어써진다.
- 인스턴스 역할에는 기존 업로드 권한과 함께 시술 사진 객체에 대한 `s3:GetObject`가 필요하다.
서버가 분석 시 S3 원본을 직접 읽으며 Presigned URL을 외부 AI 서비스에 전달하지 않는다.

Expand Down
9 changes: 9 additions & 0 deletions docs/hair-analysis-model-NOTICE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Face parsing model: jonathandinu/face-parsing (SegFormer B5, CelebAMask-HQ).
Upstream: https://huggingface.co/jonathandinu/face-parsing
Revision: 758b82e15a0178c9db39c1ff666a8b56e3a550c8
File: onnx/model_quantized.onnx (unmodified)
SHA256: 5bab9bfb3cb979f3098ac3b934b1641dbf87f835e0b03c2ca6d88dcf18c83d27
Model-card license: non-commercial research and educational purposes.
The weights are not stored in Git or bundled into the application image.
Heddy computes visual metrics from the model's segmentation output; the model
is not a trained classifier of treatment quality or physical hair damage.
23 changes: 23 additions & 0 deletions scripts/check-hair-analysis-model.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/sh
# Run inside the candidate app container, with its real user, env and model mount.
set -eu

case "${HAIR_ANALYSIS_ENABLED:-false}" in
true|TRUE|True) ;;
false|FALSE|False|'')
echo 'Hair analysis is disabled; analysis requests will return 503.'
exit 0
;;
*)
echo 'Invalid HAIR_ANALYSIS_ENABLED; expected true or false.' >&2
exit 1
;;
esac

model_path=${HAIR_ANALYSIS_MODEL_PATH:-}
if [ -z "$model_path" ] || [ ! -f "$model_path" ] || [ ! -r "$model_path" ] || [ ! -s "$model_path" ]; then
echo 'Hair analysis is enabled, but its model is missing, unreadable or empty. Deployment stopped before restarting app.' >&2
exit 1
fi

echo 'Hair analysis model file is readable. ONNX compatibility must be validated before activation.'
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,15 @@ public OnnxFaceParsingHairAnalysisAdapter(
}
try {
environment = OrtEnvironment.getEnvironment();
OrtSession.SessionOptions options = new OrtSession.SessionOptions();
options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT);
session = environment.createSession(path.toString(), options);
try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) {
options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT);
// Small production hosts also run the API, DB and recommendation model.
options.setIntraOpNumThreads(1);
options.setInterOpNumThreads(1);
options.setCPUArenaAllocator(false);
options.setMemoryPatternOptimization(false);
session = environment.createSession(path.toString(), options);
}
if (session.getInputNames().size() != 1) {
throw new IllegalStateException("얼굴 파싱 모델 입력은 하나여야 합니다");
}
Expand All @@ -81,7 +87,7 @@ public boolean isReady() {
}

@Override
public HairAnalysisOutcome analyze(byte[] imageBytes) {
public synchronized HairAnalysisOutcome analyze(byte[] imageBytes) {
final BufferedImage image;
try {
image = decode(imageBytes);
Expand Down
Loading