sairamv1 commited on
Commit
d7c5a41
Β·
verified Β·
1 Parent(s): 8063600

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +212 -100
app.py CHANGED
@@ -1,160 +1,272 @@
1
  import os
 
 
 
2
  os.environ["KERAS_BACKEND"] = "jax"
3
- os.environ["JAX_PLATFORM_NAME"] = "cpu" # Fast startup, no GPU needed
 
4
 
5
  import base64
6
  import logging
7
  import numpy as np
8
  import cv2
9
  import requests
10
- from typing import List, Dict, Any
11
 
12
  import keras
13
  from fastapi import FastAPI, File, UploadFile, HTTPException
 
14
  from huggingface_hub import hf_hub_download
 
15
 
16
- # ─── Logging & Backend ────────────────────────────────────────────────────────
17
- logging.basicConfig(level=logging.INFO)
18
  logger = logging.getLogger(__name__)
19
 
20
-
21
- # ─── Config (use Space Secrets for API_KEY) ───────────────────────────────────
22
- API_KEY = os.getenv("ROBOFLOW_API_KEY") # Set in Space Settings β†’ Secrets
23
- WORKFLOW_URL = "https://serverless.roboflow.com/my-workspace-iy9ll/find-teeth" # From your SDK
24
  IMG_SIZE = 256
25
- THRESHOLD = 0.05 # mean+2*std from healthy images [file:1]
26
-
27
  MODEL_REPO = "sairamv1/TeethCariesDetection_CNNAutoencoder"
28
- MODEL_FILENAME = "model.keras" # Change if your file is named differently
29
 
30
- # ─── Load Model at Startup ────────────────────────────────────────────────────
31
  MODEL = None
32
- try:
33
- logger.info("πŸš€ Downloading model...")
34
- local_model_path = hf_hub_download(
 
 
 
 
 
 
 
 
35
  repo_id=MODEL_REPO,
36
  filename=MODEL_FILENAME,
37
- cache_dir="/tmp"
38
  )
