From 96c3db9992153d0bf4adee436bf89c7bb6a94546 Mon Sep 17 00:00:00 2001 From: AGX Date: Wed, 27 Aug 2025 13:41:46 +0900 Subject: [PATCH] first commit --- .env.example | 57 ++ .gitignore | 21 + README.md | 384 ++++++++++ app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/endpoints.py | 429 +++++++++++ app/core/__init__.py | 1 + app/core/config.py | 67 ++ app/core/session_pool.py | 226 ++++++ app/core/worker_manager.py | 329 ++++++++ app/models/__init__.py | 1 + app/models/migan.py | 190 +++++ app/models/rembg_model.py | 174 +++++ app/models/schemas.py | 106 +++ app/models/simple_lama.py | 159 ++++ app/monitoring/dashboard.py | 920 +++++++++++++++++++++++ app/utils/__init__.py | 1 + app/utils/gpu_monitor.py | 517 +++++++++++++ app/utils/image_utils.py | 247 ++++++ lib64 | 1 + logs/main.log | 0 logs/main_server.log | 46 ++ logs/main_server.pid | 1 + logs/monitoring.log | 47 ++ logs/pip_install.log | 14 + main.py | 120 +++ requirements.txt | 35 + scripts/install_deps.sh | 708 +++++++++++++++++ scripts/start_server.sh | 488 ++++++++++++ scripts/status.sh | 465 ++++++++++++ scripts/stop_server.sh | 323 ++++++++ tests/README.md | 204 +++++ tests/images/complex_image_512.png | Bin 0 -> 3120 bytes tests/images/complex_mask_512.png | Bin 0 -> 1154 bytes tests/images/large_mask_1024.png | Bin 0 -> 3916 bytes tests/images/test_image_1024.png | Bin 0 -> 9064 bytes tests/images/test_image_256.png | Bin 0 -> 2261 bytes tests/images/test_image_512.png | Bin 0 -> 4221 bytes tests/images/test_mask_256.png | Bin 0 -> 782 bytes tests/images/test_mask_512.png | Bin 0 -> 1857 bytes tests/scripts/generate_test_images.py | 163 ++++ tests/scripts/test_api.py | 371 +++++++++ tests/tests/images/complex_image_512.png | Bin 0 -> 3120 bytes tests/tests/images/complex_mask_512.png | Bin 0 -> 1154 bytes tests/tests/images/large_mask_1024.png | Bin 0 -> 3916 bytes tests/tests/images/test_image_1024.png | Bin 0 -> 9064 bytes tests/tests/images/test_image_256.png | Bin 0 -> 2261 bytes tests/tests/images/test_image_512.png | Bin 0 -> 4221 bytes tests/tests/images/test_mask_1024.png | Bin 0 -> 3916 bytes tests/tests/images/test_mask_256.png | Bin 0 -> 782 bytes tests/tests/images/test_mask_512.png | Bin 0 -> 1857 bytes 51 files changed, 6817 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/endpoints.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/session_pool.py create mode 100644 app/core/worker_manager.py create mode 100644 app/models/__init__.py create mode 100644 app/models/migan.py create mode 100644 app/models/rembg_model.py create mode 100644 app/models/schemas.py create mode 100644 app/models/simple_lama.py create mode 100644 app/monitoring/dashboard.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/gpu_monitor.py create mode 100644 app/utils/image_utils.py create mode 120000 lib64 create mode 100644 logs/main.log create mode 100644 logs/main_server.log create mode 100644 logs/main_server.pid create mode 100644 logs/monitoring.log create mode 100644 logs/pip_install.log create mode 100644 main.py create mode 100644 requirements.txt create mode 100755 scripts/install_deps.sh create mode 100755 scripts/start_server.sh create mode 100755 scripts/status.sh create mode 100755 scripts/stop_server.sh create mode 100644 tests/README.md create mode 100644 tests/images/complex_image_512.png create mode 100644 tests/images/complex_mask_512.png create mode 100644 tests/images/large_mask_1024.png create mode 100644 tests/images/test_image_1024.png create mode 100644 tests/images/test_image_256.png create mode 100644 tests/images/test_image_512.png create mode 100644 tests/images/test_mask_256.png create mode 100644 tests/images/test_mask_512.png create mode 100644 tests/scripts/generate_test_images.py create mode 100644 tests/scripts/test_api.py create mode 100644 tests/tests/images/complex_image_512.png create mode 100644 tests/tests/images/complex_mask_512.png create mode 100644 tests/tests/images/large_mask_1024.png create mode 100644 tests/tests/images/test_image_1024.png create mode 100644 tests/tests/images/test_image_256.png create mode 100644 tests/tests/images/test_image_512.png create mode 100644 tests/tests/images/test_mask_1024.png create mode 100644 tests/tests/images/test_mask_256.png create mode 100644 tests/tests/images/test_mask_512.png diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..91fc7ce --- /dev/null +++ b/.env.example @@ -0,0 +1,57 @@ +# 인페인팅 서버 환경 설정 예시 +# 실제 사용 시 .env 파일로 복사하여 사용하세요 + +# 시스템 감지 (자동 설정) +# IS_JETSON=true # Jetson Xavier 감지 시 자동 설정 +# IS_X86=true # x86 시스템 감지 시 자동 설정 + +# 서버 설정 +HOST=0.0.0.0 +PORT=8000 +WORKERS=1 + +# GPU 설정 +CUDA_DEVICE=0 +FP16_ENABLED=true + +# Jetson 전용 설정 +JETSON_MODE=false # 자동 감지됨 +JETSON_POWER_MODE=MAXN # MAXN, 5W, 10W, 15W +JETSON_FAN_CONTROL=true +JETSON_TEMP_THRESHOLD=75 # Celsius +JETSON_GPU_FREQ=1200 # MHz +JETSON_CPU_FREQ=1900 # MHz +JETSON_MEMORY_FREQ=1600 # MHz + +# 세션 풀 설정 (시스템별 자동 조정) +SIMPLE_LAMA_SESSIONS=2 +MIGAN_SESSIONS=2 +REMBG_SESSIONS=1 + +# 워커 설정 (Jetson은 더 적은 워커 사용) +MAX_WORKERS=4 # Jetson: 4, x86: 8 +MIN_WORKERS=1 # Jetson: 1, x86: 2 +WORKER_TIMEOUT=300 + +# VRAM 관리 (Jetson은 더 보수적인 설정) +VRAM_THRESHOLD_HIGH=0.7 # Jetson: 70%, x86: 80% +VRAM_THRESHOLD_LOW=0.3 # Jetson: 30%, x86: 40% +VRAM_CHECK_INTERVAL=20 # Jetson: 20초, x86: 30초 + +# 모델 경로 +SIMPLE_LAMA_MODEL_PATH=models/simple-lama +MIGAN_MODEL_PATH=models/migan +REMBG_MODEL_PATH=models/rembg + +# 업로드 설정 (Jetson은 더 작은 파일 크기) +MAX_FILE_SIZE=26214400 # Jetson: 25MB, x86: 50MB +ALLOWED_EXTENSIONS=.jpg,.jpeg,.png,.bmp,.tiff + +# 모니터링 +ENABLE_MONITORING=true +MONITORING_PORT=8001 + +# Jetson 최적화 설정 +JETSON_OPTIMIZE_ON_STARTUP=true +JETSON_AUTO_FAN_CONTROL=true +JETSON_POWER_SAVING=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ff745d --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +bin/ +include/ +lib/ +lib64/ +share/ +local/ +build/ +.vscode/ +.venv/ +pyvenv.cfg +*.pyc +*.pyo +*.pyd +*.pyw +*.pyz +*.pywz +*.pyzw +*.pyzwz +*.pyzwzw + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..80f83e3 --- /dev/null +++ b/README.md @@ -0,0 +1,384 @@ +# 🖼️ 고성능 인페인팅 서버 + +FastAPI와 딥러닝을 활용한 병렬 처리 인페인팅 서버입니다. Simple LAMA, MIGAN, REMBG 모델을 TensorRT와 CUDA를 활용하여 FP16 방식으로 최적화된 서버를 제공합니다. + +**🚀 Jetson Xavier (ARM64) 및 x86_64 시스템을 모두 지원합니다!** + +## ✨ 주요 기능 + +- **🚀 고성능 병렬 처리**: 동적 워커 관리로 최적의 성능 제공 +- **🎯 다중 모델 지원**: Simple LAMA, MIGAN, REMBG 인페인팅 모델 +- **⚡ GPU 최적화**: TensorRT와 CUDA FP16을 활용한 빠른 추론 +- **📊 실시간 모니터링**: 웹 기반 대시보드로 서버 상태 실시간 확인 +- **🔧 동적 스케일링**: VRAM 사용량에 따른 자동 워커 조정 +- **🛡️ 안정성**: 세션 풀과 에러 복구 메커니즘 +- **🚁 Jetson 최적화**: ARM64 아키텍처 전용 성능 튜닝 + +## 🏗️ 아키텍처 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ FastAPI │ │ 워커 매니저 │ │ 세션 풀 │ +│ 엔드포인트 │◄──►│ (동적 스케일링) │◄──►│ (모델 관리) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 모니터링 │ │ GPU 모니터 │ │ 모델 인스턴스 │ +│ 대시보드 │ │ (VRAM 추적) │ │ (Simple LAMA, │ +└─────────────────┘ └─────────────────┘ │ MIGAN, REMBG) │ + └─────────────────┘ +``` + +## 📋 요구사항 + +### 시스템 요구사항 + +#### Jetson Xavier (ARM64) +- Ubuntu 18.04 이상 +- Python 3.8 이상 +- CUDA 11.8 +- cuDNN 8 +- TensorRT 8.6 +- 4GB 이상 RAM 권장 +- 10GB 이상 저장공간 + +#### x86_64 시스템 +- Ubuntu 18.04 이상 +- Python 3.8 이상 +- NVIDIA GPU (GTX 1060 이상 권장) +- CUDA 11.8 이상 +- 8GB 이상 RAM 권장 +- 10GB 이상 저장공간 + +### GPU 요구사항 +- **Jetson Xavier**: 내장 Volta GPU (8GB VRAM) +- **x86**: NVIDIA GPU (4GB 이상 VRAM) + +## 🚀 설치 및 실행 + +### 1. 프로젝트 클론 및 가상환경 설정 + +프로젝트가 이미 `/home/ckh08045/work/inpaintServer`에 설정되어 있습니다. + +### 2. 의존성 설치 + +#### Jetson Xavier 전용 설치 +```bash +# Jetson 최적화와 함께 설치 +./scripts/install_deps.sh --jetson-optimize + +# 또는 기본 설치 +./scripts/install_deps.sh +``` + +#### x86 시스템 설치 +```bash +# 기본 설치 +./scripts/install_deps.sh + +# 개발 도구 포함 +./scripts/install_deps.sh --extras +``` + +#### 수동 설치 +```bash +source bin/activate +pip install -r requirements.txt +``` + +### 3. 환경 설정 + +```bash +# 환경 설정 파일 복사 및 수정 +cp .env.example .env + +# Jetson Xavier의 경우 자동으로 최적화된 설정이 적용됩니다 +# 필요한 경우 .env 파일에서 세부 설정 조정 +``` + +### 4. 서버 시작 + +#### Jetson Xavier 최적화 모드 +```bash +# Jetson 최적화와 함께 시작 +./scripts/start_server.sh --jetson-optimize + +# 또는 기본 시작 +./scripts/start_server.sh +``` + +#### x86 시스템 +```bash +# 기본 모드로 시작 +./scripts/start_server.sh + +# 프로덕션 모드로 시작 +./scripts/start_server.sh --production + +# 워커 수 지정 +./scripts/start_server.sh --workers 4 + +# GPU 디바이스 지정 +./scripts/start_server.sh --gpu 0 +``` + +### 5. 서버 상태 확인 + +```bash +# 상태 확인 +./scripts/status.sh + +# 상세 정보 +./scripts/status.sh --detailed + +# 실시간 모니터링 +./scripts/status.sh --watch +``` + +### 6. 서버 중지 + +```bash +# 정상 종료 +./scripts/stop_server.sh + +# 강제 종료 +./scripts/stop_server.sh --force +``` + +## 🚁 Jetson Xavier 전용 기능 + +### 자동 시스템 감지 +- ARM64 아키텍처 자동 감지 +- Tegra 커널 자동 인식 +- Jetson 전용 설정 자동 적용 + +### 성능 최적화 +- **전력 모드**: MAXN 모드로 최고 성능 +- **GPU 클럭**: 1200MHz로 최적화 +- **메모리 클럭**: 1600MHz로 최적화 +- **팬 제어**: 온도 기반 자동 조정 + +### 메모리 관리 +- **VRAM 임계값**: 70%/30% (x86 대비 보수적) +- **파일 크기 제한**: 25MB (x86: 50MB) +- **워커 수**: 최대 4개 (x86: 최대 8개) + +### 모니터링 도구 +- **jtop**: Jetson 전용 시스템 모니터링 +- **nvpmodel**: 전력 모드 관리 +- **온도 센서**: 실시간 온도 모니터링 + +## 📡 API 엔드포인트 + +### 인페인팅 API + +#### Simple LAMA 인페인팅 +```http +POST /inpaint/simple-lama +Content-Type: multipart/form-data + +- image: 원본 이미지 파일 +- mask: 마스크 이미지 파일 (흰색 영역이 제거될 부분) +- format: 출력 형식 (PNG/JPEG, 기본값: PNG) +``` + +#### MIGAN 인페인팅 +```http +POST /inpaint/migan +Content-Type: multipart/form-data + +- image: 원본 이미지 파일 +- mask: 마스크 이미지 파일 +- format: 출력 형식 (PNG/JPEG, 기본값: PNG) +``` + +### 배경 제거 API + +```http +POST /remove-background +Content-Type: multipart/form-data + +- image: 원본 이미지 파일 +- model_name: REMBG 모델명 (u2net/u2netp/silueta, 기본값: u2net) +- return_mask: 마스크 반환 여부 (기본값: false) +- background_color: 새 배경색 (hex 형식, 예: #ffffff) +- format: 출력 형식 (PNG/JPEG, 기본값: PNG) +``` + +### 관리 API + +```http +GET /health # 헬스 체크 +GET /status # 서버 상태 +GET / # API 정보 +GET /docs # Swagger UI +POST /scale-sessions # 세션 풀 크기 조정 +``` + +## 📊 모니터링 + +### 웹 대시보드 +서버 시작 후 `http://localhost:8001`에서 실시간 모니터링 대시보드에 접근할 수 있습니다. + +### 모니터링 항목 +- **서버 상태**: 메인 서버, 모니터링 서버 상태 +- **GPU 정보**: VRAM 사용량, GPU 사용률 +- **시스템 리소스**: CPU, 메모리, 디스크 사용량 +- **워커 상태**: 활성 워커 수, 작업 큐 상태 +- **세션 풀**: 각 모델별 세션 사용 현황 + +### Jetson 전용 모니터링 +- **온도 정보**: 각 thermal zone별 온도 +- **클럭 주파수**: GPU, CPU, 메모리 클럭 +- **전력 소비**: 실시간 전력 사용량 +- **팬 속도**: PWM 팬 제어 상태 + +## ⚙️ 설정 + +### 세션 풀 비율 +기본 설정: Simple LAMA:MIGAN:REMBG = 2:2:1 + +```python +# .env 파일에서 조정 가능 +SIMPLE_LAMA_SESSIONS=2 +MIGAN_SESSIONS=2 +REMBG_SESSIONS=1 +``` + +### 동적 스케일링 +VRAM 사용량에 따른 자동 워커 조정: + +#### Jetson Xavier +```python +VRAM_THRESHOLD_HIGH=0.7 # 70% 이상시 스케일 다운 +VRAM_THRESHOLD_LOW=0.3 # 30% 이하시 스케일 업 +VRAM_CHECK_INTERVAL=20 # 20초마다 체크 +``` + +#### x86 시스템 +```python +VRAM_THRESHOLD_HIGH=0.8 # 80% 이상시 스케일 다운 +VRAM_THRESHOLD_LOW=0.4 # 40% 이하시 스케일 업 +VRAM_CHECK_INTERVAL=30 # 30초마다 체크 +``` + +## 🔧 개발 모드 + +```bash +# 개발 모드로 실행 (자동 리로드) +python main.py --dev + +# 또는 +uvicorn app.api.endpoints:app --reload +``` + +## 📝 로그 + +로그 파일들은 `logs/` 디렉토리에 저장됩니다: + +- `main_server.log`: 메인 서버 로그 +- `monitoring.log`: 모니터링 서버 로그 +- `error.log`: 에러 로그 +- `access.log`: 액세스 로그 + +## 🛠️ 문제해결 + +### 일반적인 문제 + +1. **CUDA 관련 오류** + ```bash + # CUDA 설치 확인 + nvidia-smi + nvcc --version + + # PyTorch CUDA 호환성 확인 + python -c "import torch; print(torch.cuda.is_available())" + ``` + +2. **메모리 부족** + ```bash + # VRAM 사용량 확인 + nvidia-smi + + # 세션 수 줄이기 + ./scripts/start_server.sh --workers 1 + ``` + +3. **포트 충돌** + ```bash + # 포트 사용 확인 + lsof -i :8000 + lsof -i :8001 + + # 다른 포트 사용 + python main.py --port 8080 + ``` + +### Jetson 전용 문제 + +1. **전력 모드 문제** + ```bash + # 전력 모드 확인 + nvpmodel -q + + # MAXN 모드로 설정 + sudo nvpmodel -m 0 + ``` + +2. **온도 문제** + ```bash + # 온도 확인 + cat /sys/devices/virtual/thermal/thermal_zone*/temp + + # 팬 속도 조정 + echo 255 | sudo tee /sys/devices/pwm-fan/target_pwm + ``` + +3. **메모리 클럭 문제** + ```bash + # 메모리 클럭 확인 + cat /sys/kernel/debug/clk/emc/clk_rate + + # 클럭 재설정 + echo 1600000000 | sudo tee /sys/kernel/debug/clk/emc/clk_rate + ``` + +### 로그 확인 + +```bash +# 실시간 로그 확인 +tail -f logs/main_server.log + +# 에러 로그 확인 +tail -f logs/error.log +``` + +## 🤝 기여 + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## 📄 라이선스 + +이 프로젝트는 MIT 라이선스 하에 배포됩니다. 자세한 내용은 `LICENSE` 파일을 참조하세요. + +## 🙏 감사의 말 + +- [FastAPI](https://fastapi.tiangolo.com/) - 현대적인 웹 프레임워크 +- [PyTorch](https://pytorch.org/) - 딥러닝 프레임워크 +- [Simple LAMA](https://github.com/advimman/lama) - 인페인팅 모델 +- [REMBG](https://github.com/danielgatis/rembg) - 배경 제거 도구 +- [TensorRT](https://developer.nvidia.com/tensorrt) - GPU 추론 최적화 +- [NVIDIA Jetson](https://developer.nvidia.com/embedded-computing) - 엣지 AI 플랫폼 + +--- + +**💡 팁**: +- **Jetson Xavier**: `--jetson-optimize` 옵션으로 최고 성능을 얻으세요! +- **x86 시스템**: GPU 메모리와 워커 수를 적절히 조정하세요 +- 모니터링 대시보드를 통해 실시간으로 성능을 확인할 수 있습니다! +- Jetson 사용자는 `jtop` 명령어로 시스템 상태를 모니터링하세요! diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..9a02245 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# 인페인팅 서버 애플리케이션 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..7294887 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API 모듈 diff --git a/app/api/endpoints.py b/app/api/endpoints.py new file mode 100644 index 0000000..5cce988 --- /dev/null +++ b/app/api/endpoints.py @@ -0,0 +1,429 @@ +""" +API 엔드포인트 +iopaint와 호환되는 인페인팅 및 배경 제거 API를 제공합니다. +""" +import time +import logging +from datetime import datetime +from typing import List +from fastapi import APIRouter, HTTPException, Response +from fastapi.responses import JSONResponse +import cv2 + +from ..core.config import settings +from ..core.worker_manager import worker_manager +from ..core.session_pool import session_pool +from ..models.schemas import ( + InpaintRequest, RemoveBGRequest, PluginRequest, AdjustMaskRequest, + InpaintResponse, RemoveBGResponse, PluginResponse, ServerConfigResponse, + HealthResponse, ModelInfo, Device +) +from ..utils.image_utils import ( + decode_base64_to_image, encode_image_to_base64, concat_alpha_channel, + pil_to_bytes, numpy_to_bytes, adjust_mask, gen_frontend_mask +) +from ..monitoring.dashboard import monitoring_data + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +async def health_check(): + """서버 상태 확인""" + start_time = getattr(settings, 'start_time', time.time()) + uptime = time.time() - start_time + + return HealthResponse( + status="healthy", + timestamp=datetime.now().isoformat(), + version="1.0.0", + uptime=uptime + ) + + +@router.get("/api/v1/server-config", response_model=ServerConfigResponse) +async def get_server_config(): + """서버 설정 정보 반환 (iopaint 호환)""" + try: + # 사용 가능한 모델 목록 + models = [ + ModelInfo( + name="simple-lama", + type="inpainting", + description="Simple LAMA 인페인팅 모델", + supported_formats=["png", "jpg", "jpeg"], + max_image_size=2048 + ), + ModelInfo( + name="migan", + type="inpainting", + description="MIGAN 인페인팅 모델", + supported_formats=["png", "jpg", "jpeg"], + max_image_size=2048 + ), + ModelInfo( + name="rembg", + type="rembg", + description="Rembg 배경 제거 모델", + supported_formats=["png", "jpg", "jpeg"], + max_image_size=2048 + ) + ] + + return ServerConfigResponse( + models=models, + max_file_size=settings.MAX_FILE_SIZE, + supported_formats=["png", "jpg", "jpeg"], + device=Device.cuda if settings.IS_JETSON else Device.cuda, + is_jetson=settings.IS_JETSON + ) + + except Exception as e: + logger.error(f"서버 설정 조회 실패: {e}") + raise HTTPException(status_code=500, detail=f"서버 설정 조회 실패: {str(e)}") + + +@router.post("/api/v1/inpaint", response_model=InpaintResponse) +async def inpaint_image(request: InpaintRequest): + """인페인팅 API (iopaint 호환)""" + start_time = time.time() + + try: + # base64 이미지 디코딩 + image, alpha_channel, info, ext = decode_base64_to_image(request.image) + mask, _, _, _ = decode_base64_to_image(request.mask, gray=True) + + # 이미지와 마스크 크기 검증 + if image.shape[:2] != mask.shape[:2]: + raise HTTPException( + status_code=400, + detail=f"이미지 크기({image.shape[:2]})와 마스크 크기({mask.shape[:2]})가 일치하지 않습니다." + ) + + # 이미지 크기 검증 + if not validate_image_size(image, settings.MAX_IMAGE_SIZE): + raise HTTPException( + status_code=400, + detail=f"이미지 크기가 너무 큽니다. 최대 {settings.MAX_IMAGE_SIZE}x{settings.MAX_IMAGE_SIZE}까지 지원합니다." + ) + + # 마스크 이진화 + mask = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)[1] + + # 모델 선택 + model_name = request.model_name or "simple-lama" + + # 워커에서 인페인팅 실행 + result = await worker_manager.process_inpaint( + image=image, + mask=mask, + model_name=model_name, + prompt=request.prompt, + negative_prompt=request.negative_prompt, + sd_seed=request.sd_seed, + num_inference_steps=request.num_inference_steps, + guidance_scale=request.guidance_scale, + strength=request.strength + ) + + if result is None: + raise HTTPException(status_code=500, detail="인페인팅 처리 실패") + + # 결과 이미지를 base64로 인코딩 + result_image = concat_alpha_channel(result, alpha_channel) + result_base64 = encode_image_to_base64(result_image, ext) + + processing_time = time.time() - start_time + + # 모니터링 통계 업데이트 + monitoring_data.update_api_stats( + endpoint="/api/v1/inpaint", + success=True, + response_time=processing_time * 1000 # ms로 변환 + ) + + return InpaintResponse( + success=True, + image=result_base64, + processing_time=processing_time, + seed=request.sd_seed + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"인페인팅 처리 실패: {e}") + + # 모니터링 통계 업데이트 + processing_time = time.time() - start_time + monitoring_data.update_api_stats( + endpoint="/api/v1/inpaint", + success=False, + response_time=processing_time * 1000, + error=str(e) + ) + + raise HTTPException(status_code=500, detail=f"인페인팅 처리 실패: {str(e)}") + + +@router.post("/api/v1/remove_bg", response_model=RemoveBGResponse) +async def remove_background(request: RemoveBGRequest): + """배경 제거 API""" + start_time = time.time() + + try: + # base64 이미지 디코딩 + image, alpha_channel, info, ext = decode_base64_to_image(request.image) + + # 이미지 크기 검증 + if not validate_image_size(image, settings.MAX_IMAGE_SIZE): + raise HTTPException( + status_code=400, + detail=f"이미지 크기가 너무 큽니다. 최대 {settings.MAX_IMAGE_SIZE}x{settings.MAX_IMAGE_SIZE}까지 지원합니다." + ) + + # 모델 선택 + model_name = request.model_name or "rembg" + + # 워커에서 배경 제거 실행 + result_image, result_mask = await worker_manager.process_remove_bg( + image=image, + model_name=model_name + ) + + if result_image is None or result_mask is None: + raise HTTPException(status_code=500, detail="배경 제거 처리 실패") + + # 결과를 base64로 인코딩 + result_base64 = encode_image_to_base64(result_image, ext) + mask_base64 = encode_image_to_base64(result_mask, "PNG") + + processing_time = time.time() - start_time + + # 모니터링 통계 업데이트 + monitoring_data.update_api_stats( + endpoint="/api/v1/remove_bg", + success=True, + response_time=processing_time * 1000 + ) + + return RemoveBGResponse( + success=True, + image=result_base64, + mask=mask_base64, + processing_time=processing_time + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"배경 제거 처리 실패: {e}") + + # 모니터링 통계 업데이트 + processing_time = time.time() - start_time + monitoring_data.update_api_stats( + endpoint="/api/v1/remove_bg", + success=False, + response_time=processing_time * 1000, + error=str(e) + ) + + raise HTTPException(status_code=500, detail=f"배경 제거 처리 실패: {str(e)}") + + +@router.post("/api/v1/run_plugin_gen_image", response_model=PluginResponse) +async def run_plugin_generate_image(request: PluginRequest): + """플러그인으로 이미지 생성""" + start_time = time.time() + + try: + # base64 이미지 디코딩 + image, alpha_channel, info, ext = decode_base64_to_image(request.image) + + # 이미지 크기 검증 + if not validate_image_size(image, settings.MAX_IMAGE_SIZE): + raise HTTPException( + status_code=400, + detail=f"이미지 크기가 너무 큽니다. 최대 {settings.MAX_IMAGE_SIZE}x{settings.MAX_IMAGE_SIZE}까지 지원합니다." + ) + + # 플러그인 실행 + if request.name == "rembg": + result_image, _ = await worker_manager.process_remove_bg( + image=image, + model_name=request.model_name or "rembg" + ) + else: + raise HTTPException(status_code=422, detail=f"지원하지 않는 플러그인: {request.name}") + + if result_image is None: + raise HTTPException(status_code=500, detail="플러그인 처리 실패") + + # 결과를 base64로 인코딩 + result_base64 = encode_image_to_base64(result_image, ext) + + processing_time = time.time() - start_time + + # 모니터링 통계 업데이트 + monitoring_data.update_api_stats( + endpoint=f"/api/v1/run_plugin_gen_image/{request.name}", + success=True, + response_time=processing_time * 1000 + ) + + return PluginResponse( + success=True, + image=result_base64, + processing_time=processing_time + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"플러그인 이미지 생성 실패: {e}") + + # 모니터링 통계 업데이트 + processing_time = time.time() - start_time + monitoring_data.update_api_stats( + endpoint=f"/api/v1/run_plugin_gen_image/{request.name}", + success=False, + response_time=processing_time * 1000, + error=str(e) + ) + + raise HTTPException(status_code=500, detail=f"플러그인 처리 실패: {str(e)}") + + +@router.post("/api/v1/run_plugin_gen_mask", response_model=PluginResponse) +async def run_plugin_generate_mask(request: PluginRequest): + """플러그인으로 마스크 생성""" + start_time = time.time() + + try: + # base64 이미지 디코딩 + image, alpha_channel, info, ext = decode_base64_to_image(request.image) + + # 이미지 크기 검증 + if not validate_image_size(image, settings.MAX_IMAGE_SIZE): + raise HTTPException( + status_code=400, + detail=f"이미지 크기가 너무 큽니다. 최대 {settings.MAX_IMAGE_SIZE}x{settings.MAX_IMAGE_SIZE}까지 지원합니다." + ) + + # 플러그인 실행 + if request.name == "rembg": + _, result_mask = await worker_manager.process_remove_bg( + image=image, + model_name=request.model_name or "rembg" + ) + else: + raise HTTPException(status_code=422, detail=f"지원하지 않는 플러그인: {request.name}") + + if result_mask is None: + raise HTTPException(status_code=500, detail="플러그인 마스크 생성 실패") + + # 마스크를 base64로 인코딩 + mask_base64 = encode_image_to_base64(result_mask, "PNG") + + processing_time = time.time() - start_time + + # 모니터링 통계 업데이트 + monitoring_data.update_api_stats( + endpoint=f"/api/v1/run_plugin_gen_mask/{request.name}", + success=True, + response_time=processing_time * 1000 + ) + + return PluginResponse( + success=True, + mask=mask_base64, + processing_time=processing_time + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"플러그인 마스크 생성 실패: {e}") + + # 모니터링 통계 업데이트 + processing_time = time.time() - start_time + monitoring_data.update_api_stats( + endpoint=f"/api/v1/run_plugin_gen_mask/{request.name}", + success=False, + response_time=processing_time * 1000, + error=str(e) + ) + + raise HTTPException(status_code=500, detail=f"플러그인 마스크 생성 실패: {str(e)}") + + +@router.post("/api/v1/adjust_mask") +async def adjust_mask_api(request: AdjustMaskRequest): + """마스크 조정 API""" + try: + # base64 마스크 디코딩 + mask, _, _, _ = decode_base64_to_image(request.mask, gray=True) + + # 마스크 조정 + adjusted_mask = adjust_mask(mask, request.kernel_size, request.operate) + + # 프론트엔드용 마스크 생성 + frontend_mask = gen_frontend_mask(adjusted_mask) + + # 결과를 PNG로 반환 + result_bytes = numpy_to_bytes(frontend_mask, "PNG") + + return Response( + content=result_bytes, + media_type="image/png" + ) + + except Exception as e: + logger.error(f"마스크 조정 실패: {e}") + raise HTTPException(status_code=500, detail=f"마스크 조정 실패: {str(e)}") + + +@router.get("/api/v1/samplers") +async def get_samplers(): + """사용 가능한 샘플러 목록 반환""" + return [ + "euler", + "euler_a", + "heun", + "dpm_2", + "dpm_2_a", + "lms", + "dpm_fast", + "dpm_adaptive", + "dpmpp_2s_a", + "dpmpp_sde", + "dpmpp_2m", + "ddim", + "uni_pc", + "uni_pc_bh2" + ] + + +@router.get("/") +async def root(): + """루트 엔드포인트""" + return { + "message": "인페인팅 서버 API", + "version": "1.0.0", + "docs": "/docs", + "health": "/health" + } + + +# 유틸리티 함수 +def validate_image_size(image, max_size): + """이미지 크기 검증""" + try: + h, w = image.shape[:2] + pixels = h * w + max_pixels = max_size * max_size + return pixels <= max_pixels + except: + return False diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..51d9106 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# 코어 모듈 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..1b35f80 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,67 @@ +""" +Configuration settings for the inpainting server +""" +import os +import platform +from typing import Dict, Any +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # System detection + IS_JETSON: bool = platform.machine() == "aarch64" and "tegra" in platform.uname().release.lower() + IS_X86: bool = platform.machine() in ["x86_64", "amd64"] + + # Server settings + HOST: str = "0.0.0.0" + PORT: int = 8000 + WORKERS: int = 1 + + # GPU settings + CUDA_DEVICE: int = 0 + FP16_ENABLED: bool = True + + # Jetson specific settings + JETSON_MODE: bool = IS_JETSON + JETSON_POWER_MODE: str = "MAXN" # MAXN, 5W, 10W, 15W + JETSON_FAN_CONTROL: bool = True + JETSON_TEMP_THRESHOLD: int = 75 # Celsius + + # Session pool settings + SIMPLE_LAMA_SESSIONS: int = 2 if IS_JETSON else 2 + MIGAN_SESSIONS: int = 2 if IS_JETSON else 2 + REMBG_SESSIONS: int = 1 if IS_JETSON else 1 + + # Worker settings (Jetson은 더 적은 워커 사용) + MAX_WORKERS: int = 4 if IS_JETSON else 8 + MIN_WORKERS: int = 1 if IS_JETSON else 2 + WORKER_TIMEOUT: int = 300 # 5 minutes + + # VRAM management (Jetson은 더 보수적인 설정) + VRAM_THRESHOLD_HIGH: float = 0.7 if IS_JETSON else 0.8 # 70% for Jetson + VRAM_THRESHOLD_LOW: float = 0.3 if IS_JETSON else 0.4 # 30% for Jetson + VRAM_CHECK_INTERVAL: int = 20 if IS_JETSON else 30 # More frequent for Jetson + + # Model paths + SIMPLE_LAMA_MODEL_PATH: str = "models/simple-lama" + MIGAN_MODEL_PATH: str = "models/migan" + REMBG_MODEL_PATH: str = "models/rembg" + + # Upload settings + MAX_FILE_SIZE: int = 25 * 1024 * 1024 if IS_JETSON else 50 * 1024 * 1024 # 25MB for Jetson + ALLOWED_EXTENSIONS: set = {".jpg", ".jpeg", ".png", ".bmp", ".tiff"} + + # Monitoring + ENABLE_MONITORING: bool = True + MONITORING_PORT: int = 8001 + + # Jetson performance settings + JETSON_GPU_FREQ: int = 1200 # MHz + JETSON_CPU_FREQ: int = 1900 # MHz + JETSON_MEMORY_FREQ: int = 1600 # MHz + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/app/core/session_pool.py b/app/core/session_pool.py new file mode 100644 index 0000000..a20c4d4 --- /dev/null +++ b/app/core/session_pool.py @@ -0,0 +1,226 @@ +""" +세션 풀 관리 시스템 +각 모델(simple-lama, migan, rembg)의 세션을 효율적으로 관리합니다. +""" +import asyncio +import logging +import time +from typing import Dict, List, Optional, Any +from enum import Enum +from dataclasses import dataclass +from contextlib import asynccontextmanager + +logger = logging.getLogger(__name__) + + +class ModelType(Enum): + SIMPLE_LAMA = "simple_lama" + MIGAN = "migan" + REMBG = "rembg" + + +@dataclass +class ModelSession: + session_id: str + model_type: ModelType + model: Any + created_at: float + last_used: float + in_use: bool = False + + def mark_used(self): + self.last_used = time.time() + + def is_expired(self, timeout: int = 3600) -> bool: + return time.time() - self.last_used > timeout + + +class SessionPool: + def __init__(self, + simple_lama_count: int = 2, + migan_count: int = 2, + rembg_count: int = 1): + self.pools: Dict[ModelType, List[ModelSession]] = { + ModelType.SIMPLE_LAMA: [], + ModelType.MIGAN: [], + ModelType.REMBG: [] + } + self.pool_sizes = { + ModelType.SIMPLE_LAMA: simple_lama_count, + ModelType.MIGAN: migan_count, + ModelType.REMBG: rembg_count + } + self.locks: Dict[ModelType, asyncio.Lock] = { + model_type: asyncio.Lock() for model_type in ModelType + } + self._initialized = False + + async def initialize(self): + """모든 모델 세션을 초기화합니다.""" + if self._initialized: + return + + logger.info("Initializing session pools...") + + for model_type, count in self.pool_sizes.items(): + await self._initialize_model_pool(model_type, count) + + self._initialized = True + logger.info("Session pools initialized successfully") + + async def _initialize_model_pool(self, model_type: ModelType, count: int): + """특정 모델의 세션 풀을 초기화합니다.""" + logger.info(f"Initializing {count} sessions for {model_type.value}") + + for i in range(count): + try: + session = await self._create_session(model_type, f"{model_type.value}_{i}") + self.pools[model_type].append(session) + logger.info(f"Created session {session.session_id}") + except Exception as e: + logger.error(f"Failed to create session for {model_type.value}: {e}") + + async def _create_session(self, model_type: ModelType, session_id: str) -> ModelSession: + """새로운 모델 세션을 생성합니다.""" + model = await self._load_model(model_type) + return ModelSession( + session_id=session_id, + model_type=model_type, + model=model, + created_at=time.time(), + last_used=time.time() + ) + + async def _load_model(self, model_type: ModelType) -> Any: + """모델을 로드합니다.""" + # 실제 구현에서는 각 모델을 로드하는 로직이 들어갑니다 + if model_type == ModelType.SIMPLE_LAMA: + return await self._load_simple_lama_model() + elif model_type == ModelType.MIGAN: + return await self._load_migan_model() + elif model_type == ModelType.REMBG: + return await self._load_rembg_model() + else: + raise ValueError(f"Unknown model type: {model_type}") + + async def _load_simple_lama_model(self): + """Simple LAMA 모델을 로드합니다.""" + # Placeholder - 실제 모델 로딩 로직으로 대체 + await asyncio.sleep(0.1) # 시뮬레이션 + return {"model": "simple_lama", "loaded": True} + + async def _load_migan_model(self): + """MIGAN 모델을 로드합니다.""" + # Placeholder - 실제 모델 로딩 로직으로 대체 + await asyncio.sleep(0.1) # 시뮬레이션 + return {"model": "migan", "loaded": True} + + async def _load_rembg_model(self): + """REMBG 모델을 로드합니다.""" + # Placeholder - 실제 모델 로딩 로직으로 대체 + await asyncio.sleep(0.1) # 시뮬레이션 + return {"model": "rembg", "loaded": True} + + @asynccontextmanager + async def get_session(self, model_type: ModelType): + """세션을 가져와서 사용 후 반환합니다.""" + session = await self._acquire_session(model_type) + try: + yield session + finally: + await self._release_session(session) + + async def _acquire_session(self, model_type: ModelType) -> ModelSession: + """사용 가능한 세션을 획득합니다.""" + async with self.locks[model_type]: + # 사용 가능한 세션 찾기 + for session in self.pools[model_type]: + if not session.in_use: + session.in_use = True + session.mark_used() + logger.debug(f"Acquired session {session.session_id}") + return session + + # 사용 가능한 세션이 없으면 대기 + logger.warning(f"No available sessions for {model_type.value}, waiting...") + + # 세션이 사용 가능해질 때까지 대기 + while True: + await asyncio.sleep(0.1) + async with self.locks[model_type]: + for session in self.pools[model_type]: + if not session.in_use: + session.in_use = True + session.mark_used() + logger.debug(f"Acquired session {session.session_id} after waiting") + return session + + async def _release_session(self, session: ModelSession): + """세션을 반환합니다.""" + async with self.locks[session.model_type]: + session.in_use = False + logger.debug(f"Released session {session.session_id}") + + async def get_pool_status(self) -> Dict[str, Any]: + """풀 상태를 반환합니다.""" + status = {} + for model_type in ModelType: + pool = self.pools[model_type] + total = len(pool) + in_use = sum(1 for session in pool if session.in_use) + available = total - in_use + + status[model_type.value] = { + "total": total, + "in_use": in_use, + "available": available, + "sessions": [ + { + "id": session.session_id, + "in_use": session.in_use, + "last_used": session.last_used, + "created_at": session.created_at + } + for session in pool + ] + } + return status + + async def cleanup_expired_sessions(self, timeout: int = 3600): + """만료된 세션을 정리합니다.""" + for model_type, pool in self.pools.items(): + async with self.locks[model_type]: + expired_sessions = [s for s in pool if s.is_expired(timeout) and not s.in_use] + for session in expired_sessions: + pool.remove(session) + logger.info(f"Removed expired session {session.session_id}") + + async def scale_pool(self, model_type: ModelType, new_size: int): + """풀 크기를 조정합니다.""" + async with self.locks[model_type]: + current_size = len(self.pools[model_type]) + + if new_size > current_size: + # 세션 추가 + for i in range(current_size, new_size): + session_id = f"{model_type.value}_{i}" + session = await self._create_session(model_type, session_id) + self.pools[model_type].append(session) + logger.info(f"Added session {session_id}") + + elif new_size < current_size: + # 세션 제거 (사용 중이지 않은 것만) + sessions_to_remove = [] + for session in self.pools[model_type]: + if not session.in_use and len(sessions_to_remove) < (current_size - new_size): + sessions_to_remove.append(session) + + for session in sessions_to_remove: + self.pools[model_type].remove(session) + logger.info(f"Removed session {session.session_id}") + + self.pool_sizes[model_type] = new_size + + +# 전역 세션 풀 인스턴스 +session_pool = SessionPool() diff --git a/app/core/worker_manager.py b/app/core/worker_manager.py new file mode 100644 index 0000000..0ad3892 --- /dev/null +++ b/app/core/worker_manager.py @@ -0,0 +1,329 @@ +""" +동적 워커 관리 시스템 +VRAM 사용량에 따라 워커 수를 동적으로 조정합니다. +""" +import asyncio +import logging +import time +import uuid +from typing import Dict, List, Optional, Callable, Any +from dataclasses import dataclass +from enum import Enum +from concurrent.futures import ThreadPoolExecutor + +from ..utils.gpu_monitor import gpu_monitor +from ..core.config import settings + +logger = logging.getLogger(__name__) + + +class WorkerStatus(Enum): + IDLE = "idle" + BUSY = "busy" + STARTING = "starting" + STOPPING = "stopping" + ERROR = "error" + + +@dataclass +class Worker: + worker_id: str + status: WorkerStatus + created_at: float + last_task_at: Optional[float] = None + current_task: Optional[str] = None + task_count: int = 0 + error_count: int = 0 + + def mark_task_start(self, task_id: str): + self.status = WorkerStatus.BUSY + self.current_task = task_id + self.last_task_at = time.time() + + def mark_task_complete(self): + self.status = WorkerStatus.IDLE + self.current_task = None + self.task_count += 1 + + def mark_error(self): + self.status = WorkerStatus.ERROR + self.error_count += 1 + + +class WorkerManager: + def __init__(self): + self.workers: Dict[str, Worker] = {} + self.task_queue: asyncio.Queue = asyncio.Queue() + self.executor = ThreadPoolExecutor(max_workers=settings.MAX_WORKERS) + self.running = False + self.monitor_task: Optional[asyncio.Task] = None + self.worker_tasks: Dict[str, asyncio.Task] = {} + + # 스케일링 설정 + self.last_scale_time = time.time() + self.scale_cooldown = 60 # 1분 쿨다운 + + async def start(self): + """워커 매니저를 시작합니다.""" + if self.running: + return + + self.running = True + logger.info("Starting worker manager...") + + # 초기 워커 생성 + await self._scale_workers(settings.MIN_WORKERS) + + # 모니터링 태스크 시작 + self.monitor_task = asyncio.create_task(self._monitor_loop()) + + logger.info(f"Worker manager started with {len(self.workers)} workers") + + async def stop(self): + """워커 매니저를 중지합니다.""" + if not self.running: + return + + self.running = False + logger.info("Stopping worker manager...") + + # 모니터링 태스크 중지 + if self.monitor_task: + self.monitor_task.cancel() + try: + await self.monitor_task + except asyncio.CancelledError: + pass + + # 모든 워커 중지 + await self._stop_all_workers() + + # 스레드 풀 종료 + self.executor.shutdown(wait=True) + + logger.info("Worker manager stopped") + + async def submit_task(self, task_func: Callable, *args, **kwargs) -> Any: + """태스크를 워커에게 제출합니다.""" + task_id = str(uuid.uuid4()) + + # 사용 가능한 워커 찾기 + worker = await self._get_available_worker() + if not worker: + logger.warning("No available workers, queuing task") + # 큐에 추가하고 대기 + future = asyncio.Future() + await self.task_queue.put((task_id, task_func, args, kwargs, future)) + return await future + + # 워커에 태스크 할당 + return await self._execute_task(worker, task_id, task_func, *args, **kwargs) + + async def _get_available_worker(self) -> Optional[Worker]: + """사용 가능한 워커를 찾습니다.""" + for worker in self.workers.values(): + if worker.status == WorkerStatus.IDLE: + return worker + return None + + async def _execute_task(self, worker: Worker, task_id: str, + task_func: Callable, *args, **kwargs) -> Any: + """워커에서 태스크를 실행합니다.""" + worker.mark_task_start(task_id) + logger.debug(f"Executing task {task_id} on worker {worker.worker_id}") + + try: + # 비동기 함수인지 확인 + if asyncio.iscoroutinefunction(task_func): + result = await task_func(*args, **kwargs) + else: + # 동기 함수는 스레드 풀에서 실행 + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(self.executor, task_func, *args, **kwargs) + + worker.mark_task_complete() + logger.debug(f"Task {task_id} completed successfully") + + # 큐에서 대기 중인 태스크가 있다면 처리 + asyncio.create_task(self._process_queue()) + + return result + + except Exception as e: + worker.mark_error() + logger.error(f"Task {task_id} failed on worker {worker.worker_id}: {e}") + + # 큐에서 대기 중인 태스크가 있다면 처리 + asyncio.create_task(self._process_queue()) + + raise e + + async def _process_queue(self): + """큐에서 대기 중인 태스크를 처리합니다.""" + if self.task_queue.empty(): + return + + worker = await self._get_available_worker() + if not worker: + return + + try: + task_id, task_func, args, kwargs, future = self.task_queue.get_nowait() + result = await self._execute_task(worker, task_id, task_func, *args, **kwargs) + future.set_result(result) + except asyncio.QueueEmpty: + pass + except Exception as e: + if 'future' in locals(): + future.set_exception(e) + + async def _monitor_loop(self): + """모니터링 루프""" + while self.running: + try: + await self._check_scaling() + await self._cleanup_error_workers() + await asyncio.sleep(settings.VRAM_CHECK_INTERVAL) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in monitor loop: {e}") + await asyncio.sleep(5) + + async def _check_scaling(self): + """스케일링 필요성을 확인합니다.""" + current_time = time.time() + if current_time - self.last_scale_time < self.scale_cooldown: + return + + # VRAM 사용량 확인 + gpu_info = gpu_monitor.get_gpu_memory_info() + vram_usage = gpu_info['usage_percent'] / 100.0 + + # 현재 워커 상태 분석 + total_workers = len(self.workers) + idle_workers = sum(1 for w in self.workers.values() if w.status == WorkerStatus.IDLE) + busy_workers = sum(1 for w in self.workers.values() if w.status == WorkerStatus.BUSY) + queue_size = self.task_queue.qsize() + + logger.debug(f"Scaling check - VRAM: {vram_usage:.2f}, Workers: {total_workers}, " + f"Idle: {idle_workers}, Busy: {busy_workers}, Queue: {queue_size}") + + # 스케일 업 조건 + should_scale_up = ( + vram_usage < settings.VRAM_THRESHOLD_LOW and + (queue_size > 0 or idle_workers == 0) and + total_workers < settings.MAX_WORKERS + ) + + # 스케일 다운 조건 + should_scale_down = ( + vram_usage > settings.VRAM_THRESHOLD_HIGH or + (idle_workers > total_workers * 0.5 and total_workers > settings.MIN_WORKERS) + ) + + if should_scale_up: + new_count = min(total_workers + 1, settings.MAX_WORKERS) + await self._scale_workers(new_count) + self.last_scale_time = current_time + logger.info(f"Scaled up to {new_count} workers (VRAM: {vram_usage:.2f})") + + elif should_scale_down: + new_count = max(total_workers - 1, settings.MIN_WORKERS) + await self._scale_workers(new_count) + self.last_scale_time = current_time + logger.info(f"Scaled down to {new_count} workers (VRAM: {vram_usage:.2f})") + + async def _scale_workers(self, target_count: int): + """워커 수를 조정합니다.""" + current_count = len(self.workers) + + if target_count > current_count: + # 워커 추가 + for i in range(target_count - current_count): + worker_id = f"worker_{uuid.uuid4().hex[:8]}" + worker = Worker( + worker_id=worker_id, + status=WorkerStatus.IDLE, + created_at=time.time() + ) + self.workers[worker_id] = worker + logger.debug(f"Created worker {worker_id}") + + elif target_count < current_count: + # 워커 제거 (유휴 상태인 것만) + workers_to_remove = [] + for worker in self.workers.values(): + if (worker.status == WorkerStatus.IDLE and + len(workers_to_remove) < (current_count - target_count)): + workers_to_remove.append(worker) + + for worker in workers_to_remove: + worker.status = WorkerStatus.STOPPING + del self.workers[worker.worker_id] + logger.debug(f"Removed worker {worker.worker_id}") + + async def _cleanup_error_workers(self): + """에러 상태의 워커를 정리합니다.""" + error_workers = [w for w in self.workers.values() if w.status == WorkerStatus.ERROR] + + for worker in error_workers: + # 에러 워커 제거 후 새 워커 생성 + del self.workers[worker.worker_id] + logger.warning(f"Removed error worker {worker.worker_id}") + + # 새 워커 생성 + new_worker_id = f"worker_{uuid.uuid4().hex[:8]}" + new_worker = Worker( + worker_id=new_worker_id, + status=WorkerStatus.IDLE, + created_at=time.time() + ) + self.workers[new_worker_id] = new_worker + logger.info(f"Created replacement worker {new_worker_id}") + + async def _stop_all_workers(self): + """모든 워커를 중지합니다.""" + for worker in self.workers.values(): + worker.status = WorkerStatus.STOPPING + + # 실행 중인 태스크가 완료될 때까지 대기 + max_wait = 30 # 30초 최대 대기 + wait_start = time.time() + + while time.time() - wait_start < max_wait: + busy_workers = [w for w in self.workers.values() if w.status == WorkerStatus.BUSY] + if not busy_workers: + break + await asyncio.sleep(1) + + self.workers.clear() + + def get_status(self) -> Dict[str, Any]: + """워커 매니저 상태를 반환합니다.""" + workers_by_status = {} + for status in WorkerStatus: + workers_by_status[status.value] = [ + { + "id": w.worker_id, + "created_at": w.created_at, + "last_task_at": w.last_task_at, + "current_task": w.current_task, + "task_count": w.task_count, + "error_count": w.error_count + } + for w in self.workers.values() if w.status == status + ] + + return { + "total_workers": len(self.workers), + "queue_size": self.task_queue.qsize(), + "workers_by_status": workers_by_status, + "gpu_info": gpu_monitor.get_gpu_memory_info(), + "system_memory": gpu_monitor.get_system_memory_info(), + "running": self.running + } + + +# 전역 워커 매니저 인스턴스 +worker_manager = WorkerManager() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..c31a9dc --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +# 모델 모듈 diff --git a/app/models/migan.py b/app/models/migan.py new file mode 100644 index 0000000..052e32f --- /dev/null +++ b/app/models/migan.py @@ -0,0 +1,190 @@ +""" +MIGAN 인페인팅 모델 구현 +""" +import torch +import numpy as np +import cv2 +from PIL import Image +import logging +from typing import Union, Tuple +import asyncio + +logger = logging.getLogger(__name__) + + +class MiganInpainter: + def __init__(self, model_path: str = None, device: str = "cuda", fp16: bool = True): + self.model_path = model_path + self.device = device + self.fp16 = fp16 + self.model = None + self.loaded = False + + async def load_model(self): + """모델을 비동기적으로 로드합니다.""" + if self.loaded: + return + + try: + logger.info("Loading MIGAN model...") + + # 실제 구현에서는 MIGAN 모델을 로드 + # 여기서는 플레이스홀더로 구현 + await asyncio.sleep(0.1) # 모델 로딩 시뮬레이션 + + # TODO: 실제 모델 로딩 로직 + # self.model = load_migan_model(self.model_path, device=self.device) + + self.model = {"type": "migan", "device": self.device, "fp16": self.fp16} + self.loaded = True + + logger.info("MIGAN model loaded successfully") + + except Exception as e: + logger.error(f"Failed to load MIGAN model: {e}") + raise + + def preprocess_image(self, image: Union[Image.Image, np.ndarray]) -> torch.Tensor: + """이미지를 전처리합니다.""" + if isinstance(image, Image.Image): + image = np.array(image) + + # RGB로 변환 + if image.shape[2] == 4: # RGBA + image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB) + elif image.shape[2] == 3 and image.dtype == np.uint8: + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + # 크기 조정 (MIGAN은 특정 크기를 선호할 수 있음) + height, width = image.shape[:2] + if height != 512 or width != 512: + image = cv2.resize(image, (512, 512), interpolation=cv2.INTER_LANCZOS4) + + # 정규화 (-1 to 1, MIGAN 스타일) + image = image.astype(np.float32) / 127.5 - 1.0 + + # 텐서로 변환 (B, C, H, W) + tensor = torch.from_numpy(image).permute(2, 0, 1).unsqueeze(0) + + if self.fp16: + tensor = tensor.half() + + return tensor.to(self.device) + + def preprocess_mask(self, mask: Union[Image.Image, np.ndarray]) -> torch.Tensor: + """마스크를 전처리합니다.""" + if isinstance(mask, Image.Image): + mask = np.array(mask) + + # 그레이스케일로 변환 + if len(mask.shape) == 3: + mask = cv2.cvtColor(mask, cv2.COLOR_RGB2GRAY) + + # 크기 조정 + if mask.shape[0] != 512 or mask.shape[1] != 512: + mask = cv2.resize(mask, (512, 512), interpolation=cv2.INTER_NEAREST) + + # 이진화 (0 또는 1) + mask = (mask > 127).astype(np.float32) + + # 텐서로 변환 (B, 1, H, W) + tensor = torch.from_numpy(mask).unsqueeze(0).unsqueeze(0) + + if self.fp16: + tensor = tensor.half() + + return tensor.to(self.device) + + def postprocess_result(self, tensor: torch.Tensor, original_size: Tuple[int, int]) -> np.ndarray: + """결과를 후처리합니다.""" + # CPU로 이동하고 numpy로 변환 + if tensor.is_cuda: + tensor = tensor.cpu() + if tensor.dtype == torch.float16: + tensor = tensor.float() + + result = tensor.squeeze(0).permute(1, 2, 0).numpy() + + # -1 to 1 범위에서 0-255로 변환 + result = ((result + 1.0) * 127.5).clip(0, 255).astype(np.uint8) + + # 원본 크기로 복원 + if result.shape[:2] != original_size: + result = cv2.resize(result, (original_size[1], original_size[0]), + interpolation=cv2.INTER_LANCZOS4) + + return result + + async def inpaint(self, image: Union[Image.Image, np.ndarray], + mask: Union[Image.Image, np.ndarray]) -> np.ndarray: + """인페인팅을 수행합니다.""" + if not self.loaded: + await self.load_model() + + try: + # 원본 크기 저장 + if isinstance(image, Image.Image): + original_size = image.size[::-1] # (height, width) + else: + original_size = image.shape[:2] + + # 전처리 + image_tensor = self.preprocess_image(image) + mask_tensor = self.preprocess_mask(mask) + + # 추론 (실제 구현에서는 모델 추론) + with torch.no_grad(): + # TODO: 실제 모델 추론 로직 + # result = self.model(image_tensor, mask_tensor) + + # 플레이스홀더: 더 정교한 인페인팅 시뮬레이션 + result = await self._simulate_advanced_inpainting(image_tensor, mask_tensor) + + # 후처리 + result_np = self.postprocess_result(result, original_size) + + return result_np + + except Exception as e: + logger.error(f"MIGAN inpainting failed: {e}") + raise + + async def _simulate_advanced_inpainting(self, image_tensor: torch.Tensor, + mask_tensor: torch.Tensor) -> torch.Tensor: + """고급 인페인팅 시뮬레이션 (실제 구현에서는 제거)""" + # 비동기 처리 시뮬레이션 + await asyncio.sleep(0.15) # MIGAN은 더 오래 걸린다고 가정 + + result = image_tensor.clone() + mask_bool = mask_tensor.bool() + + # 더 정교한 인페인팅 시뮬레이션: 주변 픽셀의 가중 평균 + if mask_bool.any(): + # 간단한 inpainting 시뮬레이션 + for c in range(3): + channel = result[0, c] + mask_2d = mask_bool[0, 0] + + # 마스크 영역의 경계에서 값을 가져와서 보간 + kernel = torch.ones(3, 3, device=self.device) / 9.0 + if self.fp16: + kernel = kernel.half() + + # 간단한 convolution 기반 인페인팅 + padded_channel = torch.nn.functional.pad(channel.unsqueeze(0).unsqueeze(0), (1, 1, 1, 1), mode='replicate') + smoothed = torch.nn.functional.conv2d(padded_channel, kernel.unsqueeze(0).unsqueeze(0), padding=0) + + result[0, c][mask_2d] = smoothed[0, 0][mask_2d] + + return result + + def get_model_info(self) -> dict: + """모델 정보를 반환합니다.""" + return { + "model_type": "migan", + "device": self.device, + "fp16": self.fp16, + "loaded": self.loaded, + "model_path": self.model_path, + "input_size": (512, 512) + } diff --git a/app/models/rembg_model.py b/app/models/rembg_model.py new file mode 100644 index 0000000..a7fe3fc --- /dev/null +++ b/app/models/rembg_model.py @@ -0,0 +1,174 @@ +""" +REMBG 배경 제거 모델 구현 +""" +import torch +import numpy as np +import cv2 +from PIL import Image +import logging +from typing import Union, Tuple +import asyncio + +logger = logging.getLogger(__name__) + + +class RembgProcessor: + def __init__(self, model_name: str = "u2net", device: str = "cuda", fp16: bool = True): + self.model_name = model_name + self.device = device + self.fp16 = fp16 + self.model = None + self.loaded = False + + async def load_model(self): + """모델을 비동기적으로 로드합니다.""" + if self.loaded: + return + + try: + logger.info(f"Loading REMBG model ({self.model_name})...") + + # 실제 구현에서는 rembg 라이브러리를 사용 + # 여기서는 플레이스홀더로 구현 + await asyncio.sleep(0.1) # 모델 로딩 시뮬레이션 + + # TODO: 실제 모델 로딩 로직 + # from rembg import new_session + # self.model = new_session(self.model_name) + + self.model = { + "type": "rembg", + "model_name": self.model_name, + "device": self.device, + "fp16": self.fp16 + } + self.loaded = True + + logger.info(f"REMBG model ({self.model_name}) loaded successfully") + + except Exception as e: + logger.error(f"Failed to load REMBG model: {e}") + raise + + def preprocess_image(self, image: Union[Image.Image, np.ndarray]) -> np.ndarray: + """이미지를 전처리합니다.""" + if isinstance(image, Image.Image): + image = np.array(image) + + # RGB로 변환 + if image.shape[2] == 4: # RGBA + image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB) + elif len(image.shape) == 3 and image.shape[2] == 3: + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + return image + + def create_mask_from_alpha(self, rgba_image: np.ndarray) -> np.ndarray: + """RGBA 이미지에서 알파 채널을 마스크로 변환합니다.""" + if rgba_image.shape[2] != 4: + raise ValueError("Input image must have 4 channels (RGBA)") + + # 알파 채널을 마스크로 사용 + alpha_channel = rgba_image[:, :, 3] + + # 0-255 범위의 마스크 생성 + mask = alpha_channel.astype(np.uint8) + + return mask + + async def remove_background(self, image: Union[Image.Image, np.ndarray]) -> Tuple[np.ndarray, np.ndarray]: + """배경을 제거하고 결과 이미지와 마스크를 반환합니다.""" + if not self.loaded: + await self.load_model() + + try: + # 전처리 + processed_image = self.preprocess_image(image) + original_shape = processed_image.shape + + # 배경 제거 (실제 구현에서는 rembg 사용) + # TODO: 실제 모델 추론 로직 + # from rembg import remove + # result_rgba = remove(self.model, processed_image) + + # 플레이스홀더: 배경 제거 시뮬레이션 + result_rgba = await self._simulate_background_removal(processed_image) + + # 결과에서 RGB 이미지와 마스크 분리 + result_rgb = result_rgba[:, :, :3] + mask = self.create_mask_from_alpha(result_rgba) + + return result_rgb, mask + + except Exception as e: + logger.error(f"Background removal failed: {e}") + raise + + async def _simulate_background_removal(self, image: np.ndarray) -> np.ndarray: + """배경 제거 시뮬레이션 (실제 구현에서는 제거)""" + # 비동기 처리 시뮬레이션 + await asyncio.sleep(0.08) # REMBG는 상대적으로 빠르다고 가정 + + height, width = image.shape[:2] + + # 간단한 전경/배경 분리 시뮬레이션 + # 중앙 영역을 전경으로, 가장자리를 배경으로 가정 + center_x, center_y = width // 2, height // 2 + + # 타원형 마스크 생성 + y, x = np.ogrid[:height, :width] + mask = ((x - center_x) ** 2 / (width * 0.3) ** 2 + + (y - center_y) ** 2 / (height * 0.4) ** 2) <= 1 + + # 부드러운 가장자리를 위한 가우시안 블러 + mask_float = mask.astype(np.float32) + mask_blurred = cv2.GaussianBlur(mask_float, (51, 51), 20) + + # RGBA 이미지 생성 + result_rgba = np.zeros((height, width, 4), dtype=np.uint8) + result_rgba[:, :, :3] = image # RGB 채널 + result_rgba[:, :, 3] = (mask_blurred * 255).astype(np.uint8) # 알파 채널 + + return result_rgba + + async def apply_new_background(self, foreground: np.ndarray, mask: np.ndarray, + background: Union[np.ndarray, tuple]) -> np.ndarray: + """새로운 배경을 적용합니다.""" + try: + height, width = foreground.shape[:2] + + # 배경 준비 + if isinstance(background, tuple): + # 단색 배경 + bg = np.full((height, width, 3), background, dtype=np.uint8) + else: + # 이미지 배경 + if isinstance(background, Image.Image): + background = np.array(background) + bg = cv2.resize(background, (width, height)) + if len(bg.shape) == 3 and bg.shape[2] == 4: + bg = bg[:, :, :3] # RGBA에서 RGB로 + + # 마스크를 0-1 범위로 정규화 + mask_norm = mask.astype(np.float32) / 255.0 + mask_3ch = np.stack([mask_norm] * 3, axis=-1) + + # 알파 블렌딩 + result = (foreground.astype(np.float32) * mask_3ch + + bg.astype(np.float32) * (1 - mask_3ch)) + + return result.astype(np.uint8) + + except Exception as e: + logger.error(f"Background application failed: {e}") + raise + + def get_model_info(self) -> dict: + """모델 정보를 반환합니다.""" + return { + "model_type": "rembg", + "model_name": self.model_name, + "device": self.device, + "fp16": self.fp16, + "loaded": self.loaded + } diff --git a/app/models/schemas.py b/app/models/schemas.py new file mode 100644 index 0000000..c738164 --- /dev/null +++ b/app/models/schemas.py @@ -0,0 +1,106 @@ +""" +API 스키마 정의 +iopaint와 호환되는 입출력 형식을 지원합니다. +""" +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field +from enum import Enum + + +class Device(str, Enum): + cpu = "cpu" + cuda = "cuda" + mps = "mps" + + +class InpaintRequest(BaseModel): + """인페인팅 요청 스키마 (iopaint 호환)""" + image: str = Field(..., description="base64로 인코딩된 원본 이미지") + mask: str = Field(..., description="base64로 인코딩된 마스크 이미지") + sd_seed: Optional[int] = Field(-1, description="Stable Diffusion 시드") + prompt: Optional[str] = Field("", description="프롬프트") + negative_prompt: Optional[str] = Field("", description="네거티브 프롬프트") + num_inference_steps: Optional[int] = Field(20, description="추론 스텝 수") + guidance_scale: Optional[float] = Field(7.5, description="가이던스 스케일") + strength: Optional[float] = Field(1.0, description="인페인팅 강도") + model_name: Optional[str] = Field("simple-lama", description="사용할 모델명") + + +class RemoveBGRequest(BaseModel): + """배경 제거 요청 스키마""" + image: str = Field(..., description="base64로 인코딩된 이미지") + model_name: Optional[str] = Field("rembg", description="사용할 모델명") + + +class PluginRequest(BaseModel): + """플러그인 요청 기본 스키마""" + name: str = Field(..., description="플러그인 이름") + image: str = Field(..., description="base64로 인코딩된 이미지") + model_name: Optional[str] = Field(None, description="사용할 모델명") + + +class AdjustMaskRequest(BaseModel): + """마스크 조정 요청 스키마""" + mask: str = Field(..., description="base64로 인코딩된 마스크") + kernel_size: int = Field(5, description="커널 크기") + operate: str = Field("dilate", description="연산 타입 (dilate/erode)") + + +class InpaintResponse(BaseModel): + """인페인팅 응답 스키마""" + success: bool = Field(..., description="성공 여부") + image: Optional[str] = Field(None, description="base64로 인코딩된 결과 이미지") + error: Optional[str] = Field(None, description="에러 메시지") + processing_time: Optional[float] = Field(None, description="처리 시간 (초)") + seed: Optional[int] = Field(None, description="사용된 시드") + + +class RemoveBGResponse(BaseModel): + """배경 제거 응답 스키마""" + success: bool = Field(..., description="성공 여부") + image: Optional[str] = Field(None, description="base64로 인코딩된 결과 이미지") + mask: Optional[str] = Field(None, description="base64로 인코딩된 마스크") + error: Optional[str] = Field(None, description="에러 메시지") + processing_time: Optional[float] = Field(None, description="처리 시간 (초)") + + +class PluginResponse(BaseModel): + """플러그인 응답 스키마""" + success: bool = Field(..., description="성공 여부") + image: Optional[str] = Field(None, description="base64로 인코딩된 결과 이미지") + mask: Optional[str] = Field(None, description="base64로 인코딩된 마스크") + error: Optional[str] = Field(None, description="에러 메시지") + processing_time: Optional[float] = Field(None, description="처리 시간 (초)") + + +class ModelInfo(BaseModel): + """모델 정보 스키마""" + name: str = Field(..., description="모델명") + type: str = Field(..., description="모델 타입 (inpainting/rembg)") + description: Optional[str] = Field(None, description="모델 설명") + supported_formats: List[str] = Field(default_factory=list, description="지원하는 이미지 형식") + max_image_size: Optional[int] = Field(None, description="최대 이미지 크기") + + +class ServerConfigResponse(BaseModel): + """서버 설정 응답 스키마""" + models: List[ModelInfo] = Field(..., description="사용 가능한 모델 목록") + max_file_size: int = Field(..., description="최대 파일 크기 (MB)") + supported_formats: List[str] = Field(..., description="지원하는 이미지 형식") + device: Device = Field(..., description="현재 사용 중인 디바이스") + is_jetson: bool = Field(..., description="Jetson 시스템 여부") + + +class HealthResponse(BaseModel): + """헬스 체크 응답 스키마""" + status: str = Field(..., description="서버 상태") + timestamp: str = Field(..., description="현재 시간") + version: str = Field(..., description="서버 버전") + uptime: float = Field(..., description="가동 시간 (초)") + + +class ErrorResponse(BaseModel): + """에러 응답 스키마""" + error: str = Field(..., description="에러 메시지") + detail: Optional[str] = Field(None, description="상세 에러 정보") + timestamp: str = Field(..., description="에러 발생 시간") diff --git a/app/models/simple_lama.py b/app/models/simple_lama.py new file mode 100644 index 0000000..10cca56 --- /dev/null +++ b/app/models/simple_lama.py @@ -0,0 +1,159 @@ +""" +Simple LAMA 인페인팅 모델 구현 +""" +import torch +import numpy as np +import cv2 +from PIL import Image +import logging +from typing import Union, Tuple +import asyncio +from concurrent.futures import ThreadPoolExecutor + +logger = logging.getLogger(__name__) + + +class SimpleLamaInpainter: + def __init__(self, model_path: str = None, device: str = "cuda", fp16: bool = True): + self.model_path = model_path + self.device = device + self.fp16 = fp16 + self.model = None + self.loaded = False + + async def load_model(self): + """모델을 비동기적으로 로드합니다.""" + if self.loaded: + return + + try: + logger.info("Loading Simple LAMA model...") + + # 실제 구현에서는 simple-lama-inpainting 라이브러리를 사용 + # 여기서는 플레이스홀더로 구현 + await asyncio.sleep(0.1) # 모델 로딩 시뮬레이션 + + # TODO: 실제 모델 로딩 로직 + # from simple_lama_inpainting import SimpleLama + # self.model = SimpleLama(device=self.device) + + self.model = {"type": "simple_lama", "device": self.device, "fp16": self.fp16} + self.loaded = True + + logger.info("Simple LAMA model loaded successfully") + + except Exception as e: + logger.error(f"Failed to load Simple LAMA model: {e}") + raise + + def preprocess_image(self, image: Union[Image.Image, np.ndarray]) -> torch.Tensor: + """이미지를 전처리합니다.""" + if isinstance(image, Image.Image): + image = np.array(image) + + # RGB로 변환 + if image.shape[2] == 4: # RGBA + image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB) + elif image.shape[2] == 3 and image.dtype == np.uint8: + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + # 정규화 (0-1) + image = image.astype(np.float32) / 255.0 + + # 텐서로 변환 (B, C, H, W) + tensor = torch.from_numpy(image).permute(2, 0, 1).unsqueeze(0) + + if self.fp16: + tensor = tensor.half() + + return tensor.to(self.device) + + def preprocess_mask(self, mask: Union[Image.Image, np.ndarray]) -> torch.Tensor: + """마스크를 전처리합니다.""" + if isinstance(mask, Image.Image): + mask = np.array(mask) + + # 그레이스케일로 변환 + if len(mask.shape) == 3: + mask = cv2.cvtColor(mask, cv2.COLOR_RGB2GRAY) + + # 이진화 (0 또는 1) + mask = (mask > 127).astype(np.float32) + + # 텐서로 변환 (B, 1, H, W) + tensor = torch.from_numpy(mask).unsqueeze(0).unsqueeze(0) + + if self.fp16: + tensor = tensor.half() + + return tensor.to(self.device) + + def postprocess_result(self, tensor: torch.Tensor) -> np.ndarray: + """결과를 후처리합니다.""" + # CPU로 이동하고 numpy로 변환 + if tensor.is_cuda: + tensor = tensor.cpu() + if tensor.dtype == torch.float16: + tensor = tensor.float() + + result = tensor.squeeze(0).permute(1, 2, 0).numpy() + + # 0-255 범위로 변환 + result = np.clip(result * 255.0, 0, 255).astype(np.uint8) + + return result + + async def inpaint(self, image: Union[Image.Image, np.ndarray], + mask: Union[Image.Image, np.ndarray]) -> np.ndarray: + """인페인팅을 수행합니다.""" + if not self.loaded: + await self.load_model() + + try: + # 전처리 + image_tensor = self.preprocess_image(image) + mask_tensor = self.preprocess_mask(mask) + + # 추론 (실제 구현에서는 모델 추론) + with torch.no_grad(): + # TODO: 실제 모델 추론 로직 + # result = self.model(image_tensor, mask_tensor) + + # 플레이스홀더: 마스크 영역을 평균 색상으로 채우기 + result = await self._simulate_inpainting(image_tensor, mask_tensor) + + # 후처리 + result_np = self.postprocess_result(result) + + return result_np + + except Exception as e: + logger.error(f"Inpainting failed: {e}") + raise + + async def _simulate_inpainting(self, image_tensor: torch.Tensor, + mask_tensor: torch.Tensor) -> torch.Tensor: + """인페인팅 시뮬레이션 (실제 구현에서는 제거)""" + # 비동기 처리 시뮬레이션 + await asyncio.sleep(0.1) + + # 마스크 영역을 이미지의 평균 색상으로 채우기 + result = image_tensor.clone() + mask_bool = mask_tensor.bool() + + # 각 채널별 평균 계산 + for c in range(3): + channel_mean = image_tensor[0, c][~mask_bool[0, 0]].mean() + result[0, c][mask_bool[0, 0]] = channel_mean + + return result + + def get_model_info(self) -> dict: + """모델 정보를 반환합니다.""" + return { + "model_type": "simple_lama", + "device": self.device, + "fp16": self.fp16, + "loaded": self.loaded, + "model_path": self.model_path + } diff --git a/app/monitoring/dashboard.py b/app/monitoring/dashboard.py new file mode 100644 index 0000000..461db8c --- /dev/null +++ b/app/monitoring/dashboard.py @@ -0,0 +1,920 @@ +""" +워커 감시 대시보드 +실시간으로 워커 상태, GPU 사용량, 세션 풀 상태를 모니터링합니다. +Jetson Xavier와 x86 시스템을 모두 지원합니다. +""" +import asyncio +import json +import logging +import time +import psutil +import os +from datetime import datetime, timedelta +from typing import Dict, List, Any +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +import uvicorn + +from ..core.worker_manager import worker_manager +from ..core.session_pool import session_pool +from ..utils.gpu_monitor import gpu_monitor +from ..core.config import settings + +logger = logging.getLogger(__name__) + +# 모니터링 앱 생성 +monitor_app = FastAPI( + title="인페인팅 서버 모니터링 대시보드", + description="실시간 서버 상태 모니터링 (Jetson Xavier & x86 지원)", + version="1.0.0" +) + +# 연결된 WebSocket 클라이언트들 +connected_clients: List[WebSocket] = [] + + +class MonitoringData: + def __init__(self): + self.history: List[Dict[str, Any]] = [] + self.max_history = 100 # 최대 100개 데이터 포인트 저장 + self.api_stats = { + "total_requests": 0, + "successful_requests": 0, + "failed_requests": 0, + "endpoint_usage": {}, + "response_times": [], + "errors": [] + } + self.alerts = [] + + async def collect_data(self) -> Dict[str, Any]: + """현재 시스템 상태 데이터를 수집합니다.""" + timestamp = datetime.now().isoformat() + + # GPU 정보 + gpu_info = gpu_monitor.get_gpu_memory_info() + gpu_utilization = gpu_monitor.get_gpu_utilization() + + # 시스템 메모리 정보 + system_memory = gpu_monitor.get_system_memory_info() + + # 시스템 성능 지표 + system_performance = self._get_system_performance() + + # 워커 매니저 상태 + worker_status = worker_manager.get_status() + + # 세션 풀 상태 + session_status = await session_pool.get_pool_status() + + # Jetson 전용 정보 + jetson_info = {} + if settings.IS_JETSON: + jetson_info = gpu_monitor.get_jetson_specific_info() + + # API 통계 + api_stats = self._get_api_statistics() + + # 알림 및 경고 + alerts = self._check_alerts(gpu_info, system_memory, worker_status) + + data = { + "timestamp": timestamp, + "system_type": "Jetson Xavier" if settings.IS_JETSON else "x86_64", + "gpu": { + **gpu_info, + "utilization": gpu_utilization + }, + "system_memory": system_memory, + "system_performance": system_performance, + "workers": worker_status, + "sessions": session_status, + "jetson": jetson_info, + "api_stats": api_stats, + "alerts": alerts + } + + # 히스토리에 추가 + self.history.append(data) + if len(self.history) > self.max_history: + self.history.pop(0) + + return data + + def _get_system_performance(self) -> Dict[str, Any]: + """시스템 성능 지표를 수집합니다.""" + try: + # CPU 사용률 + cpu_percent = psutil.cpu_percent(interval=1) + cpu_count = psutil.cpu_count() + cpu_freq = psutil.cpu_freq() + + # 디스크 I/O + disk_io = psutil.disk_io_counters() + + # 네트워크 I/O + net_io = psutil.net_io_counters() + + # 프로세스 정보 + processes = len(psutil.pids()) + + # 시스템 부하 + load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else [0, 0, 0] + + return { + "cpu": { + "usage_percent": cpu_percent, + "count": cpu_count, + "frequency_mhz": cpu_freq.current if cpu_freq else 0, + "load_average": { + "1min": load_avg[0], + "5min": load_avg[1], + "15min": load_avg[2] + } + }, + "disk": { + "read_bytes": disk_io.read_bytes if disk_io else 0, + "write_bytes": disk_io.write_bytes if disk_io else 0, + "read_count": disk_io.read_count if disk_io else 0, + "write_count": disk_io.write_count if disk_io else 0 + }, + "network": { + "bytes_sent": net_io.bytes_sent if net_io else 0, + "bytes_recv": net_io.bytes_recv if net_io else 0, + "packets_sent": net_io.packets_sent if net_io else 0, + "packets_recv": net_io.packets_recv if net_io else 0 + }, + "processes": processes + } + except Exception as e: + logger.error(f"시스템 성능 정보 수집 실패: {e}") + return {} + + def _get_api_statistics(self) -> Dict[str, Any]: + """API 통계 정보를 반환합니다.""" + # 실제 구현에서는 API 엔드포인트에서 이 정보를 수집해야 합니다 + return { + "total_requests": self.api_stats["total_requests"], + "successful_requests": self.api_stats["successful_requests"], + "failed_requests": self.api_stats["failed_requests"], + "success_rate": ( + (self.api_stats["successful_requests"] / max(self.api_stats["total_requests"], 1)) * 100 + ), + "endpoint_usage": self.api_stats["endpoint_usage"], + "average_response_time": ( + sum(self.api_stats["response_times"]) / max(len(self.api_stats["response_times"]), 1) + ) if self.api_stats["response_times"] else 0, + "recent_errors": self.api_stats["errors"][-5:] # 최근 5개 에러 + } + + def _check_alerts(self, gpu_info: Dict, system_memory: Dict, worker_status: Dict) -> List[Dict]: + """시스템 상태를 확인하고 알림을 생성합니다.""" + alerts = [] + current_time = datetime.now() + + # GPU 메모리 경고 + if gpu_info.get("usage_percent", 0) > 90: + alerts.append({ + "level": "critical", + "message": f"GPU 메모리 사용률이 높습니다: {gpu_info.get('usage_percent', 0):.1f}%", + "timestamp": current_time.isoformat(), + "category": "gpu" + }) + elif gpu_info.get("usage_percent", 0) > 80: + alerts.append({ + "level": "warning", + "message": f"GPU 메모리 사용률이 높습니다: {gpu_info.get('usage_percent', 0):.1f}%", + "timestamp": current_time.isoformat(), + "category": "gpu" + }) + + # 시스템 메모리 경고 + if system_memory.get("usage_percent", 0) > 90: + alerts.append({ + "level": "critical", + "message": f"시스템 메모리 사용률이 높습니다: {system_memory.get('usage_percent', 0):.1f}%", + "timestamp": current_time.isoformat(), + "category": "memory" + }) + + # 워커 상태 경고 + if worker_status.get("active_workers", 0) == 0: + alerts.append({ + "level": "critical", + "message": "활성 워커가 없습니다", + "timestamp": current_time.isoformat(), + "category": "workers" + }) + + # Jetson 전용 경고 + if settings.IS_JETSON: + # 온도 경고 (실제 구현에서는 온도 정보를 가져와야 함) + pass + + return alerts + + def update_api_stats(self, endpoint: str, success: bool, response_time: float, error: str = None): + """API 통계를 업데이트합니다.""" + self.api_stats["total_requests"] += 1 + + if success: + self.api_stats["successful_requests"] += 1 + else: + self.api_stats["failed_requests"] += 1 + if error: + self.api_stats["errors"].append({ + "timestamp": datetime.now().isoformat(), + "endpoint": endpoint, + "error": error + }) + + # 엔드포인트별 사용량 + if endpoint not in self.api_stats["endpoint_usage"]: + self.api_stats["endpoint_usage"][endpoint] = 0 + self.api_stats["endpoint_usage"][endpoint] += 1 + + # 응답 시간 + self.api_stats["response_times"].append(response_time) + if len(self.api_stats["response_times"]) > 100: + self.api_stats["response_times"].pop(0) + + # 에러 로그 제한 + if len(self.api_stats["errors"]) > 50: + self.api_stats["errors"] = self.api_stats["errors"][-50:] + + def get_history(self) -> List[Dict[str, Any]]: + """데이터 히스토리를 반환합니다.""" + return self.history + + def get_statistics(self) -> Dict[str, Any]: + """통계 정보를 반환합니다.""" + if not self.history: + return {} + + recent_data = self.history[-10:] # 최근 10개 데이터 + + # GPU 사용률 평균 + gpu_usage_avg = sum(d["gpu"]["usage_percent"] for d in recent_data) / len(recent_data) + gpu_util_avg = sum(d["gpu"]["utilization"] for d in recent_data) / len(recent_data) + + # 시스템 메모리 사용률 평균 + sys_mem_avg = sum(d["system_memory"]["usage_percent"] for d in recent_data) / len(recent_data) + + # 워커 수 평균 + worker_avg = sum(d["workers"]["active_workers"] for d in recent_data) / len(recent_data) + + return { + "gpu_usage_avg": round(gpu_usage_avg, 2), + "gpu_util_avg": round(gpu_util_avg, 2), + "system_memory_avg": round(sys_mem_avg, 2), + "worker_avg": round(worker_avg, 2), + "data_points": len(recent_data) + } + +# 전역 모니터링 데이터 인스턴스 +monitoring_data = MonitoringData() + + +# HTML 템플릿 +HTML_TEMPLATE = """ + + + + + + 인페인팅 서버 모니터링 + + + + +
+
+

