Spaces:
Running
Running
| import torch | |
| import io | |
| from pathlib import Path | |
| from fastapi import FastAPI, File, UploadFile, Form | |
| from fastapi.responses import HTMLResponse | |
| from PIL import Image | |
| from torchvision import transforms | |
| import uvicorn | |
| from train import BoneAgeModel | |
| app = FastAPI(title="Bone Age Assessment") | |
| device = torch.device("cpu") # CPU only for Render free tier | |
| model = None | |
| use_fp16 = False | |
| # Try fp16 model first (smaller memory), fallback to full model | |
| for model_file in ["best_model_fp16.pth", "best_model.pth"]: | |
| model_path = Path(model_file) | |
| if model_path.exists(): | |
| m = BoneAgeModel() | |
| if model_file == "best_model_fp16.pth": | |
| m = m.half() | |
| use_fp16 = True | |
| m.load_state_dict(torch.load(model_path, map_location="cpu")) | |
| m.eval() | |
| model = m | |
| print(f"Model loaded: {model_file}") | |
| break | |
| if model is None: | |
| print("No trained model found") | |
| transform = transforms.Compose([ | |
| transforms.Resize((256, 256)), | |
| transforms.ToTensor(), | |
| transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) | |
| ]) | |
| def predict_bone_age(image: Image.Image, is_male: bool) -> float: | |
| img_tensor = transform(image.convert("RGB")).unsqueeze(0).to(device) | |
| gender_tensor = torch.tensor([1.0 if is_male else 0.0]).to(device) | |
| if use_fp16: | |
| img_tensor = img_tensor.half() | |
| gender_tensor = gender_tensor.half() | |
| with torch.no_grad(): | |
| prediction = model(img_tensor, gender_tensor) | |
| return prediction.item() | |
| async def home(): | |
| return """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Bone Age Assessment</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #f0f2f5; --card: white; --text: #1a1a2e; --subtitle: #888; --label: #444; | |
| --input-bg: #fafafa; --input-border: #ddd; --preview-bg: #f8f8f8; --preview-border: #eee; | |
| --result-bg: #f0fdf4; --result-border: #bbf7d0; --result-text: #555; | |
| } | |
| body.dark { | |
| --bg: #1a1a2e; --card: #252542; --text: #e5e7eb; --subtitle: #9ca3af; --label: #d1d5db; | |
| --input-bg: #2d2d4a; --input-border: #444466; --preview-bg: #1e1e36; --preview-border: #3a3a5a; | |
| --result-bg: #14241c; --result-border: #166534; --result-text: #d1d5db; | |
| } | |
| body { font-family: Arial, sans-serif; background: var(--bg); display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 30px 16px; transition: background 0.2s; } | |
| .card { background: var(--card); padding: 40px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); width: 100%; max-width: 500px; position: relative; } | |
| h1 { color: var(--text); text-align: center; margin-bottom: 8px; font-size: 22px; } | |
| .subtitle { text-align: center; color: var(--subtitle); font-size: 13px; margin-bottom: 28px; } | |
| label { display: block; font-size: 13px; font-weight: 600; color: var(--label); margin-bottom: 6px; } | |
| .field { margin-bottom: 18px; } | |
| select { width: 100%; padding: 10px 12px; border: 1px solid var(--input-border); border-radius: 8px; font-size: 14px; background: var(--input-bg); color: var(--text); } | |
| select:focus { outline: none; border-color: #4f46e5; } | |
| .dropzone { border: 2px dashed var(--input-border); border-radius: 8px; padding: 24px 12px; text-align: center; background: var(--input-bg); cursor: pointer; transition: all 0.2s; color: var(--subtitle); font-size: 13px; } | |
| .dropzone:hover, .dropzone.dragover { border-color: #4f46e5; background: rgba(79,70,229,0.08); color: #4f46e5; } | |
| .dropzone input { display: none; } | |
| .dropzone strong { color: #4f46e5; } | |
| #preview { width: 100%; max-height: 350px; object-fit: contain; border-radius: 8px; border: 1px solid var(--preview-border); margin-top: 10px; display: none; background: var(--preview-bg); } | |
| button { width: 100%; padding: 13px; background: #4f46e5; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 4px; } | |
| button:hover { background: #4338ca; } | |
| button:disabled { background: #a5b4fc; cursor: not-allowed; } | |
| #reset-btn { background: #6b7280; margin-top: 10px; display: none; } | |
| #reset-btn:hover { background: #4b5563; } | |
| .theme-toggle { position: absolute; top: 16px; right: 16px; width: 36px; height: 36px; padding: 0; background: transparent; border: 1px solid var(--input-border); border-radius: 50%; font-size: 16px; cursor: pointer; color: var(--text); margin: 0; } | |
| .theme-toggle:hover { background: var(--input-bg); } | |
| .result { margin-top: 24px; padding: 20px; background: var(--result-bg); border: 1px solid var(--result-border); border-radius: 10px; display: none; } | |
| .result-inner { display: flex; gap: 16px; align-items: center; } | |
| .result-thumb { width: 110px; height: 110px; object-fit: contain; border-radius: 6px; border: 1px solid var(--result-border); background: var(--preview-bg); flex-shrink: 0; display: none; } | |
| .result-text { flex: 1; text-align: center; } | |
| .result-label { font-size: 13px; color: var(--result-text); margin-bottom: 6px; } | |
| .result-value { font-size: 34px; font-weight: 700; color: #16a34a; } | |
| .result-range { font-size: 12px; color: #15803d; margin-top: 3px; } | |
| .result-months { font-size: 13px; color: var(--subtitle); margin-top: 4px; } | |
| .result-gp { font-size: 12px; color: var(--result-text); margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--result-border); } | |
| .conf-bar { margin-top: 10px; height: 5px; background: rgba(0,0,0,0.08); border-radius: 3px; overflow: hidden; } | |
| .conf-fill { height: 100%; transition: width 0.4s; } | |
| .conf-text { font-size: 11px; margin-top: 4px; font-weight: 600; } | |
| .error { background: #fef2f2; border-color: #fecaca; } | |
| .error .result-value { font-size: 16px; color: #dc2626; } | |
| .disclaimer { margin-top: 20px; padding: 10px 14px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; font-size: 11px; color: #92400e; text-align: center; line-height: 1.6; } | |
| body.dark .disclaimer { background: #3d2f10; border-color: #78580c; color: #fde68a; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="Toggle dark mode">🌙</button> | |
| <h1>Bone Age Assessment</h1> | |
| <p class="subtitle">Upload a hand X-ray to estimate skeletal maturity</p> | |
| <p style="text-align:center;font-size:11px;color:#aaa;margin-top:-20px;margin-bottom:20px;">Developed by Dr. Prashanth Mamidipalli</p> | |
| <form id="form"> | |
| <div class="field"> | |
| <label>Sex</label> | |
| <select name="sex"> | |
| <option value="Male">Male</option> | |
| <option value="Female">Female</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label>Hand X-Ray</label> | |
| <label class="dropzone" id="dropzone"> | |
| <input type="file" name="file" accept="image/*" onchange="previewImage(this)"> | |
| <div><strong>Click to upload</strong> or drag & drop</div> | |
| <div style="font-size:11px;margin-top:6px;">or press <kbd style="padding:2px 5px;border-radius:3px;font-size:10px;background:rgba(0,0,0,0.05);">Ctrl/Cmd+V</kbd> to paste</div> | |
| </label> | |
| <div id="paste-hint" style="font-size:12px;color:#16a34a;margin-top:6px;"></div> | |
| <img id="preview"> | |
| </div> | |
| <button type="submit" id="btn">Assess Bone Age</button> | |
| </form> | |
| <button id="reset-btn" onclick="resetForm()">↺ New Assessment</button> | |
| <div class="result" id="result"> | |
| <div class="result-inner"> | |
| <img class="result-thumb" id="result-thumb"> | |
| <div class="result-text"> | |
| <div class="result-label">Estimated Bone Age</div> | |
| <div class="result-value" id="result-value"></div> | |
| <div class="result-range" id="result-range"></div> | |
| <div class="result-months" id="result-months"></div> | |
| <div class="conf-bar"><div class="conf-fill" id="conf-fill"></div></div> | |
| <div class="conf-text" id="conf-text"></div> | |
| </div> | |
| </div> | |
| <div class="result-gp" id="result-gp"></div> | |
| </div> | |
| <div class="disclaimer"> | |
| ⚠️ For research and educational use only.<br>Not intended for clinical diagnosis or patient management. | |
| </div> | |
| </div> | |
| <script> | |
| let clipboardFile = null; | |
| let currentPreviewSrc = null; | |
| // Greulich-Pyle standard reference ages (in months) for males and females | |
| const GP_MALE = [3, 6, 9, 12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, 120, 132, 144, 156, 168, 180, 192, 204, 216]; | |
| const GP_FEMALE = [3, 6, 9, 12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, 120, 132, 144, 156, 168, 180, 192, 204, 216]; | |
| function nearestGP(months, isMale) { | |
| const ref = isMale ? GP_MALE : GP_FEMALE; | |
| return ref.reduce((a, b) => Math.abs(b - months) < Math.abs(a - months) ? b : a); | |
| } | |
| function fmtAge(m) { | |
| const y = Math.floor(m / 12), mo = m % 12; | |
| return y > 0 ? y + 'y ' + mo + 'mo' : mo + 'mo'; | |
| } | |
| function toggleTheme() { | |
| document.body.classList.toggle('dark'); | |
| const isDark = document.body.classList.contains('dark'); | |
| localStorage.setItem('theme', isDark ? 'dark' : 'light'); | |
| document.getElementById('theme-toggle').innerHTML = isDark ? '☀️' : '🌙'; | |
| } | |
| if (localStorage.getItem('theme') === 'dark') { | |
| document.body.classList.add('dark'); | |
| document.getElementById('theme-toggle').innerHTML = '\u2600\uFE0F'; | |
| } | |
| function setPreview(blob) { | |
| const preview = document.getElementById('preview'); | |
| currentPreviewSrc = URL.createObjectURL(blob); | |
| preview.src = currentPreviewSrc; | |
| preview.style.display = 'block'; | |
| } | |
| function previewImage(input) { | |
| clipboardFile = null; | |
| document.getElementById('paste-hint').textContent = ''; | |
| if (input.files && input.files[0]) { | |
| setPreview(input.files[0]); | |
| } | |
| } | |
| function resetForm() { | |
| document.getElementById('form').reset(); | |
| document.getElementById('preview').style.display = 'none'; | |
| document.getElementById('preview').src = ''; | |
| document.getElementById('result').style.display = 'none'; | |
| document.getElementById('reset-btn').style.display = 'none'; | |
| document.getElementById('paste-hint').textContent = ''; | |
| clipboardFile = null; | |
| currentPreviewSrc = null; | |
| } | |
| // Drag and drop | |
| const dropzone = document.getElementById('dropzone'); | |
| ['dragover', 'dragenter'].forEach(ev => dropzone.addEventListener(ev, (e) => { | |
| e.preventDefault(); dropzone.classList.add('dragover'); | |
| })); | |
| ['dragleave', 'drop'].forEach(ev => dropzone.addEventListener(ev, (e) => { | |
| e.preventDefault(); dropzone.classList.remove('dragover'); | |
| })); | |
| dropzone.addEventListener('drop', (e) => { | |
| const file = e.dataTransfer.files[0]; | |
| if (file && file.type.startsWith('image/')) { | |
| clipboardFile = file; | |
| setPreview(file); | |
| document.getElementById('paste-hint').textContent = '\u2713 ' + file.name; | |
| } | |
| }); | |
| document.addEventListener('paste', (e) => { | |
| const items = e.clipboardData && e.clipboardData.items; | |
| if (!items) return; | |
| for (const item of items) { | |
| if (item.type.startsWith('image/')) { | |
| const blob = item.getAsFile(); | |
| clipboardFile = new File([blob], 'clipboard.png', { type: blob.type }); | |
| setPreview(blob); | |
| document.getElementById('paste-hint').textContent = '\u2713 Image pasted from clipboard'; | |
| break; | |
| } | |
| } | |
| }); | |
| document.getElementById('form').onsubmit = async (e) => { | |
| e.preventDefault(); | |
| const btn = document.getElementById('btn'); | |
| const result = document.getElementById('result'); | |
| btn.disabled = true; | |
| btn.textContent = 'Analyzing...'; | |
| result.style.display = 'none'; | |
| result.className = 'result'; | |
| try { | |
| const formData = new FormData(e.target); | |
| if (clipboardFile) { | |
| formData.set('file', clipboardFile, 'clipboard.png'); | |
| } | |
| const response = await fetch('/assess', { method: 'POST', body: formData }); | |
| const data = await response.json(); | |
| if (data.error) { | |
| result.classList.add('error'); | |
| document.getElementById('result-value').textContent = data.error; | |
| document.getElementById('result-range').textContent = ''; | |
| document.getElementById('result-months').textContent = ''; | |
| document.getElementById('result-thumb').style.display = 'none'; | |
| document.getElementById('result-gp').textContent = ''; | |
| document.getElementById('conf-fill').style.width = '0%'; | |
| document.getElementById('conf-text').textContent = ''; | |
| } else { | |
| document.getElementById('result-value').textContent = data.bone_age; | |
| document.getElementById('result-range').textContent = '\u00b1 8 months (model uncertainty)'; | |
| document.getElementById('result-months').textContent = '(' + data.total_months + ' months)'; | |
| // Greulich-Pyle reference | |
| const isMale = formData.get('sex') === 'Male'; | |
| const gpAge = nearestGP(Math.round(data.total_months), isMale); | |
| document.getElementById('result-gp').innerHTML = | |
| '📖 Closest Greulich-Pyle reference: <strong>' + fmtAge(gpAge) + '</strong>'; | |
| // Confidence color (based on how close to G-P standard - proxy for typical case) | |
| const diff = Math.abs(data.total_months - gpAge); | |
| let conf, color, label; | |
| if (diff <= 6) { conf = 90; color = '#16a34a'; label = 'High confidence'; } | |
| else if (diff <= 12) { conf = 65; color = '#eab308'; label = 'Moderate confidence'; } | |
| else { conf = 35; color = '#dc2626'; label = 'Low confidence - review carefully'; } | |
| const fill = document.getElementById('conf-fill'); | |
| fill.style.width = conf + '%'; | |
| fill.style.background = color; | |
| const ct = document.getElementById('conf-text'); | |
| ct.textContent = label; | |
| ct.style.color = color; | |
| if (currentPreviewSrc) { | |
| const thumb = document.getElementById('result-thumb'); | |
| thumb.src = currentPreviewSrc; | |
| thumb.style.display = 'block'; | |
| } | |
| } | |
| } catch (err) { | |
| result.classList.add('error'); | |
| document.getElementById('result-value').textContent = 'Something went wrong'; | |
| document.getElementById('result-range').textContent = ''; | |
| document.getElementById('result-months').textContent = ''; | |
| } | |
| result.style.display = 'block'; | |
| btn.disabled = false; | |
| btn.textContent = 'Assess Bone Age'; | |
| document.getElementById('reset-btn').style.display = 'block'; | |
| }; | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| async def assess( | |
| file: UploadFile = File(...), | |
| sex: str = Form(...) | |
| ): | |
| if model is None: | |
| return {"error": "Model not loaded. Please train the model first."} | |
| contents = await file.read() | |
| # Basic validation | |
| if len(contents) < 10 * 1024: # less than 10KB | |
| return {"error": "File too small. Please upload a valid hand X-ray image."} | |
| image = Image.open(io.BytesIO(contents)) | |
| # Check if image is grayscale-ish (X-rays have low color saturation) | |
| img_rgb = image.convert("RGB") | |
| import numpy as np | |
| arr = np.array(img_rgb).astype(float) | |
| r, g, b = arr[:,:,0], arr[:,:,1], arr[:,:,2] | |
| color_diff = np.mean(np.abs(r - g) + np.abs(g - b) + np.abs(r - b)) | |
| if color_diff > 30: | |
| return {"error": "Image appears to be a color photo, not an X-ray. Please upload a hand X-ray image."} | |
| is_male = sex.lower() == "male" | |
| predicted_months = predict_bone_age(image, is_male) | |
| years = int(predicted_months // 12) | |
| months = int(predicted_months % 12) | |
| years_decimal = round(predicted_months / 12, 2) | |
| return { | |
| "bone_age": f"{years} yrs {months} mo", | |
| "predicted_months": round(predicted_months, 1), | |
| "predicted_years": years_decimal, | |
| "predicted_formatted": f"{years} years {months} months", | |
| "total_months": round(predicted_months, 1) | |
| } | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=8000) | |