Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions fishing/ai/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
PORT=8000
# NODE_ENV, ENV, ENVIRONMENT 중 하나가 production 이면 이 기본값으로 서버가 기동되지 않습니다.
INTERNAL_SECRET=fishrank_ai_internal_secret
# YOLO 가중치는 개발에서 첫 /analyze 요청 때 올립니다. 운영(NODE_ENV=production)은 기동 시 예열합니다.
# 개발에서 미리 올리려면 AI_WARMUP=true. 운영에서 끄려면 AI_WARMUP=false.
# AI_WARMUP=false
# 개발 기본 스레드는 1입니다. 운영은 PyTorch 기본값을 유지합니다. 바꾸려면 숫자를 지정하세요.
# TORCH_NUM_THREADS=1
UPLOADS_DIR=../apps/api/uploads
SPECIES_CONFIDENCE_S_THRESHOLD=0.7
SPECIES_CLASSIFIER=auto
Expand Down
92 changes: 92 additions & 0 deletions fishing/ai/app/runtime_tuning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Dev-safe model startup. Production defaults stay eager and uncapped."""

from __future__ import annotations

import os

from .production_guard import runtime_env

_TRUE = {"1", "true", "yes", "on"}
_THREAD_ENV_KEYS = ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS")


def _raw(source: dict[str, str], key: str) -> str | None:
value = source.get(key)
if value is None:
return None
text = str(value).strip()
if text == "":
return None
return text


def _truthy(value: str) -> bool:
return value.strip().lower() in _TRUE


def warmup_enabled(environ: dict[str, str] | None = None) -> bool:
"""YOLO weights load at startup only when this is true.

Unset follows the process environment: production warms up, everything else
waits for the first inference request. AI_WARMUP always wins when set.
"""
source = environ if environ is not None else os.environ
raw = _raw(source, "AI_WARMUP")
if raw is None:
return runtime_env(source) == "production"
return _truthy(raw)


def torch_num_threads(environ: dict[str, str] | None = None) -> int | None:
"""Thread cap passed to torch.set_num_threads.

None means leave PyTorch's default alone (production). Dev defaults to 1.
TORCH_NUM_THREADS overrides both.
"""
source = environ if environ is not None else os.environ
raw = _raw(source, "TORCH_NUM_THREADS")
if raw is not None:
try:
return max(1, int(raw))
except ValueError:
return 1
if runtime_env(source) == "production":
return None
return 1


def prepare_process_threads(environ: dict[str, str] | None = None) -> dict[str, str]:
"""Set OpenMP/BLAS thread env before native libraries load.