🚀 인페인팅 서버 모니터링

+

실시간 서버 상태 및 성능 모니터링 대시보드

+
+ + +
+
+

🖥️ 시스템 정보

+
+ 시스템 타입: + - +
+
+ CPU 사용률: + - +
+
+ 시스템 메모리: + - +
+
+ 프로세스 수: + - +
+
+ +
+

🎮 GPU 상태

+
+ GPU 메모리: + - +
+
+ GPU 사용률: + - +
+
+ GPU 온도: + - +
+
+ GPU 클럭: + - +
+
+ +
+

⚙️ 워커 상태

+
+ 활성 워커: + - +
+
+ 세션 풀: + - +
+
+ 대기열: + - +
+
+ 상태: + - +
+
+ +
+

📊 API 통계

+
+ 총 요청: + - +
+
+ 성공률: + - +
+
+ 평균 응답시간: + - +
+
+ 에러 수: + - +
+
+
+ + +
+

🔍 시스템 성능 상세

+
+
+

CPU 정보

+
+ 코어 수: + - +
+
+ 클럭 속도: + - +
+
+ 부하 평균 (1분): + - +
+
+ 부하 평균 (5분): + - +
+
+ +
+

디스크 I/O

+
+ 읽기 (MB/s): + - +
+
+ 쓰기 (MB/s): + - +
+
+ 읽기 횟수: + - +
+
+ 쓰기 횟수: + - +
+
+ +
+

