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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/core/logging_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from logging.config import dictConfig

"""
μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ „μ²΄μ—μ„œμ˜ λ‘œκΉ… 레벨 μ„€μ •
"""
def setup_logging():
dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s %(levelname)s [%(name)s] %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
},
},
"root": {
"level": "INFO",
"handlers": ["console"],
},
"loggers": {
"uvicorn": {"level": "INFO"},
"uvicorn.error": {"level": "INFO"},
"uvicorn.access": {"level": "INFO"},
"app": {"level": "INFO", "handlers": ["console"], "propagate": False},
},
})
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from fastapi import FastAPI, Request
from app.core.lifespan import lifespan
from app.api.endpoints import predictions
from app.core.logging_config import setup_logging

setup_logging()
app = FastAPI(
title="DearBelly CV API",
description="DearBelly CVλ₯Ό μœ„ν•œ Swagger μž…λ‹ˆλ‹€.",
Expand Down
Binary file removed app/models/models/best_model_0823.pt
Binary file not shown.
Binary file added app/models/models/best_model_0920.pt
Binary file not shown.
6 changes: 5 additions & 1 deletion app/services/openai_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from app.core.config import settings
from openai import OpenAI
import json
import logging

logger = logging.getLogger(__name__)

client = OpenAI(api_key=settings.OPENAI_KEY)

Expand Down Expand Up @@ -32,7 +35,7 @@ def ask_chatgpt_about_pregnancy_safety(self, pill_name: str) -> tuple[str, int]:
max_tokens=600,
response_format={"type": "json_object"}
)
print("GPT Asking 성곡...")
logger.info("GPT Asking 성곡...")
raw = response.choices[0].message.content.strip()

try:
Expand All @@ -45,6 +48,7 @@ def ask_chatgpt_about_pregnancy_safety(self, pill_name: str) -> tuple[str, int]:
else:
# 디버깅
preview = raw[:200].replace("\n", "\\n")
logger.warning("JSON νŒŒμ‹± μ‹€νŒ¨")
raise ValueError(f"응닡이 μœ νš¨ν•œ JSON이 μ•„λ‹™λ‹ˆλ‹€. preview='{preview}'")

description = data.get("description")
Expand Down
154 changes: 115 additions & 39 deletions app/services/predictor_service.py
Original file line number Diff line number Diff line change
@@ -1,76 +1,152 @@

import torch
import torch.nn as nn
import torch.nn.functional as F
import timm
from torchvision import transforms
from PIL import Image
import json
from pathlib import Path
from io import BytesIO
import logging

class LightCNN(nn.Module):
def __init__(self, num_classes):
logger = logging.getLogger(__name__)