When environ is passed, nothing is written to os.environ (tests).
Existing values are kept.
"""
source = environ if environ is not None else os.environ
count = torch_num_threads(source)
if count is None:
return {}
updates: dict[str, str] = {}
for key in _THREAD_ENV_KEYS:
if _raw(source, key) is None:
updates[key] = str(count)
if environ is None:
os.environ.update(updates)
return updates


def apply_torch_thread_cap() -> int | None:
"""Cap torch threads on the dev model-load path. No-op in production."""
count = torch_num_threads()
if count is None:
return None
prepare_process_threads()
try:
import torch
except ImportError:
return count
torch.set_num_threads(count)
try:
torch.set_num_interop_threads(1)
except RuntimeError:
pass
return count
3 changes: 3 additions & 0 deletions fishing/ai/app/species_clip.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ def _get_classifier():
if _classifier is not None:
return _classifier, _label_to_slug or {}

from .runtime_tuning import apply_torch_thread_cap

apply_torch_thread_cap()
from transformers import pipeline
import torch

Expand Down
9 changes: 6 additions & 3 deletions fishing/ai/app/species_yolo.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ def is_yolo_available() -> bool:


def get_yolo_class_count() -> int | None:
model = _load_model()
if model is None:
"""Class count for an already-loaded model. Never loads weights."""
if _model is None:
return None
return len(model.names)
return len(_model.names)


def warmup_yolo() -> bool:
Expand Down Expand Up @@ -108,6 +108,9 @@ def _load_model():
if not path.is_file():
return None

from .runtime_tuning import apply_torch_thread_cap

apply_torch_thread_cap()
from ultralytics import YOLO

logger.info("Loading YOLO classifier: %s", path)
Expand Down
16 changes: 15 additions & 1 deletion fishing/ai/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import logging

from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException

from app.analyzer import analyze_image, resolve_image_path
from app.auth import verify_internal_secret
from app.config import settings
from app.production_guard import assert_production_secret, runtime_env
from app.keypoints import detect_keypoints
from app.runtime_tuning import prepare_process_threads, warmup_enabled
from app.schemas import (
AnalyzeRequest,
AnalyzeResponse,
Expand All @@ -16,20 +20,30 @@
from app.species_yolo import INFERENCE_VERSION, get_yolo_class_count, is_yolo_available, warmup_yolo
import cv2

logger = logging.getLogger(__name__)

app = FastAPI(title="FishRank AI Server", version="1.0.0")


@app.on_event("startup")
def _startup_warmup() -> None:
load_dotenv()
assert_production_secret(runtime_env(), settings.internal_secret)
if is_yolo_available():
prepare_process_threads()
if not is_yolo_available():
return
if warmup_enabled():
logger.info("YOLO warmup enabled")
warmup_yolo()
return
logger.info("YOLO warmup skipped; weights load on the first inference request")


@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
yolo_ready = is_yolo_available()
model_name = "yolo+opencv" if yolo_ready else "opencv+clip"
# Do not load weights here. Class count stays empty until warmup or /analyze.
yolo_class_count = get_yolo_class_count() if yolo_ready else None
return HealthResponse(
status="ok",
Expand Down
5 changes: 5 additions & 0 deletions fishing/ai/production.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 운영과 Docker 프로덕션 AI 프로세스에 넣습니다.
# 개발용 ai/.env.example 를 운영에 그대로 복사하지 마세요.
# 이 값이 있으면 예전과 같이 기동 시 YOLO 가중치를 올리고, 스레드 수는 제한하지 않습니다.
NODE_ENV=production
AI_WARMUP=true
4 changes: 4 additions & 0 deletions fishing/ai/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@

load_dotenv(AI_DIR / ".env")

from app.runtime_tuning import prepare_process_threads # noqa: E402

prepare_process_threads()

from app.config import settings # noqa: E402

if __name__ == "__main__":
Expand Down
49 changes: 49 additions & 0 deletions fishing/ai/tests/test_runtime_tuning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import unittest

from app.runtime_tuning import prepare_process_threads, torch_num_threads, warmup_enabled


class RuntimeTuningTest(unittest.TestCase):
def test_dev_warmup_defaults_off(self) -> None:
self.assertFalse(warmup_enabled({}))
self.assertFalse(warmup_enabled({"NODE_ENV": "development"}))
self.assertFalse(warmup_enabled({"AI_WARMUP": ""}))

def test_production_warmup_defaults_on(self) -> None:
self.assertTrue(warmup_enabled({"NODE_ENV": "production"}))
self.assertTrue(warmup_enabled({"ENV": "Production"}))
self.assertTrue(warmup_enabled({"ENVIRONMENT": "production"}))

def test_explicit_warmup_flag_wins(self) -> None:
self.assertFalse(warmup_enabled({"NODE_ENV": "production", "AI_WARMUP": "false"}))
self.assertFalse(warmup_enabled({"AI_WARMUP": "0"}))
self.assertFalse(warmup_enabled({"AI_WARMUP": "no"}))
self.assertFalse(warmup_enabled({"AI_WARMUP": "off"}))
self.assertTrue(warmup_enabled({"AI_WARMUP": "true"}))
self.assertTrue(warmup_enabled({"AI_WARMUP": " YES "}))
self.assertTrue(warmup_enabled({"NODE_ENV": "development", "AI_WARMUP": "1"}))

def test_torch_threads_default_small_in_dev(self) -> None:
self.assertEqual(torch_num_threads({}), 1)
self.assertEqual(torch_num_threads({"NODE_ENV": "development"}), 1)
self.assertEqual(torch_num_threads({"TORCH_NUM_THREADS": "2"}), 2)
self.assertEqual(torch_num_threads({"TORCH_NUM_THREADS": "0"}), 1)
self.assertEqual(torch_num_threads({"TORCH_NUM_THREADS": "nope"}), 1)

def test_production_does_not_cap_threads_unless_asked(self) -> None:
self.assertIsNone(torch_num_threads({"NODE_ENV": "production"}))
self.assertEqual(
torch_num_threads({"NODE_ENV": "production", "TORCH_NUM_THREADS": "4"}),
4,
)

def test_prepare_keeps_existing_omp_and_skips_production(self) -> None:
updates = prepare_process_threads({"TORCH_NUM_THREADS": "1", "OMP_NUM_THREADS": "8"})
self.assertNotIn("OMP_NUM_THREADS", updates)
self.assertEqual(updates["MKL_NUM_THREADS"], "1")
self.assertEqual(updates["OPENBLAS_NUM_THREADS"], "1")
self.assertEqual(prepare_process_threads({"NODE_ENV": "production"}), {})


if __name__ == "__main__":
unittest.main()
8 changes: 6 additions & 2 deletions fishing/docs/06-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ S3_BUCKET_NAME=fishrank-images
# 내부 통신 인증
INTERNAL_SECRET=내부_통신용_시크릿

# 개발은 비우거나 false. 운영/Docker 프로덕션은 true (예: ai/production.env.example).
# NODE_ENV=production 이면 AI_WARMUP 을 비워도 기동 시 YOLO 를 올립니다.
AI_WARMUP=true

# 모델 경로
YOLO_MODEL_PATH=models/fish_classifier.pt
YOLO_CONFIDENCE=0.5
Expand Down Expand Up @@ -227,9 +231,9 @@ pm2 start apps/api/dist/main.js --name fishrank-api
npm run build --prefix apps/web
pm2 start "npm run start --prefix apps/web" --name fishrank-web

# FastAPI 배포
# FastAPI 배포. AI_WARMUP=true 는 기동 시 YOLO 예열(기존 운영 동작)을 유지합니다.
cd ai
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2
NODE_ENV=production AI_WARMUP=true uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2
```

---
Expand Down
2 changes: 2 additions & 0 deletions fishing/docs/12-ai-server-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ npm run dev:all
npm run dev
```

로컬 `npm run ai` 는 `AI_WARMUP` 이 없으면 YOLO 가중치를 기동 때 올리지 않습니다. 첫 `/analyze` 에서 읽고, 개발 중 PyTorch 스레드는 기본 1개입니다 (`TORCH_NUM_THREADS`). `NODE_ENV=production` 이거나 `AI_WARMUP=true` 이면 예전처럼 기동 시 예열하고 스레드를 제한하지 않습니다. 운영 env 예시는 `ai/production.env.example` 입니다. `/health` 는 가중치를 로드하지 않으므로, 예열 전에는 `yoloClassCount` 가 비어 있을 수 있습니다.

| 서비스 | 포트 | URL |
|---|---|---|
| Web | 3000 | http://localhost:3000 |
Expand Down
Loading