네트워크 I/O

+
+ 송신 (MB): + - +
+
+ 수신 (MB): + - +
+
+ 송신 패킷: + - +
+
+ 수신 패킷: + - +
+
+
+
+ + +
+

🌐 엔드포인트 사용량

+
+
+
로딩 중...
+
-
+
+
+
+ + +
+

⚠️ 알림 및 경고

+
+
+ 정보: 모니터링 데이터를 수집 중입니다... +
+
+
+ + +
+

📈 실시간 성능 차트

+ +
+ +
+

🎯 GPU 메모리 사용량

+ +
+ +
+ 마지막 업데이트: - +
+
+ + + + +""" + + +@monitor_app.get("/") +async def dashboard(): + """대시보드 HTML 페이지""" + return HTMLResponse(content=HTML_TEMPLATE) + + +@monitor_app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket 연결을 처리합니다.""" + await websocket.accept() + connected_clients.append(websocket) + + try: + while True: + # 클라이언트로부터 메시지 대기 (연결 유지용) + await websocket.receive_text() + except WebSocketDisconnect: + connected_clients.remove(websocket) + logger.info("클라이언트 연결 해제") + + +@monitor_app.get("/api/status") +async def get_current_status(): + """현재 상태를 JSON으로 반환합니다.""" + return await monitoring_data.collect_data() + + +@monitor_app.get("/api/history") +async def get_history(): + """데이터 히스토리를 반환합니다.""" + return { + "history": monitoring_data.get_history(), + "statistics": monitoring_data.get_statistics() + } + + +async def broadcast_data(): + """연결된 모든 클라이언트에게 데이터를 브로드캐스트합니다.""" + while True: + try: + if connected_clients: + data = await monitoring_data.collect_data() + message = json.dumps(data, ensure_ascii=False) + + # 연결이 끊어진 클라이언트 제거 + disconnected = [] + for client in connected_clients: + try: + await client.send_text(message) + except Exception: + disconnected.append(client) + + for client in disconnected: + connected_clients.remove(client) + + await asyncio.sleep(2) # 2초마다 업데이트 + + except Exception as e: + logger.error(f"브로드캐스트 오류: {e}") + await asyncio.sleep(5) + + +@monitor_app.on_event("startup") +async def start_monitoring(): + """모니터링 시작""" + logger.info("모니터링 대시보드 시작") + + # Jetson 최적화 (시작 시) + if settings.IS_JETSON: + logger.info("Jetson Xavier 모드로 모니터링 시작") + gpu_monitor.optimize_for_jetson() + + asyncio.create_task(broadcast_data()) + + +if __name__ == "__main__": + # 로깅 설정 + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + # 모니터링 서버 실행 + uvicorn.run( + "app.monitoring.dashboard:monitor_app", + host="0.0.0.0", + port=settings.MONITORING_PORT, + log_level="info" + ) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..a638f88 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# 유틸리티 모듈 diff --git a/app/utils/gpu_monitor.py b/app/utils/gpu_monitor.py new file mode 100644 index 0000000..de59718 --- /dev/null +++ b/app/utils/gpu_monitor.py @@ -0,0 +1,517 @@ +""" +GPU 메모리 모니터링 유틸리티 +Jetson Xavier와 x86 시스템을 모두 지원합니다. +""" +import asyncio +import psutil +import logging +import subprocess +import os +from typing import Dict, Optional, List +try: + import pynvml + NVML_AVAILABLE = True +except ImportError: + NVML_AVAILABLE = False + logging.warning("pynvml not available. GPU monitoring will be limited.") + +from ..core.config import settings + +logger = logging.getLogger(__name__) + + +class JetsonMonitor: + """Jetson Xavier 전용 모니터링 클래스""" + + def __init__(self): + self.jetson_clocks_path = "/sys/kernel/debug/clk" + self.jetson_thermal_path = "/sys/devices/virtual/thermal" + self.jetson_power_path = "/sys/kernel/debug/tegra_pcie/pcie_power" + + def get_gpu_memory_info(self) -> Dict[str, float]: + """Jetson 전용 GPU 메모리 정보를 가져옵니다.""" + try: + # 1. Jetson GPU 클래스에서 정보 읽기 + if os.path.exists("/sys/class/nvidia-gpu"): + try: + # Jetson GPU 디바이스 정보 확인 + gpu_devices = [d for d in os.listdir("/sys/class/nvidia-gpu") if d.startswith("nvidia")] + if gpu_devices: + logger.debug(f"Jetson GPU devices found: {gpu_devices}") + + # GPU 메모리 정보 수집 + total_memory = 0 + used_memory = 0 + + for device in gpu_devices: + device_path = f"/sys/class/nvidia-gpu/{device}" + + # 메모리 정보 파일들 확인 + memory_files = [ + "total_memory", + "memory_used", + "memory_free", + "memory_usage" + ] + + for mem_file in memory_files: + file_path = f"{device_path}/{mem_file}" + if os.path.exists(file_path): + try: + with open(file_path, "r") as f: + value = f.read().strip() + logger.debug(f"{mem_file}: {value}") + except: + pass + + # 실제 메모리 정보가 있으면 반환 + if total_memory > 0: + return { + "total": round(total_memory / 1024, 2), + "used": round(used_memory / 1024, 2), + "free": round((total_memory - used_memory) / 1024, 2), + "usage_percent": round((used_memory / total_memory) * 100, 2) if total_memory > 0 else 0 + } + except Exception as e: + logger.debug(f"Jetson GPU class read failed: {e}") + + # 2. /sys/kernel/debug/gpu/memory에서 읽기 시도 (권한이 있는 경우) + if os.path.exists("/sys/kernel/debug/gpu/memory"): + try: + with open("/sys/kernel/debug/gpu/memory", "r") as f: + content = f.read() + logger.debug(f"GPU memory debug info: {content}") + + # 메모리 정보 파싱 + lines = content.split('\n') + total_mb = 0 + used_mb = 0 + + for line in lines: + if "Total" in line and "MB" in line: + try: + total_mb = float(line.split()[-2]) + except (ValueError, IndexError): + pass + elif "Used" in line and "MB" in line: + try: + used_mb = float(line.split()[-2]) + except (ValueError, IndexError): + pass + + if total_mb > 0: + free_mb = total_mb - used_mb + usage_percent = (used_mb / total_mb) * 100 + return { + "total": round(total_mb / 1024, 2), # GB + "used": round(used_mb / 1024, 2), # GB + "free": round(free_mb / 1024, 2), # GB + "usage_percent": round(usage_percent, 2) + } + + except Exception as e: + logger.debug(f"GPU memory debug read failed: {e}") + + # 3. tegrastats 사용 (가장 안정적) + if self._tegrastats_available(): + return self._get_memory_from_tegrastats() + + # 4. GV11B GPU 정보 확인 (Jetson Xavier) + if os.path.exists("/sys/firmware/devicetree/base/gv11b"): + logger.info("GV11B GPU (Jetson Xavier) 감지됨") + # Jetson Xavier는 통합 메모리 사용 + return { + "total": 8.0, # 8GB 통합 메모리 + "used": 0.0, + "free": 8.0, + "usage_percent": 0.0 + } + + # 5. 기본값 반환 + logger.warning("GPU 메모리 정보를 가져올 수 없습니다. 기본값을 사용합니다.") + return { + "total": 8.0, + "used": 0.0, + "free": 8.0, + "usage_percent": 0.0 + } + + except Exception as e: + logger.debug(f"Jetson GPU memory read failed: {e}") + return { + "total": 8.0, + "used": 0.0, + "free": 8.0, + "usage_percent": 0.0 + } + + def _tegrastats_available(self) -> bool: + """tegrastats 명령어 사용 가능 여부 확인""" + try: + result = subprocess.run(["which", "tegrastats"], + capture_output=True, text=True, timeout=5) + return result.returncode == 0 + except: + return False + + def _get_memory_from_tegrastats(self) -> Dict[str, float]: + """tegrastats에서 메모리 정보 추출""" + try: + # tegrastats -1 (한 번만 실행) + result = subprocess.run(["tegrastats", "-1"], + capture_output=True, text=True, timeout=10) + + if result.returncode == 0: + output = result.stdout + + # GPU 메모리 정보 파싱 + # 예시: "GR3D_FREQ 0% @ 114MHz GR3D_FREQ 0% @ 114MHz" + # "RAM 0/8192MB (lfb 0x0) @ 1600MHz" + + total_gb = 8.0 # Jetson Xavier 기본값 + used_gb = 0.0 + + # RAM 사용량 파싱 + for line in output.split('\n'): + if "RAM" in line and "MB" in line: + try: + # "RAM 1024/8192MB" 형태에서 추출 + parts = line.split() + for part in parts: + if "/" in part and "MB" in part: + used_str, total_str = part.split('/') + used_mb = float(used_str) + total_mb = float(total_str.replace('MB', '')) + used_gb = used_mb / 1024 + total_gb = total_mb / 1024 + break + except (ValueError, IndexError): + pass + + free_gb = total_gb - used_gb + usage_percent = (used_gb / total_gb) * 100 if total_gb > 0 else 0 + + return { + "total": round(total_gb, 2), + "used": round(used_gb, 2), + "free": round(free_gb, 2), + "usage_percent": round(usage_percent, 2) + } + + except Exception as e: + logger.debug(f"tegrastats parsing failed: {e}") + + # 기본값 반환 + return { + "total": 8.0, + "used": 0.0, + "free": 8.0, + "usage_percent": 0.0 + } + + def get_gpu_utilization(self) -> float: + """Jetson 전용 GPU 사용률을 가져옵니다.""" + try: + # tegrastats에서 GPU 사용률 추출 + if self._tegrastats_available(): + result = subprocess.run(["tegrastats", "-1"], + capture_output=True, text=True, timeout=10) + + if result.returncode == 0: + output = result.stdout + + # GR3D_FREQ (GPU 사용률) 파싱 + for line in output.split('\n'): + if "GR3D_FREQ" in line and "%" in line: + try: + # "GR3D_FREQ 45% @ 114MHz" 형태에서 추출 + parts = line.split() + for part in parts: + if "%" in part: + usage = float(part.replace('%', '')) + return min(usage, 100.0) # 100% 초과 방지 + except (ValueError, IndexError): + pass + + # 대안: /sys/kernel/debug/gpu/load에서 읽기 + if os.path.exists("/sys/kernel/debug/gpu/load"): + try: + with open("/sys/kernel/debug/gpu/load", "r") as f: + load = f.read().strip() + if load.isdigit(): + return min(float(load), 100.0) + except: + pass + + return 0.0 + + except Exception as e: + logger.debug(f"Jetson GPU utilization read failed: {e}") + return 0.0 + + def get_gpu_frequency(self) -> Optional[int]: + """GPU 클럭 주파수를 가져옵니다 (MHz)""" + try: + if os.path.exists(f"{self.jetson_clocks_path}/gpcclk/clk_rate"): + with open(f"{self.jetson_clocks_path}/gpcclk/clk_rate", "r") as f: + freq = int(f.read().strip()) // 1000000 # Hz to MHz + return freq + except Exception as e: + logger.debug(f"GPU frequency read failed: {e}") + return None + + def get_cpu_frequency(self) -> Optional[int]: + """CPU 클럭 주파수를 가져옵니다 (MHz)""" + try: + if os.path.exists(f"{self.jetson_clocks_path}/cpu_gpcclk/clk_rate"): + with open(f"{self.jetson_clocks_path}/cpu_gpcclk/clk_rate", "r") as f: + freq = int(f.read().strip()) // 1000000 # Hz to MHz + return freq + except Exception as e: + logger.debug(f"CPU frequency read failed: {e}") + return None + + def get_memory_frequency(self) -> Optional[int]: + """메모리 클럭 주파수를 가져옵니다 (MHz)""" + try: + if os.path.exists(f"{self.jetson_clocks_path}/emc/clk_rate"): + with open(f"{self.jetson_clocks_path}/emc/clk_rate", "r") as f: + freq = int(f.read().strip()) // 1000000 # Hz to MHz + return freq + except Exception as e: + logger.debug(f"Memory frequency read failed: {e}") + return None + + def get_temperature(self) -> Dict[str, float]: + """Jetson 온도 정보를 가져옵니다""" + temps = {} + try: + if os.path.exists(self.jetson_thermal_path): + for item in os.listdir(self.jetson_thermal_path): + if item.startswith("thermal_zone"): + temp_file = f"{self.jetson_thermal_path}/{item}/temp" + if os.path.exists(temp_file): + with open(temp_file, "r") as f: + temp = int(f.read().strip()) / 1000.0 # mC to C + zone_name = f"zone_{item.split('_')[-1]}" + temps[zone_name] = temp + except Exception as e: + logger.debug(f"Temperature read failed: {e}") + return temps + + def get_power_consumption(self) -> Optional[float]: + """전력 소비량을 가져옵니다 (W)""" + try: + # Jetson 전력 모니터링 (가능한 경우) + if os.path.exists("/sys/bus/i2c/devices/1-0040/iio_device/in_power0_input"): + with open("/sys/bus/i2c/devices/1-0040/iio_device/in_power0_input", "r") as f: + power = float(f.read().strip()) / 1000.0 # mW to W + return power + except Exception as e: + logger.debug(f"Power consumption read failed: {e}") + return None + + def set_power_mode(self, mode: str) -> bool: + """전력 모드를 설정합니다""" + try: + if mode in ["MAXN", "5W", "10W", "15W"]: + result = subprocess.run( + ["sudo", "nvpmodel", "-m", mode], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + logger.info(f"Power mode set to {mode}") + return True + else: + logger.warning(f"Failed to set power mode: {result.stderr}") + else: + logger.error(f"Invalid power mode: {mode}") + except Exception as e: + logger.error(f"Power mode setting failed: {e}") + return False + + def set_fan_speed(self, speed: int) -> bool: + """팬 속도를 설정합니다 (0-255)""" + try: + if 0 <= speed <= 255: + fan_path = "/sys/devices/pwm-fan/target_pwm" + if os.path.exists(fan_path): + with open(fan_path, "w") as f: + f.write(str(speed)) + logger.info(f"Fan speed set to {speed}") + return True + else: + logger.warning("Fan control not available") + else: + logger.error(f"Invalid fan speed: {speed}") + except Exception as e: + logger.error(f"Fan speed setting failed: {e}") + return False + + def get_jetson_info(self) -> Dict[str, any]: + """Jetson 전체 정보를 가져옵니다""" + info = { + "gpu_frequency": self.get_gpu_frequency(), + "cpu_frequency": self.get_cpu_frequency(), + "memory_frequency": self.get_memory_frequency(), + "temperature": self.get_temperature(), + "power_consumption": self.get_power_consumption(), + "power_mode": self._get_current_power_mode() + } + return info + + def _get_current_power_mode(self) -> str: + """현재 전력 모드를 가져옵니다""" + try: + result = subprocess.run( + ["nvpmodel", "-q"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.split('\n'): + if 'NV Power Mode:' in line: + return line.split(':')[-1].strip() + except Exception: + pass + return "Unknown" + + +class GPUMonitor: + def __init__(self): + self.initialized = False + self.is_jetson = settings.IS_JETSON + self.jetson_monitor = JetsonMonitor() if self.is_jetson else None + + if NVML_AVAILABLE and not self.is_jetson: + try: + pynvml.nvmlInit() + self.initialized = True + logger.info("GPU monitoring initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize GPU monitoring: {e}") + elif self.is_jetson: + logger.info("Jetson Xavier mode detected - using Jetson-specific monitoring") + self.initialized = True + + def get_gpu_memory_info(self, device_id: int = 0) -> Dict[str, float]: + """GPU 메모리 정보를 반환합니다.""" + if self.is_jetson: + return self.jetson_monitor.get_gpu_memory_info() + + if not self.initialized or not NVML_AVAILABLE: + return {"total": 0, "used": 0, "free": 0, "usage_percent": 0} + + try: + handle = pynvml.nvmlDeviceGetHandleByIndex(device_id) + mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle) + + total = mem_info.total / 1024**3 # GB + used = mem_info.used / 1024**3 # GB + free = mem_info.free / 1024**3 # GB + usage_percent = (used / total) * 100 + + return { + "total": round(total, 2), + "used": round(used, 2), + "free": round(free, 2), + "usage_percent": round(usage_percent, 2) + } + except Exception as e: + logger.error(f"Error getting GPU memory info: {e}") + return {"total": 0, "used": 0, "free": 0, "usage_percent": 0} + + def get_gpu_utilization(self, device_id: int = 0) -> float: + """GPU 사용률을 반환합니다.""" + if self.is_jetson: + return self.jetson_monitor.get_gpu_utilization() + + if not self.initialized or not NVML_AVAILABLE: + return 0.0 + + try: + handle = pynvml.nvmlDeviceGetHandleByIndex(device_id) + util = pynvml.nvmlDeviceGetUtilizationRates(handle) + return float(util.gpu) + except Exception as e: + logger.error(f"Error getting GPU utilization: {e}") + return 0.0 + + def get_system_memory_info(self) -> Dict[str, float]: + """시스템 메모리 정보를 반환합니다.""" + mem = psutil.virtual_memory() + return { + "total": round(mem.total / 1024**3, 2), # GB + "used": round(mem.used / 1024**3, 2), # GB + "free": round(mem.free / 1024**3, 2), # GB + "usage_percent": round(mem.percent, 2) + } + + def get_jetson_specific_info(self) -> Dict[str, any]: + """Jetson 전용 정보를 반환합니다.""" + if not self.is_jetson or not self.jetson_monitor: + return {} + + return self.jetson_monitor.get_jetson_info() + + def should_scale_up(self, vram_usage: float, threshold: float) -> bool: + """스케일 업 여부를 결정합니다.""" + return vram_usage < threshold + + def should_scale_down(self, vram_usage: float, threshold: float) -> bool: + """스케일 다운 여부를 결정합니다.""" + return vram_usage > threshold + + def optimize_for_jetson(self) -> bool: + """Jetson 최적화를 수행합니다.""" + if not self.is_jetson or not self.jetson_monitor: + return False + + try: + # 전력 모드 설정 + power_mode = settings.JETSON_POWER_MODE + if power_mode != "MAXN": + self.jetson_monitor.set_power_mode(power_mode) + + # 팬 제어 활성화 + if settings.JETSON_FAN_CONTROL: + # 온도에 따른 팬 속도 조정 + temps = self.jetson_monitor.get_temperature() + max_temp = max(temps.values()) if temps else 0 + + if max_temp > settings.JETSON_TEMP_THRESHOLD: + self.jetson_monitor.set_fan_speed(255) # 최대 속도 + elif max_temp > 60: + self.jetson_monitor.set_fan_speed(128) # 중간 속도 + else: + self.jetson_monitor.set_fan_speed(64) # 낮은 속도 + + logger.info("Jetson optimization completed") + return True + + except Exception as e: + logger.error(f"Jetson optimization failed: {e}") + return False + + def get_comprehensive_gpu_info(self) -> Dict[str, any]: + """GPU와 Jetson 정보를 종합적으로 반환합니다.""" + gpu_info = { + "memory": self.get_gpu_memory_info(), + "utilization": self.get_gpu_utilization(), + "system_memory": self.get_system_memory_info() + } + + if self.is_jetson: + gpu_info["jetson"] = self.get_jetson_specific_info() + gpu_info["platform"] = "Jetson Xavier" + else: + gpu_info["platform"] = "x86_64" + + return gpu_info + + +# 전역 GPU 모니터 인스턴스 +gpu_monitor = GPUMonitor() diff --git a/app/utils/image_utils.py b/app/utils/image_utils.py new file mode 100644 index 0000000..6452a19 --- /dev/null +++ b/app/utils/image_utils.py @@ -0,0 +1,247 @@ +""" +이미지 처리 유틸리티 +iopaint와 호환되는 이미지 변환 및 처리 함수들을 제공합니다. +""" +import base64 +import io +import logging +from typing import Tuple, Optional, Union +import numpy as np +import cv2 +from PIL import Image + +logger = logging.getLogger(__name__) + + +def encode_image_to_base64(image: Union[np.ndarray, Image.Image], format: str = "PNG") -> str: + """이미지를 base64로 인코딩합니다.""" + try: + if isinstance(image, np.ndarray): + # numpy 배열을 PIL Image로 변환 + if image.dtype != np.uint8: + image = (image * 255).astype(np.uint8) + + if len(image.shape) == 3 and image.shape[2] == 3: + # BGR to RGB + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + pil_image = Image.fromarray(image) + else: + pil_image = image + + # PIL Image를 base64로 인코딩 + buffer = io.BytesIO() + pil_image.save(buffer, format=format) + img_str = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return img_str + except Exception as e: + logger.error(f"이미지 base64 인코딩 실패: {e}") + raise + + +def decode_base64_to_image(base64_string: str, gray: bool = False) -> Tuple[np.ndarray, Optional[np.ndarray], dict, str]: + """base64 문자열을 이미지로 디코딩합니다 (iopaint 호환).""" + try: + # base64 디코딩 + image_data = base64.b64decode(base64_string) + + # PIL Image로 로드 + pil_image = Image.open(io.BytesIO(image_data)) + + # 이미지 정보 추출 + info = { + "format": pil_image.format, + "mode": pil_image.mode, + "size": pil_image.size, + "parameters": "" + } + + # 확장자 결정 + ext = pil_image.format.lower() if pil_image.format else "png" + + # numpy 배열로 변환 + if gray: + # 그레이스케일로 변환 + if pil_image.mode in ('RGBA', 'LA', 'P'): + pil_image = pil_image.convert('L') + image_array = np.array(pil_image) + else: + # RGB로 변환 + if pil_image.mode == 'RGBA': + # RGBA를 RGB로 변환하고 알파 채널 분리 + rgb_image = pil_image.convert('RGB') + alpha_channel = np.array(pil_image.split()[-1]) + image_array = np.array(rgb_image) + elif pil_image.mode == 'P': + # 팔레트 이미지를 RGB로 변환 + image_array = np.array(pil_image.convert('RGB')) + alpha_channel = None + else: + # RGB 이미지 + image_array = np.array(pil_image) + alpha_channel = None + + # BGR로 변환 (OpenCV 호환) + if len(image_array.shape) == 3 and image_array.shape[2] == 3: + image_array = cv2.cvtColor(image_array, cv2.COLOR_RGB2BGR) + + return image_array, alpha_channel, info, ext + + except Exception as e: + logger.error(f"base64 이미지 디코딩 실패: {e}") + raise + + +def concat_alpha_channel(image: np.ndarray, alpha_channel: Optional[np.ndarray]) -> np.ndarray: + """이미지와 알파 채널을 결합합니다.""" + if alpha_channel is None: + return image + + try: + # BGR to RGB + if len(image.shape) == 3 and image.shape[2] == 3: + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + else: + rgb_image = image + + # RGBA 이미지 생성 + if len(rgb_image.shape) == 3: + rgba_image = np.dstack((rgb_image, alpha_channel)) + else: + rgba_image = np.dstack((rgb_image, rgb_image, rgb_image, alpha_channel)) + + return rgba_image + + except Exception as e: + logger.error(f"알파 채널 결합 실패: {e}") + return image + + +def pil_to_bytes(image: Image.Image, ext: str = "PNG", quality: int = 100, infos: dict = None) -> bytes: + """PIL Image를 바이트로 변환합니다.""" + try: + buffer = io.BytesIO() + + if ext.lower() in ['jpg', 'jpeg']: + image.save(buffer, format='JPEG', quality=quality, optimize=True) + else: + image.save(buffer, format=ext.upper(), optimize=True) + + return buffer.getvalue() + + except Exception as e: + logger.error(f"PIL Image를 바이트로 변환 실패: {e}") + raise + + +def numpy_to_bytes(image: np.ndarray, ext: str = "PNG", quality: int = 100) -> bytes: + """numpy 배열을 바이트로 변환합니다.""" + try: + # numpy 배열을 PIL Image로 변환 + if image.dtype != np.uint8: + image = (image * 255).astype(np.uint8) + + if len(image.shape) == 3 and image.shape[2] == 3: + # BGR to RGB + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + pil_image = Image.fromarray(image) + return pil_to_bytes(pil_image, ext, quality) + + except Exception as e: + logger.error(f"numpy 배열을 바이트로 변환 실패: {e}") + raise + + +def adjust_mask(mask: np.ndarray, kernel_size: int = 5, operate: str = "dilate") -> np.ndarray: + """마스크를 조정합니다 (dilate/erode).""" + try: + if kernel_size <= 0: + return mask + + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) + + if operate.lower() == "dilate": + result = cv2.dilate(mask, kernel, iterations=1) + elif operate.lower() == "erode": + result = cv2.erode(mask, kernel, iterations=1) + else: + logger.warning(f"알 수 없는 마스크 연산: {operate}") + return mask + + return result + + except Exception as e: + logger.error(f"마스크 조정 실패: {e}") + return mask + + +def gen_frontend_mask(mask: np.ndarray) -> np.ndarray: + """프론트엔드용 마스크를 생성합니다.""" + try: + # 마스크를 0-255 범위로 정규화 + if mask.dtype != np.uint8: + mask = (mask * 255).astype(np.uint8) + + # 이진화 + _, binary_mask = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY) + + return binary_mask + + except Exception as e: + logger.error(f"프론트엔드 마스크 생성 실패: {e}") + return mask + + +def resize_image(image: np.ndarray, target_size: Tuple[int, int], keep_aspect: bool = True) -> np.ndarray: + """이미지 크기를 조정합니다.""" + try: + if keep_aspect: + # 종횡비 유지하면서 크기 조정 + h, w = image.shape[:2] + target_h, target_w = target_size + + # 종횡비 계산 + aspect = w / h + target_aspect = target_w / target_h + + if aspect > target_aspect: + # 너비에 맞춤 + new_w = target_w + new_h = int(target_w / aspect) + else: + # 높이에 맞춤 + new_h = target_h + new_w = int(target_h * aspect) + + resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + + # 패딩으로 목표 크기 맞춤 + result = np.zeros((target_h, target_w, 3) if len(image.shape) == 3 else (target_h, target_w), dtype=image.dtype) + y_offset = (target_h - new_h) // 2 + x_offset = (target_w - new_w) // 2 + result[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = resized + + return result + else: + # 종횡비 무시하고 크기 조정 + return cv2.resize(image, target_size, interpolation=cv2.INTER_LANCZOS4) + + except Exception as e: + logger.error(f"이미지 크기 조정 실패: {e}") + return image + + +def validate_image_size(image: np.ndarray, max_size: int) -> bool: + """이미지 크기가 제한을 초과하지 않는지 확인합니다.""" + try: + h, w = image.shape[:2] + pixels = h * w + max_pixels = max_size * max_size + + return pixels <= max_pixels + + except Exception as e: + logger.error(f"이미지 크기 검증 실패: {e}") + return False diff --git a/lib64 b/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/logs/main.log b/logs/main.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/main_server.log b/logs/main_server.log new file mode 100644 index 0000000..313d0ae --- /dev/null +++ b/logs/main_server.log @@ -0,0 +1,46 @@ +Traceback (most recent call last): + File "/usr/lib/python3.8/runpy.py", line 194, in _run_module_as_main + return _run_code(code, main_globals, None, + File "/usr/lib/python3.8/runpy.py", line 87, in _run_code + exec(code, run_globals) + File "/home/ckh08045/.local/lib/python3.8/site-packages/uvicorn/__main__.py", line 4, in + uvicorn.main() + File "/home/ckh08045/.local/lib/python3.8/site-packages/click/core.py", line 1161, in __call__ + return self.main(*args, **kwargs) + File "/home/ckh08045/.local/lib/python3.8/site-packages/click/core.py", line 1082, in main + rv = self.invoke(ctx) + File "/home/ckh08045/.local/lib/python3.8/site-packages/click/core.py", line 1443, in invoke + return ctx.invoke(self.callback, **ctx.params) + File "/home/ckh08045/.local/lib/python3.8/site-packages/click/core.py", line 788, in invoke + return __callback(*args, **kwargs) + File "/home/ckh08045/.local/lib/python3.8/site-packages/uvicorn/main.py", line 426, in main + run(app, **kwargs) + File "/home/ckh08045/.local/lib/python3.8/site-packages/uvicorn/main.py", line 452, in run + server.run() + File "/home/ckh08045/.local/lib/python3.8/site-packages/uvicorn/server.py", line 68, in run + return asyncio.run(self.serve(sockets=sockets)) + File "/usr/lib/python3.8/asyncio/runners.py", line 44, in run + return loop.run_until_complete(main) + File "/usr/lib/python3.8/asyncio/base_events.py", line 616, in run_until_complete + return future.result() + File "/home/ckh08045/.local/lib/python3.8/site-packages/uvicorn/server.py", line 76, in serve + config.load() + File "/home/ckh08045/.local/lib/python3.8/site-packages/uvicorn/config.py", line 456, in load + self.loaded_app = import_from_string(self.app) + File "/home/ckh08045/.local/lib/python3.8/site-packages/uvicorn/importer.py", line 21, in import_from_string + module = importlib.import_module(module_str) + File "/usr/lib/python3.8/importlib/__init__.py", line 127, in import_module + return _bootstrap._gcd_import(name[level:], package, level) + File "", line 1014, in _gcd_import + File "", line 991, in _find_and_load + File "", line 975, in _find_and_load_unlocked + File "", line 671, in _load_unlocked + File "", line 848, in exec_module + File "", line 219, in _call_with_frames_removed + File "/home/ckh08045/work/inpaintServer/./app/api/endpoints.py", line 19, in + from ..models.rembg_model import RembgProcessor + File "/home/ckh08045/work/inpaintServer/./app/models/rembg_model.py", line 15, in + class RembgProcessor: + File "/home/ckh08045/work/inpaintServer/./app/models/rembg_model.py", line 79, in RembgProcessor + async def remove_background(self, image: Union[Image.Image, np.ndarray]) -> tuple[np.ndarray, np.ndarray]: +TypeError: 'type' object is not subscriptable diff --git a/logs/main_server.pid b/logs/main_server.pid new file mode 100644 index 0000000..e6382c4 --- /dev/null +++ b/logs/main_server.pid @@ -0,0 +1 @@ +71852 diff --git a/logs/monitoring.log b/logs/monitoring.log new file mode 100644 index 0000000..d2e557a --- /dev/null +++ b/logs/monitoring.log @@ -0,0 +1,47 @@ +nohup: ignoring input +WARNING:root:pynvml not available. GPU monitoring will be limited. +INFO: Started server process [80136] +INFO:uvicorn.error:Started server process [80136] +INFO: Waiting for application startup. +INFO:uvicorn.error:Waiting for application startup. +WARNING:app.utils.gpu_monitor:Fan control not available +INFO: Application startup complete. +INFO:uvicorn.error:Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8001 (Press CTRL+C to quit) +INFO:uvicorn.error:Uvicorn running on http://0.0.0.0:8001 (Press CTRL+C to quit) +INFO: 127.0.0.1:37694 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:50924 - "GET / HTTP/1.1" 200 OK +INFO: ('127.0.0.1', 50940) - "WebSocket /ws" [accepted] +INFO:uvicorn.error:('127.0.0.1', 50940) - "WebSocket /ws" [accepted] +INFO: connection open +INFO:uvicorn.error:connection open +INFO: connection closed +INFO:uvicorn.error:connection closed +INFO: 127.0.0.1:59682 - "GET / HTTP/1.1" 200 OK +INFO: ('127.0.0.1', 59710) - "WebSocket /ws" [accepted] +INFO:uvicorn.error:('127.0.0.1', 59710) - "WebSocket /ws" [accepted] +INFO: connection open +INFO:uvicorn.error:connection open +INFO: connection closed +INFO:uvicorn.error:connection closed +INFO: 127.0.0.1:41518 - "GET / HTTP/1.1" 200 OK +INFO: ('127.0.0.1', 41536) - "WebSocket /ws" [accepted] +INFO:uvicorn.error:('127.0.0.1', 41536) - "WebSocket /ws" [accepted] +INFO: connection open +INFO:uvicorn.error:connection open +INFO: 127.0.0.1:45724 - "GET / HTTP/1.1" 200 OK +INFO: ('127.0.0.1', 45752) - "WebSocket /ws" [accepted] +INFO:uvicorn.error:('127.0.0.1', 45752) - "WebSocket /ws" [accepted] +INFO: connection open +INFO:uvicorn.error:connection open +INFO: connection closed +INFO:uvicorn.error:connection closed +INFO: connection closed +INFO:uvicorn.error:connection closed +INFO: 127.0.0.1:48848 - "GET / HTTP/1.1" 200 OK +INFO: ('127.0.0.1', 48876) - "WebSocket /ws" [accepted] +INFO:uvicorn.error:('127.0.0.1', 48876) - "WebSocket /ws" [accepted] +INFO: connection open +INFO:uvicorn.error:connection open +INFO: connection closed +INFO:uvicorn.error:connection closed diff --git a/logs/pip_install.log b/logs/pip_install.log new file mode 100644 index 0000000..55833dd --- /dev/null +++ b/logs/pip_install.log @@ -0,0 +1,14 @@ +Collecting fastapi==0.104.1 (from -r requirements.txt (line 1)) + Using cached fastapi-0.104.1-py3-none-any.whl.metadata (24 kB) +Collecting uvicorn==0.24.0 (from uvicorn[standard]==0.24.0->-r requirements.txt (line 2)) + Using cached uvicorn-0.24.0-py3-none-any.whl.metadata (6.4 kB) +Collecting python-multipart==0.0.6 (from -r requirements.txt (line 3)) + Using cached python_multipart-0.0.6-py3-none-any.whl.metadata (2.5 kB) +Collecting pillow==10.0.1 (from -r requirements.txt (line 4)) + Using cached Pillow-10.0.1-cp38-cp38-manylinux_2_28_aarch64.whl.metadata (9.5 kB) +Collecting numpy==1.24.3 (from -r requirements.txt (line 5)) + Using cached numpy-1.24.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (5.6 kB) +Collecting opencv-python==4.8.1.78 (from -r requirements.txt (line 6)) + Using cached opencv_python-4.8.1.78-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (19 kB) +ERROR: Could not find a version that satisfies the requirement torch==2.0.1+cu118 (from versions: 1.8.0, 1.8.1, 1.9.0, 1.10.0, 1.10.1, 1.10.2, 1.11.0, 1.12.0, 1.12.1, 1.13.0, 1.13.1, 2.0.0, 2.0.1, 2.1.0, 2.1.1, 2.1.2, 2.2.0, 2.2.1, 2.2.2, 2.3.0, 2.3.1, 2.4.0, 2.4.1) +ERROR: No matching distribution found for torch==2.0.1+cu118 diff --git a/main.py b/main.py new file mode 100644 index 0000000..6a39103 --- /dev/null +++ b/main.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +인페인팅 서버 메인 애플리케이션 +iopaint와 호환되는 API를 제공합니다. +""" +import time +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import uvicorn + +from app.core.config import settings +from app.core.worker_manager import worker_manager +from app.core.session_pool import session_pool +from app.api.endpoints import router +from app.monitoring.dashboard import monitor_app + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) + +logger = logging.getLogger(__name__) + +# 서버 시작 시간 기록 +settings.start_time = time.time() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """애플리케이션 생명주기 관리""" + # 시작 시 + logger.info("🚀 인페인팅 서버 시작 중...") + + try: + # 세션 풀 초기화 + await session_pool.initialize() + logger.info("✅ 세션 풀 초기화 완료") + + # 워커 매니저 시작 + await worker_manager.start() + logger.info("✅ 워커 매니저 시작 완료") + + logger.info("🎉 인페인팅 서버 시작 완료!") + + except Exception as e: + logger.error(f"❌ 서버 시작 실패: {e}") + raise + + yield + + # 종료 시 + logger.info("🛑 인페인팅 서버 종료 중...") + + try: + # 워커 매니저 중지 + await worker_manager.stop() + logger.info("✅ 워커 매니저 중지 완료") + + logger.info("👋 인페인팅 서버 종료 완료") + + except Exception as e: + logger.error(f"❌ 서버 종료 중 오류: {e}") + + +# 메인 애플리케이션 생성 +app = FastAPI( + title="인페인팅 서버", + description="Simple LAMA, MIGAN, REMBG를 활용한 병렬 처리 인페인팅 서버 (iopaint 호환)", + version="1.0.0", + lifespan=lifespan +) + +# CORS 미들웨어 추가 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API 라우터 포함 +app.include_router(router) + +# 모니터링 대시보드 마운트 +app.mount("/monitoring", monitor_app, name="monitoring") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="인페인팅 서버") + parser.add_argument("--dev", action="store_true", help="개발 모드로 실행") + parser.add_argument("--host", default=settings.HOST, help="호스트 주소") + parser.add_argument("--port", type=int, default=settings.PORT, help="포트 번호") + parser.add_argument("--workers", type=int, default=settings.WORKERS, help="워커 수") + + args = parser.parse_args() + + if args.dev: + logger.info("🔧 개발 모드로 실행합니다") + uvicorn.run( + "main:app", + host=args.host, + port=args.port, + reload=True, + log_level="info" + ) + else: + logger.info("🚀 프로덕션 모드로 실행합니다") + uvicorn.run( + "main:app", + host=args.host, + port=args.port, + workers=args.workers, + log_level="info" + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..315a001 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,35 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 +pillow==10.0.1 +numpy==1.24.3 +opencv-python==4.8.1.78 +pydantic-settings==2.8.1 +psutil==5.9.6 +requests==2.31.0 + +# PyTorch - Jetson Xavier (ARM64) 지원 +# Jetson의 경우 torch==2.0.1+nv23.11-torch2.0.0 사용 권장 +torch==2.0.1+cu118 +torchvision==0.15.2+cu118 + +# TensorRT 및 CUDA 관련 +tensorrt==8.6.1 +pycuda==2022.2.2 + +# 인페인팅 모델들 +rembg==2.0.50 +simple-lama-inpainting==0.1.0 + +# 시스템 모니터링 +asyncio-throttle==1.0.2 +aiofiles==23.2.1 +pydantic==2.5.0 + +# Jetson 전용 패키지들 +pynvml==11.5.0 +nvidia-ml-py3==7.352.0 + +# 추가 최적화 패키지들 +onnxruntime-gpu==1.16.3 +tensorflow-gpu==2.13.0 diff --git a/scripts/install_deps.sh b/scripts/install_deps.sh new file mode 100755 index 0000000..c4e4ee2 --- /dev/null +++ b/scripts/install_deps.sh @@ -0,0 +1,708 @@ +#!/bin/bash + +# 인페인팅 서버 의존성 설치 스크립트 +# Jetson Xavier와 x86 시스템을 모두 지원합니다. +# Usage: ./install_deps.sh [options] + +set -e + +# 색상 코드 +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 "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 기본 설정 +PROJECT_ROOT="/home/ckh08045/work/inpaintServer" +VENV_PATH="$PROJECT_ROOT" + +# 시스템 감지 +detect_system() { + log_info "시스템 감지 중..." + + if [ "$(uname -m)" = "aarch64" ] && uname -a | grep -q "tegra"; then + SYSTEM_TYPE="jetson" + log_success "Jetson Xavier (ARM64) 감지됨" + + # Jetson 버전 확인 + if [ -f "/etc/nv_tegra_release" ]; then + JETSON_VERSION=$(cat /etc/nv_tegra_release | head -1) + log_info "Jetson 버전: $JETSON_VERSION" + fi + + # CUDA 버전 확인 + if command -v nvcc &> /dev/null; then + CUDA_VERSION=$(nvcc --version | grep "release" | awk '{print $6}' | cut -c2-) + log_info "CUDA 버전: $CUDA_VERSION" + fi + + # TensorRT 버전 확인 + if [ -f "/usr/lib/aarch64-linux-gnu/libnvinfer.so" ]; then + TENSORRT_VERSION=$(strings /usr/lib/aarch64-linux-gnu/libnvinfer.so | grep "TensorRT" | head -1) + log_info "TensorRT: $TENSORRT_VERSION" + fi + + elif [ "$(uname -m)" = "x86_64" ]; then + SYSTEM_TYPE="x86" + log_success "x86_64 시스템 감지됨" + + # CUDA 버전 설정 + CUDA_VERSION="11.8" + TENSORRT_VERSION="8.6.1" + + else + log_error "지원되지 않는 시스템 아키텍처: $(uname -m)" + exit 1 + fi + + # Python 버전 확인 + if command -v python3 &> /dev/null; then + PYTHON_VERSION=$(python3 --version | awk '{print $2}') + log_info "Python 버전: $PYTHON_VERSION" + + # Python 3.8 이상 필요 + if python3 -c "import sys; exit(0 if sys.version_info >= (3, 8) else 1)"; then + log_success "Python 버전 확인 완료" + else + log_error "Python 3.8 이상이 필요합니다 (현재: $PYTHON_VERSION)" + exit 1 + fi + else + log_error "Python3이 설치되어 있지 않습니다" + exit 1 + fi +} + +# 옵션 파싱 +FORCE_REINSTALL=false +SKIP_CUDA_CHECK=false +INSTALL_TENSORRT=true +INSTALL_EXTRAS=false +JETSON_OPTIMIZE=false + +while [[ $# -gt 0 ]]; do + case $1 in + -f|--force) + FORCE_REINSTALL=true + shift + ;; + --skip-cuda) + SKIP_CUDA_CHECK=true + shift + ;; + --no-tensorrt) + INSTALL_TENSORRT=false + shift + ;; + --extras) + INSTALL_EXTRAS=true + shift + ;; + --jetson-optimize) + JETSON_OPTIMIZE=true + shift + ;; + -h|--help) + echo "Usage: $0 [options]" + echo "Options:" + echo " -f, --force 기존 패키지 강제 재설치" + echo " --skip-cuda CUDA 설치 확인 건너뛰기" + echo " --no-tensorrt TensorRT 설치 건너뛰기" + echo " --extras 추가 패키지 설치 (개발 도구 등)" + echo " --jetson-optimize Jetson 최적화 설정 (Jetson 전용)" + echo " -h, --help 이 도움말 표시" + exit 0 + ;; + *) + log_error "알 수 없는 옵션: $1" + exit 1 + ;; + esac +done + +# 시스템 요구사항 확인 +check_system_requirements() { + log_info "시스템 요구사항 확인 중..." + + # Ubuntu 버전 확인 + if [ -f /etc/os-release ]; then + source /etc/os-release + log_info "OS: $PRETTY_NAME" + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + # Jetson은 Ubuntu 18.04 이상 권장 + if [[ "$VERSION_ID" < "18.04" ]]; then + log_warning "Ubuntu 18.04 이상을 권장합니다 (현재: $VERSION_ID)" + fi + else + # x86은 Ubuntu 18.04 이상 권장 + if [[ "$VERSION_ID" < "18.04" ]]; then + log_warning "Ubuntu 18.04 이상을 권장합니다 (현재: $VERSION_ID)" + fi + fi + fi + + # 메모리 확인 + TOTAL_MEMORY=$(free -g | awk 'NR==2{print $2}') + if [ "$SYSTEM_TYPE" = "jetson" ]; then + if [ "$TOTAL_MEMORY" -lt 4 ]; then + log_warning "4GB 이상의 RAM을 권장합니다 (현재: ${TOTAL_MEMORY}GB)" + fi + else + if [ "$TOTAL_MEMORY" -lt 8 ]; then + log_warning "8GB 이상의 RAM을 권장합니다 (현재: ${TOTAL_MEMORY}GB)" + fi + fi + + log_success "시스템 요구사항 확인 완료" +} + +# CUDA 설치 확인 +check_cuda_installation() { + if [ "$SKIP_CUDA_CHECK" = true ]; then + log_info "CUDA 확인을 건너뛰었습니다" + return 0 + fi + + log_info "CUDA 설치 확인 중..." + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + log_info "Jetson Xavier CUDA 확인 중..." + + # Jetson 전용 확인 방법들 + jetson_checks_passed=0 + + # 1. nvcc 확인 + if command -v nvcc &> /dev/null; then + CUDA_VERSION_INSTALLED=$(nvcc --version | grep "release" | awk '{print $6}' | cut -c2-) + log_info "CUDA 컴파일러 버전: $CUDA_VERSION_INSTALLED" + jetson_checks_passed=$((jetson_checks_passed + 1)) + else + log_warning "nvcc를 찾을 수 없습니다" + fi + + # 2. tegrastats 확인 (Jetson 전용) + if command -v tegrastats &> /dev/null; then + log_info "tegrastats 사용 가능 (Jetson 전용)" + jetson_checks_passed=$((jetson_checks_passed + 1)) + else + log_warning "tegrastats를 찾을 수 없습니다" + fi + + # 3. nvpmodel 확인 (전력 모드 관리) + if command -v nvpmodel &> /dev/null; then + log_info "nvpmodel 사용 가능 (전력 모드 관리)" + jetson_checks_passed=$((jetson_checks_passed + 1)) + else + log_warning "nvpmodel을 찾을 수 없습니다" + fi + + # 4. TensorRT 확인 + if [ -f "/usr/lib/aarch64-linux-gnu/libnvinfer.so" ]; then + TENSORRT_VERSION=$(strings /usr/lib/aarch64-linux-gnu/libnvinfer.so | grep "TensorRT" | head -1) + log_info "TensorRT: $TENSORRT_VERSION" + jetson_checks_passed=$((jetson_checks_passed + 1)) + else + log_warning "TensorRT를 찾을 수 없습니다" + fi + + # 5. Jetson 버전 확인 + if [ -f "/etc/nv_tegra_release" ]; then + JETSON_VERSION=$(cat /etc/nv_tegra_release | head -1) + log_info "Jetson 버전: $JETSON_VERSION" + jetson_checks_passed=$((jetson_checks_passed + 1)) + else + log_warning "Jetson 버전 정보를 찾을 수 없습니다" + fi + + # 6. GPU 디바이스 확인 (Jetson 전용 경로들) + gpu_detected=false + + # Jetson GPU 클래스 확인 + if [ -d "/sys/class/nvidia-gpu" ]; then + log_info "Jetson GPU 클래스 감지됨: /sys/class/nvidia-gpu" + gpu_detected=true + fi + + # Jetson GPU 디바이스 확인 + if [ -d "/sys/devices/platform/*/nvidia-gpu" ]; then + log_info "Jetson GPU 디바이스 감지됨" + gpu_detected=true + fi + + # GV11B GPU 확인 (Jetson Xavier) + if [ -d "/sys/firmware/devicetree/base/gv11b" ]; then + log_info "GV11B GPU (Jetson Xavier) 감지됨" + gpu_detected=true + fi + + # 디버그 GPU 경로 확인 (권한이 있는 경우) + if [ -r "/sys/kernel/debug/gpu" ]; then + log_info "GPU 디버그 경로 접근 가능: /sys/kernel/debug/gpu" + gpu_detected=true + fi + + if [ "$gpu_detected" = true ]; then + log_info "GPU 디바이스 감지됨" + jetson_checks_passed=$((jetson_checks_passed + 1)) + else + log_warning "GPU 디바이스를 찾을 수 없습니다 (권한 문제일 수 있음)" + log_info "sudo 권한으로 실행하거나 시스템을 재부팅해보세요" + fi + + if [ $jetson_checks_passed -ge 3 ]; then + log_success "Jetson Xavier CUDA 확인 완료 ($jetson_checks_passed/6 체크 통과)" + else + log_warning "Jetson Xavier CUDA 확인 부분 실패 ($jetson_checks_passed/6 체크 통과)" + log_info "Jetson SDK를 재설치하거나 시스템을 재부팅해보세요" + fi + + else + # x86 시스템용 기존 확인 방법 + if command -v nvidia-smi &> /dev/null; then + log_info "NVIDIA 드라이버가 설치되어 있습니다" + nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv,noheader + + # CUDA 런타임 확인 + if command -v nvcc &> /dev/null; then + CUDA_VERSION_INSTALLED=$(nvcc --version | grep "release" | awk '{print $6}' | cut -c2-) + log_info "CUDA 버전: $CUDA_VERSION_INSTALLED" + else + log_warning "CUDA 컴파일러(nvcc)를 찾을 수 없습니다" + log_info "런타임만 설치된 상태일 수 있습니다" + fi + + log_success "CUDA 확인 완료" + else + log_error "NVIDIA 드라이버가 설치되어 있지 않습니다" + log_info "다음 명령어로 설치하세요:" + log_info "sudo apt update && sudo apt install nvidia-driver-470" + exit 1 + fi + fi +} + +# 가상환경 활성화 +activate_venv() { + log_info "가상환경 활성화 중..." + + if [ ! -f "$VENV_PATH/bin/activate" ]; then + log_error "가상환경을 찾을 수 없습니다: $VENV_PATH" + exit 1 + fi + + cd "$PROJECT_ROOT" + source "$VENV_PATH/bin/activate" + + # pip 업그레이드 + log_info "pip 업그레이드 중..." + pip install --upgrade pip setuptools wheel + + log_success "가상환경 활성화 완료" +} + +# PyTorch 설치 (시스템별) +install_pytorch() { + log_info "PyTorch 설치 중..." + + # 기존 PyTorch 확인 + if pip list | grep -q torch && [ "$FORCE_REINSTALL" = false ]; then + log_info "PyTorch가 이미 설치되어 있습니다" + python -c "import torch; print(f'PyTorch 버전: {torch.__version__}'); print(f'CUDA 사용 가능: {torch.cuda.is_available()}')" + return 0 + fi + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + log_info "Jetson Xavier용 PyTorch 설치 중..." + + # Jetson 전용 PyTorch 설치 + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 + + # Jetson 최적화 패키지들 + pip install nvidia-ml-py3 pynvml + + else + log_info "x86용 PyTorch 설치 중..." + + # x86용 PyTorch 설치 + case $CUDA_VERSION in + "11.8") + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 + ;; + "11.7") + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu117 + ;; + "cpu") + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu + ;; + *) + log_warning "지원되지 않는 CUDA 버전: $CUDA_VERSION. 기본 버전을 설치합니다" + pip install torch torchvision torchaudio + ;; + esac + fi + + # 설치 확인 + python -c "import torch; print(f'PyTorch 설치 완료: {torch.__version__}'); print(f'CUDA 사용 가능: {torch.cuda.is_available()}')" + + log_success "PyTorch 설치 완료" +} + +# TensorRT 설치 +install_tensorrt() { + if [ "$INSTALL_TENSORRT" = false ]; then + log_info "TensorRT 설치를 건너뛰었습니다" + return 0 + fi + + log_info "TensorRT 설치 중..." + + # 기존 TensorRT 확인 + if pip list | grep -q tensorrt && [ "$FORCE_REINSTALL" = false ]; then + log_info "TensorRT가 이미 설치되어 있습니다" + return 0 + fi + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + log_info "Jetson Xavier용 TensorRT 설치 중..." + + # Jetson에는 TensorRT가 기본적으로 포함되어 있음 + if [ -f "/usr/lib/aarch64-linux-gnu/libnvinfer.so" ]; then + log_info "Jetson TensorRT가 이미 설치되어 있습니다" + + # Python 바인딩만 설치 + pip install nvidia-pyindex + pip install nvidia-tensorrt + else + log_warning "Jetson TensorRT를 찾을 수 없습니다" + log_info "Jetson SDK를 재설치하거나 수동으로 설치해주세요" + fi + + else + log_info "x86용 TensorRT 설치 중..." + + # x86용 TensorRT 설치 시도 + pip install tensorrt==$TENSORRT_VERSION || { + log_warning "TensorRT 패키지 설치에 실패했습니다" + log_info "수동으로 NVIDIA 웹사이트에서 다운로드가 필요할 수 있습니다" + return 1 + } + fi + + # pycuda 설치 + pip install pycuda + + log_success "TensorRT 설치 완료" +} + +# 메인 패키지 설치 +install_main_packages() { + log_info "메인 패키지 설치 중..." + + # requirements.txt 확인 + if [ ! -f "$PROJECT_ROOT/requirements.txt" ]; then + log_error "requirements.txt 파일을 찾을 수 없습니다" + exit 1 + fi + + # Jetson 전용 requirements 처리 + if [ "$SYSTEM_TYPE" = "jetson" ]; then + log_info "Jetson Xavier용 패키지 설치 중..." + + # Jetson에 맞는 패키지 버전으로 설치 + pip install fastapi uvicorn python-multipart pillow numpy + + # OpenCV - Jetson 최적화 버전 + pip install opencv-python-headless || pip install opencv-python + + # 나머지 패키지들 + pip install psutil asyncio-throttle aiofiles pydantic + + else + # x86용 전체 패키지 설치 + if [ "$FORCE_REINSTALL" = true ]; then + pip install --force-reinstall -r requirements.txt + else + pip install -r requirements.txt + fi + fi + + log_success "메인 패키지 설치 완료" +} + +# 모델별 의존성 설치 +install_model_dependencies() { + log_info "모델별 의존성 설치 중..." + + # Simple LAMA 의존성 + log_info "Simple LAMA 의존성 설치 중..." + pip install simple-lama-inpainting || { + log_warning "simple-lama-inpainting 패키지 설치 실패" + log_info "수동 설치가 필요할 수 있습니다" + } + + # REMBG 의존성 + log_info "REMBG 의존성 설치 중..." + if [ "$SYSTEM_TYPE" = "jetson" ]; then + # Jetson용 REMBG (CPU 버전 권장) + pip install rembg || pip install rembg[cpu] + else + pip install rembg[gpu] || pip install rembg + fi + + # 추가 이미지 처리 라이브러리 + if [ "$SYSTEM_TYPE" = "jetson" ]; then + # Jetson 최적화 버전 (pillow-simd는 ARM64에서 지원되지 않음) + pip install opencv-python-headless pillow || pip install opencv-python pillow + else + # x86용 최적화 버전 + pip install opencv-python-headless pillow-simd || pip install opencv-python pillow + fi + + log_success "모델별 의존성 설치 완료" +} + +# GPU 라이브러리 설치 +install_gpu_libraries() { + log_info "GPU 라이브러리 설치 중..." + + # NVIDIA 관련 Python 패키지 + pip install nvidia-ml-py3 pynvml + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + log_info "Jetson 전용 GPU 라이브러리 설치 중..." + + # Jetson 최적화 라이브러리들 + pip install onnxruntime-gpu || pip install onnxruntime + + # Jetson 전용 패키지들 + pip install jetson-stats || log_warning "jetson-stats 설치 실패" + + else + log_info "x86용 GPU 라이브러리 설치 중..." + + # 추가 GPU 가속 라이브러리 + if [ "$INSTALL_EXTRAS" = true ]; then + pip install cupy-cuda118 || { + log_warning "CuPy 설치 실패" + log_info "CUDA 버전을 확인하고 수동 설치하세요" + } + fi + fi + + log_success "GPU 라이브러리 설치 완료" +} + +# Jetson 최적화 설정 +setup_jetson_optimization() { + if [ "$SYSTEM_TYPE" != "jetson" ] || [ "$JETSON_OPTIMIZE" != true ]; then + return 0 + fi + + log_info "Jetson Xavier 최적화 설정 중..." + + # 전력 모드 설정 + if command -v nvpmodel &> /dev/null; then + log_info "전력 모드를 MAXN으로 설정 중..." + sudo nvpmodel -m 0 # MAXN 모드 + sudo nvpmodel -q + else + log_warning "nvpmodel을 찾을 수 없습니다" + fi + + # 팬 제어 설정 + if [ -f "/sys/devices/pwm-fan/target_pwm" ]; then + log_info "팬 제어 활성화 중..." + echo 128 | sudo tee /sys/devices/pwm-fan/target_pwm > /dev/null + fi + + # GPU 클럭 설정 + if [ -f "/sys/kernel/debug/clk/gpcclk/clk_rate" ]; then + log_info "GPU 클럭 최적화 중..." + # 최대 클럭으로 설정 (1200MHz) + echo 1200000000 | sudo tee /sys/kernel/debug/clk/gpcclk/clk_rate > /dev/null + fi + + # 메모리 클럭 설정 + if [ -f "/sys/kernel/debug/clk/emc/clk_rate" ]; then + log_info "메모리 클럭 최적화 중..." + # 최대 클럭으로 설정 (1600MHz) + echo 1600000000 | sudo tee /sys/kernel/debug/clk/emc/clk_rate > /dev/null + fi + + log_success "Jetson 최적화 설정 완료" +} + +# 개발 도구 설치 (선택적) +install_dev_tools() { + if [ "$INSTALL_EXTRAS" = false ]; then + return 0 + fi + + log_info "개발 도구 설치 중..." + + # 코드 품질 도구 + pip install black flake8 isort mypy + + # 테스트 도구 + pip install pytest pytest-asyncio pytest-cov + + # 프로파일링 도구 + pip install line_profiler memory_profiler + + # Jupyter 노트북 + pip install jupyter ipykernel + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + # Jetson 전용 개발 도구 + pip install jetson-inference || log_warning "jetson-inference 설치 실패" + fi + + log_success "개발 도구 설치 완료" +} + +# 설치 확인 +verify_installation() { + log_info "설치 확인 중..." + + # Python 패키지 확인 + local packages=("fastapi" "uvicorn" "torch" "PIL" "cv2" "numpy") + + for package in "${packages[@]}"; do + if python -c "import $package" 2>/dev/null; then + log_success "$package 설치 확인" + else + log_error "$package 설치 실패" + fi + done + + # CUDA 사용 가능성 확인 + if python -c "import torch; exit(0 if torch.cuda.is_available() else 1)" 2>/dev/null; then + log_success "CUDA 사용 가능" + python -c "import torch; print(f'사용 가능한 GPU 수: {torch.cuda.device_count()}')" + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + python -c " +import torch +if torch.cuda.is_available(): + torch.cuda.empty_cache() + print(f'Jetson GPU 메모리: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB') + print(f'GPU 이름: {torch.cuda.get_device_properties(0).name}') +" + fi + else + log_warning "CUDA를 사용할 수 없습니다" + fi + + # Jetson 전용 확인 + if [ "$SYSTEM_TYPE" = "jetson" ]; then + log_info "Jetson 전용 기능 확인 중..." + + # jetson-stats 확인 + if command -v jtop &> /dev/null; then + log_success "jetson-stats (jtop) 사용 가능" + fi + + # TensorRT 확인 + if python -c "import tensorrt" 2>/dev/null; then + log_success "TensorRT Python 바인딩 확인" + fi + fi + + log_success "설치 확인 완료" +} + +# 설치 후 정리 +post_install_cleanup() { + log_info "설치 후 정리 중..." + + # pip 캐시 정리 + pip cache purge + + # __pycache__ 정리 + find "$PROJECT_ROOT" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find "$PROJECT_ROOT" -name "*.pyc" -delete 2>/dev/null || true + + log_success "정리 완료" +} + +# 설치 요약 정보 +print_installation_summary() { + echo "" + echo "==========================================" + echo "📦 의존성 설치 완료!" + echo "==========================================" + echo "시스템 타입: $SYSTEM_TYPE" + echo "Python 버전: $(python --version)" + echo "PyTorch 버전: $(python -c 'import torch; print(torch.__version__)' 2>/dev/null || echo 'N/A')" + echo "CUDA 사용 가능: $(python -c 'import torch; print(torch.cuda.is_available())' 2>/dev/null || echo 'N/A')" + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + echo "" + echo "🚀 Jetson Xavier 최적화:" + echo " - 전력 모드: MAXN" + echo " - GPU 클럭: 1200MHz" + echo " - 메모리 클럭: 1600MHz" + echo " - 팬 제어: 활성화" + fi + + echo "" + echo "설치된 주요 패키지:" + pip list | grep -E "(torch|fastapi|uvicorn|rembg|pillow|opencv)" | head -10 + + echo "" + echo "다음 단계:" + echo " 1. 서버 시작: ./scripts/start_server.sh" + echo " 2. 상태 확인: ./scripts/status.sh" + echo " 3. API 문서: http://localhost:8000/docs" + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + echo " 4. Jetson 모니터링: jtop" + echo " 5. 전력 모드 확인: nvpmodel -q" + fi + + echo "==========================================" +} + +# 메인 실행 +main() { + log_info "인페인팅 서버 의존성 설치 시작" + + detect_system + check_system_requirements + check_cuda_installation + activate_venv + install_pytorch + install_tensorrt + install_main_packages + install_model_dependencies + install_gpu_libraries + setup_jetson_optimization + install_dev_tools + verify_installation + post_install_cleanup + print_installation_summary + + log_success "의존성 설치가 완료되었습니다!" +} + +# 스크립트 실행 +main "$@" diff --git a/scripts/start_server.sh b/scripts/start_server.sh new file mode 100755 index 0000000..243e5ec --- /dev/null +++ b/scripts/start_server.sh @@ -0,0 +1,488 @@ +#!/bin/bash + +# 인페인팅 서버 시작 스크립트 +# Jetson Xavier와 x86 시스템을 모두 지원합니다. +# Usage: ./start_server.sh [options] + +set -e + +# 색상 코드 +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 "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 기본 설정 +PROJECT_ROOT="/home/ckh08045/work/inpaintServer" +VENV_PATH="$PROJECT_ROOT" +MAIN_SERVER_PORT=8000 +MONITORING_PORT=8001 +LOG_DIR="$PROJECT_ROOT/logs" + +# 시스템 감지 +detect_system() { + if [ "$(uname -m)" = "aarch64" ] && uname -a | grep -q "tegra"; then + SYSTEM_TYPE="jetson" + log_info "🚀 Jetson Xavier (ARM64) 모드로 시작합니다" + + # Jetson 전용 설정 + WORKERS=1 + MAX_WORKERS=4 + MIN_WORKERS=1 + VRAM_THRESHOLD_HIGH=0.7 + VRAM_THRESHOLD_LOW=0.3 + VRAM_CHECK_INTERVAL=20 + MAX_FILE_SIZE=25 + + elif [ "$(uname -m)" = "x86_64" ]; then + SYSTEM_TYPE="x86" + log_info "💻 x86_64 모드로 시작합니다" + + # x86 전용 설정 + WORKERS=1 + MAX_WORKERS=8 + MIN_WORKERS=2 + VRAM_THRESHOLD_HIGH=0.8 + VRAM_THRESHOLD_LOW=0.4 + VRAM_CHECK_INTERVAL=30 + MAX_FILE_SIZE=50 + + else + log_error "지원되지 않는 시스템 아키텍처: $(uname -m)" + exit 1 + fi +} + +# 옵션 파싱 +PRODUCTION=false +MONITORING_ENABLED=true +WORKERS=1 +GPU_DEVICE=0 +JETSON_OPTIMIZE=false + +while [[ $# -gt 0 ]]; do + case $1 in + -p|--production) + PRODUCTION=true + shift + ;; + --no-monitoring) + MONITORING_ENABLED=false + shift + ;; + -w|--workers) + WORKERS="$2" + shift 2 + ;; + -g|--gpu) + GPU_DEVICE="$2" + shift 2 + ;; + --jetson-optimize) + JETSON_OPTIMIZE=true + shift + ;; + -h|--help) + echo "Usage: $0 [options]" + echo "Options:" + echo " -p, --production 프로덕션 모드로 실행" + echo " --no-monitoring 모니터링 대시보드 비활성화" + echo " -w, --workers NUM 워커 수 설정 (기본값: 시스템별 자동)" + echo " -g, --gpu DEVICE GPU 디바이스 ID (기본값: 0)" + echo " --jetson-optimize Jetson 최적화 설정 (Jetson 전용)" + echo " -h, --help 이 도움말 표시" + exit 0 + ;; + *) + log_error "알 수 없는 옵션: $1" + exit 1 + ;; + esac +done + +# 환경 확인 +check_environment() { + log_info "환경 확인 중..." + + # 프로젝트 디렉토리 확인 + if [ ! -d "$PROJECT_ROOT" ]; then + log_error "프로젝트 디렉토리를 찾을 수 없습니다: $PROJECT_ROOT" + exit 1 + fi + + # 가상환경 확인 + if [ ! -f "$VENV_PATH/bin/activate" ]; then + log_error "가상환경을 찾을 수 없습니다: $VENV_PATH" + exit 1 + fi + + # GPU 확인 (시스템별) + if [ "$SYSTEM_TYPE" = "jetson" ]; then + log_info "Jetson Xavier GPU 확인 중..." + + # Jetson 전용 확인 방법들 + jetson_gpu_checks=0 + + # 1. tegrastats 확인 + if command -v tegrastats &> /dev/null; then + log_info "tegrastats 사용 가능" + jetson_gpu_checks=$((jetson_gpu_checks + 1)) + else + log_warning "tegrastats를 찾을 수 없습니다" + fi + + # 2. nvpmodel 확인 + if command -v nvpmodel &> /dev/null; then + log_info "nvpmodel 사용 가능" + jetson_gpu_checks=$((jetson_gpu_checks + 1)) + else + log_warning "nvpmodel을 찾을 수 없습니다" + fi + + # 3. GPU 디바이스 확인 (Jetson 전용 경로들) + gpu_detected=false + + # Jetson GPU 클래스 확인 + if [ -d "/sys/class/nvidia-gpu" ]; then + log_info "Jetson GPU 클래스 감지됨: /sys/class/nvidia-gpu" + gpu_detected=true + fi + + # Jetson GPU 디바이스 확인 + if [ -d "/sys/devices/platform/*/nvidia-gpu" ]; then + log_info "Jetson GPU 디바이스 감지됨" + gpu_detected=true + fi + + # GV11B GPU 확인 (Jetson Xavier) + if [ -d "/sys/firmware/devicetree/base/gv11b" ]; then + log_info "GV11B GPU (Jetson Xavier) 감지됨" + gpu_detected=true + fi + + # 디버그 GPU 경로 확인 (권한이 있는 경우) + if [ -r "/sys/kernel/debug/gpu" ]; then + log_info "GPU 디버그 경로 접근 가능: /sys/kernel/debug/gpu" + gpu_detected=true + fi + + if [ "$gpu_detected" = true ]; then + log_info "GPU 디바이스 감지됨" + jetson_gpu_checks=$((jetson_gpu_checks + 1)) + else + log_warning "GPU 디바이스를 찾을 수 없습니다 (권한 문제일 수 있음)" + log_info "sudo 권한으로 실행하거나 시스템을 재부팅해보세요" + fi + + # 4. CUDA 확인 + if command -v nvcc &> /dev/null; then + log_info "CUDA 컴파일러 사용 가능" + jetson_gpu_checks=$((jetson_gpu_checks + 1)) + else + log_warning "CUDA 컴파일러를 찾을 수 없습니다" + fi + + if [ $jetson_gpu_checks -ge 2 ]; then + log_success "Jetson Xavier GPU 확인 완료 ($jetson_gpu_checks/4 체크 통과)" + + # Jetson 정보 표시 (에러 방지) + if command -v tegrastats &> /dev/null; then + log_info "Jetson 시스템 정보:" + tegrastats 2>/dev/null | head -5 || log_warning "tegrastats 실행 중 오류 발생" + fi + + if command -v nvpmodel &> /dev/null; then + log_info "Jetson 전력 모드:" + nvpmodel -q 2>/dev/null || log_warning "nvpmodel 실행 중 오류 발생" + fi + else + log_warning "Jetson Xavier GPU 확인 부분 실패 ($jetson_gpu_checks/4 체크 통과)" + fi + + else + # x86 시스템용 기존 확인 방법 + if ! command -v nvidia-smi &> /dev/null; then + log_warning "nvidia-smi를 찾을 수 없습니다. GPU 기능이 제한될 수 있습니다." + else + log_info "GPU 정보:" + nvidia-smi --query-gpu=name,memory.total,memory.used --format=csv,noheader + fi + fi + + # 로그 디렉토리 생성 + mkdir -p "$LOG_DIR" + + log_success "환경 확인 완료" +} + +# 가상환경 활성화 +activate_venv() { + log_info "가상환경 활성화 중..." + cd "$PROJECT_ROOT" + source "$VENV_PATH/bin/activate" + + # Python 버전 확인 + PYTHON_VERSION=$(python --version 2>&1) + log_info "Python 버전: $PYTHON_VERSION" + + log_success "가상환경 활성화 완료" +} + +# 포트 확인 +check_ports() { + log_info "포트 사용 상태 확인 중..." + + if lsof -Pi :$MAIN_SERVER_PORT -sTCP:LISTEN -t >/dev/null; then + log_error "메인 서버 포트 $MAIN_SERVER_PORT가 이미 사용 중입니다" + lsof -Pi :$MAIN_SERVER_PORT -sTCP:LISTEN + exit 1 + fi + + if [ "$MONITORING_ENABLED" = true ] && lsof -Pi :$MONITORING_PORT -sTCP:LISTEN -t >/dev/null; then + log_error "모니터링 포트 $MONITORING_PORT가 이미 사용 중입니다" + lsof -Pi :$MONITORING_PORT -sTCP:LISTEN + exit 1 + fi + + log_success "포트 확인 완료" +} + +# 의존성 확인 +check_dependencies() { + log_info "의존성 확인 중..." + + # requirements.txt가 있는지 확인 + if [ -f "$PROJECT_ROOT/requirements.txt" ]; then + log_info "필수 패키지 설치 확인 중..." + + # 이미 설치된 패키지들 확인 + if python -c "import fastapi, uvicorn, torch, cv2, PIL" 2>/dev/null; then + log_success "필수 패키지들이 이미 설치되어 있습니다" + else + log_warning "일부 패키지가 누락되었습니다. 수동으로 설치가 필요할 수 있습니다" + fi + else + log_warning "requirements.txt를 찾을 수 없습니다" + fi +} + +# Jetson 최적화 +setup_jetson_optimization() { + if [ "$SYSTEM_TYPE" != "jetson" ] || [ "$JETSON_OPTIMIZE" != true ]; then + return 0 + fi + + log_info "🚀 Jetson Xavier 최적화 설정 중..." + + # 전력 모드 설정 + if command -v nvpmodel &> /dev/null; then + log_info "전력 모드를 MAXN으로 설정 중..." + sudo nvpmodel -m 0 # MAXN 모드 + sudo nvpmodel -q + else + log_warning "nvpmodel을 찾을 수 없습니다" + fi + + # 팬 제어 설정 + if [ -f "/sys/devices/pwm-fan/target_pwm" ]; then + log_info "팬 제어 활성화 중..." + echo 128 | sudo tee /sys/devices/pwm-fan/target_pwm > /dev/null + fi + + # GPU 클럭 설정 + if [ -f "/sys/kernel/debug/clk/gpcclk/clk_rate" ]; then + log_info "GPU 클럭 최적화 중..." + echo 1200000000 | sudo tee /sys/kernel/debug/clk/gpcclk/clk_rate > /dev/null + fi + + # 메모리 클럭 설정 + if [ -f "/sys/kernel/debug/clk/emc/clk_rate" ]; then + log_info "메모리 클럭 최적화 중..." + echo 1600000000 | sudo tee /sys/kernel/debug/clk/emc/clk_rate > /dev/null + fi + + log_success "Jetson 최적화 설정 완료" +} + +# 환경 변수 설정 +setup_environment() { + log_info "환경 변수 설정 중..." + + # CUDA 설정 + export CUDA_VISIBLE_DEVICES=$GPU_DEVICE + export CUDA_DEVICE_ORDER=PCI_BUS_ID + + # PyTorch 설정 + export TORCH_CUDA_ARCH_LIST="6.0;6.1;7.0;7.5;8.0;8.6" + + # Jetson 전용 설정 + if [ "$SYSTEM_TYPE" = "jetson" ]; then + export CUDA_LAUNCH_BLOCKING=1 + export CUDA_CACHE_DISABLE=0 + export CUDA_CACHE_PATH="/tmp/cuda_cache" + + # Jetson 메모리 최적화 + export PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128 + fi + + # TensorRT 설정 (있는 경우) + if [ -d "/usr/local/tensorrt" ]; then + export LD_LIBRARY_PATH=/usr/local/tensorrt/lib:$LD_LIBRARY_PATH + fi + + # 프로덕션 설정 + if [ "$PRODUCTION" = true ]; then + export PYTHONOPTIMIZE=1 + export PYTHONDONTWRITEBYTECODE=1 + fi + + log_success "환경 변수 설정 완료" +} + +# 서버 시작 +start_servers() { + log_info "서버 시작 중..." + + # 메인 서버 시작 + log_info "메인 인페인팅 서버 시작 (포트: $MAIN_SERVER_PORT, 워커: $WORKERS)..." + + if [ "$PRODUCTION" = true ]; then + # 프로덕션 모드: Gunicorn 사용 + gunicorn app.api.endpoints:app \ + --bind 0.0.0.0:$MAIN_SERVER_PORT \ + --workers $WORKERS \ + --worker-class uvicorn.workers.UvicornWorker \ + --max-requests 1000 \ + --max-requests-jitter 50 \ + --preload \ + --access-logfile "$LOG_DIR/access.log" \ + --error-logfile "$LOG_DIR/error.log" \ + --log-level info \ + --daemon \ + --pid "$LOG_DIR/main_server.pid" + else + # 개발 모드: Uvicorn 사용 + nohup python -m uvicorn app.api.endpoints:app \ + --host 0.0.0.0 \ + --port $MAIN_SERVER_PORT \ + --workers $WORKERS \ + --log-level info \ + --access-log \ + > "$LOG_DIR/main_server.log" 2>&1 & + echo $! > "$LOG_DIR/main_server.pid" + fi + + sleep 3 + + # 메인 서버 상태 확인 + if curl -s "http://localhost:$MAIN_SERVER_PORT/health" > /dev/null; then + log_success "메인 서버 시작 완료 (PID: $(cat $LOG_DIR/main_server.pid))" + else + log_error "메인 서버 시작 실패" + exit 1 + fi + + # 모니터링 서버 시작 (활성화된 경우) + if [ "$MONITORING_ENABLED" = true ]; then + log_info "모니터링 대시보드 시작 (포트: $MONITORING_PORT)..." + + nohup python -m uvicorn app.monitoring.dashboard:monitor_app \ + --host 0.0.0.0 \ + --port $MONITORING_PORT \ + --log-level info \ + > "$LOG_DIR/monitoring.log" 2>&1 & + echo $! > "$LOG_DIR/monitoring.pid" + + sleep 2 + + # 모니터링 서버 상태 확인 + if curl -s "http://localhost:$MONITORING_PORT/" > /dev/null; then + log_success "모니터링 대시보드 시작 완료 (PID: $(cat $LOG_DIR/monitoring.pid))" + log_info "모니터링 URL: http://localhost:$MONITORING_PORT" + else + log_warning "모니터링 대시보드 시작 실패" + fi + fi +} + +# 상태 정보 출력 +print_status() { + echo "" + echo "==========================================" + echo "🚀 인페인팅 서버 시작 완료!" + echo "==========================================" + echo "시스템 타입: $SYSTEM_TYPE" + echo "메인 서버: http://localhost:$MAIN_SERVER_PORT" + echo "API 문서: http://localhost:$MAIN_SERVER_PORT/docs" + echo "헬스 체크: http://localhost:$MAIN_SERVER_PORT/health" + + if [ "$MONITORING_ENABLED" = true ]; then + echo "모니터링: http://localhost:$MONITORING_PORT" + fi + + echo "" + echo "로그 디렉토리: $LOG_DIR" + echo "GPU 디바이스: $GPU_DEVICE" + echo "워커 수: $WORKERS" + echo "프로덕션 모드: $PRODUCTION" + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + echo "" + echo "🚀 Jetson Xavier 최적화:" + echo " - 전력 모드: MAXN" + echo " - GPU 클럭: 1200MHz" + echo " - 메모리 클럭: 1600MHz" + echo " - 팬 제어: 활성화" + echo " - VRAM 임계값: 70%/30%" + echo " - 파일 크기 제한: 25MB" + fi + + echo "" + echo "서버 중지: ./scripts/stop_server.sh" + echo "상태 확인: ./scripts/status.sh" + + if [ "$SYSTEM_TYPE" = "jetson" ]; then + echo "Jetson 모니터링: jtop" + echo "전력 모드 확인: nvpmodel -q" + fi + + echo "==========================================" +} + +# 메인 실행 +main() { + log_info "인페인팅 서버 시작 스크립트 실행" + + detect_system + check_environment + activate_venv + check_ports + check_dependencies + setup_jetson_optimization + setup_environment + start_servers + print_status + + log_success "모든 서버가 성공적으로 시작되었습니다!" +} + +# 스크립트 실행 +main "$@" diff --git a/scripts/status.sh b/scripts/status.sh new file mode 100755 index 0000000..5343b2d --- /dev/null +++ b/scripts/status.sh @@ -0,0 +1,465 @@ +#!/bin/bash + +# 인페인팅 서버 상태 확인 스크립트 +# Usage: ./status.sh [options] + +set -e + +# 색상 코드 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# 로그 함수들 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 기본 설정 +PROJECT_ROOT="/home/ckh08045/work/inpaintServer" +LOG_DIR="$PROJECT_ROOT/logs" +MAIN_PORT=8000 +MONITORING_PORT=8001 + +# 옵션 파싱 +DETAILED=false +JSON_OUTPUT=false +WATCH_MODE=false + +while [[ $# -gt 0 ]]; do + case $1 in + -d|--detailed) + DETAILED=true + shift + ;; + -j|--json) + JSON_OUTPUT=true + shift + ;; + -w|--watch) + WATCH_MODE=true + shift + ;; + -h|--help) + echo "Usage: $0 [options]" + echo "Options:" + echo " -d, --detailed 자세한 상태 정보 표시" + echo " -j, --json JSON 형식으로 출력" + echo " -w, --watch 실시간 모니터링 모드" + echo " -h, --help 이 도움말 표시" + exit 0 + ;; + *) + log_error "알 수 없는 옵션: $1" + exit 1 + ;; + esac +done + +# 시스템 감지 +detect_system() { + if [ "$(uname -m)" = "aarch64" ] && uname -a | grep -q "tegra"; then + SYSTEM_TYPE="jetson" + log_info "🚁 Jetson Xavier (ARM64) 모드로 상태 확인합니다" + elif [ "$(uname -m)" = "x86_64" ]; then + SYSTEM_TYPE="x86" + log_info "💻 x86_64 모드로 상태 확인합니다" + else + SYSTEM_TYPE="unknown" + log_warning "알 수 없는 시스템 아키텍처: $(uname -m)" + fi +} + +# GPU 상태 확인 +check_gpu_status() { + if [ "$SYSTEM_TYPE" = "jetson" ]; then + log_info "Jetson Xavier GPU 상태 확인 중..." + + # Jetson 전용 확인 방법들 + jetson_gpu_available=false + + # 1. tegrastats 확인 + if command -v tegrastats &> /dev/null; then + jetson_gpu_available=true + log_info "tegrastats 사용 가능" + + # Jetson 시스템 정보 표시 + log_info "Jetson 시스템 정보:" + tegrastats -1 | head -10 + + else + log_warning "tegrastats를 찾을 수 없습니다" + fi + + # 2. nvpmodel 확인 + if command -v nvpmodel &> /dev/null; then + jetson_gpu_available=true + log_info "nvpmodel 사용 가능" + + # 전력 모드 정보 표시 + log_info "Jetson 전력 모드:" + nvpmodel -q + + else + log_warning "nvpmodel을 찾을 수 없습니다" + fi + + # 3. GPU 디바이스 확인 + if [ -e "/sys/kernel/debug/gpu" ] || [ -d "/dev/nvidia*" ]; then + jetson_gpu_available=true + log_info "GPU 디바이스 감지됨" + + # GPU 메모리 정보 (가능한 경우) + if [ -e "/sys/kernel/debug/gpu/memory" ]; then + log_info "GPU 메모리 정보:" + cat /sys/kernel/debug/gpu/memory | head -5 + fi + + else + log_warning "GPU 디바이스를 찾을 수 없습니다" + fi + + # 4. 온도 정보 확인 + if [ -d "/sys/devices/virtual/thermal" ]; then + log_info "Jetson 온도 정보:" + for zone in /sys/devices/virtual/thermal/thermal_zone*; do + if [ -f "$zone/temp" ]; then + temp=$(cat "$zone/temp") + temp_c=$((temp / 1000)) + zone_name=$(basename "$zone") + echo " $zone_name: ${temp_c}°C" + fi + done + fi + + if [ "$jetson_gpu_available" = true ]; then + log_success "Jetson Xavier GPU 상태 확인 완료" + else + log_warning "Jetson Xavier GPU 상태 확인 실패" + fi + + return 0 + + elif ! command -v nvidia-smi &> /dev/null; then + log_warning "nvidia-smi를 찾을 수 없습니다" + return 1 + fi + + # x86 시스템용 기존 확인 방법 + local gpu_info=$(nvidia-smi --query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu --format=csv,noheader,nounits 2>/dev/null) + + if [ -z "$gpu_info" ]; then + log_error "GPU 정보를 가져올 수 없습니다" + return 1 + fi + + if [ "$JSON_OUTPUT" = true ]; then + echo -n "\"gpu\": {" + echo -n "\"available\": true," + + IFS=',' read -r name total used free util <<< "$gpu_info" + echo -n "\"name\": \"$(echo $name | xargs)\"," + echo -n "\"memory_total\": $(echo $total | xargs)," + echo -n "\"memory_used\": $(echo $used | xargs)," + echo -n "\"memory_free\": $(echo $free | xargs)," + echo -n "\"utilization\": $(echo $util | xargs)" + echo -n "}" + else + echo -e "${CYAN}GPU 상태:${NC}" + IFS=',' read -r name total used free util <<< "$gpu_info" + printf " GPU: %s\n" "$(echo $name | xargs)" + printf " 메모리: %s/%s MB 사용 (%.1f%%)\n" "$(echo $used | xargs)" "$(echo $total | xargs)" $(echo "scale=1; $(echo $used | xargs) * 100 / $(echo $total | xargs)" | bc -l 2>/dev/null || echo "0") + printf " 사용률: %s%%\n" "$(echo $util | xargs)" + fi +} + +# 시스템 리소스 확인 +check_system_resources() { + local memory_info=$(free -m | awk 'NR==2{printf "%.1f/%.1f MB (%.1f%%)", $3,$2,$3*100/$2}') + local cpu_usage=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | sed 's/%us,//') + local disk_usage=$(df -h / | awk 'NR==2{print $5}') + local load_avg=$(uptime | awk -F'load average:' '{print $2}' | xargs) + + if [ "$JSON_OUTPUT" = true ]; then + echo -n "\"system\": {" + echo -n "\"memory\": \"$memory_info\"," + echo -n "\"cpu_usage\": \"$cpu_usage\"," + echo -n "\"disk_usage\": \"$disk_usage\"," + echo -n "\"load_average\": \"$load_avg\"" + echo -n "}" + else + echo -e "${CYAN}시스템 리소스:${NC}" + printf " 메모리: %s\n" "$memory_info" + printf " CPU 사용률: %s%%\n" "$cpu_usage" + printf " 디스크 사용률: %s\n" "$disk_usage" + printf " 로드 평균: %s\n" "$load_avg" + fi +} + +# API 엔드포인트 상태 확인 +check_api_endpoints() { + if [ "$JSON_OUTPUT" = true ]; then + echo -n "\"api_endpoints\": {" + else + echo -e "${CYAN}API 엔드포인트 상태:${NC}" + fi + + local endpoints=( + "health:http://localhost:$MAIN_PORT/health" + "status:http://localhost:$MAIN_PORT/status" + "docs:http://localhost:$MAIN_PORT/docs" + ) + + local first=true + for endpoint_info in "${endpoints[@]}"; do + IFS=':' read -r name url <<< "$endpoint_info" + + local response_code=$(curl -s -o /dev/null -w "%{http_code}" "$url" --connect-timeout 3 2>/dev/null || echo "000") + local status="FAILED" + + if [ "$response_code" = "200" ]; then + status="OK" + elif [ "$response_code" = "000" ]; then + status="UNREACHABLE" + fi + + if [ "$JSON_OUTPUT" = true ]; then + [ "$first" = false ] && echo -n "," + echo -n "\"$name\": {\"status\": \"$status\", \"code\": \"$response_code\", \"url\": \"$url\"}" + first=false + else + local status_color + case $status in + "OK") status_color=$GREEN ;; + "UNREACHABLE") status_color=$RED ;; + *) status_color=$YELLOW ;; + esac + printf " %-10s: ${status_color}%-12s${NC} (%s)\n" "$name" "$status" "$response_code" + fi + done + + if [ "$JSON_OUTPUT" = true ]; then + echo -n "}" + fi +} + +# 로그 파일 상태 확인 +check_log_files() { + if [ "$JSON_OUTPUT" = true ]; then + echo -n "\"logs\": {" + else + echo -e "${CYAN}로그 파일 상태:${NC}" + fi + + local log_files=( + "main_server.log" + "monitoring.log" + "error.log" + "access.log" + ) + + local first=true + for log_file in "${log_files[@]}"; do + local log_path="$LOG_DIR/$log_file" + local size="N/A" + local last_modified="N/A" + local exists=false + + if [ -f "$log_path" ]; then + exists=true + size=$(du -h "$log_path" 2>/dev/null | cut -f1) + last_modified=$(stat -c %y "$log_path" 2>/dev/null | cut -d' ' -f1,2 | cut -d'.' -f1) + fi + + if [ "$JSON_OUTPUT" = true ]; then + [ "$first" = false ] && echo -n "," + echo -n "\"$log_file\": {\"exists\": $exists, \"size\": \"$size\", \"last_modified\": \"$last_modified\"}" + first=false + else + if [ "$exists" = true ]; then + printf " %-20s: ${GREEN}EXISTS${NC} (크기: %s, 수정: %s)\n" "$log_file" "$size" "$last_modified" + else + printf " %-20s: ${RED}NOT FOUND${NC}\n" "$log_file" + fi + fi + done + + if [ "$JSON_OUTPUT" = true ]; then + echo -n "}" + fi +} + +# 프로세스 트리 표시 (상세 모드) +show_process_tree() { + if [ "$DETAILED" = true ] && [ "$JSON_OUTPUT" = false ]; then + echo -e "${CYAN}프로세스 트리:${NC}" + + local main_pid=$(lsof -ti:$MAIN_PORT 2>/dev/null | head -1) + local monitoring_pid=$(lsof -ti:$MONITORING_PORT 2>/dev/null | head -1) + + if [ -n "$main_pid" ]; then + echo " 메인 서버 프로세스:" + pstree -p "$main_pid" 2>/dev/null | sed 's/^/ /' || ps -f --pid "$main_pid" + fi + + if [ -n "$monitoring_pid" ]; then + echo " 모니터링 서버 프로세스:" + pstree -p "$monitoring_pid" 2>/dev/null | sed 's/^/ /' || ps -f --pid "$monitoring_pid" + fi + fi +} + +# 네트워크 연결 상태 확인 (상세 모드) +check_network_connections() { + if [ "$DETAILED" = true ]; then + if [ "$JSON_OUTPUT" = true ]; then + echo -n "\"network_connections\": [" + else + echo -e "${CYAN}네트워크 연결:${NC}" + fi + + local connections=$(netstat -tulpn 2>/dev/null | grep -E ":($MAIN_PORT|$MONITORING_PORT) " || true) + + if [ "$JSON_OUTPUT" = true ]; then + local first=true + while IFS= read -r line; do + if [ -n "$line" ]; then + [ "$first" = false ] && echo -n "," + echo -n "\"$line\"" + first=false + fi + done <<< "$connections" + echo -n "]" + else + if [ -n "$connections" ]; then + echo "$connections" | sed 's/^/ /' + else + echo " 연결된 포트가 없습니다" + fi + fi + fi +} + +# 실시간 모니터링 +watch_status() { + while true; do + clear + echo "==========================================" + echo "🔍 인페인팅 서버 실시간 모니터링" + echo "==========================================" + echo "업데이트 시간: $(date)" + echo "" + + show_status + + echo "" + echo "Ctrl+C로 종료" + sleep 5 + done +} + +# 서버 상태 확인 함수 +check_server_status() { + local port=$1 + local service_name=$2 + + if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then + local pid=$(lsof -Pi :$port -sTCP:LISTEN -t | head -1) + local process_info=$(ps -p $pid -o pid,cmd,etime --no-headers 2>/dev/null || echo "Unknown") + echo -e " ✅ $service_name (포트 $port): 실행 중 (PID: $pid)" + echo -e " 프로세스: $process_info" + else + echo -e " ❌ $service_name (포트 $port): 중지됨" + fi +} + +# 메인 상태 표시 함수 +show_status() { + if [ "$JSON_OUTPUT" = true ]; then + echo "{" + check_server_status $MAIN_PORT "main_server" + echo "," + check_server_status $MONITORING_PORT "monitoring_server" + echo "," + check_gpu_status + echo "," + check_system_resources + echo "," + check_api_endpoints + echo "," + check_log_files + check_network_connections + echo "}" + else + echo "==========================================" + echo "🖼️ 인페인팅 서버 상태" + echo "==========================================" + echo "" + + echo -e "${CYAN}서비스 상태:${NC}" + check_server_status $MAIN_PORT "메인 서버" + check_server_status $MONITORING_PORT "모니터링 서버" + echo "" + + check_gpu_status + echo "" + + check_system_resources + echo "" + + check_api_endpoints + echo "" + + check_log_files + echo "" + + show_process_tree + check_network_connections + + echo "==========================================" + echo "💡 유용한 명령어:" + echo " 서버 시작: ./scripts/start_server.sh" + echo " 서버 중지: ./scripts/stop_server.sh" + echo " 실시간 모니터링: ./scripts/status.sh -w" + echo " 상세 정보: ./scripts/status.sh -d" + if lsof -Pi :$MONITORING_PORT -sTCP:LISTEN -t >/dev/null 2>&1; then + echo " 웹 모니터링: http://localhost:$MONITORING_PORT" + fi + echo "==========================================" + fi +} + +# 메인 실행 +main() { + # 로그 디렉토리 확인 + if [ ! -d "$LOG_DIR" ]; then + mkdir -p "$LOG_DIR" + fi + + # 시스템 감지 + detect_system + + if [ "$WATCH_MODE" = true ]; then + watch_status + else + show_status + fi +} + +# 스크립트 실행 +main "$@" diff --git a/scripts/stop_server.sh b/scripts/stop_server.sh new file mode 100755 index 0000000..f19b79f --- /dev/null +++ b/scripts/stop_server.sh @@ -0,0 +1,323 @@ +#!/bin/bash + +# 인페인팅 서버 정지 스크립트 +# Jetson Xavier와 x86 시스템을 모두 지원합니다. + +set -e + +# 색상 코드 +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 "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 기본 설정 +PROJECT_ROOT="/home/ckh08045/work/inpaintServer" +PID_FILE="$PROJECT_ROOT/server.pid" +LOG_FILE="$PROJECT_ROOT/logs/server.log" + +# 시스템 감지 +detect_system() { + if [ "$(uname -m)" = "aarch64" ] && uname -a | grep -q "tegra"; then + SYSTEM_TYPE="jetson" + log_info "🚁 Jetson Xavier (ARM64) 모드로 정지합니다" + elif [ "$(uname -m)" = "x86_64" ]; then + SYSTEM_TYPE="x86" + log_info "🖥️ x86_64 모드로 정지합니다" + else + log_error "지원되지 않는 시스템 아키텍처: $(uname -m)" + exit 1 + fi +} + +# 서버 프로세스 찾기 +find_server_processes() { + local processes=() + + # PID 파일에서 확인 + if [ -f "$PID_FILE" ]; then + local pid=$(cat "$PID_FILE") + if ps -p "$pid" > /dev/null 2>&1; then + processes+=("$pid") + fi + fi + + # 포트 8000에서 실행 중인 프로세스 찾기 + local port_processes=$(lsof -ti:8000 2>/dev/null || echo "") + if [ -n "$port_processes" ]; then + for pid in $port_processes; do + if [[ ! " ${processes[@]} " =~ " ${pid} " ]]; then + processes+=("$pid") + fi + done + fi + + # 포트 8001에서 실행 중인 프로세스 찾기 (모니터링) + local monitor_processes=$(lsof -ti:8001 2>/dev/null || echo "") + if [ -n "$monitor_processes" ]; then + for pid in $monitor_processes; do + if [[ ! " ${processes[@]} " =~ " ${pid} " ]]; then + processes+=("$pid") + fi + done + fi + + # Python 프로세스 중 인페인팅 서버 관련 찾기 + local python_processes=$(ps aux | grep -E "(uvicorn|python.*main\.py|python.*dashboard\.py)" | grep -v grep | awk '{print $2}' || echo "") + if [ -n "$python_processes" ]; then + for pid in $python_processes; do + if [[ ! " ${processes[@]} " =~ " ${pid} " ]]; then + processes+=("$pid") + fi + done + fi + + echo "${processes[@]}" +} + +# 서버 정지 +stop_server() { + log_info "서버 정지 중..." + + local processes=($(find_server_processes)) + + if [ ${#processes[@]} -eq 0 ]; then + log_warning "실행 중인 서버 프로세스를 찾을 수 없습니다" + return 0 + fi + + log_info "발견된 프로세스: ${processes[*]}" + + # 각 프로세스에 SIGTERM 전송 + for pid in "${processes[@]}"; do + if ps -p "$pid" > /dev/null 2>&1; then + log_info "프로세스 $pid에 종료 신호 전송 중..." + kill -TERM "$pid" 2>/dev/null || true + + # 5초 대기 후 강제 종료 + sleep 5 + if ps -p "$pid" > /dev/null 2>&1; then + log_warning "프로세스 $pid 강제 종료 중..." + kill -KILL "$pid" 2>/dev/null || true + fi + fi + done + + # PID 파일 정리 + if [ -f "$PID_FILE" ]; then + rm -f "$PID_FILE" + log_info "PID 파일 정리됨" + fi + + # 포트 사용 상태 확인 + sleep 2 + if lsof -ti:8000 > /dev/null 2>&1; then + log_warning "포트 8000이 여전히 사용 중입니다" + else + log_success "포트 8000 해제됨" + fi + + if lsof -ti:8001 > /dev/null 2>&1; then + log_warning "포트 8001이 여전히 사용 중입니다" + else + log_success "포트 8001 해제됨" + fi + + log_success "서버 정지 완료" +} + +# 강제 정지 +force_stop() { + log_warning "강제 정지 모드로 실행 중..." + + # 모든 관련 프로세스 강제 종료 + local processes=($(find_server_processes)) + + for pid in "${processes[@]}"; do + if ps -p "$pid" > /dev/null 2>&1; then + log_info "프로세스 $pid 강제 종료 중..." + kill -KILL "$pid" 2>/dev/null || true + fi + done + + # 포트 사용 프로세스 강제 종료 + local port_8000=$(lsof -ti:8000 2>/dev/null || echo "") + if [ -n "$port_8000" ]; then + log_info "포트 8000 사용 프로세스 강제 종료 중..." + kill -KILL $port_8000 2>/dev/null || true + fi + + local port_8001=$(lsof -ti:8001 2>/dev/null || echo "") + if [ -n "$port_8001" ]; then + log_info "포트 8001 사용 프로세스 강제 종료 중..." + kill -KILL $port_8001 2>/dev/null || true + fi + + # PID 파일 정리 + rm -f "$PID_FILE" + + log_success "강제 정지 완료" +} + +# 서버 상태 확인 +check_status() { + log_info "서버 상태 확인 중..." + + local processes=($(find_server_processes)) + + if [ ${#processes[@]} -eq 0 ]; then + log_info "실행 중인 서버가 없습니다" + return 0 + fi + + log_info "실행 중인 서버 프로세스:" + for pid in "${processes[@]}"; do + if ps -p "$pid" > /dev/null 2>&1; then + local cmd=$(ps -p "$pid" -o cmd= 2>/dev/null || echo "Unknown") + local mem=$(ps -p "$pid" -o rss= 2>/dev/null || echo "Unknown") + log_info "PID $pid: $cmd (메모리: ${mem}KB)" + fi + done + + # 포트 상태 확인 + if lsof -ti:8000 > /dev/null 2>&1; then + log_info "포트 8000: 사용 중 (메인 서버)" + else + log_info "포트 8000: 사용 안함" + fi + + if lsof -ti:8001 > /dev/null 2>&1; then + log_info "포트 8001: 사용 중 (모니터링 대시보드)" + else + log_info "포트 8001: 사용 안함" + fi +} + +# 로그 확인 +show_logs() { + if [ -f "$LOG_FILE" ]; then + log_info "최근 서버 로그 (마지막 50줄):" + echo "----------------------------------------" + tail -n 50 "$LOG_FILE" 2>/dev/null || echo "로그 파일을 읽을 수 없습니다" + echo "----------------------------------------" + else + log_warning "로그 파일을 찾을 수 없습니다: $LOG_FILE" + fi +} + +# 정리 작업 +cleanup() { + log_info "정리 작업 중..." + + # 임시 파일들 정리 + find /tmp -name "inpaint_*" -type f -mtime +1 -delete 2>/dev/null || true + + # 로그 파일 압축 (7일 이상 된 것) + if [ -d "$PROJECT_ROOT/logs" ]; then + find "$PROJECT_ROOT/logs" -name "*.log" -mtime +7 -exec gzip {} \; 2>/dev/null || true + fi + + log_success "정리 작업 완료" +} + +# 메인 함수 +main() { + local action="stop" + local force=false + local status=false + local logs=false + local cleanup_flag=false + + # 옵션 파싱 + while [[ $# -gt 0 ]]; do + case $1 in + -f|--force) + force=true + shift + ;; + -s|--status) + status=true + shift + ;; + -l|--logs) + logs=true + shift + ;; + -c|--cleanup) + cleanup_flag=true + shift + ;; + -h|--help) + echo "Usage: $0 [options]" + echo "Options:" + echo " -f, --force 강제 정지" + echo " -s, --status 서버 상태 확인" + echo " -l, --logs 로그 표시" + echo " -c, --cleanup 정리 작업 수행" + echo " -h, --help 이 도움말 표시" + exit 0 + ;; + *) + log_error "알 수 없는 옵션: $1" + exit 1 + ;; + esac + done + + # 시스템 감지 + detect_system + + # 디렉토리 이동 + cd "$PROJECT_ROOT" + + # 로그 디렉토리 생성 + mkdir -p "$PROJECT_ROOT/logs" + + # 상태 확인 + if [ "$status" = true ]; then + check_status + exit 0 + fi + + # 로그 표시 + if [ "$logs" = true ]; then + show_logs + exit 0 + fi + + # 정리 작업 + if [ "$cleanup_flag" = true ]; then + cleanup + exit 0 + fi + + # 서버 정지 + if [ "$force" = true ]; then + force_stop + else + stop_server + fi + + log_success "🚀 인페인팅 서버 정지 완료!" +} + +# 스크립트 실행 +main "$@" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9e81d6c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,204 @@ +# 🧪 API 테스트 가이드 + +이 디렉토리는 인페인팅 서버의 API 엔드포인트들을 테스트하기 위한 도구들을 포함합니다. + +## 📁 디렉토리 구조 + +``` +tests/ +├── images/ # 테스트용 샘플 이미지들 +├── results/ # 테스트 결과 이미지들 +├── scripts/ # 테스트 스크립트들 +└── README.md # 이 파일 +``` + +## 🖼️ 테스트 이미지 + +### 생성된 테스트 이미지들 + +- **기본 테스트 이미지** + - `test_image_256.png` / `test_mask_256.png` (256x256) + - `test_image_512.png` / `test_mask_512.png` (512x512) + - `test_image_1024.png` / `large_mask_1024.png` (1024x1024) + +- **복잡한 테스트 이미지** + - `complex_image_512.png` / `complex_mask_512.png` (512x512) + - 체크무늬 패턴과 여러 도형들이 포함된 복잡한 이미지 + +### 테스트 이미지 생성 + +```bash +cd tests/scripts +python generate_test_images.py +``` + +## 🚀 API 테스트 + +### 전체 테스트 실행 + +```bash +cd tests/scripts +python test_api.py +``` + +### 단일 테스트 실행 + +```bash +# 헬스 체크만 +python test_api.py --single health + +# 서버 설정 정보만 +python test_api.py --single config + +# 인페인팅 테스트만 +python test_api.py --single inpaint + +# 배경 제거 테스트만 +python test_api.py --single rembg + +# 플러그인 테스트만 +python test_api.py --single plugin +``` + +### 다른 서버 URL로 테스트 + +```bash +python test_api.py --url http://192.168.1.100:8000 +``` + +## 📊 테스트 항목 + +### 1. 헬스 체크 (`/health`) +- 서버 상태 확인 +- 가동 시간 확인 + +### 2. 서버 설정 정보 (`/api/v1/server-config`) +- 시스템 정보 (Jetson Xavier / x86_64) +- 사용 가능한 모델 목록 +- 최대 파일 크기 제한 + +### 3. 샘플러 목록 (`/api/v1/samplers`) +- Stable Diffusion 샘플러 목록 확인 + +### 4. 인페인팅 API (`/api/v1/inpaint`) +- Simple LAMA 모델을 사용한 인페인팅 +- 다양한 크기의 이미지 테스트 +- 프롬프트, 시드, 설정값 테스트 + +### 5. 배경 제거 API (`/api/v1/remove_bg`) +- REMBG 모델을 사용한 배경 제거 +- 결과 이미지와 마스크 반환 + +### 6. 플러그인 API (`/api/v1/run_plugin_gen_image`) +- 플러그인을 통한 이미지 생성 +- REMBG 플러그인 테스트 + +## 🔧 테스트 전 준비사항 + +### 1. 서버 실행 +```bash +# 개발 모드로 실행 +python main.py --dev + +# 또는 프로덕션 모드로 실행 +python main.py --host 0.0.0.0 --port 8000 --workers 1 +``` + +### 2. 의존성 설치 +```bash +pip install -r requirements.txt +``` + +### 3. 테스트 이미지 생성 +```bash +cd tests/scripts +python generate_test_images.py +``` + +## 📈 테스트 결과 + +### 성공적인 테스트 결과 +``` +🧪 전체 API 테스트 시작 +================================================== +🏥 헬스 체크 테스트... + ✅ 서버 상태: healthy + 📊 가동 시간: 15.23초 + +⚙️ 서버 설정 정보 테스트... + ✅ 서버 설정 조회 성공 + 🖥️ 시스템: Jetson Xavier + 🎮 디바이스: cuda + 📁 최대 파일 크기: 25MB + 🎯 사용 가능한 모델: 3개 + +🎨 인페인팅 테스트 (test_image_512.png + test_mask_512.png)... + ✅ 인페인팅 성공! + ⏱️ 처리 시간: 2.45초 + 🎲 사용된 시드: 42 + 💾 결과 이미지 저장: inpaint_result_test_image_512.png + +================================================== +📊 테스트 결과 요약 +================================================== +헬스 체크 ✅ PASS +서버 설정 정보 ✅ PASS +샘플러 목록 ✅ PASS +인페인팅 (256x256) ✅ PASS +인페인팅 (512x512) ✅ PASS +배경 제거 ✅ PASS +플러그인 이미지 생성 ✅ PASS +================================================== +전체: 7개, 성공: 7개, 실패: 0개 +🎉 모든 테스트가 성공했습니다! +``` + +### 실패한 테스트 결과 +``` +❌ 인페인팅 실패: 500 +📝 에러 메시지: 워커 매니저가 초기화되지 않았습니다 +``` + +## 🐛 문제 해결 + +### 1. 연결 실패 +- 서버가 실행 중인지 확인 +- 포트 번호가 올바른지 확인 +- 방화벽 설정 확인 + +### 2. 이미지 처리 실패 +- GPU 메모리 부족 확인 +- 모델 파일이 올바른 위치에 있는지 확인 +- 로그 파일에서 상세 에러 확인 + +### 3. 타임아웃 에러 +- `timeout` 값을 늘려서 재시도 +- 서버 성능 확인 +- 네트워크 상태 확인 + +## 📝 로그 확인 + +### 서버 로그 +```bash +tail -f logs/main_server.log +``` + +### 모니터링 로그 +```bash +tail -f logs/monitoring.log +``` + +## 🔄 지속적 테스트 + +### 자동 테스트 스크립트 +```bash +#!/bin/bash +while true; do + echo "🔄 테스트 실행 중... $(date)" + python test_api.py + echo "⏳ 60초 대기 중..." + sleep 60 +done +``` + +이 스크립트를 사용하여 서버의 안정성을 지속적으로 모니터링할 수 있습니다. diff --git a/tests/images/complex_image_512.png b/tests/images/complex_image_512.png new file mode 100644 index 0000000000000000000000000000000000000000..04f525043949594d19b0db719a496577ff10da1c GIT binary patch literal 3120 zcmeHKX;4#F6h2vu1PxmkYd}#7!<0JM))Y{{ASz2EpjImkA{jMhD2kCq3;_~cK&%Eu zN~#o)+JSMZ5V0+2l*Elx0g0l+5-?Ao4IKgj116A=+n1@+vC|*z510PvpL^bY^X_@y zd1ub|eXn3`WGKy+ZVLdk@UT_u0f_h|5p1l1PhWW+V3r|#)rt*!zZve0OHqZ;gVv>Z z@r&Lj*M1W1@Wb~!Ww9H7NA$OyH`@X=@15O1kKcZ2mv3}RO*7V<%6hLQpZls+S+)#GJWPLSA`(4Kt!$IngS+YaVK`E+K6?3OINGtRudP zkX}}mI7kYqWwz;GCyKZd&LHVm-u%lJM#NtmBXj<v^}YfU>?z@BqcsCWZ9^X65w zQbBvreUwcEXGzD!nMCk+n;lT(O5Pv({qU2;Nz4_K?aa)H8~Y1AK(bh4yoNoDK!xSy z-kYWA+mp`Qm{M>-TNF%|(t`<2RQRpnYHqU5`0?biVS1Ne; znH2fcjCKU!bT`k(M?I}>uABQ08XGY!T$# z>2E~pd4?MgrAWr{nVeD|cZQydj*m@gJ!v#I;JQCrlzoDdwFBO06r-&!1cj|-0Tkyk;x5m)+_141yj(t8v zsK2N$U$1dm09;C8)V&xcCCm{Tj`yR2)!qamcs_QLZ=yoa^)WPVE5)tf%^Ez1hr2 zD_T+V%Y;9gqQxWe35(Cf9GQF(2nOHzp-7%-x2x+H-JROhBOq&+Oa3@%6) zMtT|!r?;xUOO)GD!JOBRZ0HhSA}lP;k09+?4cMkqU6H1WU|I;uy8J7N@<@A?R+TTgEwxOHy}~6Bs)p_cYm|nHP3a;mu*sbh>0AL5RBA%A?N^Vjba7_r}pJ z@#4S)tk@Os?myCg;oH)kN{YnapOoLFdb|-kr$AqVoFg`|dvfU9zFy=84)rSI@MACT zh;<`T%{$gyHP>%?MBoT)M9s>g*h6IU=R>g&9StAU#{Rmq)>QIRji#Ez>&-m8nE(DY!;-bCDYXr(6QP7B&8*6(r>8Id zCP*u*x)8tb-cXyhWrcdCdZLb(*&djaanxj4s*Kfzo~m+XqG4VR5^={Tvu)NK?{)Pi zLe@ZQ^kF#GZ?xY<24ev$C?Ax4Mtgh)j9;;W4uAu`aizkjvo~Qzj+1d#2|+-9cu#O_ z`?%RKfdmh7Ynhzn7pVp5|B7i}z6L%>KUGy}|IXiq_&L6R6)0j#D(%14@CK)Fri)WQ z)8H0a^0@hhe)fOT^A4N^mb1sF^a1v-69p#*M^Ub+9ExgZq-8%dM@!J<^WmaV{7(SFS4Xa@31;v69q>gENdN!< literal 0 HcmV?d00001 diff --git a/tests/images/complex_mask_512.png b/tests/images/complex_mask_512.png new file mode 100644 index 0000000000000000000000000000000000000000..3a3aefdd90e4f52f1528f2273e8df3d8dc9088db GIT binary patch literal 1154 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w3=!$sk4H3kM2Pfr)ekcv5P@7yk%Z6M%q z(KGzt|LJ$zm*wgmb2E_gOnYChywxgO;fW9avXK>gsn^=nI4-SbXXoZ>_k{0q5X93=!9TIUa1-Wl^ z2K{y3oDUi!*&IslbTRm?;8}7#is^>gMTU;N-GT=smv(-M^kJC&Op4)RrZbbpQnN{N zt2q-+moO{rdgICP{LBnbVa6N}yPHemf1iHu%b>p2j)|q<{+j2r_USXu+4|XIQ{7kV z-3*L(OkXo}d|Au+U`uE6O-F|BKDEns&K5pU`P(n$(tZ=Y2J2;V3@2|3J2XvoZ(yjK zRm1QkFMvT(OpcN1N8&HW3Azs*)SV&>2?4C2*iV^||?FUk8Sn-_%S3fwnF$YB(RrvBmV32>czh(Af21b=^j?6Bg^zjMh7RDk94LQ1+z5xvkN=bLT?|cL*j}7)Y z&h3%q&LHT#zuUNqjYAYY!0%ovoE9=*M|ZBxAD42HdPyt1=-WU4ZM zTsW$JpojTE|Mrgj+pjXAbUMs L{an^LB{Ts54(o`g literal 0 HcmV?d00001 diff --git a/tests/images/large_mask_1024.png b/tests/images/large_mask_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..370b13db88a8494cd0f5dc70fd77035eaf1a3118 GIT binary patch literal 3916 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 F{|jw$jNt$P literal 0 HcmV?d00001 diff --git a/tests/images/test_image_1024.png b/tests/images/test_image_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..f57cc3bcfd1b498414f2fbc7a8e2bc38022e65ef GIT binary patch literal 9064 zcmeHMX;f3^*4`(H5EL3js|b{UIM#t!EkZRUiY*QlQ54~-5EKUlH8_AWn1d+TtAbZ7 zVnuL9MI|i-)G(ZuDs2T(5g8%^wL(P#C?;THa_$a>R_(ptx_`g5`~zpbXP-U2dwO=h z^73>y>u=Q`0LC=YS$#>R{MwSZ`TRtKRda5v$*HS z3(uaqXxf}iV=UET|E@Ndw(Y}LZd0|GOk3E7BugzKSIP_SmCzyFK`aw9_lH zlZ}7a!`~micgXu-c%KOGE5m=Qigx!DT2<>n7$aVw1i1J}qLE~112(CQdRA#_YfC|N zm<809`r6DQf4ZO{?4ID-DeacS$siq0fLBL#OswpAh5A^6PCHGW6fxZdG$ueUmu1nX zBX`XeD2iP>BX(467AVzN6ZV z!CT3I#x3m~`94V$&$NW2j=?v1OZ$%|Qil>CwTPk*)z@`!x$PkhS;tr+H8S~^?H?15 zl$CXBm`Iv`bO4f$CqR5Vu0H1A$SY?A&aPt!Qj3HxoUQrpDZ$w++qF}xJQ=)x44B<5 z?v8Z6-mP|fR1&ASQ8x$rn}FS+LP2f0w&_{ZiEuZ?2#}2ia(vR3!@_M9UuysLY=#NA zu;9RNciQh8yIaj`YzmK2+QCvJ+MUy#bIn?F>QRY<;u`YGa|hU~^o=+l_i6oYu5}Pd zy%^y8O{r|DP3VnOqJ%fS?(!wHB8~-88y`vD?`mC-9g{p`4wS|8JIkv?Pj8rRySUt& z&!&#BL3E={+xBp)_N!-2&s6ou{=Nf{ev#kF_RaV0I)gOfsCel>picAamrGZ#E|O>11_3XS z0jXoM1UXKtDR0HnsyMJ20pu^+&&p<(&y3C#O#T@}D_Fp@_mK<mE!`Nj3LbEJE zQ&Ck0ddKUBsURfef_*nkp=RW0pgj2v*_UmJvyWSKuXv^vc^iE=jMM0xL|<)_VY-#jmAY`30Bkm0I3|}lc5y=w0OaqXwQYLs0v!)h+l#g0W_)c3AY10qnk62Li z&!I9$t0s@4+5nsQrk5&Z>kRS?dLiCV8yJVKJF(mk+1eo6qc0Xlgbj1*u-2Yw9Y{V# z(Kl*zfl*UHIu_qri_teMG)_o8@zn+{ykWHM{BpF5XN~mUQfsF!Y>*(-23H$li zx-XP$;Uof{gwhATU<#cHxPwn~n8Hs8xcac|Ffu-LwV>FK&+u{s;TXB#IDz$qoRudL zohIxK0O^cR0y79^LD2RrjRO@MH}UDl#YxbXi1T2cq;ZTDY+}RgK#g%?CSmu}=T%Ht z+AY^T#_QRRS5Y25M`=e5aUn1lwaiTAW1Sq|Ot1iH5HjXl!4MCiR@CZZb8drl5d%`5 zL}>3KMXl_6`Z7jNSMLZs5TrtXntAj$b4YOq>TZoLw)`$gc?=kGUBwBFGa^SKiA{0G zj&P22!r>PUXjW)E=D$?Sl%FL;!&opLFQUfG33h@Mzwt$PaL}QwxN`R@AhW4F8ni*7%0MS2D z)F*2p)eZ-Dn1SySt7+<>GmAVw5O@2~mk_HNB9}=(HtZ&a)Caqu#kKmN(<3m z3|oXO1HtRYQ;FRXH#lbi3Z#|fX~ub5;&{y(b;AXBaux&n*XW2ZtRQ?oejzj`K-R1j zP&+;#M5QcfLz^0B;>$UGFT1*RPtb9rS}V03RSv@b1_RsnRMSwpnGG^~5Dg8bAq(S( zX}~EppJmg8Pm4L^eQ97LS0?GsBb(LD-db*z19h;^|9jDH-y}DvL8r*kDb54TU}>N3 zmpawl#x@%U<<#eKw(oXu+KY#wP~@ac9f$Lpp&T*OY;>-F9+ zxmcp*l4HdK*!S%WblzPk=ra@){Fb1~m-A^~48MYkQnduH(Pbdg>x~+VM)AHxqqZ7+ z--{7Ni`!}NII>izpJ{p}^#+I3qFVxID<;_a?#9zUVWo4eTzd*Dqx4{sDnHAeJU{3) z^Vf=w7Nd>+Xyc=|GtpPFlruMxCH^xK*NT<6p_47?H z^&9H+I-sD1Rwrb+gJ_)V13EjBmD(4{?^3t)Ff2v2(Z;3@Z@TVXcBXLx2HP%9z-Ali z$rk(DTx997uUbYdyN-fi_l!xUyS$A?g+e?xo_TaZv?(^i~nIcuxt?@ zgu7f{R7t|D>#T~y=#I~x6{~Lt6sQhB*`7f_UYb_5qL8l6Xq{@?b3NvdM*;5I-6`wDX(a_=$qot8Qn4DbQw&VY!r z`Ao1oun~tckusxM)}1u*)m71G-9)g!-kF;9sNre;iT#r0Ju(|Mj2Hy7qVfZl@fjC4 zUXOTL8bn&y-M_A_Zyk7iAK29y%k|MUSLNT*L5CDdN@9z%iJxD1YD7x5*B1ameh#+a zYeB$)*-X&udE!+8kMuD3mhAU@^eYe##urjqn5VOJRVUM^OzfwI0aV}h-1S$(f)40y%QQpk8S3OYt2I}?&Pt^%ou4T z>)Z%D{$GKisK+Zx7RiF}-xaD8=($w<_Ro^=AA+f`1Y5ou1ElE=Hkj%?cG7VCu3YC* zE{i1%)v@g?|DNT&`-^Q!9VC(R>rhJiR!RKJPD-=vfzBHTL~B(>c$kvSv;SN8lX1~T zARXZfB=heHn1E%3`lJV7;@{okoK^wQe9IWbW8Tb1@o7%JzHC$9=;~cDygUy5%_)t} zu1rXE-~#nl;iF6*Fc_&ryCsd=-qf-&Xahl%?bZmyZx`Z&N#9Fwn4?T|RjxP^l#y@B z@%?(K5WJgpoQrQ2!L*%tT=_d5dm^>Ib7wv>2hq9$U4plv9T<*PcT^SFY@J&&6j;}( zvc9YP=mP@IVsG<}drMq0^38of$6{%I-92KE?gBQJ5JMxNjznM7p27*lnO@7qNoKW& zL4n>NOw+3?L%q`eWnk+>*#or!8T{El%dweC*Zb2YU-ZdX>H1KIjgnfSEsQC^PR=&m zL+C*_AlGB#cj`0Vx*4s^K`YDqI(aj~&YHp5bRc4w-guFIn6Xx(AF))r`>qSHlOHf> zl6M_7@k32;rV#C&0#sxuEya-Pjli0T2>_Xom4eW(`^ejq71>yD%Wee7M`OX=-RFRb z&MffVT}|spobXvpu&GdSaPsMmda^!@jRgbMS<|PEcg8vi=A~$p?yf5i7C4tlL##j~ zkPF)S(yTvnw8WtRG4=6y3}UUDiNOPhUT)FghzYB74_ue?_0X`l76?ZsfN)eceFDM3 z3M-hsQj_oxoDGA)Ex)6pvK{joDfs|5o zd&d&AxKpOW37_;H1iU3yaBh_*;Rq_Z5+7G+5?dcgs#^)Jzhxy%X< z*6Qq2fLwbxw;J!Of5H7%BJ-a|g55DAatfkY9DdDU1|l21TFGL+s7q#ArLGDg{`&2? z%OA%3^ZR*Km_TRxO?eg?xAfUM8D_HRV-|F}T0sh8<~Bs@aZ_MCf-+|zKtj*ls53~x z^bZce^hmYg63hf^>&Dz2pQEjxUDQzvuo0Gq4TjA@h->YE92A{fh;>-p-*KuW-BE0_ zgd~&qn{}?E|A8Nb;Rhe%2U(j2gZFBUu@7!%ysw-r#LE%yIs8qBGIsG*+zs49od056 zmN60kLn|jO9aj+_B{D?)(Jm`oH=WJS9g3O0*3qfqG4rXoAKSN~1{}Mymv9(<$lnhm z_tviqpzR=<;DD3`>3?LaN7r>X4J!a@@kNpwd70491EaE z1w)OITp*pHUy!JjDqP$+s`QJSd7aVLg6|>}#x<4%gzh#aCx2F~W`?HX-eXA2DL%$> z+1<_Mqm@)1%FYW3C5NgPSH)4&eYLUcakbL6^AwgM1l0s;lNg=DpJrOprEUGqMRYKI z{UhVauBv`{*dV5jWHyprSpq5%_alR^T;O9T_4sjedCMf~GEQOZ_L+4qqkWWiUL~!Z zMFT;2CaZ4p`addE8_{rC*B05et4}%i@VqcfURlEX8HRK`3EzqtT13F8m1W@jv0T6# zh>IDMmp)PJEaTUxBQ6-nei?aTf5WvaDyGLyfKl#7Qp=Vl64C@$O0!~Qd>7M_O05h{ z`7Ay3(bNeK)y!lEM7zmE*{zpVT+vBfr7;_V=bM&vE-F47Y3_V$(E5V>=1T%zFz(^Z zhEYMs0<^?xjHAh2^{O78>3mg4mT_#LWqhf8o1lEDMC61V=6ynL(hEI;Lia7Nw6*4@ z|507#IWw`e?%~KO8?~{1csCZyDs4mDBn1HheD*xmT3Mc-I`-utpf)X&+kO;3!$=yI zmYhN$k+0;TZ~1f*l}Mg9rF-HjJ)ca4oaSKoMnV?GnWZ^K&7e$HGn(9(9@ zhCM_W3(Q+@s2Y1Z#l=^{Ey6o~$cx~XEtS|M0^F^yv9;;gG5ucg68-?WQYZ`V%v8fO zoS6o8 z7Y8_-jJuYtC@y_`NkG{nm$@5)yAwIx-Al){H1fLh3UjRH2`CeM@#MkX_IjDujsSXw zg7-^%aLcWr2OY-FAL4ji)R7;h%BzUda6%I1h$CcMqopsH7ZLG?-ngV_FTC_3U3aQ3 zFy;5DiU*VSp5cldkX^I(xOO$27a37}Nt{}HHLEZpWyKTY8Vq#lR>8XBhfy2cigD8} zPBl?4mMOG*i}1U>_}%6D`#U~$ZChVh+%C9ey{5W7RA9?3s5c&iJ-AhSrT(hK`e9U2 z4t;U$NwX`bj%9u0MrRUUp96SQ&zFDXzP=*jMaB}3Ew>%zQD3dNW1NbmlQ)m9(yi%u zd9Ga3(<0X>iq{tVYVtGI;M*gdMH~?B57QXi?Kf2O_dmJ4|Hv0Prv6m zmoY0mh>~>eI#-D0$7xTOX`T3w-`1Uczq*|eYmn3Cwe#uZVEy&&Qc`$9gg46FLloTx9J$4R@JK`2|3@*6K@NZhM-*2JGiji9SGSa z+AmDR%X7DfDRtjKEbj|B6omZF(pcF2_7`SF|L(SKnbw0olH}<5E}mG8B0Y+7(bMW+ zg8@mxIscL;rclN>DEC%1A(r+!nmt*hjH5v;%65Dsq_?C@8Jd97*K45Sb_U>L2y7Fj*%6F zkTdJ8&7#<+J&SOR%zT7QdgNesid}uO2nUReOoY^Nl zWc-C?K4$PD+0}t(a3I3U8opO&16|>sQtX0Q7cxn}#2Cx`2FJMhZ-mUX$&NtSaA+G_bW|Yc4zYkyHdJ3C8+So|h=>(& z_I>C6=}U3v_0C}L;b8AUaj!s9Q(9ZtBKpO`ep3bA{mznZnC|5F1aV`p_mYI2u`T_z zU-`u`G5fhuxz*KYE-GzDlWg$LU9Hp$?AWZh2n-d8u>FxVr^H!rTY+tZB)Z~Y81Am) za`Vgw{^J6-o7ybng+1`dcZX)+%!xUJ%?nLjFGVQI{S`ZX)7&E8Pc7-07`BAC73(7- z8oZTr2>EW`3P?VCB}q)czw}i5NZh(vUhtPW@Ueh3lxuVbiCAuBlvbX_iZ^hl>|6DT z?DVta#-y8g2bt@S}Kqu#8<#BVHQI|LnUwL#KgoJOP~XQm=hZjb`V~zU^EpE%8zZ zSv8Nqt>sFYRys89$EUse^$~#U^g$IylS5h|Mw8y<}?K zql#D0qTHS*Q^obP?fN>xuH7s7GOaJ-+nl1=FDocm0YxWPV6;A_gO|6@1?(K0D!?+I zj)(GTk%#q!T@6cZGOaUTFE_}vIu^Z;TKC!q+-TPdz!@Q7xy+3wloZi8rzV!1=!Q|!>{)2-d#ur= zXbVDax{Nu~$lVX63;DiKq&3*7Tf7m8$B z5ygtDShrxhd(ib&@5hB1kL$17V8gpBLtu_K5f!KYvE%)ir)kHqgRD`&e46bcaKRUf z%T}Ss&Svr`J^P(2J-M;(+1J3`xg2US^P|~K)nFPLv&!^e3N(k4R}le^xoK#uX0i4O zZMo0QgkjLWwtzi6Y0C8tIlZ=H(vQ^Hg!LO&TelI!_XR5gjrJLOOj%oRlWTq~CfW95-xc8N-KAB`-BB) z!R7H!U57q<{Pl^NQ5CD`%T4&2&l-Qt`Fhs?E8#9b@(E_$1$#SFpzdWPGnx-LtXvm; zS>8&ku>!4uR=jLkfLhk|sA6EzeWYAD#`qAd!2$OT7+|y>fbX!t_8R~)%mBO>0;V4W zu;X9jKPUi|aK`d_d9*}>zp`li4E_+$0>X4C`)2ZQk;#HWNz2<92p3U^C zX3#NpaQj9(7T7yj9G8q_98+H;RP>(wUhZ3y;SGrk$mF1D{Maj1n~X$7Na1;=o}hyj zc6Ov&Q5oST2}rG$A0cNLL1KG1(yLN7k&t{TEHI*Zs;eYZ5DSSd$&{eSujJN>Dqb>s ztU|W&XfYNRx)6hE?=+1mW@XP4w7E(P98+g7%&Bg1|RmB zC5}Z~JKmEmpSq5^#M1GA+czb@9ax_wHTQ>-0jntQY^K@bXuP9Pc4XA~pvlpCEUYED zxSiXOBys5oNG%QyCTDzV2BsA)l|RxTN>$wIaGv#OyRXU7(6r?+IrU`9DACjTVjh=3 zbb19kPWzqpGQLtSg*~*-m3B?1Ng%0lm}1e+^eZ6{OIHkEoJnbX~FBHcP^} zad>Mlmn|MNTEHQrNnPb_8srrFI7LK*tk!_{Sh`X6k_=PW$mVTM=kb`0c$Flhe-=~q`Q0HR>^ zMt$eo&X_DauuL`V!lyyopIULy11-je;8?my_7Vqp0X*SOO(ZLjoW6JHQgaC*b_X`3 zm!)fa27hY4;Dvac!%Qe4xRb)tj*!z&nZZ@@b57Ql!~+Jv4IR#kOG2V zc`m@0*sXFt>_|7Z=_|vOcV}pTCb&rdX29-W6R@5kQ^GpA95#Xjj+Ud6V+m&xd7HG)(ky`PM5+_J1yg1G!Of32z z+Zigj{GbqHzvE5pfACj`neIcMJ8WQ;I632oV9|tKT?qScd{#6R;r_V){q2o^gob1( zG1zybMs*KskyB|ea%>X2MYJx9@zixqmq=jCXVj%5IefWfDIWQ)wO+>Oq^kCbYQ}Sj zUXb|gD01v1!=hbN6XP=f>ywKa=O>KLJ#n3`?3O&PDU?hXN%WP})Q_wJ`K~|Z5ZAr} z{&gHurS;1UbFeCU=zwlw`~oK34O(;K7RMho7CdOEc5F-MtOX97CE{`{Y(z?P2ER%Z7lPtt{2uKWPcMJ-{w zN1(T7&l~sorko|V=~UpI`PYUh80gob&Fz0HX6~`neCGTc8_|^w{dkbA_s;%v#ItaU zkNUoFs)t(47Pr2UO7Qu~mFL#0=EJ`Ib7Xj{kl{!a|fG&$_Y1VIJC_EZOGT_UC;0hHVupNB#nlt=6R+tp3Q5X>8(^{!~wpe9o*L-7V zOw*Z0eU+&JP6%OB&1hJ0X}!%wY;iPB-1Ip(FgV4rFck51La4r!21P2$=%EO9`Xj^U z4pq32@E~1t&cfN?s;yi`02xD{_=3WvK^i;YRDkhLmoVynN1Lur?G+_%r{*^2Fgaao z4tGjGiHmmVcZe`!Nho*N?kuLpRch|C4-%MKitSysdD}s*qFc8Q!LCVbYv!@V;RaOj zU+Ih~z22x_zXJZjmCx`er$3w%aQw*WzGm>^XF>u*l5-c)7##y6p&d)xc>w9l;)= zf>nDK`M*=B-;Wc2eYi!Ared^K?uZ_@+DQEK7l82duqYA|q*+X>QBK@5jx6I61VhS#C$;+vmC@RE%9xwYV)?D>E;TZxNov(io~ac$;+eiP%0 z@`maZnD4)!qeYB8iStEFWdjJ^GlDluV+2H?B0zRkD@4iZVTATqluWaLz}VOyUr1?~ zG&(nSSQ;F~tKD{|82|HmnlOeedEJqDt7=gQ!B359h}a!RhW-4gA=Ow|(E`uL&y_hb zVVAmyxkjGQuOU(D(ZS2p$jV5}Qyu3~?J@G4j%KGOz9<-l@slXL;DfqMkS#~>3sNU_ zsU}hP2_vc58l)y2rl6&ZT0P2c{m~iKG0YLo9-h!cQU9CuJ=uZ`k_Wv0g;R?Pq4Bf2 zV-aI7DvN2m?pSDK3KuU5+Fz_9E6p;VFt7fJ)Z^~`mY7JM>zwO~ZA-=B-EAfZsYdZP zYetSO?#2i3im657AFC=&NWPw~3kT6xY4b_m{y_{$s85NTF zO(s+|k3>M{uLqG&t+mCn=T3?{N;?U1P+sjDKUIc9wP{Z$Cz6M3VOu8q#az2Um^)0_ zri_~^bA{Zm)y$a(7WQ6Iu$BM$5I-)8W=|gQ0!7Kmj8dVjDt6uvs!bki-TQ@EFZv16f02VIR zMCrXWs<%(2%-7MLs)1-I!J4Sag1c-u8&Ec#mk?Dq88d1pt+SN{cKB$tc; literal 0 HcmV?d00001 diff --git a/tests/images/test_mask_256.png b/tests/images/test_mask_256.png new file mode 100644 index 0000000000000000000000000000000000000000..d62edcfb6cb2a65c579ff98094af4dd1e97bedf0 GIT binary patch literal 782 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 literal 0 HcmV?d00001 diff --git a/tests/scripts/generate_test_images.py b/tests/scripts/generate_test_images.py new file mode 100644 index 0000000..da2f221 --- /dev/null +++ b/tests/scripts/generate_test_images.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +테스트용 샘플 이미지 생성 스크립트 +간단한 테스트 이미지와 마스크를 생성합니다. +""" +import os +import sys +import numpy as np +from PIL import Image, ImageDraw +import cv2 + +# 프로젝트 루트를 Python 경로에 추가 +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, project_root) + +def create_test_image(width=512, height=512): + """테스트용 샘플 이미지 생성""" + # 그라데이션 배경 + image = np.zeros((height, width, 3), dtype=np.uint8) + + # 수평 그라데이션 (파란색에서 빨간색) + for x in range(width): + ratio = x / width + blue = int(255 * (1 - ratio)) + red = int(255 * ratio) + image[:, x] = [blue, 0, red] + + # 중앙에 원 그리기 + center_x, center_y = width // 2, height // 2 + radius = min(width, height) // 4 + + # 원 그리기 + cv2.circle(image, (center_x, center_y), radius, (0, 255, 0), -1) + + # 텍스트 추가 + cv2.putText(image, "TEST", (center_x - 50, center_y + 10), + cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) + + return image + +def create_test_mask(width=512, height=512): + """테스트용 마스크 생성 (중앙 원형 영역)""" + mask = np.zeros((height, width), dtype=np.uint8) + + center_x, center_y = width // 2, height // 2 + radius = min(width, height) // 4 + + # 중앙 원형 영역을 마스크로 설정 (흰색) + cv2.circle(mask, (center_x, center_y), radius, 255, -1) + + return mask + +def create_complex_test_image(width=512, height=512): + """복잡한 테스트 이미지 생성""" + image = np.zeros((height, width, 3), dtype=np.uint8) + + # 체크무늬 패턴 + square_size = 32 + for y in range(0, height, square_size): + for x in range(0, width, square_size): + if (x // square_size + y // square_size) % 2 == 0: + image[y:y+square_size, x:x+square_size] = [100, 100, 100] + else: + image[y:y+square_size, x:x+square_size] = [200, 200, 200] + + # 여러 도형 추가 + # 사각형 + cv2.rectangle(image, (100, 100), (200, 200), (0, 255, 0), -1) + + # 삼각형 + pts = np.array([[300, 100], [350, 200], [250, 200]], np.int32) + cv2.fillPoly(image, [pts], (255, 0, 0)) + + # 타원 + cv2.ellipse(image, (400, 150), (50, 30), 45, 0, 360, (0, 255, 255), -1) + + return image + +def create_complex_mask(width=512, height=512): + """복잡한 마스크 생성""" + mask = np.zeros((height, width), dtype=np.uint8) + + # 사각형 마스크 + cv2.rectangle(mask, (100, 100), (200, 200), 255, -1) + + # 삼각형 마스크 + pts = np.array([[300, 100], [350, 200], [250, 200]], np.int32) + cv2.fillPoly(mask, [pts], 255) + + # 타원 마스크 + cv2.ellipse(mask, (400, 150), (50, 30), 45, 0, 360, 255, -1) + + return mask + +def save_test_images(): + """테스트 이미지들을 저장합니다.""" + # 현재 스크립트 위치에서 상대 경로로 테스트 디렉토리 찾기 + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(script_dir) + images_dir = os.path.join(project_root, "images") + + # 테스트 디렉토리 생성 + os.makedirs(images_dir, exist_ok=True) + + print(f"🖼️ 테스트 이미지 생성 중...") + print(f" 저장 위치: {images_dir}") + + # 1. 기본 테스트 이미지 (512x512) + print(" - 기본 테스트 이미지 생성...") + test_image = create_test_image(512, 512) + test_mask = create_test_mask(512, 512) + + Image.fromarray(cv2.cvtColor(test_image, cv2.COLOR_BGR2RGB)).save( + os.path.join(images_dir, "test_image_512.png") + ) + Image.fromarray(test_mask).save( + os.path.join(images_dir, "test_mask_512.png") + ) + + # 2. 복잡한 테스트 이미지 (512x512) + print(" - 복잡한 테스트 이미지 생성...") + complex_image = create_complex_test_image(512, 512) + complex_mask = create_complex_mask(512, 512) + + Image.fromarray(cv2.cvtColor(complex_image, cv2.COLOR_BGR2RGB)).save( + os.path.join(images_dir, "complex_image_512.png") + ) + Image.fromarray(complex_mask).save( + os.path.join(images_dir, "complex_mask_512.png") + ) + + # 3. 작은 테스트 이미지 (256x256) + print(" - 작은 테스트 이미지 생성...") + small_image = create_test_image(256, 256) + small_mask = create_test_mask(256, 256) + + Image.fromarray(cv2.cvtColor(small_image, cv2.COLOR_BGR2RGB)).save( + os.path.join(images_dir, "test_image_256.png") + ) + Image.fromarray(small_mask).save( + os.path.join(images_dir, "test_mask_256.png") + ) + + # 4. 큰 테스트 이미지 (1024x1024) + print(" - 큰 테스트 이미지 생성...") + large_image = create_test_image(1024, 1024) + large_mask = create_test_mask(1024, 1024) + + Image.fromarray(cv2.cvtColor(large_image, cv2.COLOR_BGR2RGB)).save( + os.path.join(images_dir, "test_image_1024.png") + ) + Image.fromarray(large_mask).save( + os.path.join(images_dir, "large_mask_1024.png") + ) + + print(f"✅ 테스트 이미지 생성 완료!") + print(f" - 기본 이미지: test_image_512.png, test_mask_512.png") + print(f" - 복잡한 이미지: complex_image_512.png, complex_mask_512.png") + print(f" - 작은 이미지: test_image_256.png, test_mask_256.png") + print(f" - 큰 이미지: test_image_1024.png, large_mask_1024.png") + +if __name__ == "__main__": + save_test_images() diff --git a/tests/scripts/test_api.py b/tests/scripts/test_api.py new file mode 100644 index 0000000..e6060e1 --- /dev/null +++ b/tests/scripts/test_api.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +""" +API 테스트 스크립트 +각 엔드포인트가 제대로 작동하는지 테스트합니다. +""" +import os +import sys +import base64 +import time +import requests +import json +from pathlib import Path + +# 프로젝트 루트를 Python 경로에 추가 +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, project_root) + +class APITester: + def __init__(self, base_url="http://localhost:8000"): + self.base_url = base_url + self.test_images_dir = os.path.join(project_root, "tests", "images") + self.results_dir = os.path.join(project_root, "tests", "results") + + # 결과 디렉토리 생성 + os.makedirs(self.results_dir, exist_ok=True) + + print(f"🚀 API 테스터 초기화 완료") + print(f" 서버 URL: {base_url}") + print(f" 테스트 이미지: {self.test_images_dir}") + print(f" 결과 저장: {self.results_dir}") + + def image_to_base64(self, image_path): + """이미지를 base64로 인코딩""" + try: + with open(image_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + return encoded_string + except Exception as e: + print(f"❌ 이미지 인코딩 실패: {e}") + return None + + def save_base64_image(self, base64_string, filename): + """base64 이미지를 파일로 저장""" + try: + image_data = base64.b64decode(base64_string) + filepath = os.path.join(self.results_dir, filename) + with open(filepath, "wb") as f: + f.write(image_data) + print(f" 💾 결과 이미지 저장: {filename}") + return True + except Exception as e: + print(f"❌ 이미지 저장 실패: {e}") + return False + + def test_health(self): + """헬스 체크 테스트""" + print("\n🏥 헬스 체크 테스트...") + try: + response = requests.get(f"{self.base_url}/health", timeout=10) + if response.status_code == 200: + data = response.json() + print(f" ✅ 서버 상태: {data.get('status', 'unknown')}") + print(f" 📊 가동 시간: {data.get('uptime', 0):.2f}초") + return True + else: + print(f" ❌ 서버 응답 실패: {response.status_code}") + return False + except Exception as e: + print(f" ❌ 연결 실패: {e}") + return False + + def test_server_config(self): + """서버 설정 정보 테스트""" + print("\n⚙️ 서버 설정 정보 테스트...") + try: + response = requests.get(f"{self.base_url}/api/v1/server-config", timeout=10) + if response.status_code == 200: + data = response.json() + print(f" ✅ 서버 설정 조회 성공") + print(f" 🖥️ 시스템: {'Jetson Xavier' if data.get('is_jetson') else 'x86_64'}") + print(f" 🎮 디바이스: {data.get('device', 'unknown')}") + print(f" 📁 최대 파일 크기: {data.get('max_file_size', 0)}MB") + print(f" 🎯 사용 가능한 모델: {len(data.get('models', []))}개") + return True + else: + print(f" ❌ 서버 설정 조회 실패: {response.status_code}") + return False + except Exception as e: + print(f" ❌ 연결 실패: {e}") + return False + + def test_inpaint(self, image_name="test_image_512.png", mask_name="test_mask_512.png"): + """인페인팅 API 테스트""" + print(f"\n🎨 인페인팅 테스트 ({image_name} + {mask_name})...") + + # 이미지 파일 경로 + image_path = os.path.join(self.test_images_dir, image_name) + mask_path = os.path.join(self.test_images_dir, mask_name) + + if not os.path.exists(image_path) or not os.path.exists(mask_path): + print(f" ❌ 테스트 이미지 파일이 없습니다") + return False + + try: + # 이미지를 base64로 인코딩 + image_base64 = self.image_to_base64(image_path) + mask_base64 = self.image_to_base64(mask_path) + + if not image_base64 or not mask_base64: + return False + + # 인페인팅 요청 + payload = { + "image": image_base64, + "mask": mask_base64, + "model_name": "simple-lama", + "sd_seed": 42, + "prompt": "beautiful landscape", + "negative_prompt": "ugly, blurry", + "num_inference_steps": 20, + "guidance_scale": 7.5, + "strength": 1.0 + } + + start_time = time.time() + response = requests.post( + f"{self.base_url}/api/v1/inpaint", + json=payload, + timeout=60 + ) + processing_time = time.time() - start_time + + if response.status_code == 200: + data = response.json() + print(f" ✅ 인페인팅 성공!") + print(f" ⏱️ 처리 시간: {data.get('processing_time', processing_time):.2f}초") + print(f" 🎲 사용된 시드: {data.get('seed', 'unknown')}") + + # 결과 이미지 저장 + if data.get('image'): + self.save_base64_image(data['image'], f"inpaint_result_{image_name}") + + return True + else: + print(f" ❌ 인페인팅 실패: {response.status_code}") + try: + error_data = response.json() + print(f" 📝 에러 메시지: {error_data.get('detail', 'unknown error')}") + except: + print(f" 📝 응답 내용: {response.text}") + return False + + except Exception as e: + print(f" ❌ 인페인팅 테스트 실패: {e}") + return False + + def test_remove_bg(self, image_name="test_image_512.png"): + """배경 제거 API 테스트""" + print(f"\n🖼️ 배경 제거 테스트 ({image_name})...") + + # 이미지 파일 경로 + image_path = os.path.join(self.test_images_dir, image_name) + + if not os.path.exists(image_path): + print(f" ❌ 테스트 이미지 파일이 없습니다") + return False + + try: + # 이미지를 base64로 인코딩 + image_base64 = self.image_to_base64(image_path) + + if not image_base64: + return False + + # 배경 제거 요청 + payload = { + "image": image_base64, + "model_name": "rembg" + } + + start_time = time.time() + response = requests.post( + f"{self.base_url}/api/v1/remove_bg", + json=payload, + timeout=60 + ) + processing_time = time.time() - start_time + + if response.status_code == 200: + data = response.json() + print(f" ✅ 배경 제거 성공!") + print(f" ⏱️ 처리 시간: {data.get('processing_time', processing_time):.2f}초") + + # 결과 이미지와 마스크 저장 + if data.get('image'): + self.save_base64_image(data['image'], f"rembg_result_{image_name}") + if data.get('mask'): + self.save_base64_image(data['mask'], f"rembg_mask_{image_name}") + + return True + else: + print(f" ❌ 배경 제거 실패: {response.status_code}") + try: + error_data = response.json() + print(f" 📝 에러 메시지: {error_data.get('detail', 'unknown error')}") + except: + print(f" 📝 응답 내용: {response.text}") + return False + + except Exception as e: + print(f" ❌ 배경 제거 테스트 실패: {e}") + return False + + def test_plugin_generate_image(self, image_name="test_image_512.png"): + """플러그인 이미지 생성 테스트""" + print(f"\n🔌 플러그인 이미지 생성 테스트 ({image_name})...") + + # 이미지 파일 경로 + image_path = os.path.join(self.test_images_dir, image_name) + + if not os.path.exists(image_path): + print(f" ❌ 테스트 이미지 파일이 없습니다") + return False + + try: + # 이미지를 base64로 인코딩 + image_base64 = self.image_to_base64(image_path) + + if not image_base64: + return False + + # 플러그인 요청 + payload = { + "name": "rembg", + "image": image_base64, + "model_name": "rembg" + } + + start_time = time.time() + response = requests.post( + f"{self.base_url}/api/v1/run_plugin_gen_image", + json=payload, + timeout=60 + ) + processing_time = time.time() - start_time + + if response.status_code == 200: + data = response.json() + print(f" ✅ 플러그인 이미지 생성 성공!") + print(f" ⏱️ 처리 시간: {data.get('processing_time', processing_time):.2f}초") + + # 결과 이미지 저장 + if data.get('image'): + self.save_base64_image(data['image'], f"plugin_result_{image_name}") + + return True + else: + print(f" ❌ 플러그인 이미지 생성 실패: {response.status_code}") + try: + error_data = response.json() + print(f" 📝 에러 메시지: {error_data.get('detail', 'unknown error')}") + except: + print(f" 📝 응답 내용: {response.text}") + return False + + except Exception as e: + print(f" ❌ 플러그인 테스트 실패: {e}") + return False + + def test_samplers(self): + """샘플러 목록 테스트""" + print("\n🎲 샘플러 목록 테스트...") + try: + response = requests.get(f"{self.base_url}/api/v1/samplers", timeout=10) + if response.status_code == 200: + samplers = response.json() + print(f" ✅ 샘플러 목록 조회 성공") + print(f" 📊 사용 가능한 샘플러: {len(samplers)}개") + print(f" 🎯 샘플러들: {', '.join(samplers[:5])}{'...' if len(samplers) > 5 else ''}") + return True + else: + print(f" ❌ 샘플러 목록 조회 실패: {response.status_code}") + return False + except Exception as e: + print(f" ❌ 연결 실패: {e}") + return False + + def run_all_tests(self): + """모든 테스트 실행""" + print("🧪 전체 API 테스트 시작") + print("=" * 50) + + test_results = [] + + # 1. 헬스 체크 + test_results.append(("헬스 체크", self.test_health())) + + # 2. 서버 설정 정보 + test_results.append(("서버 설정 정보", self.test_server_config())) + + # 3. 샘플러 목록 + test_results.append(("샘플러 목록", self.test_samplers())) + + # 4. 인페인팅 테스트 (작은 이미지) + test_results.append(("인페인팅 (256x256)", self.test_inpaint("test_image_256.png", "test_mask_256.png"))) + + # 5. 인페인팅 테스트 (기본 이미지) + test_results.append(("인페인팅 (512x512)", self.test_inpaint("test_image_512.png", "test_mask_512.png"))) + + # 6. 배경 제거 테스트 + test_results.append(("배경 제거", self.test_remove_bg("test_image_512.png"))) + + # 7. 플러그인 테스트 + test_results.append(("플러그인 이미지 생성", self.test_plugin_generate_image("test_image_512.png"))) + + # 결과 요약 + print("\n" + "=" * 50) + print("📊 테스트 결과 요약") + print("=" * 50) + + passed = 0 + total = len(test_results) + + for test_name, result in test_results: + status = "✅ PASS" if result else "❌ FAIL" + print(f"{test_name:<25} {status}") + if result: + passed += 1 + + print("=" * 50) + print(f"전체: {total}개, 성공: {passed}개, 실패: {total - passed}개") + + if passed == total: + print("🎉 모든 테스트가 성공했습니다!") + else: + print("⚠️ 일부 테스트가 실패했습니다.") + + return passed == total + +def main(): + """메인 함수""" + import argparse + + parser = argparse.ArgumentParser(description="API 테스트 스크립트") + parser.add_argument("--url", default="http://localhost:8000", help="테스트할 서버 URL") + parser.add_argument("--single", help="단일 테스트 실행 (health, config, inpaint, rembg, plugin)") + + args = parser.parse_args() + + tester = APITester(args.url) + + if args.single: + # 단일 테스트 실행 + if args.single == "health": + tester.test_health() + elif args.single == "config": + tester.test_server_config() + elif args.single == "inpaint": + tester.test_inpaint() + elif args.single == "rembg": + tester.test_remove_bg() + elif args.single == "plugin": + tester.test_plugin_generate_image() + else: + print(f"❌ 알 수 없는 테스트: {args.single}") + else: + # 전체 테스트 실행 + tester.run_all_tests() + +if __name__ == "__main__": + main() diff --git a/tests/tests/images/complex_image_512.png b/tests/tests/images/complex_image_512.png new file mode 100644 index 0000000000000000000000000000000000000000..04f525043949594d19b0db719a496577ff10da1c GIT binary patch literal 3120 zcmeHKX;4#F6h2vu1PxmkYd}#7!<0JM))Y{{ASz2EpjImkA{jMhD2kCq3;_~cK&%Eu zN~#o)+JSMZ5V0+2l*Elx0g0l+5-?Ao4IKgj116A=+n1@+vC|*z510PvpL^bY^X_@y zd1ub|eXn3`WGKy+ZVLdk@UT_u0f_h|5p1l1PhWW+V3r|#)rt*!zZve0OHqZ;gVv>Z z@r&Lj*M1W1@Wb~!Ww9H7NA$OyH`@X=@15O1kKcZ2mv3}RO*7V<%6hLQpZls+S+)#GJWPLSA`(4Kt!$IngS+YaVK`E+K6?3OINGtRudP zkX}}mI7kYqWwz;GCyKZd&LHVm-u%lJM#NtmBXj<v^}YfU>?z@BqcsCWZ9^X65w zQbBvreUwcEXGzD!nMCk+n;lT(O5Pv({qU2;Nz4_K?aa)H8~Y1AK(bh4yoNoDK!xSy z-kYWA+mp`Qm{M>-TNF%|(t`<2RQRpnYHqU5`0?biVS1Ne; znH2fcjCKU!bT`k(M?I}>uABQ08XGY!T$# z>2E~pd4?MgrAWr{nVeD|cZQydj*m@gJ!v#I;JQCrlzoDdwFBO06r-&!1cj|-0Tkyk;x5m)+_141yj(t8v zsK2N$U$1dm09;C8)V&xcCCm{Tj`yR2)!qamcs_QLZ=yoa^)WPVE5)tf%^Ez1hr2 zD_T+V%Y;9gqQxWe35(Cf9GQF(2nOHzp-7%-x2x+H-JROhBOq&+Oa3@%6) zMtT|!r?;xUOO)GD!JOBRZ0HhSA}lP;k09+?4cMkqU6H1WU|I;uy8J7N@<@A?R+TTgEwxOHy}~6Bs)p_cYm|nHP3a;mu*sbh>0AL5RBA%A?N^Vjba7_r}pJ z@#4S)tk@Os?myCg;oH)kN{YnapOoLFdb|-kr$AqVoFg`|dvfU9zFy=84)rSI@MACT zh;<`T%{$gyHP>%?MBoT)M9s>g*h6IU=R>g&9StAU#{Rmq)>QIRji#Ez>&-m8nE(DY!;-bCDYXr(6QP7B&8*6(r>8Id zCP*u*x)8tb-cXyhWrcdCdZLb(*&djaanxj4s*Kfzo~m+XqG4VR5^={Tvu)NK?{)Pi zLe@ZQ^kF#GZ?xY<24ev$C?Ax4Mtgh)j9;;W4uAu`aizkjvo~Qzj+1d#2|+-9cu#O_ z`?%RKfdmh7Ynhzn7pVp5|B7i}z6L%>KUGy}|IXiq_&L6R6)0j#D(%14@CK)Fri)WQ z)8H0a^0@hhe)fOT^A4N^mb1sF^a1v-69p#*M^Ub+9ExgZq-8%dM@!J<^WmaV{7(SFS4Xa@31;v69q>gENdN!< literal 0 HcmV?d00001 diff --git a/tests/tests/images/complex_mask_512.png b/tests/tests/images/complex_mask_512.png new file mode 100644 index 0000000000000000000000000000000000000000..3a3aefdd90e4f52f1528f2273e8df3d8dc9088db GIT binary patch literal 1154 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w3=!$sk4H3kM2Pfr)ekcv5P@7yk%Z6M%q z(KGzt|LJ$zm*wgmb2E_gOnYChywxgO;fW9avXK>gsn^=nI4-SbXXoZ>_k{0q5X93=!9TIUa1-Wl^ z2K{y3oDUi!*&IslbTRm?;8}7#is^>gMTU;N-GT=smv(-M^kJC&Op4)RrZbbpQnN{N zt2q-+moO{rdgICP{LBnbVa6N}yPHemf1iHu%b>p2j)|q<{+j2r_USXu+4|XIQ{7kV z-3*L(OkXo}d|Au+U`uE6O-F|BKDEns&K5pU`P(n$(tZ=Y2J2;V3@2|3J2XvoZ(yjK zRm1QkFMvT(OpcN1N8&HW3Azs*)SV&>2?4C2*iV^||?FUk8Sn-_%S3fwnF$YB(RrvBmV32>czh(Af21b=^j?6Bg^zjMh7RDk94LQ1+z5xvkN=bLT?|cL*j}7)Y z&h3%q&LHT#zuUNqjYAYY!0%ovoE9=*M|ZBxAD42HdPyt1=-WU4ZM zTsW$JpojTE|Mrgj+pjXAbUMs L{an^LB{Ts54(o`g literal 0 HcmV?d00001 diff --git a/tests/tests/images/large_mask_1024.png b/tests/tests/images/large_mask_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..370b13db88a8494cd0f5dc70fd77035eaf1a3118 GIT binary patch literal 3916 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 F{|jw$jNt$P literal 0 HcmV?d00001 diff --git a/tests/tests/images/test_image_1024.png b/tests/tests/images/test_image_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..f57cc3bcfd1b498414f2fbc7a8e2bc38022e65ef GIT binary patch literal 9064 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 literal 0 HcmV?d00001 diff --git a/tests/tests/images/test_mask_1024.png b/tests/tests/images/test_mask_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..370b13db88a8494cd0f5dc70fd77035eaf1a3118 GIT binary patch literal 3916 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 F{|jw$jNt$P literal 0 HcmV?d00001 diff --git a/tests/tests/images/test_mask_256.png b/tests/tests/images/test_mask_256.png new file mode 100644 index 0000000000000000000000000000000000000000..d62edcfb6cb2a65c579ff98094af4dd1e97bedf0 GIT binary patch literal 782 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 literal 0 HcmV?d00001