39
- logger.info(f"πŸ“₯ Model at: {local_model_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- logger.info("πŸ”„ Loading Keras model...")
42
- MODEL = keras.saving.load_model(local_model_path)
43
- logger.info("βœ… Model loaded successfully!")
44
- except Exception as e:
45
- logger.error(f"❌ Model error: {e}")
46
- MODEL = None
47
 
48
- app = FastAPI(title="Teeth Caries Detection API")
 
 
 
 
 
49
 
50
- def first(seq: Any) -> Any:
51
- return seq[0] if seq else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
 
53
  @app.get("/")
54
  async def root():
 
55
  return {
56
- "status": "alive βœ…",
57
- "model_loaded": MODEL is not None,
58
- "endpoints": ["/analyze"],
59
- "tooth_size": f"{IMG_SIZE}x{IMG_SIZE}",
60
- "threshold": THRESHOLD
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
63
  @app.post("/analyze")
64
  async def analyze(file: UploadFile = File(...)):
65
- if MODEL is None:
66
- raise HTTPException(status_code=500, detail="Model failed to load at startup")
67
 
68
- # 1. Decode image
69
- contents = await file.read()
70
- nparr = np.frombuffer(contents, np.uint8)
71
- img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
72
- if img is None:
73
- raise HTTPException(status_code=400, detail="Invalid image")
74
 
75
- img_h, img_w = img.shape[:2]
 
76
 
77
- # 2. Roboflow workflow
78
- image_base64 = base64.b64encode(contents).decode("utf-8")
79
  try:
80
- response = requests.post(
81
- WORKFLOW_URL,
82
- params={"api_key": API_KEY},
83
- json={"inputs": {"image": {"type": "base64", "value": image_base64}}},
84
- timeout=60
85
- )
86
- response.raise_for_status()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  except Exception as e:
88
- raise HTTPException(status_code=502, detail=f"Roboflow error: {str(e)}")
 
89
 
90
- # 3. Parse predictions (flexible for different response formats)
91
- result_json = response.json()
92
- predictions_list = []
93
  try:
94
  if isinstance(result_json, list):
95
- outputs = first(result_json).get("outputs", {})
96
  else:
97
- outputs = result_json.get("outputs", [{}])[0]
98
- pred_blob = outputs.get("predictions", {})
99
- predictions_list = pred_blob.get("predictions", []) if isinstance(pred_blob, dict) else pred_blob
100
- except:
101
- predictions_list = []
 
 
 
102
 
103
- if not predictions_list:
104
- return {"status": "success", "tooth_count": 0, "results": [], "message": "No teeth detected"}
 
 
 
 
 
105
 
106
- # 4. Crop + preprocess teeth
107
  batch_images = []
108
  metadata = []
109
- for i, det in enumerate(predictions_list[:5]): # Max 5 teeth
110
- xc, yc, w, h = det["x"], det["y"], det["width"], det["height"]
111
- x1, y1 = max(0, int(xc - w/2)), max(0, int(yc - h/2))
112
- x2, y2 = min(img_w, int(xc + w/2)), min(img_h, int(yc + h/2))
113
-
114
- crop = img[y1:y2, x1:x2]
115
- if crop.size == 0: continue
116
-
117
- # Grayscale + CLAHE enhancement
118
- gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
119
- clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
120
- gray = clahe.apply(gray)
121
-
122
- # Resize with padding (preserves aspect ratio)
123
- ch, cw = gray.shape
124
- scale = IMG_SIZE / max(ch, cw)
125
- new_w, new_h = max(1, int(cw * scale)), max(1, int(ch * scale))
126
- resized = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_AREA)
127
-
128
- canvas = np.zeros((IMG_SIZE, IMG_SIZE), dtype=np.uint8)
129
- y0, x0 = (IMG_SIZE - new_h)//2, (IMG_SIZE - new_w)//2
130
- canvas[y0:y0+new_h, x0:x0+new_w] = resized
131
-
132
- # Normalize to [0,1] + channel dim
133
- final_img = (canvas.astype(np.float32) / 255.0)[..., np.newaxis]
134
- batch_images.append(final_img)
135
- metadata.append({"id": i, "box": [x1,y1,x2,y2]})
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  if not batch_images:
138
- return {"status": "success", "tooth_count": 0, "results": []}
 
 
 
 
 
139
 
140
- # 5. Autoencoder inference + anomaly scoring
141
- batch_array = np.array(batch_images)
142
- reconstructions = MODEL.predict(batch_array, verbose=0)
143
- errors = np.mean(np.abs(batch_array - reconstructions), axis=(1,2,3))
 
 
 
 
144
 
 
145
  results = []
146
  for idx, score in enumerate(errors):
 
147
  results.append({
148
  "tooth_index": metadata[idx]["id"],
149
- "score": float(score),
150
- "is_anomaly": score > THRESHOLD,
151
- "confidence": float(1.0 - score), # Lower error = higher confidence healthy
152
- "bounding_box": metadata[idx]["box"]
153
  })
154
 
155
  return {
156
  "status": "success",
157
  "tooth_count": len(results),
158
- "results": results,
159
- "threshold_used": THRESHOLD
160
  }
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ from contextlib import asynccontextmanager
3
+
4
+ # ─── 1. MANDATORY BACKEND CONFIG (Must be at the very top) ───────────────────
5
  os.environ["KERAS_BACKEND"] = "jax"
6
+ os.environ["JAX_PLATFORM_NAME"] = "cpu"
7
+ os.environ["HF_HOME"] = "/tmp/huggingface" # HF Spaces writable directory
8
 
9
  import base64
10
  import logging
11
  import numpy as np
12
  import cv2
13
  import requests
14
+ from typing import List, Dict, Any, Optional
15
 
16
  import keras
17
  from fastapi import FastAPI, File, UploadFile, HTTPException
18
+ from fastapi.responses import JSONResponse
19
  from huggingface_hub import hf_hub_download
20
+ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
21
 
22
+ # ─── 2. LOGGING & CONFIG ─────────────────────────────────────────────────────
23
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
24
  logger = logging.getLogger(__name__)
25
 
26
+ API_KEY = os.getenv("ROBOFLOW_API_KEY")
27
+ WORKFLOW_URL = "https://serverless.roboflow.com/my-workspace-iy9ll/find-teeth"
 
 
28
  IMG_SIZE = 256
29
+ THRESHOLD = 0.05
 
30
  MODEL_REPO = "sairamv1/TeethCariesDetection_CNNAutoencoder"
31
+ MODEL_FILENAME = "model.keras"
32
 
 
33
  MODEL = None
34
+
35
+ # ─── 3. RETRY-ENABLED MODEL DOWNLOAD ─────────────────────────────────────────
36
+ @retry(
37
+ stop=stop_after_attempt(3),
38
+ wait=wait_exponential(multiplier=1, min=2, max=10),
39
+ retry=retry_if_exception_type((requests.RequestException, OSError))
40
+ )
41
+ def download_model() -> str:
42
+ """Download model from HuggingFace Hub with retry logic."""
43
+ logger.info(f"πŸš€ Downloading model from {MODEL_REPO}...")
44
+ local_path = hf_hub_download(
45
  repo_id=MODEL_REPO,
46
  filename=MODEL_FILENAME,
47
+ cache_dir="/tmp/huggingface"
48
  )
49
+ logger.info(f"βœ… Model downloaded to {local_path}")
50
+ return local_path
51
+
52
+ # ─── 4. LIFESPAN EVENT HANDLER ───────────────────────────────────────────────
53
+ @asynccontextmanager
54
+ async def lifespan(app: FastAPI):
55
+ """Manage model loading at startup and cleanup at shutdown."""
56
+ global MODEL
57
+ try:
58
+ local_model_path = download_model()
59
+ logger.info("πŸ”„ Loading Keras model with JAX backend...")
60
+ MODEL = keras.saving.load_model(local_model_path)
61
+ logger.info(f"βœ… Model loaded successfully! Input shape: {MODEL.input_shape}")
62
+ except Exception as e:
63
+ logger.error(f"❌ Model load failed: {e}")
64
+ MODEL = None
65
+
66
+ yield # Application runs here
67
 
68
+ # Cleanup (if needed)
69
+ logger.info("πŸ›‘ Shutting down...")
 
 
 
 
70
 
71
+ app = FastAPI(
72
+ title="Teeth Caries Detection API",
73
+ description="Dental anomaly detection using CNN Autoencoder",
74
+ version="1.0.0",
75
+ lifespan=lifespan
76
+ )
77
 
78
+ # ─── 5. ROBOFLOW API WITH RETRY ──────────────────────────────────────────────
79
+ @retry(
80
+ stop=stop_after_attempt(2),
81
+ wait=wait_exponential(multiplier=1, min=1, max=5),
82
+ retry=retry_if_exception_type(requests.RequestException)
83
+ )
84
+ def call_roboflow_api(image_base64: str) -> Dict:
85
+ """Call Roboflow workflow with retry logic."""
86
+ response = requests.post(
87
+ WORKFLOW_URL,
88
+ params={"api_key": API_KEY},
89
+ json={"inputs": {"image": {"type": "base64", "value": image_base64}}},
90
+ timeout=30
91
+ )
92
+ response.raise_for_status()
93
+ return response.json()
94
 
95
+ # ─── 6. ROUTES ───────────────────────────────────────────────────────────────
96
  @app.get("/")
97
  async def root():
98
+ """Health check endpoint."""
99
  return {
100
+ "status": "active",
101
+ "model_ready": MODEL is not None,
102
+ "backend": os.environ.get("KERAS_BACKEND"),
103
+ "api_configured": API_KEY is not None
 
104
  }
105
 
106
+ @app.get("/health")
107
+ async def health():
108
+ """Detailed health check."""
109
+ if MODEL is None:
110
+ return JSONResponse(
111
+ status_code=503,
112
+ content={"status": "unhealthy", "reason": "Model not loaded"}
113
+ )
114
+ return {"status": "healthy", "model_input_shape": str(MODEL.input_shape)}
115
+
116
  @app.post("/analyze")
117
  async def analyze(file: UploadFile = File(...)):
118
+ """
119
+ Analyze dental image for caries detection.
120
 
121
+ Returns anomaly scores for each detected tooth.
122
+ """
123
+ if MODEL is None:
124
+ raise HTTPException(status_code=503, detail="Model not initialized. Please retry later.")
 
 
125
 
126
+ if not API_KEY:
127
+ raise HTTPException(status_code=500, detail="ROBOFLOW_API_KEY not configured")
128
 
129
+ # ─── Read and validate image ─────────────────────────────────────────────
 
130
  try:
131
+ contents = await file.read()
132
+ if len(contents) == 0:
133
+ raise HTTPException(status_code=400, detail="Empty file uploaded")
134
+
135
+ nparr = np.frombuffer(contents, np.uint8)
136
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
137
+
138
+ if img is None or img.size == 0:
139
+ raise HTTPException(status_code=400, detail="Invalid image format or corrupted file")
140
+
141
+ img_h, img_w = img.shape[:2]
142
+ if img_h < 50 or img_w < 50:
143
+ raise HTTPException(status_code=400, detail="Image too small (min 50x50 pixels)")
144
+
145
+ except HTTPException:
146
+ raise
147
+ except Exception as e:
148
+ logger.error(f"Image decode error: {e}")
149
+ raise HTTPException(status_code=400, detail=f"Image processing failed: {str(e)}")
150
+
151
+ # ─── Call Roboflow detection ─────────────────────────────────────────────
152
+ try:
153
+ image_base64 = base64.b64encode(contents).decode("utf-8")
154
+ result_json = call_roboflow_api(image_base64)
155
+ except requests.Timeout:
156
+ raise HTTPException(status_code=504, detail="Detection service timeout")
157
+ except requests.RequestException as e:
158
+ logger.error(f"Roboflow API error: {e}")
159
+ raise HTTPException(status_code=502, detail="Detection service unavailable")
160
  except Exception as e:
161
+ logger.error(f"Unexpected error: {e}")
162
+ raise HTTPException(status_code=500, detail="Internal processing error")
163
 
164
+ # ─── Parse detections ────────────────────────────────────────────────────
 
 
165
  try:
166
  if isinstance(result_json, list):
167
+ output_data = result_json[0].get("outputs", {})
168
  else:
169
+ output_data = result_json.get("outputs", [{}])[0]
170
+
171
+ predictions = output_data.get("predictions", [])
172
+ if isinstance(predictions, dict):
173
+ predictions = predictions.get("predictions", [])
174
+ except Exception as e:
175
+ logger.warning(f"Response parsing issue: {e}")
176
+ predictions = []
177
 
178
+ if not predictions:
179
+ return {
180
+ "status": "success",
181
+ "tooth_count": 0,
182
+ "results": [],
183
+ "message": "No teeth detected in image"
184
+ }
185
 
186
+ # ─── Process crops ───────────────────────────────────────────────────────
187
  batch_images = []
188
  metadata = []
189
+
190
+ for i, det in enumerate(predictions[:12]): # Limit for performance
191
+ try:
192
+ xc, yc, w, h = det["x"], det["y"], det["width"], det["height"]
193
+ x1 = max(0, int(xc - w/2))
194
+ y1 = max(0, int(yc - h/2))
195
+ x2 = min(img_w, int(xc + w/2))
196
+ y2 = min(img_h, int(yc + h/2))
197
+
198
+ if x2 <= x1 or y2 <= y1:
199
+ continue
200
+
201
+ crop = img[y1:y2, x1:x2]
202
+ if crop.size == 0:
203
+ continue
204
+
205
+ # Preprocessing pipeline
206
+ gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
207
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
208
+ gray = clahe.apply(gray)
209
+
210
+ # Aspect-ratio preserving resize with padding
211
+ h_c, w_c = gray.shape
212
+ scale = IMG_SIZE / max(h_c, w_c)
213
+ nw, nh = int(w_c * scale), int(h_c * scale)
214
+ resized = cv2.resize(gray, (nw, nh), interpolation=cv2.INTER_AREA)
215
+
216
+ canvas = np.zeros((IMG_SIZE, IMG_SIZE), dtype=np.uint8)
217
+ y_offset = (IMG_SIZE - nh) // 2
218
+ x_offset = (IMG_SIZE - nw) // 2
219
+ canvas[y_offset:y_offset + nh, x_offset:x_offset + nw] = resized
220
+
221
+ final_img = (canvas.astype(np.float32) / 255.0)[..., np.newaxis]
222
+ batch_images.append(final_img)
223
+ metadata.append({"id": i, "box": [x1, y1, x2, y2]})
224
+
225
+ except Exception as e:
226
+ logger.warning(f"Skipping detection {i} due to error: {e}")
227
+ continue
228
 
229
  if not batch_images:
230
+ return {
231
+ "status": "success",
232
+ "tooth_count": 0,
233
+ "results": [],
234
+ "message": "No valid tooth crops extracted"
235
+ }
236
 
237
+ # ─── Model inference ─────────────────────────────────────────────────────
238
+ try:
239
+ batch_array = np.array(batch_images)
240
+ reconstructions = MODEL.predict(batch_array, verbose=0)
241
+ errors = np.mean(np.abs(batch_array - reconstructions), axis=(1, 2, 3))
242
+ except Exception as e:
243
+ logger.error(f"Inference error: {e}")
244
+ raise HTTPException(status_code=500, detail="Model inference failed")
245
 
246
+ # ─── Format results ──────────────────────────────────────────────────────
247
  results = []
248
  for idx, score in enumerate(errors):
249
+ score_val = float(score)
250
  results.append({
251
  "tooth_index": metadata[idx]["id"],
252
+ "anomaly_score": round(score_val, 4),
253
+ "is_caries_detected": score_val > THRESHOLD,
254
+ "bounding_box": metadata[idx]["box"],
255
+ "confidence": "high" if score_val > THRESHOLD * 1.5 else "medium" if score_val > THRESHOLD else "low"
256
  })
257
 
258
  return {
259
  "status": "success",
260
  "tooth_count": len(results),
261
+ "detection_threshold": THRESHOLD,
262
+ "results": results
263
  }
264
+
265
+ # ─── 7. ERROR HANDLER ────────────────────────────────────────────────────────
266
+ @app.exception_handler(Exception)
267
+ async def global_exception_handler(request, exc):
268
+ logger.error(f"Unhandled exception: {exc}", exc_info=True)
269
+ return JSONResponse(
270
+ status_code=500,
271
+ content={"detail": "Internal server error occurred"}
272
+ )