Spaces:
Running
Running
| 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 | |
| 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 | |
| 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') | |
| 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} | |
| 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} | |
| 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} | |
| 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() | |
| } | |
| 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} | |
| 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") | |