class EfficientNetBaseline(nn.Module):
def __init__(self, num_classes, pretrained=True, dropout=0.2):
super().__init__()
self.conv1 = nn.Conv2d(3, 8, 3, padding=1)
self.conv2 = nn.Conv2d(8, 16, 3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
self.gap = nn.AdaptiveAvgPool2d((4, 4))
self.fc1 = nn.Linear(16 * 4 * 4, 64)
self.fc2 = nn.Linear(64, num_classes)
logger.info(
f"Initializing EfficientNetBaseline with num_classes={num_classes}, pretrained={pretrained}, dropout={dropout}")

self.backbone = timm.create_model(
"efficientnet_b3", pretrained=pretrained, num_classes=0, global_pool="avg"
)
feat_dim = self.backbone.num_features

self.bn = nn.BatchNorm1d(feat_dim)
self.dp = nn.Dropout(dropout)
self.fc = nn.Linear(feat_dim, num_classes)

def forward(self, x):
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = self.gap(x)
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x
feats = self.backbone(x)
feats = self.bn(feats)
feats = self.dp(feats)
logits = self.fc(feats)
return logits

class PredictorService:
def __init__(self, model_path: Path, json_path: Path):

logger.info(f"Initializing PredictorService with model_path={model_path}, json_path={json_path}")

self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# cuda λͺ¨λΈ 확인
logger.info(f"Using device: {self.device}")

self.idx2label = self._load_idx2label(json_path)
self.num_classes = len(self.idx2label)
logger.info(f"Loaded {self.num_classes} classes")

self.model = self._load_model(model_path)
self.transform = transforms.Compose([
transforms.Resize((64, 64)),
transforms.ToTensor(),
])
logger.info("PredictorService initialized successfully")

def _load_idx2label(self, json_path: Path) -> dict:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
idx2label = data.get("idx2label")
if not idx2label:
unique_labels = sorted(set(sample["label"] for sample in data["samples"]))
idx2label = {str(label): f"K-{label:06d}" for label in unique_labels}
return idx2label

def _load_model(self, model_path: Path) -> LightCNN:

# json μ œλŒ€λ‘œ μ½μ—ˆλŠ”μ§€ 확인
logger.info(f"Loading idx2label from {json_path}")

try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
idx2label = data.get("idx2label")
if not idx2label:
logger.warning("idx2label not found in JSON, generating from samples")
unique_labels = sorted(set(sample["label"] for sample in data["samples"]))
idx2label = {str(label): f"K-{label:06d}" for label in unique_labels}
return idx2label

# μ˜ˆμ™Έ 사항 μΆ”κ°€
except FileNotFoundError:
logger.error(f"JSON file not found: {json_path}")
raise
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON file: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error loading idx2label: {e}", exc_info=True)
raise

def _load_model(self, model_path: Path) -> EfficientNetBaseline:
# model path ν™•μΈν•˜κΈ°
logger.info(f"Loading model from {model_path}")

import __main__
__main__.LightCNN = LightCNN
model = torch.load(model_path, map_location=self.device, weights_only=False)
model.eval()
return model
__main__.EfficientNetBaseline = EfficientNetBaseline

try:
object = torch.load(model_path, map_location=self.device, weights_only=False)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

torch.loadλ₯Ό weights_only=False둜 ν˜ΈμΆœν•˜λŠ” 것은 λ³΄μ•ˆμƒ μœ„ν—˜ν•©λ‹ˆλ‹€. 이 μ˜΅μ…˜μ€ Python의 pickle을 μ‚¬μš©ν•˜μ—¬ 객체λ₯Ό μ—­μ§λ ¬ν™”ν•˜λŠ”λ°, μ•…μ˜μ μœΌλ‘œ μ‘°μž‘λœ λͺ¨λΈ 파일(.pt)을 λ‘œλ“œν•  경우 μž„μ˜ μ½”λ“œκ°€ 싀행될 수 μžˆμŠ΅λ‹ˆλ‹€. λͺ¨λΈμ˜ state_dict만 μ €μž₯ν•˜κ³ , model.load_state_dict()λ₯Ό μ‚¬μš©ν•˜μ—¬ κ°€μ€‘μΉ˜λ₯Ό λ‘œλ“œν•˜λŠ” 것이 훨씬 μ•ˆμ „ν•©λ‹ˆλ‹€. ν˜„μž¬ μ½”λ“œμ—μ„œ 이미 state_dictλ₯Ό μ²˜λ¦¬ν•˜λŠ” 둜직이 μžˆμœΌλ―€λ‘œ, 이 방식을 ν‘œμ€€μœΌλ‘œ μ‚Όκ³  weights_only=False μ‚¬μš©μ„ ν”Όν•˜λŠ” 것을 κ°•λ ₯히 ꢌμž₯ν•©λ‹ˆλ‹€.


if isinstance(object, nn.Module) :
# κ·Έ 자체둜 λͺ¨λΈμΌ λ•Œ
model = object.to(self.device)
elif isinstance(object, dict) :
# λ°˜ν™˜ νƒ€μž…μ΄ state_dict
state_dict = object
for k in ['state_dict', 'model_state_dict', 'model']:
if k in object and isinstance(object[k], dict):
state_dict = object[k]
break

model = EfficientNetBaseline(self.num_classes).to(self.device)

missing, unexpected = model.load_state_dict(state_dict, strict=False)
if missing or unexpected:
logger.warning(f"[load_state_dict] missing keys: {missing}, unexpected keys: {unexpected}")
else:
# type μΌμΉ˜ν•˜μ§€ μ•ŠμŒ
error_msg = f"Unsupported checkpoint type: {type(object)}"
logger.error(error_msg)
raise TypeError(f"Unsupported checkpoint type: {type(object)}")

# μ™„λ£Œ
model.eval()
logger.info("Model loaded and set to evaluation mode")
return model

except FileNotFoundError:
logger.error(f"Model file not found: {model_path}")
raise
except Exception as e:
logger.error(f"Failed to load model: {e}", exc_info=True)
raise


def predict(self, stream_file: BytesIO) -> tuple[str, str, float]:
image = Image.open(stream_file).convert('RGB')
input_tensor = self.transform(image).unsqueeze(0).to(self.device)

with torch.no_grad():
output = self.model(input_tensor)
predicted_idx = torch.argmax(output, dim=1).item()
confidence = torch.softmax(output, dim=1)[0][predicted_idx].item()
try :
image = Image.open(stream_file).convert('RGB')
input_tensor = self.transform(image).unsqueeze(0).to(self.device)

with torch.no_grad():
output = self.model(input_tensor)
predicted_idx = torch.argmax(output, dim=1).item()
confidence = torch.softmax(output, dim=1)[0][predicted_idx].item()

label = str(predicted_idx)
pill_name = self.idx2label.get(label, f"Unknown Label: {label}")
label = str(predicted_idx)
pill_name = self.idx2label.get(label, f"Unknown Label: {label}")
logger.info(f"Prediction completed - pill_name: {pill_name}, label: {label}, confidence: {confidence:.4f}")

return pill_name, label, confidence
return pill_name, label, confidence
except Exception as e:
logger.error(f"Prediction failed: {e}", exc_info=True)
raise


HERE = Path(__file__).resolve().parent.parent
MODEL_PATH = HERE / "models" / "models" / "best_model_0823.pt"
MODEL_PATH = HERE / "models" / "models" / "best_model_0920.pt"
JSON_PATH = HERE / "models" / "models" / "matched_all.json"

predictor_service = PredictorService(MODEL_PATH, JSON_PATH)
13 changes: 8 additions & 5 deletions app/worker/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
from app.services.openai_service import checker
from app.services.predictor_service import predictor_service
from app.services.s3_service import s3_service
import logging

logger = logging.getLogger(__name__)

"""
이미지λ₯Ό λ‹€μš΄ -> λ‹€μš΄ ν•œ 것에 λŒ€ν•˜μ—¬ λͺ¨λΈ 뢄석 μš”μ²­
"""
async def process_image_scan(job: ImageJob, redis_client: redis.Redis):
correlationId = job.correlationId
print(f"[task] Start image scan for job_id={correlationId}")
logging.info(f"[task] Start image scan for job_id={correlationId}")
try:

stream_file = await asyncio.to_thread(
Expand All @@ -28,7 +31,7 @@ async def process_image_scan(job: ImageJob, redis_client: redis.Redis):
predictor_service.predict,
stream_file
)
print(f"[task] Start Asking GPT for job_id={correlationId}")
logging.info(f"[task] Start Asking GPT for job_id={correlationId}")
description, isSafe = checker.ask_chatgpt_about_pregnancy_safety(pillName)
finishedAt = datetime.utcnow().isoformat()

Expand All @@ -50,9 +53,9 @@ async def process_image_scan(job: ImageJob, redis_client: redis.Redis):
approximate=True,
)

