raidAthmaneBenlala's picture
fix: update api_base for dev/prod and make model checkpoints path dynamic
9d8ec5b
Raw
History Blame Contribute Delete
9.25 kB
from fastapi import FastAPI, UploadFile, File
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
import cv2
import numpy as np
import torch
import albumentations as A
from albumentations.pytorch import ToTensorV2
from fastapi.middleware.cors import CORSMiddleware
import models as M
from ultralytics import YOLO
import os
app = FastAPI(title="ToothMap AI API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Dynamic checkpoint directory
if os.path.exists("/app/checkpoints"):
CKPT_DIR = "/app/checkpoints"
else:
CKPT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "checkpoints"))
yolo_model = None
@app.on_event("startup")
async def startup_event():
global yolo_model
print("Loading internal PyTorch models...")
M.load_models(CKPT_DIR)
yolo_path = os.path.join(CKPT_DIR, "yolo_best.pt")
if os.path.exists(yolo_path):
yolo_model = YOLO(yolo_path)
print("✅ YOLO loaded.")
# --- Transforms ---
DET_VAL_TF = A.Compose([
A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
ToTensorV2(),
])
CLS_VAL_TF = A.Compose([
A.Resize(224, 224),
A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
ToTensorV2(),
])
SEG_VAL_TF = A.Compose([
A.Resize(512, 512),
A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
ToTensorV2(),
])
def read_image(file_bytes):
nparr = np.frombuffer(file_bytes, np.uint8)
img_bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return img_bgr
@app.get("/api/health")
def health():
return {
"frcnn": "frcnn" in M.MODELS,
"cls": "cls" in M.MODELS,
"unet": "unet" in M.MODELS,
"yolo": yolo_model is not None,
"device": str(M.DEVICE),
}
def _classify_crop(img_rgb_crop: np.ndarray) -> dict:
"""Run ResNet18 on a single cropped tooth and return FDI + confidence."""
cls = M.MODELS.get("cls")
if cls is None:
return {"fdi": -1, "confidence": 0.0}
t = CLS_VAL_TF(image=img_rgb_crop)
img_t = t["image"].unsqueeze(0).to(M.DEVICE)
with torch.no_grad():
pred = cls(img_t)
probs = torch.softmax(pred, dim=1)
fdi_idx = pred.argmax(dim=1).item()
score = probs[0, fdi_idx].item()
quad = fdi_idx // 8
num = fdi_idx % 8
real_fdi = (quad + 1) * 10 + (num + 1)
return {"fdi": real_fdi, "confidence": round(score, 4)}
def _segment_crop(img_rgb_crop: np.ndarray) -> str:
"""Run U-Net on a single cropped tooth, return base64 PNG mask with alpha."""
import base64
unet = M.MODELS.get("unet")
if unet is None or img_rgb_crop.size == 0:
return ""
t = SEG_VAL_TF(image=img_rgb_crop)
img_t = t["image"].unsqueeze(0).to(M.DEVICE)
with torch.no_grad():
out = unet(img_t)
mask = (torch.sigmoid(out) > 0.5).squeeze().cpu().numpy()
mask_cv = (mask * 255).astype(np.uint8)
rgba = np.zeros((mask_cv.shape[0], mask_cv.shape[1], 4), dtype=np.uint8)
rgba[:, :, 0] = 255 # B
rgba[:, :, 1] = 255 # G
rgba[:, :, 2] = 255 # R
rgba[:, :, 3] = mask_cv # A (transparency)
_, buf = cv2.imencode('.png', rgba)
return base64.b64encode(buf).decode('utf-8')
@app.post("/api/pipeline/yolo")
async def pipeline_yolo(file: UploadFile = File(...)):
"""Detect teeth (YOLO) then classify each crop (ResNet18) → returns annotated boxes."""
if yolo_model is None:
return {"error": "YOLO not loaded"}
img_bgr = read_image(await file.read())
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
orig_h, orig_w = img_rgb.shape[:2]
img_yolo = cv2.resize(img_rgb, (640, 640))
ypred = yolo_model.predict(img_yolo, conf=0.20, verbose=False)[0]
boxes = ypred.boxes.xyxy.cpu().numpy()
if len(boxes) > 0:
boxes[:, [0, 2]] *= (orig_w / 640.0)
boxes[:, [1, 3]] *= (orig_h / 640.0)
scores = ypred.boxes.conf.cpu().numpy().tolist()
results = []
for i, box in enumerate(boxes):
x1, y1, x2, y2 = map(int, box)
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(orig_w, x2), min(orig_h, y2)
crop = img_rgb[y1:y2, x1:x2]
cls_result = _classify_crop(crop) if crop.size > 0 else {"fdi": -1, "confidence": 0.0}
mask_b64 = _segment_crop(crop)
results.append({"box": list(map(float, box)), "score": scores[i], **cls_result, "mask_base64": mask_b64})
return {"results": results}
@app.post("/api/pipeline/frcnn")
async def pipeline_frcnn(file: UploadFile = File(...)):
"""Detect teeth (FRCNN) then classify each crop (ResNet18) → returns annotated boxes."""
frcnn = M.MODELS.get("frcnn")
if frcnn is None:
return {"error": "FRCNN not loaded"}
img_bgr = read_image(await file.read())
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
orig_h, orig_w = img_rgb.shape[:2]
IMG_W, IMG_H = 1000, 500
img_det = cv2.resize(img_rgb, (IMG_W, IMG_H))
t = DET_VAL_TF(image=img_det)
img_t = t["image"].unsqueeze(0).to(M.DEVICE)
with torch.no_grad():
pred = frcnn(img_t)[0]
keep = pred["scores"] >= 0.20
boxes = pred["boxes"][keep].cpu().numpy()
scores = pred["scores"][keep].cpu().numpy().tolist()
if len(boxes) > 0:
boxes[:, [0, 2]] *= (orig_w / float(IMG_W))
boxes[:, [1, 3]] *= (orig_h / float(IMG_H))
results = []
for i, box in enumerate(boxes):
x1, y1, x2, y2 = map(int, box)
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(orig_w, x2), min(orig_h, y2)
crop = img_rgb[y1:y2, x1:x2]
cls_result = _classify_crop(crop) if crop.size > 0 else {"fdi": -1, "confidence": 0.0}
mask_b64 = _segment_crop(crop)
results.append({"box": list(map(float, box)), "score": scores[i], **cls_result, "mask_base64": mask_b64})
return {"results": results}
@app.post("/api/detect/yolo")
async def detect_yolo(file: UploadFile = File(...)):
if yolo_model is None:
return {"error": "YOLO not loaded"}
img_bgr = read_image(await file.read())
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
orig_h, orig_w = img_rgb.shape[:2]
img_yolo = cv2.resize(img_rgb, (640, 640))
ypred = yolo_model.predict(img_yolo, conf=0.20, verbose=False)[0]
boxes = ypred.boxes.xyxy.cpu().numpy()
if len(boxes) > 0:
boxes[:, [0, 2]] *= (orig_w / 640.0)
boxes[:, [1, 3]] *= (orig_h / 640.0)
scores = ypred.boxes.conf.cpu().numpy().tolist()
labels = ypred.boxes.cls.cpu().numpy().astype(int).tolist()
return {"boxes": boxes.tolist(), "scores": scores, "labels": labels}
@app.post("/api/detect/frcnn")
async def detect_frcnn(file: UploadFile = File(...)):
frcnn = M.MODELS.get("frcnn")
if frcnn is None:
return {"error": "FRCNN not loaded"}
img_bgr = read_image(await file.read())
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
orig_h, orig_w = img_rgb.shape[:2]
IMG_W, IMG_H = 1000, 500
img_det = cv2.resize(img_rgb, (IMG_W, IMG_H))
t = DET_VAL_TF(image=img_det)
img_t = t["image"].unsqueeze(0).to(M.DEVICE)
with torch.no_grad():
pred = frcnn(img_t)[0]
keep = pred["scores"] >= 0.20
boxes = pred["boxes"][keep].cpu().numpy()
if len(boxes) > 0:
boxes[:, [0, 2]] *= (orig_w / float(IMG_W))
boxes[:, [1, 3]] *= (orig_h / float(IMG_H))
return {
"boxes": boxes.tolist(),
"scores": pred["scores"][keep].cpu().numpy().tolist(),
"labels": pred["labels"][keep].cpu().numpy().tolist()
}
@app.post("/api/classify")
async def classify_crops(file: UploadFile = File(...)):
cls = M.MODELS.get("cls")
if cls is None:
return {"error": "Classifier not loaded"}
img_bgr = read_image(await file.read())
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
t = CLS_VAL_TF(image=img_rgb)
img_t = t["image"].unsqueeze(0).to(M.DEVICE)
with torch.no_grad():
pred = cls(img_t)
confidences = torch.softmax(pred, dim=1)
fdi_idx = pred.argmax(dim=1).item()
score = confidences[0, fdi_idx].item()
quad = fdi_idx // 8
num = fdi_idx % 8
real_fdi = (quad + 1) * 10 + (num + 1)
return {"fdi": real_fdi, "confidence": score}
@app.post("/api/segment")
async def segment_unet(file: UploadFile = File(...)):
unet = M.MODELS.get("unet")
if unet is None:
return {"error": "U-Net not loaded"}
import base64
img_bgr = read_image(await file.read())
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
t = SEG_VAL_TF(image=img_rgb)
img_t = t["image"].unsqueeze(0).to(M.DEVICE)
with torch.no_grad():
out = unet(img_t)
mask = (torch.sigmoid(out) > 0.5).squeeze().cpu().numpy()
mask_img = (mask * 255).astype(np.uint8)
_, buffer = cv2.imencode('.png', mask_img)
encoded = base64.b64encode(buffer).decode('utf-8')
return {"mask_base64": encoded}
# --- Mount React Frontend ---
# This MUST be the last route registered so it doesn't shadow API routes
app.mount("/assets", StaticFiles(directory="static/assets"), name="assets")
app.mount("/", StaticFiles(directory="static", html=True), name="static")