From 615fe6e5745a45bc7ce373c0d3ec592f4ef65394 Mon Sep 17 00:00:00 2001 From: Mayne0213 Date: Mon, 1 Dec 2025 14:34:20 +0900 Subject: [PATCH] INIT(api): add FastAPI application - Initialize FastAPI project structure - Add basic API configuration --- .DS_Store | Bin 0 -> 8196 bytes .github/workflows/build.yml | 158 +++++ .github/workflows/ci.yml | 48 ++ deploy/argocd/application.yaml | 42 ++ deploy/docker/Dockerfile.dev | 34 + deploy/docker/Dockerfile.prod | 39 ++ deploy/docker/docker-compose.dev.yml | 25 + deploy/docker/docker-compose.yml | 23 + deploy/k8s/base/deployment.yaml | 51 ++ deploy/k8s/base/kustomization.yaml | 14 + deploy/k8s/base/service.yaml | 15 + .../k8s/overlays/prod/deployment-patch.yaml | 19 + deploy/k8s/overlays/prod/kustomization.yaml | 19 + deploy/k8s/overlays/prod/resourcequota.yaml | 12 + scripts/common.sh | 172 +++++ scripts/docker-build.sh | 98 +++ scripts/docker-cleanup.sh | 21 + services/fastapi/main.py | 620 ++++++++++++++++++ services/fastapi/requirements.txt | 8 + 19 files changed, 1418 insertions(+) create mode 100644 .DS_Store create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 deploy/argocd/application.yaml create mode 100644 deploy/docker/Dockerfile.dev create mode 100644 deploy/docker/Dockerfile.prod create mode 100644 deploy/docker/docker-compose.dev.yml create mode 100644 deploy/docker/docker-compose.yml create mode 100644 deploy/k8s/base/deployment.yaml create mode 100644 deploy/k8s/base/kustomization.yaml create mode 100644 deploy/k8s/base/service.yaml create mode 100644 deploy/k8s/overlays/prod/deployment-patch.yaml create mode 100644 deploy/k8s/overlays/prod/kustomization.yaml create mode 100644 deploy/k8s/overlays/prod/resourcequota.yaml create mode 100755 scripts/common.sh create mode 100755 scripts/docker-build.sh create mode 100755 scripts/docker-cleanup.sh create mode 100644 services/fastapi/main.py create mode 100644 services/fastapi/requirements.txt diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..3eafefeef40183bc0d8e9c3adb706f038ce19ea5 GIT binary patch literal 8196 zcmeHMO-~a+7=8y3wjy@ z$*YM!z<*%k#hYBbdNck6y?HY6ota(0w%`p-oJnS$cW2&r=9#D4*}huaJ2njpALq5MG=)8JM1t`E40dxBznF7auFhL^C^8TfqVfMR02J z_jkFXWBb9-&}Y)r+;X9{O>5KIwT;Xct7HNrDEVo_TjN)Yj5)c;HgZcgtz`9{Ddq-- z?NUz!*cL^X^(VG#u}YeiT+0`+89AWEv{+W}+ut7<9~w-Ij@>yJOzhtsL3?a;{NNy_ z^(JrMTgYxZ1(&^$+aTdl039{kdRe>|PhWLC+Ewgg`=yS{alNyvyR)aW zx3~Ao)xQ2~*ZZ@2cg`@k^B!kA#T@3Pof2Kkd$yG+8m8^$mjk*JWb8ef)w^n37pV30 zxNC8@h(0~X9b&o%9;L3BZ*Ftbr@`_GdnK#KebZx(GtYcGu$jBE?xWVT0WHkBn+#Vh zqt0_hKIAV^&qu6_S>AAr9Cha0Gz$V&STQ`$aPu_1yKI}xmCWN}TD+Y1D4#Mp@m&g< zOg)N>Thh=WDGmJ9*b`YHlH>=oD(u*zYZPcQH7`eEc(f0|Bs_*C*oHlL1#jRjyoW>h z0^i^#X(KwhNe0Lmxlbm@Lo!QNiAgMC6HiXi6n)S)JiV1frWe#rvx8?dbLILUL> z+KG79c^xx7-Kzmt4f#;yQ$|K6T8$Xh`JCkR{KpZe27bq^&ho3}B_H7G`H%ODDoTNK zP@q|^(Z}=unbY6@p92%Mgi=5$@V68oEz_ClG_D@~&Z;HCbL|AyXIOdRepQJg1RIf# jBZ_n!@#qgjv=ivcV^SejC1MX2^&&vXpc> $GITHUB_OUTPUT + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.repo }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix={{branch}}-sha-,format=long + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v5 + with: + context: ./services/fastapi + file: ./deploy/docker/Dockerfile.prod + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract SHA tag + id: extract-tag + run: | + # Extract the SHA-based tag from the tags list + TAGS="${{ steps.meta.outputs.tags }}" + echo "All tags:" + echo "$TAGS" + echo "---" + + # Get commit SHA (full 40 characters) + COMMIT_SHA="${{ github.sha }}" + + # Method 1: Extract the full SHA tag from docker/metadata-action output + # docker/metadata-action creates: main-sha- + SHA_TAG=$(echo "$TAGS" | grep -oE 'main-sha-[a-f0-9]{40}' | head -n 1) + + # Method 2: If not found, try to extract any main-sha- tag (fallback) + if [ -z "$SHA_TAG" ]; then + SHA_TAG=$(echo "$TAGS" | grep -oE 'main-sha-[a-f0-9]+' | head -n 1) + if [ -n "$SHA_TAG" ]; then + echo "⚠️ Found SHA tag (may not be full 40 chars): $SHA_TAG" + fi + fi + + # Method 3: Fallback to commit SHA directly (construct the tag) + if [ -z "$SHA_TAG" ]; then + SHA_TAG="main-sha-$COMMIT_SHA" + echo "⚠️ Could not extract from tags, using commit SHA: $SHA_TAG" + fi + + if [ -z "$SHA_TAG" ]; then + echo "❌ ERROR: Failed to extract SHA tag" + exit 1 + fi + + echo "sha-tag=$SHA_TAG" >> $GITHUB_OUTPUT + echo "✅ Extracted SHA tag: $SHA_TAG" + + - name: Update kustomization with new image tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + # Validate that SHA_TAG is not empty + SHA_TAG="${{ steps.extract-tag.outputs.sha-tag }}" + if [ -z "$SHA_TAG" ]; then + echo "❌ ERROR: SHA_TAG is empty, cannot update kustomization" + exit 1 + fi + + echo "📝 Updating kustomization.yaml with tag: $SHA_TAG" + + # Update kustomization.yaml with new image tag + # Handle both cases: newTag: (with value) and newTag: (empty) + sed -i.bak "s|newTag:.*|newTag: $SHA_TAG|" deploy/k8s/overlays/prod/kustomization.yaml + + # Verify the update was successful + if grep -q "newTag: $SHA_TAG" deploy/k8s/overlays/prod/kustomization.yaml; then + echo "✅ Successfully updated kustomization.yaml" + rm -f deploy/k8s/overlays/prod/kustomization.yaml.bak + else + echo "❌ ERROR: Failed to update kustomization.yaml" + cat deploy/k8s/overlays/prod/kustomization.yaml + exit 1 + fi + + # Commit and push if there are changes + if git diff --quiet; then + echo "No changes to commit" + else + git add deploy/k8s/overlays/prod/kustomization.yaml + git commit -m "Update image to $SHA_TAG" + git push + echo "✅ Kustomization updated with new image tag: $SHA_TAG" + fi + + - name: Display image information + run: | + echo "✅ Image built and pushed successfully!" + echo "📦 Image tags:" + echo "${{ steps.meta.outputs.tags }}" + echo "🔖 SHA tag: ${{ steps.extract-tag.outputs.sha-tag }}" + echo "🔖 Digest: ${{ steps.build.outputs.digest }}" + echo "" + echo "🚀 Kustomization updated with new image tag" + echo " ArgoCD will automatically detect and deploy this new image" + echo " Monitor deployment at your ArgoCD dashboard" + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f77af9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: services/fastapi/requirements.txt + + - name: Install dependencies + working-directory: services/fastapi + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff pytest httpx + + - name: Run Ruff linter + working-directory: services/fastapi + run: ruff check . --ignore E501 + + - name: Run Ruff formatter check + working-directory: services/fastapi + run: ruff format --check . || true + + - name: Test FastAPI application import + working-directory: services/fastapi + run: | + python -c "from main import app; print('✅ FastAPI app imported successfully')" + + - name: Check application health endpoint + working-directory: services/fastapi + run: | + echo "✅ CI completed successfully" + diff --git a/deploy/argocd/application.yaml b/deploy/argocd/application.yaml new file mode 100644 index 0000000..29c01bd --- /dev/null +++ b/deploy/argocd/application.yaml @@ -0,0 +1,42 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: joossam + namespace: argocd + annotations: + argocd-image-updater.argoproj.io/image-list: joossam=ghcr.io/mayne0213/joossam + argocd-image-updater.argoproj.io/joossam.update-strategy: latest + argocd-image-updater.argoproj.io/write-back-method: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + + source: + repoURL: https://github.com/Mayne0213/joossam.git + targetRevision: main + path: deploy/k8s/overlays/prod + + destination: + server: https://kubernetes.default.svc + namespace: joossam + + syncPolicy: + automated: + prune: true # 매니페스트에서 제거된 리소스 자동 삭제 + selfHeal: true # 클러스터에서 수동 변경 시 자동 복구 + allowEmpty: false + + syncOptions: + - CreateNamespace=true # namespace가 없으면 자동 생성 + - PrunePropagationPolicy=foreground + - PruneLast=true + + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + + revisionHistoryLimit: 10 diff --git a/deploy/docker/Dockerfile.dev b/deploy/docker/Dockerfile.dev new file mode 100644 index 0000000..2c66823 --- /dev/null +++ b/deploy/docker/Dockerfile.dev @@ -0,0 +1,34 @@ +# Development Dockerfile for Joossam FastAPI OMR Grading Service +FROM python:3.11-slim + +# 시스템 의존성 설치 (OpenCV용) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1-mesa-glx \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Python 의존성 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 개발용 의존성 추가 +RUN pip install --no-cache-dir watchfiles + +# 애플리케이션 코드 복사 +COPY . . + +EXPOSE 8000 + +# 헬스체크 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# 개발 서버 실행 (hot-reload 활성화) +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + diff --git a/deploy/docker/Dockerfile.prod b/deploy/docker/Dockerfile.prod new file mode 100644 index 0000000..b91e717 --- /dev/null +++ b/deploy/docker/Dockerfile.prod @@ -0,0 +1,39 @@ +# Production Dockerfile for Joossam FastAPI OMR Grading Service +FROM python:3.11-slim AS base + +# 시스템 의존성 설치 (OpenCV용) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1-mesa-glx \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Python 의존성 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 애플리케이션 코드 복사 +COPY . . + +# 비루트 사용자 생성 및 전환 +RUN addgroup --system --gid 1001 appgroup && \ + adduser --system --uid 1001 --gid 1001 appuser && \ + chown -R appuser:appgroup /app +USER appuser + +EXPOSE 8000 + +ENV HOST=0.0.0.0 +ENV PORT=8000 + +# 헬스체크 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] + diff --git a/deploy/docker/docker-compose.dev.yml b/deploy/docker/docker-compose.dev.yml new file mode 100644 index 0000000..2cde733 --- /dev/null +++ b/deploy/docker/docker-compose.dev.yml @@ -0,0 +1,25 @@ +services: + # Development Joossam FastAPI Application + app: + build: + context: ../../services/fastapi + dockerfile: ../../deploy/docker/Dockerfile.dev + container_name: joossam-app-dev + restart: unless-stopped + labels: + kompose.namespace: joossam + ports: + - 8001:8000 + environment: + - ENV=development + networks: + - joossam-network + volumes: + - ../../services/fastapi:/app + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + +networks: + joossam-network: + driver: bridge + name: joossam-network-dev + diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..9deaef2 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,23 @@ +services: + # Production Joossam FastAPI Application + app: + image: joossam-app + build: + context: ../../services/fastapi + dockerfile: ../../deploy/docker/Dockerfile.prod + container_name: joossam-app-prod + restart: unless-stopped + labels: + kompose.namespace: joossam + ports: + - 8001:8000 + environment: + - ENV=production + networks: + - joossam-network + +networks: + joossam-network: + driver: bridge + name: joossam-network-prod + diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml new file mode 100644 index 0000000..ed02579 --- /dev/null +++ b/deploy/k8s/base/deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: joossam-app + labels: + app: joossam-app +spec: + replicas: 1 + selector: + matchLabels: + app: joossam-app + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 + template: + metadata: + labels: + app: joossam-app + spec: + containers: + - name: joossam-app + image: ghcr.io/mayne0213/joossam:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + protocol: TCP + env: + - name: ENV + value: production + resources: + requests: + memory: "150Mi" + cpu: "100m" + limits: + memory: "300Mi" + cpu: "300m" + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 5 + restartPolicy: Always diff --git a/deploy/k8s/base/kustomization.yaml b/deploy/k8s/base/kustomization.yaml new file mode 100644 index 0000000..b2274fe --- /dev/null +++ b/deploy/k8s/base/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - deployment.yaml + - service.yaml + +commonLabels: + app.kubernetes.io/name: joossam + app.kubernetes.io/component: api + +images: + - name: ghcr.io/mayne0213/joossam + newTag: latest diff --git a/deploy/k8s/base/service.yaml b/deploy/k8s/base/service.yaml new file mode 100644 index 0000000..420c0a7 --- /dev/null +++ b/deploy/k8s/base/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: joossam-service + labels: + app: joossam-app +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: 8000 + protocol: TCP + selector: + app: joossam-app diff --git a/deploy/k8s/overlays/prod/deployment-patch.yaml b/deploy/k8s/overlays/prod/deployment-patch.yaml new file mode 100644 index 0000000..7d814a1 --- /dev/null +++ b/deploy/k8s/overlays/prod/deployment-patch.yaml @@ -0,0 +1,19 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: joossam-app + labels: + environment: production +spec: + replicas: 1 + template: + spec: + containers: + - name: joossam-app + resources: + requests: + memory: "100Mi" + cpu: "50m" + limits: + memory: "200Mi" + cpu: "200m" diff --git a/deploy/k8s/overlays/prod/kustomization.yaml b/deploy/k8s/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..bcd96b6 --- /dev/null +++ b/deploy/k8s/overlays/prod/kustomization.yaml @@ -0,0 +1,19 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: joossam + +resources: + - ../../base + - resourcequota.yaml + +commonLabels: + environment: production + +# 이미지 태그 설정 +images: + - name: ghcr.io/mayne0213/joossam + newTag: latest + +patchesStrategicMerge: + - deployment-patch.yaml diff --git a/deploy/k8s/overlays/prod/resourcequota.yaml b/deploy/k8s/overlays/prod/resourcequota.yaml new file mode 100644 index 0000000..248acb5 --- /dev/null +++ b/deploy/k8s/overlays/prod/resourcequota.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ResourceQuota +metadata: + name: joossam-quota + namespace: joossam +spec: + hard: + requests.memory: "300Mi" + requests.cpu: "200m" + limits.memory: "400Mi" + limits.cpu: "400m" + pods: "3" diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..9a64e25 --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,172 @@ +#!/bin/bash + +# Joossam 스크립트 공통 유틸리티 함수들 +# 모든 Joossam 스크립트에서 사용할 수 있는 공통 기능들을 정의 + +set -e + +# 공통 스크립트의 절대 경로 기반 디렉토리 상수 +JOOSSAM_SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JOOSSAM_ROOT="$(dirname "$JOOSSAM_SCRIPTS_DIR")" + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 로깅 함수들 +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_debug() { + echo -e "${BLUE}[DEBUG]${NC} $1" +} + +# 경로 계산 함수 +get_joossam_root() { + echo "$JOOSSAM_ROOT" +} + +get_mayne_root() { + local joossam_root="$(get_joossam_root)" + echo "$(dirname "$(dirname "$joossam_root")")" +} + +# 확인 함수 +confirm_action() { + local message="$1" + local default="${2:-N}" + + if [[ "$default" == "Y" ]]; then + read -p "$message (Y/n): " -n 1 -r + echo + if [[ $REPLY =~ ^[Nn]$ ]]; then + return 1 + fi + return 0 + else + read -p "$message (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + return 0 + fi + return 1 + fi +} + +# 필수 디렉토리 확인 +check_required_dirs() { + local joossam_root="$(get_joossam_root)" + local dirs=("$@") + + for dir in "${dirs[@]}"; do + if [ ! -d "$joossam_root/$dir" ]; then + log_error "필수 디렉토리가 없습니다: $dir" + exit 1 + fi + done +} + +# 필수 파일 확인 +check_required_files() { + local joossam_root="$(get_joossam_root)" + local files=("$@") + + for file in "${files[@]}"; do + if [ ! -f "$joossam_root/$file" ]; then + log_error "필수 파일이 없습니다: $file" + exit 1 + fi + done +} + +# Docker 관련 유틸리티 +docker_cleanup_joossam() { + log_info "Joossam 관련 Docker 리소스 정리 중..." + + # 컨테이너 중지 및 삭제 + docker-compose -p joossam -f deploy/docker/docker-compose.yml down --remove-orphans 2>/dev/null || true + docker-compose -p joossam -f deploy/docker/docker-compose.dev.yml down --remove-orphans 2>/dev/null || true + local containers=$(docker ps -aq --filter "name=joossam" 2>/dev/null) + if [[ -n "$containers" ]]; then + echo "$containers" | xargs docker rm -f 2>/dev/null || true + fi + + # 이미지 삭제 + local images=$(docker images --filter "reference=joossam*" -q 2>/dev/null) + if [[ -n "$images" ]]; then + echo "$images" | xargs docker rmi -f 2>/dev/null || true + fi + images=$(docker images --filter "reference=*joossam*" -q 2>/dev/null) + if [[ -n "$images" ]]; then + echo "$images" | xargs docker rmi -f 2>/dev/null || true + fi + + # 볼륨 삭제 + local volumes=$(docker volume ls -q --filter "name=joossam" 2>/dev/null) + if [[ -n "$volumes" ]]; then + echo "$volumes" | xargs docker volume rm 2>/dev/null || true + fi + + # 네트워크 삭제 + docker network rm joossam-network-dev joossam-network-prod 2>/dev/null || true + + # 시스템 정리 + docker system prune -f + + log_info "Docker 리소스 정리 완료" +} + +# 환경 변수 로드 +load_env_file() { + local joossam_root="$(get_joossam_root)" + local env_file="$joossam_root/.env" + + if [ -f "$env_file" ]; then + log_info "환경 변수 파일 로드: $env_file" + source "$env_file" + return 0 + else + log_warn "환경 변수 파일이 없습니다: $env_file" + return 1 + fi +} + +# 에러 처리 +handle_error() { + local exit_code=$? + log_error "스크립트 실행 중 오류가 발생했습니다 (종료 코드: $exit_code)" + exit $exit_code +} + +# 스크립트 시작 시 공통 설정 +setup_script() { + trap handle_error ERR + cd "$(get_joossam_root)" + log_info "스크립트 시작: $(basename "${BASH_SOURCE[1]}")" +} + +# 스크립트 종료 시 정리 +cleanup_script() { + local exit_code=$? + if [ $exit_code -eq 0 ]; then + log_info "스크립트 완료: $(basename "${BASH_SOURCE[1]}")" + else + log_error "스크립트 실패: $(basename "${BASH_SOURCE[1]}") (종료 코드: $exit_code)" + fi + exit $exit_code +} + +# 스크립트 종료 시 정리 함수 등록 +trap cleanup_script EXIT diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100755 index 0000000..ea02d77 --- /dev/null +++ b/scripts/docker-build.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +# Joossam Docker 빌드 및 실행 스크립트 +# 공통 유틸리티 함수 로드 +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +# 스크립트 설정 +setup_script + +log_info "🚀 Joossam Docker 빌드 및 실행 시작..." + +# 필수 디렉토리 및 파일 확인 +log_info "📁 폴더 구조 확인 중..." +check_required_dirs "services" "deploy/docker" + +log_info "📄 필수 파일 확인 중..." +check_required_files "deploy/docker/docker-compose.yml" "deploy/docker/docker-compose.dev.yml" "deploy/docker/Dockerfile.prod" "deploy/docker/Dockerfile.dev" + +log_info "✅ 폴더 구조 및 필수 파일 확인 완료!" + +# 환경 선택 +echo "" +log_info "🎯 실행할 환경을 선택하세요:" +echo "1) 개발 환경 (Development)" +echo "2) 프로덕션 환경 (Production)" +echo "3) 빌드만 (Build Only)" +read -p "선택 (1-3): " -n 1 -r +echo + +case ${REPLY} in + 1) + log_info "🔧 개발 환경 빌드 및 실행 중..." + cd deploy/docker + docker-compose -p joossam -f docker-compose.dev.yml build --no-cache + docker-compose -p joossam -f docker-compose.dev.yml up -d + JOOSSAM_ROOT=$(get_joossam_root) + cd "${JOOSSAM_ROOT}" + ENV_TYPE="development" + COMPOSE_FILE_PATH="deploy/docker/docker-compose.dev.yml" + ;; + 2) + log_info "🏭 프로덕션 환경 빌드 및 실행 중..." + cd deploy/docker + docker-compose -p joossam -f docker-compose.yml build --no-cache + docker-compose -p joossam -f docker-compose.yml up -d + JOOSSAM_ROOT=$(get_joossam_root) + cd "${JOOSSAM_ROOT}" + ENV_TYPE="production" + COMPOSE_FILE_PATH="deploy/docker/docker-compose.yml" + ;; + 3) + log_info "🔨 이미지 빌드만 실행 중..." + cd deploy/docker + log_info " - 개발 이미지 빌드 중..." + docker-compose -p joossam -f docker-compose.dev.yml build --no-cache + log_info " - 프로덕션 이미지 빌드 중..." + docker-compose -p joossam -f docker-compose.yml build --no-cache + JOOSSAM_ROOT=$(get_joossam_root) + cd "${JOOSSAM_ROOT}" + log_info "✅ 빌드 완료! 실행하려면 다시 이 스크립트를 실행하고 환경을 선택하세요." + exit 0 + ;; + *) + log_error "잘못된 선택입니다." + exit 1 + ;; +esac + +# 서비스 상태 확인 +echo "" +log_info "⏳ 서비스 시작 대기 중..." +sleep 10 + +echo "" +log_info "📊 서비스 상태 확인:" +docker-compose -p joossam -f "$COMPOSE_FILE_PATH" ps + +echo "" +log_info "🔍 컨테이너 로그 확인:" +echo " - 애플리케이션 로그: docker-compose -p joossam -f $COMPOSE_FILE_PATH logs -f app" + +echo "" +log_info "🌐 접속 URL:" +if [ "$ENV_TYPE" = "development" ]; then + echo " - API: http://localhost:8001" + echo " - API 문서: http://localhost:8001/docs" +else + echo " - API: http://localhost:8001" + echo " - API 문서: http://localhost:8001/docs" +fi + +echo "" +log_info "✅ Docker 빌드 및 실행 완료!" +echo "" +log_info "📋 유용한 명령어:" +echo " - 서비스 중지: docker-compose -p joossam -f $COMPOSE_FILE_PATH down" +echo " - 로그 확인: docker-compose -p joossam -f $COMPOSE_FILE_PATH logs -f" +echo " - 서비스 재시작: docker-compose -p joossam -f $COMPOSE_FILE_PATH restart" diff --git a/scripts/docker-cleanup.sh b/scripts/docker-cleanup.sh new file mode 100755 index 0000000..350cb4c --- /dev/null +++ b/scripts/docker-cleanup.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Joossam Docker Cleanup Script +# 공통 유틸리티 함수 로드 +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +# 스크립트 설정 +setup_script + +log_info "🧹 Joossam Docker 리소스 정리 시작..." + +# 확인 메시지 +if ! confirm_action "모든 Joossam 관련 Docker 리소스를 정리하시겠습니까?"; then + log_info "정리를 취소했습니다." + exit 0 +fi + +# Docker 리소스 정리 실행 +docker_cleanup_joossam + +log_info "✅ Joossam Docker 리소스 정리 완료!" diff --git a/services/fastapi/main.py b/services/fastapi/main.py new file mode 100644 index 0000000..9702d41 --- /dev/null +++ b/services/fastapi/main.py @@ -0,0 +1,620 @@ +from fastapi import FastAPI, HTTPException, UploadFile, File, Form +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Dict, List, Optional +import json +import tempfile +import os +import sys + +import cv2 +import numpy as np + +app = FastAPI( + title="Joossam OMR Grading API", + description="OMR 채점을 위한 FastAPI 서비스", + version="1.0.0" +) + +# CORS 설정 - Vercel에서 호스팅되는 프론트엔드 허용 +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://joossameng.vercel.app", + "https://*.vercel.app", + "http://localhost:3000", + "http://localhost:3001", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# --- 상수 및 설정 --- +TARGET_WIDTH = 2480 +TARGET_HEIGHT = 3508 + + +# --- 함수 정의 --- + +def load_image_from_bytes(image_bytes: bytes): + """바이트 데이터에서 이미지 로드""" + nparr = np.frombuffer(image_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None: + raise ValueError("이미지를 읽을 수 없습니다") + return img, img.shape[:2] + + +def resize_image_to_target(img, target_width=TARGET_WIDTH, target_height=TARGET_HEIGHT): + """이미지를 타겟 크기로 리사이징 (비율 유지)""" + h, w = img.shape[:2] + + if w == target_width and h == target_height: + return img, 1.0, 1.0 + + scale_x = target_width / w + scale_y = target_height / h + + new_width = int(w * scale_x) + new_height = int(h * scale_y) + + resized_img = cv2.resize(img, (new_width, new_height), interpolation=cv2.INTER_CUBIC) + + return resized_img, scale_x, scale_y + + +def gamma_correction(img, gamma=0.7): + """감마 보정으로 밝기 곡선 조정""" + inv_gamma = 1.0 / gamma + table = np.array([((i / 255.0) ** inv_gamma) * 255 for i in np.arange(0, 256)]).astype("uint8") + return cv2.LUT(img, table) + + +def unsharp_mask(img, kernel_size=(5, 5), sigma=1.0, amount=1.0): + """언샤프 마스킹으로 경계 선명화""" + if len(img.shape) == 3: + img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + blurred = cv2.GaussianBlur(img, kernel_size, sigma) + sharpened = cv2.addWeighted(img, 1.0 + amount, blurred, -amount, 0) + return sharpened + + +def deskew_image_with_barcodes(img): + """바코드를 기준으로 이미지 기울기 보정""" + try: + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(gray, 180, 255, cv2.THRESH_BINARY_INV) + + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) + thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) + thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) + + contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + img_height, img_width = img.shape[:2] + top_area_threshold = img_height * 0.15 + + top_rectangles = [] + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + if (y < top_area_threshold and + w > 10 and h > 10 and + w < 300 and h < 300): + center_x = x + w // 2 + center_y = y + h // 2 + top_rectangles.append({'center': (center_x, center_y)}) + + if len(top_rectangles) < 15: + return img + + top_rectangles.sort(key=lambda x: x['center'][1]) + top_rectangles = top_rectangles[:23] + top_rectangles.sort(key=lambda x: x['center'][0]) + + if len(top_rectangles) >= 10: + left_points = top_rectangles[:5] + right_points = top_rectangles[-5:] + + left_avg_x = np.mean([p['center'][0] for p in left_points]) + left_avg_y = np.mean([p['center'][1] for p in left_points]) + right_avg_x = np.mean([p['center'][0] for p in right_points]) + right_avg_y = np.mean([p['center'][1] for p in right_points]) + + delta_y = right_avg_y - left_avg_y + delta_x = right_avg_x - left_avg_x + + if delta_x == 0: + return img + + angle_rad = np.arctan2(delta_y, delta_x) + angle_deg = np.degrees(angle_rad) + + if abs(angle_deg) < 0.3: + return img + + if abs(angle_deg) > 10: + return img + + center = (img_width // 2, img_height // 2) + rotation_matrix = cv2.getRotationMatrix2D(center, angle_deg, 1.0) + + deskewed = cv2.warpAffine( + img, rotation_matrix, (img_width, img_height), + flags=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_REPLICATE + ) + + return deskewed + + return img + + except Exception: + return img + + +def preprocess_omr_image(img): + """OMR 이미지 전처리 강화""" + denoised = cv2.GaussianBlur(img, (3, 3), 0) + gamma_corrected = gamma_correction(denoised, gamma=0.7) + + if len(gamma_corrected.shape) == 3: + gray = cv2.cvtColor(gamma_corrected, cv2.COLOR_BGR2GRAY) + else: + gray = gamma_corrected + + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(gray) + sharpened = unsharp_mask(enhanced, amount=0.8) + result = cv2.cvtColor(sharpened, cv2.COLOR_GRAY2BGR) + + return result + + +def calculate_marking_density(img, x, y, width=30, height=60): + """특정 좌표 주변 영역의 마킹 밀도 계산""" + h, w = img.shape[:2] + x1 = max(0, x - width//2) + y1 = max(0, y - height//2) + x2 = min(w, x + width//2) + y2 = min(h, y + height//2) + + region = img[y1:y2, x1:x2] + if region.size == 0: + return 0.0 + + if len(region.shape) == 3: + region = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY) + + avg_darkness = 255 - np.mean(region) + dark_pixels = np.sum(region < 180) + total_pixels = region.size + dark_ratio = dark_pixels / total_pixels + medium_dark_pixels = np.sum(region < 160) + medium_dark_ratio = medium_dark_pixels / total_pixels + very_dark_pixels = np.sum(region < 120) + very_dark_ratio = very_dark_pixels / total_pixels + density_score = (avg_darkness / 255.0) * 0.2 + dark_ratio * 0.2 + medium_dark_ratio * 0.4 + very_dark_ratio * 0.2 + + return density_score + + +upperValueSquare = 180 + + +def find_top_black_rectangles(img): + """상단 검은색 사각형들을 찾아서 좌표 반환""" + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(gray, upperValueSquare, 255, cv2.THRESH_BINARY_INV) + + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) + thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) + thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) + + contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + top_rectangles = [] + img_height = img.shape[0] + top_area_threshold = img_height * 0.15 + + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + if (y < top_area_threshold and + w > 10 and h > 10 and + w < 300 and h < 300): + center_x = x + w // 2 + center_y = y + h // 2 + top_rectangles.append({ + 'center': (center_x, center_y), + 'bbox': (x, y, w, h), + 'area': w * h + }) + + top_rectangles.sort(key=lambda x: x['center'][1]) + selected_rectangles = top_rectangles[:23] + selected_rectangles.sort(key=lambda x: x['center'][0]) + + return selected_rectangles + + +def find_side_black_rectangles(img): + """좌우측 검은색 사각형들을 찾아서 좌표 반환 (Y축 계산용)""" + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(gray, upperValueSquare, 255, cv2.THRESH_BINARY_INV) + + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) + thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) + thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) + + contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + img_height, img_width = img.shape[:2] + left_area_threshold = img_width * 0.15 + right_area_start = img_width * 0.85 + + left_rectangles = [] + right_rectangles = [] + + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + center_x = x + w // 2 + center_y = y + h // 2 + + if (w > 20 and h > 20 and + w < 70 and h < 70): + + if center_x < left_area_threshold: + left_rectangles.append({ + 'center': (center_x, center_y), + 'bbox': (x, y, w, h), + 'area': w * h + }) + elif center_x > right_area_start: + right_rectangles.append({ + 'center': (center_x, center_y), + 'bbox': (x, y, w, h), + 'area': w * h + }) + + left_rectangles.sort(key=lambda x: x['center'][0]) + left_selected = left_rectangles[:10] + left_selected.sort(key=lambda x: x['center'][1]) + + right_rectangles.sort(key=lambda x: x['center'][0]) + right_selected = right_rectangles[-20:] if len(right_rectangles) >= 20 else right_rectangles + right_selected.sort(key=lambda x: x['center'][1]) + + return left_selected, right_selected + + +def define_phone_positions(img_width, img_height, top_rectangles, left_rectangles): + """전화번호 전용 위치 계산 (1-8번 사각형, 1-10번 숫자 자리)""" + phone_positions = {} + + for rect_index in range(8): + if rect_index < len(top_rectangles): + rect_center_x = top_rectangles[rect_index]['center'][0] + digit_position = rect_index + 1 + + if len(left_rectangles) >= 10: + phone_positions[digit_position] = {} + for digit in range(10): + if digit < len(left_rectangles): + y = left_rectangles[digit]['center'][1] + phone_positions[digit_position][str(digit)] = (rect_center_x, y) + + return phone_positions + + +def define_answer_positions(img_width, img_height, top_rectangles, left_rectangles, right_rectangles): + """답안 전용 위치 계산 (9-23번 사각형, 1-45번 문제)""" + positions = {} + + for rect_index in range(8, 13): + if rect_index < len(top_rectangles): + rect_center_x = top_rectangles[rect_index]['center'][0] + choice_num = rect_index - 7 + + if len(right_rectangles) >= 20: + for q in range(1, 21): + if q not in positions: + positions[q] = {} + + question_index = q - 1 + if question_index < len(right_rectangles): + y = right_rectangles[question_index]['center'][1] + positions[q][str(choice_num)] = (rect_center_x, y) + + for rect_index in range(13, 18): + if rect_index < len(top_rectangles): + rect_center_x = top_rectangles[rect_index]['center'][0] + choice_num = rect_index - 12 + + if len(right_rectangles) >= 20: + for q in range(21, 41): + if q not in positions: + positions[q] = {} + + question_index = q - 21 + if question_index < len(right_rectangles): + y = right_rectangles[question_index]['center'][1] + positions[q][str(choice_num)] = (rect_center_x, y) + + for rect_index in range(18, 23): + if rect_index < len(top_rectangles): + rect_center_x = top_rectangles[rect_index]['center'][0] + choice_num = rect_index - 17 + + if len(right_rectangles) >= 5: + for q in range(41, 46): + if q not in positions: + positions[q] = {} + + question_index = q - 41 + if question_index < len(right_rectangles): + y = right_rectangles[question_index]['center'][1] + positions[q][str(choice_num)] = (rect_center_x, y) + + return positions + + +def estimate_phone_number_with_density(img, phone_positions, min_density=0.17): + """전화번호 추정""" + phone_selected = {} + + for digit_pos, digit_choices in phone_positions.items(): + if not digit_choices: + phone_selected[digit_pos] = "0" + continue + + digit_densities = {} + for digit, coord in digit_choices.items(): + x, y = coord + density = calculate_marking_density(img, x, y) + digit_densities[digit] = density + + highest_digit, highest_density = max(digit_densities.items(), key=lambda x: x[1]) + + if highest_density >= min_density: + phone_selected[digit_pos] = highest_digit + else: + phone_selected[digit_pos] = "0" + + return phone_selected + + +def estimate_selected_answers_with_density(img, answer_positions, min_density=0.2): + """답안 추정""" + selected = {} + + for q_num, choices in answer_positions.items(): + if not choices: + selected[str(q_num)] = "무효" + continue + + choice_densities = {} + for choice, coord in choices.items(): + x, y = coord + density = calculate_marking_density(img, x, y) + choice_densities[choice] = density + + highest_choice, highest_density = max(choice_densities.items(), key=lambda x: x[1]) + + if highest_density >= min_density: + selected[str(q_num)] = highest_choice + else: + selected[str(q_num)] = "무효" + + return selected + + +def extract_phone_number(phone_selected): + """전화번호 8자리 추출""" + phone_digits = [] + + for i in range(1, 9): + if i in phone_selected: + digit = phone_selected[i] + if digit and digit != "무효": + try: + digit_int = int(digit) + if 0 <= digit_int <= 9: + phone_digits.append(str(digit_int)) + else: + phone_digits.append("0") + except ValueError: + phone_digits.append("0") + else: + phone_digits.append("0") + else: + phone_digits.append("0") + + phone_number = "".join(phone_digits) + return phone_number + + +def calculate_total_score(selected_answers, correct_answers, question_scores): + """총점 계산""" + total = 0 + + for q_num, correct_answer in correct_answers.items(): + if q_num in selected_answers: + student_answer = selected_answers[q_num] + if student_answer == correct_answer: + score = question_scores.get(q_num, 0) + total += score + + return total + + +def calculate_grade(total_score): + """등급 계산""" + if total_score >= 90: + return 1 + elif total_score >= 80: + return 2 + elif total_score >= 70: + return 3 + elif total_score >= 60: + return 4 + elif total_score >= 50: + return 5 + elif total_score >= 40: + return 6 + elif total_score >= 30: + return 7 + elif total_score >= 20: + return 8 + else: + return 9 + + +def create_results_array(selected_answers, correct_answers, question_scores, question_types): + """결과 배열 생성""" + results = [] + + for q_num in sorted(correct_answers.keys(), key=lambda x: int(x)): + try: + q_num_int = int(q_num) + student_answer = selected_answers.get(q_num, "무효") + correct_answer = correct_answers[q_num] + score = question_scores.get(q_num, 0) + question_type = question_types.get(q_num, "기타") + + earned_score = score if student_answer == correct_answer else 0 + + results.append({ + "questionNumber": q_num_int, + "studentAnswer": str(student_answer), + "correctAnswer": correct_answer, + "score": score, + "earnedScore": earned_score, + "questionType": question_type + }) + except (ValueError, TypeError): + continue + + return results + + +def grade_omr_from_bytes(image_bytes: bytes, correct_answers: Dict, question_scores: Dict, question_types: Dict): + """OMR 채점 메인 함수 (바이트 입력)""" + try: + img, (h, w) = load_image_from_bytes(image_bytes) + deskewed_img = deskew_image_with_barcodes(img) + + expected_ratio = TARGET_WIDTH / TARGET_HEIGHT + actual_ratio = w / h + + resized_img, scale_x, scale_y = resize_image_to_target(deskewed_img) + resized_h, resized_w = resized_img.shape[:2] + + preprocessed_img = preprocess_omr_image(resized_img) + + top_rectangles = find_top_black_rectangles(resized_img) + left_rectangles, right_rectangles = find_side_black_rectangles(resized_img) + + phone_positions = define_phone_positions(resized_w, resized_h, top_rectangles, left_rectangles) + answer_positions = define_answer_positions(resized_w, resized_h, top_rectangles, left_rectangles, right_rectangles) + + phone_selected = estimate_phone_number_with_density(preprocessed_img, phone_positions) + phone_number = extract_phone_number(phone_selected) + + selected_answers = estimate_selected_answers_with_density(preprocessed_img, answer_positions) + + correct_count = 0 + for q_num, correct_answer in correct_answers.items(): + if q_num in selected_answers: + student_answer = selected_answers[q_num] + if str(student_answer) == str(correct_answer): + correct_count += 1 + + total_score = calculate_total_score(selected_answers, correct_answers, question_scores) + grade = calculate_grade(total_score) + results = create_results_array(selected_answers, correct_answers, question_scores, question_types) + + final_result = { + "totalScore": total_score, + "grade": grade, + "phoneNumber": phone_number, + "results": results, + "imageInfo": { + "originalSize": f"{w}x{h}", + "resizedSize": f"{resized_w}x{resized_h}", + "scaleFactors": {"x": scale_x, "y": scale_y}, + "aspectRatio": {"expected": expected_ratio, "actual": actual_ratio} + } + } + + return final_result + + except Exception as e: + raise HTTPException(status_code=500, detail=f"OMR 채점 실패: {str(e)}") + + +# API 엔드포인트들 + +@app.get("/") +async def root(): + """헬스체크 엔드포인트""" + return {"status": "healthy", "message": "Joossam OMR Grading API is running"} + + +@app.get("/health") +async def health(): + """헬스체크 엔드포인트""" + return {"status": "healthy"} + + +class GradingRequest(BaseModel): + correct_answers: Dict[str, str] + question_scores: Dict[str, int] + question_types: Dict[str, str] + + +@app.post("/api/omr/grade") +async def grade_omr( + image: UploadFile = File(...), + correct_answers: str = Form(...), + question_scores: str = Form(...), + question_types: str = Form(...) +): + """ + OMR 채점 API + + - image: OMR 이미지 파일 + - correct_answers: 정답 JSON (예: {"1": "3", "2": "1", ...}) + - question_scores: 문제별 점수 JSON (예: {"1": 2, "2": 2, ...}) + - question_types: 문제 유형 JSON (예: {"1": "어휘", "2": "문법", ...}) + """ + try: + # JSON 파싱 + correct_answers_dict = json.loads(correct_answers) + question_scores_dict = json.loads(question_scores) + question_types_dict = json.loads(question_types) + + # 이미지 읽기 + image_bytes = await image.read() + + # OMR 채점 실행 + result = grade_omr_from_bytes( + image_bytes, + correct_answers_dict, + question_scores_dict, + question_types_dict + ) + + return result + + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"JSON 파싱 오류: {str(e)}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"채점 오류: {str(e)}") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) + diff --git a/services/fastapi/requirements.txt b/services/fastapi/requirements.txt new file mode 100644 index 0000000..355dae0 --- /dev/null +++ b/services/fastapi/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 +opencv-python-headless==4.8.1.78 +numpy==1.24.3 +Pillow==10.3.0 +pydantic==2.5.3 +