print(f"[task] Image scan successfully finished for job_id={correlationId}")
logging.info(f"[task] Image scan successfully finished for job_id={correlationId}")

except Exception as e:
print(f"[task] Failed to process job_id={correlationId}: {e}")
logging.warning(f"[task] Failed to process job_id={correlationId}: {e}")
finally:
print(f"[task] Image scan finished for job_id={correlationId}")
logging.info(f"[task] Image scan finished for job_id={correlationId}")
15 changes: 9 additions & 6 deletions app/worker/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
from app.core.config import settings
from app.schemas.job import ImageJob
from app.worker.tasks import process_image_scan
import logging

logger = logging.getLogger(__name__)

"""
Redis Stream에 μ •μ˜ν•œ μœ νš¨ν•œ ν˜•μ‹ λ©”μ‹œμ§€λ₯Ό μœ„ν•œ μ „μ²˜λ¦¬ ν•¨μˆ˜
Expand Down Expand Up @@ -49,7 +52,7 @@ def __init__(self, redis_client: redis_client):
self.redis_client = redis_client

async def run(self):
print(f"[worker] start consumer={settings.CONSUMER_NAME} group={settings.GROUP_NAME} stream={settings.STREAM_JOB}")
logging.info(f"[worker] start consumer={settings.CONSUMER_NAME} group={settings.GROUP_NAME} stream={settings.STREAM_JOB}")
reclaim_every_sec = 30
last_reclaim = 0.0

Expand Down Expand Up @@ -82,12 +85,12 @@ async def run(self):

# μ΅œμ’… λ°˜ν™˜ data
data = json.loads(payload_str)
print(f"Job received id={msg_id} correlationId={correlation_id} payload={data}")
logging.info(f"Job received id={msg_id} correlationId={correlation_id} payload={data}")

job = ImageJob.model_validate(data)
# XADDκΉŒμ§€ 호좜
task = asyncio.create_task(process_image_scan(job, redis_client))
print(f"[worker] {task} λ°œν–‰ 성곡")
logging.info(f"[worker] {task} λ°œν–‰ 성곡")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

task 객체 자체λ₯Ό λ‘œκΉ…ν•˜λ©΄ <Task pending ...>와 같은 μ •λ³΄λ§Œ 좜λ ₯λ˜μ–΄ μ–΄λ–€ μž‘μ—…μ— λŒ€ν•œ λ‘œκ·ΈμΈμ§€ νŒŒμ•…ν•˜κΈ° μ–΄λ ΅μŠ΅λ‹ˆλ‹€. λŒ€μ‹  μž‘μ—…κ³Ό κ΄€λ ¨λœ correlationIdλ₯Ό ν•¨κ»˜ λ‘œκΉ…ν•˜λ©΄ νŠΉμ • μš”μ²­μ„ μΆ”μ ν•˜κΈ°κ°€ 훨씬 μš©μ΄ν•΄μ§‘λ‹ˆλ‹€.

Suggested change
logging.info(f"[worker] {task} λ°œν–‰ 성곡")
logging.info(f"[worker] Task for job {job.correlationId} created")


# 처리 성곡 μ‹œμ—λ§Œ ack ν›„ del
task.add_done_callback(lambda t: asyncio.create_task(
Expand Down Expand Up @@ -132,7 +135,7 @@ async def run(self):
job = ImageJob.model_validate_json(payload)

task = asyncio.create_task(process_image_scan(job, self.redis_client))
print(f"[worker] {task} λ°œν–‰ 성곡")
logging.info(f"[worker] {task} λ°œν–‰ 성곡")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

task 객체 자체λ₯Ό λ‘œκΉ…ν•˜λ©΄ <Task pending ...>와 같은 μ •λ³΄λ§Œ 좜λ ₯λ˜μ–΄ μ–΄λ–€ μž‘μ—…μ— λŒ€ν•œ λ‘œκ·ΈμΈμ§€ νŒŒμ•…ν•˜κΈ° μ–΄λ ΅μŠ΅λ‹ˆλ‹€. λŒ€μ‹  μž¬μ²˜λ¦¬λ˜λŠ” μž‘μ—…μ˜ correlationIdλ₯Ό ν•¨κ»˜ λ‘œκΉ…ν•˜λ©΄ νŠΉμ • μš”μ²­μ„ μΆ”μ ν•˜κΈ°κ°€ 훨씬 μš©μ΄ν•΄μ§‘λ‹ˆλ‹€.

Suggested change
logging.info(f"[worker] {task} λ°œν–‰ 성곡")
logging.info(f"[worker] Reclaimed task for job {job.correlationId} created")


def _on_done(t: asyncio.Task, *, msg_id=msg_id, fields=fields):
async def _ack_or_dlq():
Expand All @@ -155,8 +158,8 @@ async def _ack_or_dlq():
await self.redis_client.xadd(f"{settings.STREAM_JOB}:DLQ", clean)

except asyncio.CancelledError:
print("[worker] cancelled; bye")
logging.warning("[worker] cancelled; bye")
break
except Exception as e:
print(f"[worker] error: {e}")
logging.warning(f"[worker] error: {e}")
await asyncio.sleep(1)
Loading
Loading