raidAthmaneBenlala commited on
Commit
e73e5ff
ยท
1 Parent(s): ef91338

๐Ÿš€ Initial deploy of ToothMap Full-Stack App

Browse files
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ node_modules/
2
+ .git/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build the React Frontend
2
+ FROM node:18 AS build-frontend
3
+ WORKDIR /app/frontend
4
+ COPY toothmap_frontend/package*.json ./
5
+ RUN npm install
6
+ COPY toothmap_frontend/ ./
7
+ RUN npm run build
8
+
9
+ # Stage 2: Setup the Python Backend
10
+ FROM python:3.12-slim
11
+ WORKDIR /app
12
+
13
+ # Install system dependencies required by OpenCV
14
+ RUN apt-get update && apt-get install -y \
15
+ libgl1-mesa-glx \
16
+ libglib2.0-0 \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ # Install Python dependencies
20
+ COPY requirements.txt .
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ # Copy models
24
+ COPY checkpoints/ /app/checkpoints/
25
+
26
+ # Copy backend code
27
+ COPY toothmap_backend/ /app/
28
+
29
+ # Copy built React frontend to static folder
30
+ COPY --from=build-frontend /app/frontend/dist /app/static
31
+
32
+ # Expose Hugging Face default port
33
+ EXPOSE 7860
34
+
35
+ # Run FastAPI serving both the backend APIs and frontend static files
36
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
checkpoints/frcnn_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:527a79f84d68360fd2ee3f60a87b4476c287dec83d1069077eb8c33583bdf6e4
3
+ size 174040805
checkpoints/resnet18_cls_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:25d220e93a7e07f365d1646f47b20d114531a93bf7afac31e4bf3a4a8f372783
3
+ size 44852299
checkpoints/unet_resnet34_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:56743ec2eafd622bb8637704abacdf490a3fb5fd35fffe367a053d7040c7ceaa
3
+ size 97922503
checkpoints/yolo_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:458c476524831cde80ef996b023815b904e5f379cf491122c1821a0e89556dcc
3
+ size 22543466
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.110.0
2
+ uvicorn==0.27.1
3
+ python-multipart==0.0.9
4
+ torch==2.5.1
5
+ torchvision==0.20.1
6
+ ultralytics==8.3.40
7
+ segmentation-models-pytorch==0.3.4
8
+ albumentations==1.4.23
9
+ opencv-python-headless==4.10.0.84
10
+ numpy==2.1.3
toothmap_backend/main.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, UploadFile, File
2
+ from fastapi.staticfiles import StaticFiles
3
+ from fastapi.responses import HTMLResponse
4
+ import cv2
5
+ import numpy as np
6
+ import torch
7
+ import albumentations as A
8
+ from albumentations.pytorch import ToTensorV2
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ import models as M
11
+ from ultralytics import YOLO
12
+ import os
13
+
14
+ app = FastAPI(title="ToothMap AI API")
15
+
16
+ app.add_middleware(
17
+ CORSMiddleware,
18
+ allow_origins=["*"],
19
+ allow_methods=["*"],
20
+ allow_headers=["*"],
21
+ )
22
+
23
+ CKPT_DIR = "/app/checkpoints"
24
+ yolo_model = None
25
+
26
+ @app.on_event("startup")
27
+ async def startup_event():
28
+ global yolo_model
29
+ print("Loading internal PyTorch models...")
30
+ M.load_models(CKPT_DIR)
31
+
32
+ yolo_path = os.path.join(CKPT_DIR, "yolo_best.pt")
33
+ if os.path.exists(yolo_path):
34
+ yolo_model = YOLO(yolo_path)
35
+ print("โœ… YOLO loaded.")
36
+
37
+ # --- Transforms ---
38
+ DET_VAL_TF = A.Compose([
39
+ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
40
+ ToTensorV2(),
41
+ ])
42
+
43
+ CLS_VAL_TF = A.Compose([
44
+ A.Resize(224, 224),
45
+ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
46
+ ToTensorV2(),
47
+ ])
48
+
49
+ SEG_VAL_TF = A.Compose([
50
+ A.Resize(512, 512),
51
+ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
52
+ ToTensorV2(),
53
+ ])
54
+
55
+ def read_image(file_bytes):
56
+ nparr = np.frombuffer(file_bytes, np.uint8)
57
+ img_bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
58
+ return img_bgr
59
+
60
+ @app.get("/api/health")
61
+ def health():
62
+ return {
63
+ "frcnn": "frcnn" in M.MODELS,
64
+ "cls": "cls" in M.MODELS,
65
+ "unet": "unet" in M.MODELS,
66
+ "yolo": yolo_model is not None,
67
+ "device": str(M.DEVICE),
68
+ }
69
+
70
+ def _classify_crop(img_rgb_crop: np.ndarray) -> dict:
71
+ """Run ResNet18 on a single cropped tooth and return FDI + confidence."""
72
+ cls = M.MODELS.get("cls")
73
+ if cls is None:
74
+ return {"fdi": -1, "confidence": 0.0}
75
+ t = CLS_VAL_TF(image=img_rgb_crop)
76
+ img_t = t["image"].unsqueeze(0).to(M.DEVICE)
77
+ with torch.no_grad():
78
+ pred = cls(img_t)
79
+ probs = torch.softmax(pred, dim=1)
80
+ fdi_idx = pred.argmax(dim=1).item()
81
+ score = probs[0, fdi_idx].item()
82
+ quad = fdi_idx // 8
83
+ num = fdi_idx % 8
84
+ real_fdi = (quad + 1) * 10 + (num + 1)
85
+ return {"fdi": real_fdi, "confidence": round(score, 4)}
86
+
87
+ def _segment_crop(img_rgb_crop: np.ndarray) -> str:
88
+ """Run U-Net on a single cropped tooth, return base64 PNG mask with alpha."""
89
+ import base64
90
+ unet = M.MODELS.get("unet")
91
+ if unet is None or img_rgb_crop.size == 0:
92
+ return ""
93
+ t = SEG_VAL_TF(image=img_rgb_crop)
94
+ img_t = t["image"].unsqueeze(0).to(M.DEVICE)
95
+ with torch.no_grad():
96
+ out = unet(img_t)
97
+ mask = (torch.sigmoid(out) > 0.5).squeeze().cpu().numpy()
98
+ mask_cv = (mask * 255).astype(np.uint8)
99
+ rgba = np.zeros((mask_cv.shape[0], mask_cv.shape[1], 4), dtype=np.uint8)
100
+ rgba[:, :, 0] = 255 # B
101
+ rgba[:, :, 1] = 255 # G
102
+ rgba[:, :, 2] = 255 # R
103
+ rgba[:, :, 3] = mask_cv # A (transparency)
104
+ _, buf = cv2.imencode('.png', rgba)
105
+ return base64.b64encode(buf).decode('utf-8')
106
+
107
+ @app.post("/api/pipeline/yolo")
108
+ async def pipeline_yolo(file: UploadFile = File(...)):
109
+ """Detect teeth (YOLO) then classify each crop (ResNet18) โ†’ returns annotated boxes."""
110
+ if yolo_model is None:
111
+ return {"error": "YOLO not loaded"}
112
+ img_bgr = read_image(await file.read())
113
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
114
+ orig_h, orig_w = img_rgb.shape[:2]
115
+ img_yolo = cv2.resize(img_rgb, (640, 640))
116
+ ypred = yolo_model.predict(img_yolo, conf=0.20, verbose=False)[0]
117
+ boxes = ypred.boxes.xyxy.cpu().numpy()
118
+ if len(boxes) > 0:
119
+ boxes[:, [0, 2]] *= (orig_w / 640.0)
120
+ boxes[:, [1, 3]] *= (orig_h / 640.0)
121
+ scores = ypred.boxes.conf.cpu().numpy().tolist()
122
+ results = []
123
+ for i, box in enumerate(boxes):
124
+ x1, y1, x2, y2 = map(int, box)
125
+ x1, y1 = max(0, x1), max(0, y1)
126
+ x2, y2 = min(orig_w, x2), min(orig_h, y2)
127
+ crop = img_rgb[y1:y2, x1:x2]
128
+ cls_result = _classify_crop(crop) if crop.size > 0 else {"fdi": -1, "confidence": 0.0}
129
+ mask_b64 = _segment_crop(crop)
130
+ results.append({"box": list(map(float, box)), "score": scores[i], **cls_result, "mask_base64": mask_b64})
131
+ return {"results": results}
132
+
133
+ @app.post("/api/pipeline/frcnn")
134
+ async def pipeline_frcnn(file: UploadFile = File(...)):
135
+ """Detect teeth (FRCNN) then classify each crop (ResNet18) โ†’ returns annotated boxes."""
136
+ frcnn = M.MODELS.get("frcnn")
137
+ if frcnn is None:
138
+ return {"error": "FRCNN not loaded"}
139
+ img_bgr = read_image(await file.read())
140
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
141
+ orig_h, orig_w = img_rgb.shape[:2]
142
+ IMG_W, IMG_H = 1000, 500
143
+ img_det = cv2.resize(img_rgb, (IMG_W, IMG_H))
144
+ t = DET_VAL_TF(image=img_det)
145
+ img_t = t["image"].unsqueeze(0).to(M.DEVICE)
146
+ with torch.no_grad():
147
+ pred = frcnn(img_t)[0]
148
+ keep = pred["scores"] >= 0.20
149
+ boxes = pred["boxes"][keep].cpu().numpy()
150
+ scores = pred["scores"][keep].cpu().numpy().tolist()
151
+ if len(boxes) > 0:
152
+ boxes[:, [0, 2]] *= (orig_w / float(IMG_W))
153
+ boxes[:, [1, 3]] *= (orig_h / float(IMG_H))
154
+ results = []
155
+ for i, box in enumerate(boxes):
156
+ x1, y1, x2, y2 = map(int, box)
157
+ x1, y1 = max(0, x1), max(0, y1)
158
+ x2, y2 = min(orig_w, x2), min(orig_h, y2)
159
+ crop = img_rgb[y1:y2, x1:x2]
160
+ cls_result = _classify_crop(crop) if crop.size > 0 else {"fdi": -1, "confidence": 0.0}
161
+ mask_b64 = _segment_crop(crop)
162
+ results.append({"box": list(map(float, box)), "score": scores[i], **cls_result, "mask_base64": mask_b64})
163
+ return {"results": results}
164
+
165
+ @app.post("/api/detect/yolo")
166
+ async def detect_yolo(file: UploadFile = File(...)):
167
+ if yolo_model is None:
168
+ return {"error": "YOLO not loaded"}
169
+ img_bgr = read_image(await file.read())
170
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
171
+ orig_h, orig_w = img_rgb.shape[:2]
172
+ img_yolo = cv2.resize(img_rgb, (640, 640))
173
+ ypred = yolo_model.predict(img_yolo, conf=0.20, verbose=False)[0]
174
+ boxes = ypred.boxes.xyxy.cpu().numpy()
175
+ if len(boxes) > 0:
176
+ boxes[:, [0, 2]] *= (orig_w / 640.0)
177
+ boxes[:, [1, 3]] *= (orig_h / 640.0)
178
+ scores = ypred.boxes.conf.cpu().numpy().tolist()
179
+ labels = ypred.boxes.cls.cpu().numpy().astype(int).tolist()
180
+ return {"boxes": boxes.tolist(), "scores": scores, "labels": labels}
181
+
182
+ @app.post("/api/detect/frcnn")
183
+ async def detect_frcnn(file: UploadFile = File(...)):
184
+ frcnn = M.MODELS.get("frcnn")
185
+ if frcnn is None:
186
+ return {"error": "FRCNN not loaded"}
187
+ img_bgr = read_image(await file.read())
188
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
189
+ orig_h, orig_w = img_rgb.shape[:2]
190
+ IMG_W, IMG_H = 1000, 500
191
+ img_det = cv2.resize(img_rgb, (IMG_W, IMG_H))
192
+ t = DET_VAL_TF(image=img_det)
193
+ img_t = t["image"].unsqueeze(0).to(M.DEVICE)
194
+ with torch.no_grad():
195
+ pred = frcnn(img_t)[0]
196
+ keep = pred["scores"] >= 0.20
197
+ boxes = pred["boxes"][keep].cpu().numpy()
198
+ if len(boxes) > 0:
199
+ boxes[:, [0, 2]] *= (orig_w / float(IMG_W))
200
+ boxes[:, [1, 3]] *= (orig_h / float(IMG_H))
201
+ return {
202
+ "boxes": boxes.tolist(),
203
+ "scores": pred["scores"][keep].cpu().numpy().tolist(),
204
+ "labels": pred["labels"][keep].cpu().numpy().tolist()
205
+ }
206
+
207
+ @app.post("/api/classify")
208
+ async def classify_crops(file: UploadFile = File(...)):
209
+ cls = M.MODELS.get("cls")
210
+ if cls is None:
211
+ return {"error": "Classifier not loaded"}
212
+ img_bgr = read_image(await file.read())
213
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
214
+ t = CLS_VAL_TF(image=img_rgb)
215
+ img_t = t["image"].unsqueeze(0).to(M.DEVICE)
216
+ with torch.no_grad():
217
+ pred = cls(img_t)
218
+ confidences = torch.softmax(pred, dim=1)
219
+ fdi_idx = pred.argmax(dim=1).item()
220
+ score = confidences[0, fdi_idx].item()
221
+ quad = fdi_idx // 8
222
+ num = fdi_idx % 8
223
+ real_fdi = (quad + 1) * 10 + (num + 1)
224
+ return {"fdi": real_fdi, "confidence": score}
225
+
226
+ @app.post("/api/segment")
227
+ async def segment_unet(file: UploadFile = File(...)):
228
+ unet = M.MODELS.get("unet")
229
+ if unet is None:
230
+ return {"error": "U-Net not loaded"}
231
+ import base64
232
+ img_bgr = read_image(await file.read())
233
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
234
+ t = SEG_VAL_TF(image=img_rgb)
235
+ img_t = t["image"].unsqueeze(0).to(M.DEVICE)
236
+ with torch.no_grad():
237
+ out = unet(img_t)
238
+ mask = (torch.sigmoid(out) > 0.5).squeeze().cpu().numpy()
239
+ mask_img = (mask * 255).astype(np.uint8)
240
+ _, buffer = cv2.imencode('.png', mask_img)
241
+ encoded = base64.b64encode(buffer).decode('utf-8')
242
+ return {"mask_base64": encoded}
243
+
244
+ # --- Mount React Frontend ---
245
+ # This MUST be the last route registered so it doesn't shadow API routes
246
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
toothmap_backend/models.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+ from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2
4
+ from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
5
+ from torchvision.models import resnet18
6
+ import segmentation_models_pytorch as smp
7
+ import os
8
+
9
+ DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
10
+ print(f"๐Ÿ–ฅ๏ธ Using device: {DEVICE}")
11
+
12
+ # Shared model registry - avoids Python import scoping issues with module globals
13
+ MODELS = {}
14
+
15
+ def build_frcnn():
16
+ model = fasterrcnn_resnet50_fpn_v2(weights=None)
17
+ in_features = model.roi_heads.box_predictor.cls_score.in_features
18
+ model.roi_heads.box_predictor = FastRCNNPredictor(in_features, 33)
19
+ return model
20
+
21
+ def build_classifier():
22
+ model = resnet18(weights=None)
23
+ model.fc = nn.Sequential(nn.Dropout(0.3), nn.Linear(model.fc.in_features, 32))
24
+ return model
25
+
26
+ def build_unet():
27
+ return smp.Unet(encoder_name="resnet34", encoder_weights=None, in_channels=3, classes=1)
28
+
29
+ def _strip_dp(state: dict) -> dict:
30
+ """Strip 'module.' prefix added by nn.DataParallel on Kaggle multi-GPU."""
31
+ if any(k.startswith("module.") for k in state.keys()):
32
+ print(" โ†ณ Stripping DataParallel 'module.' prefix from checkpoint")
33
+ return {k.replace("module.", "", 1): v for k, v in state.items()}
34
+ return state
35
+
36
+ def _load(path: str, model):
37
+ if not os.path.exists(path):
38
+ print(f"โš ๏ธ Checkpoint not found: {path}")
39
+ return model
40
+ try:
41
+ state = torch.load(path, map_location=DEVICE, weights_only=False)
42
+ state = _strip_dp(state)
43
+ model.load_state_dict(state, strict=False)
44
+ print(f"โœ… Loaded: {os.path.basename(path)}")
45
+ except Exception as e:
46
+ print(f"โŒ Failed loading {os.path.basename(path)}: {e}")
47
+ return model
48
+
49
+ def load_models(ckpt_dir: str):
50
+ print(f"๐Ÿ“ฆ Loading models from: {ckpt_dir}")
51
+ MODELS["frcnn"] = _load(os.path.join(ckpt_dir, "frcnn_best.pt"), build_frcnn()).to(DEVICE).eval()
52
+ MODELS["cls"] = _load(os.path.join(ckpt_dir, "resnet18_cls_best.pt"), build_classifier()).to(DEVICE).eval()
53
+ MODELS["unet"] = _load(os.path.join(ckpt_dir, "unet_resnet34_best.pt"), build_unet()).to(DEVICE).eval()
54
+ print("๐Ÿš€ All PyTorch models ready.")
toothmap_backend/requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ # Use latest stable versions to strictly match Kaggle env
5
+ torch>=2.0.0
6
+ torchvision>=0.15.0
7
+ ultralytics
8
+ albumentations
9
+ opencv-python-headless
10
+ segmentation-models-pytorch
11
+ pydantic
toothmap_frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
toothmap_frontend/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
toothmap_frontend/eslint.config.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import react from 'eslint-plugin-react'
4
+ import reactHooks from 'eslint-plugin-react-hooks'
5
+ import reactRefresh from 'eslint-plugin-react-refresh'
6
+
7
+ export default [
8
+ { ignores: ['dist'] },
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ languageOptions: {
12
+ ecmaVersion: 2020,
13
+ globals: globals.browser,
14
+ parserOptions: {
15
+ ecmaVersion: 'latest',
16
+ ecmaFeatures: { jsx: true },
17
+ sourceType: 'module',
18
+ },
19
+ },
20
+ settings: { react: { version: '18.3' } },
21
+ plugins: {
22
+ react,
23
+ 'react-hooks': reactHooks,
24
+ 'react-refresh': reactRefresh,
25
+ },
26
+ rules: {
27
+ ...js.configs.recommended.rules,
28
+ ...react.configs.recommended.rules,
29
+ ...react.configs['jsx-runtime'].rules,
30
+ ...reactHooks.configs.recommended.rules,
31
+ 'react/jsx-no-target-blank': 'off',
32
+ 'react-refresh/only-export-components': [
33
+ 'warn',
34
+ { allowConstantExport: true },
35
+ ],
36
+ },
37
+ },
38
+ ]
toothmap_frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Vite + React</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
toothmap_frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
toothmap_frontend/package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "toothmap_frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1"
15
+ },
16
+ "devDependencies": {
17
+ "@eslint/js": "^9.13.0",
18
+ "@types/react": "^18.3.12",
19
+ "@types/react-dom": "^18.3.1",
20
+ "@vitejs/plugin-react": "^4.3.3",
21
+ "autoprefixer": "^10.5.0",
22
+ "axios": "^1.15.0",
23
+ "eslint": "^9.13.0",
24
+ "eslint-plugin-react": "^7.37.2",
25
+ "eslint-plugin-react-hooks": "^5.0.0",
26
+ "eslint-plugin-react-refresh": "^0.4.14",
27
+ "globals": "^15.11.0",
28
+ "lucide-react": "^1.8.0",
29
+ "postcss": "^8.5.10",
30
+ "react-router-dom": "^7.14.1",
31
+ "tailwindcss": "^3.4.19",
32
+ "vite": "^5.4.10"
33
+ }
34
+ }
toothmap_frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
toothmap_frontend/public/vite.svg ADDED
toothmap_frontend/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
toothmap_frontend/src/App.jsx ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import axios from 'axios';
3
+ import { UploadCloud, Activity, Loader2, ScanLine } from 'lucide-react';
4
+
5
+ // Leave empty for relative routing (needed for Hugging Face Spaces subdomains)
6
+ const API_BASE = "";
7
+
8
+ const MODES = [
9
+ { id: 'pipeline_yolo', label: '๐Ÿฆท Full Pipeline (YOLO + FDI)', desc: 'Detect โ†’ FDI label + per-tooth segmentation mask' },
10
+ { id: 'pipeline_frcnn', label: '๐Ÿฆท Full Pipeline (FRCNN + FDI)', desc: 'Detect โ†’ FDI label + per-tooth segmentation mask' },
11
+ { id: 'seg_yolo', label: '๐Ÿ”ฌ Segmentation (YOLO + UNet)', desc: 'Detect teeth โ†’ highlight each tooth boundary with U-Net' },
12
+ { id: 'seg_frcnn', label: '๐Ÿ”ฌ Segmentation (FRCNN + UNet)', desc: 'Detect teeth โ†’ highlight each tooth boundary with U-Net' },
13
+ { id: 'yolo', label: 'Detection Only (YOLOv8)', desc: 'Raw bounding boxes only' },
14
+ { id: 'frcnn', label: 'Detection Only (Faster R-CNN)', desc: 'Raw bounding boxes only' },
15
+ ];
16
+
17
+ // Distinct colours for FDI quadrants 1-4
18
+ const QUADRANT_COLOURS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444'];
19
+
20
+ function getColour(fdi) {
21
+ if (!fdi || fdi < 0) return '#a855f7';
22
+ const quad = Math.floor(fdi / 10) - 1;
23
+ return QUADRANT_COLOURS[Math.min(quad, 3)] ?? '#a855f7';
24
+ }
25
+
26
+ export default function App() {
27
+ const [file, setFile] = useState(null);
28
+ const [imagePreview, setImagePreview] = useState(null);
29
+ const [mode, setMode] = useState('pipeline_yolo');
30
+ const [pipelineResults, setPipelineResults] = useState(null); // [{box,score,fdi,confidence}]
31
+ const [rawBoxes, setRawBoxes] = useState(null); // {boxes,scores}
32
+ const [maskSrc, setMaskSrc] = useState(null);
33
+ const [loading, setLoading] = useState(false);
34
+ const imgRef = useRef(null);
35
+ const canvasRef = useRef(null);
36
+
37
+ const handleFileSelect = (f) => {
38
+ setFile(f);
39
+ setImagePreview(URL.createObjectURL(f));
40
+ setPipelineResults(null);
41
+ setRawBoxes(null);
42
+ setMaskSrc(null);
43
+ };
44
+
45
+ const handleAPI = async () => {
46
+ if (!file) return;
47
+ setLoading(true);
48
+ setPipelineResults(null); setRawBoxes(null); setMaskSrc(null);
49
+ const fd = new FormData();
50
+ fd.append("file", file);
51
+ try {
52
+ if (mode === 'pipeline_yolo') {
53
+ const r = await axios.post(`${API_BASE}/api/pipeline/yolo`, fd);
54
+ setPipelineResults(r.data.results);
55
+ } else if (mode === 'pipeline_frcnn') {
56
+ const r = await axios.post(`${API_BASE}/api/pipeline/frcnn`, fd);
57
+ setPipelineResults(r.data.results);
58
+ } else if (mode === 'seg_yolo') {
59
+ const r = await axios.post(`${API_BASE}/api/pipeline/yolo`, fd);
60
+ setPipelineResults(r.data.results);
61
+ } else if (mode === 'seg_frcnn') {
62
+ const r = await axios.post(`${API_BASE}/api/pipeline/frcnn`, fd);
63
+ setPipelineResults(r.data.results);
64
+ } else if (mode === 'yolo') {
65
+ const r = await axios.post(`${API_BASE}/api/detect/yolo`, fd);
66
+ setRawBoxes(r.data);
67
+ } else if (mode === 'frcnn') {
68
+ const r = await axios.post(`${API_BASE}/api/detect/frcnn`, fd);
69
+ setRawBoxes(r.data);
70
+ }
71
+ } catch (e) {
72
+ console.error(e);
73
+ alert("Error contacting the AI server. Ensure the FastAPI server is running on port 8000.");
74
+ } finally {
75
+ setLoading(false);
76
+ }
77
+ };
78
+
79
+ // Draw canvas overlays
80
+ useEffect(() => {
81
+ if (!canvasRef.current || !imgRef.current) return;
82
+ const img = imgRef.current;
83
+ const ctx = canvasRef.current.getContext('2d');
84
+ const { width, height } = img.getBoundingClientRect();
85
+ canvasRef.current.width = width;
86
+ canvasRef.current.height = height;
87
+ const sx = width / img.naturalWidth;
88
+ const sy = height / img.naturalHeight;
89
+ ctx.clearRect(0, 0, width, height);
90
+
91
+ // Pipeline / segmentation mode
92
+ if (pipelineResults) {
93
+ const showFDI = (mode === 'pipeline_yolo' || mode === 'pipeline_frcnn');
94
+ pipelineResults.forEach(({ box, score, fdi, mask_base64 }) => {
95
+ const [x1, y1, x2, y2] = box;
96
+ const rx = x1 * sx, ry = y1 * sy, rw = (x2 - x1) * sx, rh = (y2 - y1) * sy;
97
+ const col = showFDI ? getColour(fdi) : '#22d3ee';
98
+
99
+ // Draw segmentation mask โ€” tinted fill inside the actual tooth boundary
100
+ if (mask_base64) {
101
+ const maskImg = new Image();
102
+ maskImg.onload = () => {
103
+ const off = document.createElement('canvas');
104
+ off.width = rw; off.height = rh;
105
+ const octx = off.getContext('2d');
106
+
107
+ // Draw solid colour background
108
+ octx.fillStyle = col;
109
+ octx.fillRect(0, 0, rw, rh);
110
+
111
+ // Mask the solid colour using the alpha channel of the PNG
112
+ octx.globalCompositeOperation = 'destination-in';
113
+ octx.drawImage(maskImg, 0, 0, rw, rh);
114
+
115
+ // Draw onto main canvas
116
+ ctx.save();
117
+ ctx.globalAlpha = 0.50;
118
+ ctx.drawImage(off, rx, ry);
119
+ ctx.restore();
120
+ };
121
+ maskImg.src = `data:image/png;base64,${mask_base64}`;
122
+ }
123
+
124
+ // Bounding box
125
+ ctx.strokeStyle = col; ctx.lineWidth = 2;
126
+ ctx.strokeRect(rx, ry, rw, rh);
127
+ // Label โ€” only in pipeline (FDI) modes
128
+ const label = showFDI
129
+ ? (fdi > 0 ? `FDI ${fdi}` : `${(score*100).toFixed(0)}%`)
130
+ : `${(score*100).toFixed(0)}%`;
131
+ const textW = ctx.measureText(label).width + 10;
132
+ ctx.fillStyle = col;
133
+ ctx.fillRect(rx, ry - 20, textW, 20);
134
+ ctx.fillStyle = 'white'; ctx.font = 'bold 12px Arial';
135
+ ctx.fillText(label, rx + 5, ry - 5);
136
+ });
137
+ }
138
+
139
+ // Raw detection mode
140
+ if (rawBoxes?.boxes) {
141
+ rawBoxes.boxes.forEach((box, i) => {
142
+ const [x1, y1, x2, y2] = box;
143
+ const rx = x1 * sx, ry = y1 * sy, rw = (x2 - x1) * sx, rh = (y2 - y1) * sy;
144
+ ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2;
145
+ ctx.strokeRect(rx, ry, rw, rh);
146
+ const s = `${(rawBoxes.scores[i] * 100).toFixed(0)}%`;
147
+ ctx.fillStyle = '#3b82f6'; ctx.fillRect(rx, ry - 20, 40, 20);
148
+ ctx.fillStyle = 'white'; ctx.font = '12px Arial';
149
+ ctx.fillText(s, rx + 4, ry - 5);
150
+ });
151
+ }
152
+ }, [pipelineResults, rawBoxes]);
153
+
154
+ // Legend for pipeline mode
155
+ const quadrantNames = ['Q1 (Upper Right)', 'Q2 (Upper Left)', 'Q3 (Lower Left)', 'Q4 (Lower Right)'];
156
+
157
+ return (
158
+ <div className="min-h-screen flex flex-col bg-zinc-950 font-sans text-slate-200">
159
+ <header className="px-8 py-4 border-b border-zinc-800/50 flex items-center gap-3">
160
+ <Activity size={22} className="text-blue-500" />
161
+ <h1 className="text-lg font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
162
+ ToothMap AI Dashboard
163
+ </h1>
164
+ </header>
165
+
166
+ <main className="flex-1 flex overflow-hidden">
167
+ {/* Sidebar */}
168
+ <aside className="w-80 bg-zinc-900/40 border-r border-zinc-800/50 p-5 flex flex-col gap-4">
169
+ <div className="space-y-2">
170
+ <p className="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-2">AI Mode</p>
171
+ {MODES.map(m => (
172
+ <button key={m.id} onClick={() => setMode(m.id)}
173
+ className={`w-full text-left px-3 py-2.5 rounded-lg border text-sm transition-all ${
174
+ mode === m.id
175
+ ? 'bg-blue-500/10 border-blue-500/50 text-blue-300'
176
+ : 'bg-zinc-800/20 border-zinc-700/30 text-slate-400 hover:bg-zinc-800/40'
177
+ }`}>
178
+ <div className="font-medium">{m.label}</div>
179
+ <div className="text-xs text-zinc-500 mt-0.5">{m.desc}</div>
180
+ </button>
181
+ ))}
182
+ </div>
183
+
184
+ <div className="border-2 border-dashed border-zinc-700/50 hover:border-blue-500/50 transition-colors rounded-xl p-6 flex flex-col items-center text-center cursor-pointer bg-zinc-900/20 group"
185
+ onClick={() => document.getElementById('fu').click()}
186
+ onDragOver={e => e.preventDefault()}
187
+ onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) handleFileSelect(e.dataTransfer.files[0]); }}>
188
+ <UploadCloud size={32} className="text-zinc-500 group-hover:text-blue-400 mb-2 transition-colors" />
189
+ <p className="text-sm font-medium">{file ? file.name : 'Drag & Drop X-Ray'}</p>
190
+ <p className="text-xs text-zinc-500 mt-1">or click to browse</p>
191
+ <input type="file" id="fu" className="hidden" accept="image/*"
192
+ onChange={e => handleFileSelect(e.target.files[0])} />
193
+ </div>
194
+
195
+ <button onClick={handleAPI} disabled={!file || loading}
196
+ className={`w-full py-3 rounded-lg font-semibold flex items-center justify-center gap-2 transition-all ${
197
+ !file ? 'bg-zinc-800 text-zinc-500 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-900/30'
198
+ }`}>
199
+ {loading ? <Loader2 size={18} className="animate-spin" /> : <ScanLine size={18} />}
200
+ {loading ? 'Analysing...' : 'Run AI Analysis'}
201
+ </button>
202
+
203
+ {/* Legend */}
204
+ {pipelineResults && (
205
+ <div className="mt-2">
206
+ <p className="text-xs text-zinc-500 uppercase tracking-widest mb-2">FDI Quadrants</p>
207
+ {quadrantNames.map((name, i) => (
208
+ <div key={i} className="flex items-center gap-2 text-xs mb-1">
209
+ <span className="w-3 h-3 rounded-sm flex-shrink-0" style={{ background: QUADRANT_COLOURS[i] }} />
210
+ <span className="text-zinc-400">{name}</span>
211
+ </div>
212
+ ))}
213
+ </div>
214
+ )}
215
+
216
+ {/* Stats Panel */}
217
+ {pipelineResults && (
218
+ <div className="bg-zinc-800/30 rounded-lg p-3 text-sm">
219
+ <p className="text-zinc-400 text-xs mb-1">Detected Teeth</p>
220
+ <p className="text-2xl font-bold text-white">{pipelineResults.length}</p>
221
+ {pipelineResults.slice(0,6).map((r, i) => (
222
+ <div key={i} className="flex justify-between text-xs text-zinc-500 mt-1">
223
+ <span style={{ color: getColour(r.fdi) }}>FDI {r.fdi > 0 ? r.fdi : '?'}</span>
224
+ <span>{(r.confidence * 100).toFixed(0)}% conf</span>
225
+ </div>
226
+ ))}
227
+ {pipelineResults.length > 6 && <p className="text-xs text-zinc-600 mt-1">+{pipelineResults.length - 6} more...</p>}
228
+ </div>
229
+ )}
230
+ </aside>
231
+
232
+ {/* Canvas */}
233
+ <section className="flex-1 bg-zinc-950/80 p-8 flex items-center justify-center overflow-auto">
234
+ {!imagePreview ? (
235
+ <div className="text-center text-zinc-600">
236
+ <UploadCloud size={64} className="mx-auto opacity-20 mb-4" />
237
+ <p className="text-lg">No patient X-Ray selected</p>
238
+ </div>
239
+ ) : (
240
+ <div className="relative inline-block border border-zinc-800 rounded-lg overflow-hidden shadow-2xl">
241
+ <img ref={imgRef} src={imagePreview} alt="X-ray"
242
+ className="max-w-full max-h-[85vh] object-contain block rounded" />
243
+ <canvas ref={canvasRef} className="absolute top-0 left-0 pointer-events-none" />
244
+ {maskSrc && (
245
+ <img src={maskSrc} className="absolute top-0 left-0 w-full h-full object-fill pointer-events-none opacity-50 mix-blend-screen" alt="Segmentation Mask" />
246
+ )}
247
+ </div>
248
+ )}
249
+ </section>
250
+ </main>
251
+ </div>
252
+ );
253
+ }
toothmap_frontend/src/assets/react.svg ADDED
toothmap_frontend/src/index.css ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ body {
7
+ @apply bg-zinc-950 text-slate-200 antialiased;
8
+ }
9
+ }
10
+
11
+ .glass-panel {
12
+ @apply bg-zinc-900/40 backdrop-blur-md border border-zinc-700/50 shadow-xl;
13
+ }
toothmap_frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
toothmap_frontend/tailwind.config.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ theme: {
8
+ extend: {},
9
+ },
10
+ plugins: [],
11
+ }
toothmap_frontend/vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })