Spaces:
Running
Running
| import torch | |
| import io | |
| import cv2 | |
| import threading | |
| import numpy as np | |
| import time | |
| from pathlib import Path | |
| from fastapi import FastAPI, File, UploadFile, Form | |
| from fastapi.responses import HTMLResponse | |
| from PIL import Image, ImageOps | |
| from torchvision import transforms | |
| import uvicorn | |
| import os | |
| from model import BoneAgeModel | |
| try: | |
| import clip | |
| clip_available = True | |
| except ImportError: | |
| clip_available = False | |
| try: | |
| import pydicom | |
| dicom_available = True | |
| except ImportError: | |
| dicom_available = False | |
| # File size limits (in bytes) | |
| MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB | |
| MIN_FILE_SIZE = 10 * 1024 # 10 KB | |
| # Ensure deterministic inference | |
| torch.manual_seed(42) | |
| np.random.seed(42) | |
| torch.use_deterministic_algorithms(True) | |
| os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':16:8' # For GPU (if used) | |
| app = FastAPI(title="Bone Age Assessment") | |
| device = torch.device("cpu") | |
| # CPU inference: always use fp32. fp16 on CPU lacks proper kernels and is unstable. | |
| model = None | |
| for model_file in ["best_model.pth", "best_model_fp16.pth"]: | |
| model_path = Path(model_file) | |
| if model_path.exists(): | |
| try: | |
| m = BoneAgeModel() | |
| state = torch.load(model_path, map_location="cpu") | |
| # If loaded weights are fp16, promote to fp32 for stable CPU inference | |
| state = {k: (v.float() if torch.is_tensor(v) and v.dtype == torch.float16 else v) | |
| for k, v in state.items()} | |
| m.load_state_dict(state) | |
| m = m.float() | |
| m.eval() | |
| model = m | |
| print(f"Model loaded: {model_file} (fp32)") | |
| break | |
| except Exception as e: | |
| print(f"Failed to load {model_file}: {e}") | |
| continue | |
| if model is None: | |
| print("No trained model found") | |
| # Load CLIP for image validation (hand X-ray detection) | |
| clip_model = None | |
| clip_preprocess = None | |
| if clip_available: | |
| try: | |
| clip_model, clip_preprocess = clip.load("ViT-B/32", device=device) | |
| clip_model.eval() | |
| print("CLIP model loaded for image validation") | |
| except Exception as e: | |
| print(f"Failed to load CLIP: {e}") | |
| clip_available = False | |
| def _otsu_threshold(gray: np.ndarray) -> int: | |
| """Vectorized Otsu's threshold.""" | |
| hist, _ = np.histogram(gray, bins=256, range=(0, 256)) | |
| hist = hist.astype(float) | |
| total = hist.sum() | |
| if total == 0: | |
| return 127 | |
| cumsum = np.cumsum(hist) | |
| cum_mean = np.cumsum(hist * np.arange(256)) | |
| w_b = cumsum | |
| w_f = total - cumsum | |
| valid = (w_b > 0) & (w_f > 0) | |
| m_b = np.zeros_like(cumsum) | |
| m_f = np.zeros_like(cumsum) | |
| m_b[valid] = cum_mean[valid] / w_b[valid] | |
| m_f[valid] = (cum_mean[-1] - cum_mean[valid]) / w_f[valid] | |
| var_between = w_b * w_f * (m_b - m_f) ** 2 | |
| var_between[~valid] = 0 | |
| return int(np.argmax(var_between)) | |
| def auto_crop_hand(image: Image.Image, padding: float = 0.05) -> Image.Image: | |
| """Crop to the hand bounding box using Otsu thresholding — removes black borders.""" | |
| gray = np.array(image.convert("L")) | |
| if gray.std() < 10: | |
| return image # near-uniform image, skip | |
| thresh = _otsu_threshold(gray) | |
| # Normal X-ray: hand brighter than background. Inverted: hand darker. | |
| mask = gray < thresh if np.mean(gray) > 128 else gray > thresh | |
| rows = np.any(mask, axis=1) | |
| cols = np.any(mask, axis=0) | |
| if not rows.any() or not cols.any(): | |
| return image | |
| rmin, rmax = np.where(rows)[0][[0, -1]] | |
| cmin, cmax = np.where(cols)[0][[0, -1]] | |
| h, w = gray.shape | |
| pad_h = int((rmax - rmin) * padding) | |
| pad_w = int((cmax - cmin) * padding) | |
| rmin = max(0, rmin - pad_h) | |
| rmax = min(h, rmax + pad_h + 1) | |
| cmin = max(0, cmin - pad_w) | |
| cmax = min(w, cmax + pad_w + 1) | |
| return image.crop((cmin, rmin, cmax, rmax)) | |
| transform = transforms.Compose([ | |
| transforms.Resize((512, 512)), # direct resize, no padding (matches training) | |
| transforms.ToTensor(), | |
| transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) | |
| ]) | |
| # CLAHE objects are stateful; serialize access for thread safety under FastAPI threadpool | |
| _clahe_lock = threading.Lock() | |
| _clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) | |
| def normalize_xray(image: Image.Image) -> Image.Image: | |
| """CLAHE local contrast enhancement.""" | |
| gray = np.array(image.convert("L")) | |
| # CLAHE: tile-based adaptive histogram equalization (8x8 grid, clip 2.0) | |
| with _clahe_lock: | |
| gray = _clahe.apply(gray.astype(np.uint8)) | |
| return Image.fromarray(gray).convert("RGB") | |
| def load_dicom_image(dicom_bytes: bytes) -> Image.Image: | |
| """Load DICOM file and convert to PIL Image.""" | |
| if not dicom_available: | |
| return None | |
| try: | |
| dicom_data = pydicom.dcmread(io.BytesIO(dicom_bytes)) | |
| pixel_array = dicom_data.pixel_array | |
| # Handle 16-bit DICOM data | |
| if pixel_array.dtype != np.uint8: | |
| pixel_array = np.uint8(255 * (pixel_array - pixel_array.min()) / (pixel_array.max() - pixel_array.min())) | |
| # Handle multi-frame (take first frame) | |
| if len(pixel_array.shape) == 3: | |
| pixel_array = pixel_array[0] | |
| return Image.fromarray(pixel_array).convert("L") | |
| except Exception as e: | |
| print(f"DICOM loading error: {e}") | |
| return None | |
| def validate_hand_xray(image: Image.Image, log_timing: bool = False, debug: bool = True) -> tuple[bool, str, float, dict]: | |
| """Validate that image is a hand X-ray using CLIP. Returns (is_valid, message, confidence, timing_dict).""" | |
| timings = {} | |
| if not clip_available or clip_model is None: | |
| return True, "CLIP validation skipped", 0.0, timings | |
| try: | |
| # Preprocess image for CLIP | |
| t0 = time.perf_counter() | |
| image_input = clip_preprocess(image).unsqueeze(0).to(device) | |
| timings['clip_preprocess'] = (time.perf_counter() - t0) * 1000 | |
| # Descriptions to match against | |
| descriptions = [ | |
| "a hand X-ray image", | |
| "a pediatric hand X-ray", | |
| "a chest X-ray", | |
| "a leg X-ray", | |
| "a spine X-ray", | |
| "a CT scan", | |
| "an MRI image", | |
| "a photograph", | |
| "a random image" | |
| ] | |
| t0 = time.perf_counter() | |
| with torch.no_grad(): | |
| # Encode image | |
| image_features = clip_model.encode_image(image_input) | |
| image_features /= image_features.norm(dim=-1, keepdim=True) | |
| # Encode text descriptions | |
| text_tokens = clip.tokenize(descriptions).to(device) | |
| text_features = clip_model.encode_text(text_tokens) | |
| text_features /= text_features.norm(dim=-1, keepdim=True) | |
| # Calculate similarity scores | |
| similarity = (image_features @ text_features.T).squeeze(0) | |
| scores = similarity.softmax(dim=0).cpu().numpy() | |
| timings['clip_inference'] = (time.perf_counter() - t0) * 1000 | |
| # Hand X-ray descriptions (indices 0, 1) | |
| hand_xray_score = float(scores[0] + scores[1]) | |
| # Non-hand/non-xray descriptions (indices 2-8) | |
| non_hand_score = float(scores[2:].sum()) | |
| # Confidence: how confident we are it's a hand X-ray | |
| confidence = hand_xray_score - non_hand_score | |
| if log_timing or debug: | |
| total = sum(timings.values()) | |
| print(f"[CLIP TIMING] Preprocess: {timings['clip_preprocess']:.1f}ms | Inference: {timings['clip_inference']:.1f}ms | Total: {total:.1f}ms") | |
| if debug: | |
| print("[CLIP DEBUG] Individual scores:") | |
| for i, desc in enumerate(descriptions): | |
| print(f" [{i}] {desc}: {scores[i]:.4f}") | |
| print(f"[CLIP DEBUG] Hand X-ray score (0+1): {hand_xray_score:.4f} ({hand_xray_score:.1%})") | |
| print(f"[CLIP DEBUG] Non-hand score (2-8): {non_hand_score:.4f}") | |
| print(f"[CLIP DEBUG] Confidence (hand - non-hand): {confidence:.4f}") | |
| print(f"[CLIP DEBUG] Decision: hand_xray > non_hand? {hand_xray_score > non_hand_score}") | |
| # Threshold: hand X-ray score must be higher than non-hand categories | |
| if hand_xray_score > non_hand_score: | |
| return True, f"Valid hand X-ray detected (confidence: {hand_xray_score:.1%})", hand_xray_score, timings | |
| else: | |
| return False, "Image does not appear to be a hand X-ray. Upload a PA hand radiograph for interpretation.", hand_xray_score, timings | |
| except Exception as e: | |
| print(f"CLIP validation error: {e}") | |
| return True, "Validation check failed, proceeding", 0.0, timings | |
| def predict_bone_age(image: Image.Image, is_male: bool, log_timing: bool = False) -> tuple[float, dict]: | |
| """Inference with test-time augmentation (original + flip) for better accuracy. | |
| Returns: | |
| tuple: (predicted_age_in_months, timing_dict) | |
| """ | |
| timings = {} | |
| # EXIF transpose | |
| t0 = time.perf_counter() | |
| image = ImageOps.exif_transpose(image) | |
| timings['exif_transpose'] = (time.perf_counter() - t0) * 1000 | |
| # Auto-crop hand ROI | |
| t0 = time.perf_counter() | |
| image = auto_crop_hand(image) | |
| timings['auto_crop'] = (time.perf_counter() - t0) * 1000 | |
| # CLAHE normalization | |
| t0 = time.perf_counter() | |
| image = normalize_xray(image) | |
| timings['clahe_normalize'] = (time.perf_counter() - t0) * 1000 | |
| gender_tensor = torch.tensor([1.0 if is_male else 0.0]).to(device) | |
| # TTA: average predictions from original and horizontally flipped image | |
| t0 = time.perf_counter() | |
| preds = [] | |
| for img in (image, image.transpose(Image.FLIP_LEFT_RIGHT)): | |
| img_tensor = transform(img).unsqueeze(0).to(device) | |
| with torch.no_grad(): | |
| preds.append(model(img_tensor, gender_tensor).item()) | |
| timings['model_inference_tta'] = (time.perf_counter() - t0) * 1000 | |
| result = sum(preds) / len(preds) | |
| if log_timing: | |
| total = sum(timings.values()) | |
| print(f"[TIMING] EXIF: {timings['exif_transpose']:.1f}ms | Auto-crop: {timings['auto_crop']:.1f}ms | " | |
| f"CLAHE: {timings['clahe_normalize']:.1f}ms | Model: {timings['model_inference_tta']:.1f}ms | " | |
| f"Total: {total:.1f}ms") | |
| return result, timings | |
| async def home(): | |
| return """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> | |
| <title>Bone age AI tool</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #f5f5f4; --card: white; --text: #1F2933; --subtitle: #6B7280; --label: #374151; | |
| --input-bg: #fafaf9; --input-border: #d6d3d1; --preview-bg: #f5f5f4; --preview-border: #e7e5e4; | |
| --result-bg: #fafaf9; --result-border: #e7e5e4; --result-text: #6B7280; | |
| } | |
| body.dark { | |
| --bg: #1a1f1e; --card: #232b2a; --text: #e8eded; --subtitle: #8a9a97; --label: #c8d4d1; | |
| --input-bg: #2c3432; --input-border: #3a4442; --preview-bg: #1e2523; --preview-border: #344240; | |
| --result-bg: #1e2523; --result-border: #344240; --result-text: #8a9a97; | |
| } | |
| body { font-family: 'Inter', system-ui, -apple-system, 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.08); width: 100%; max-width: 500px; position: relative; } | |
| body.dark { background: radial-gradient(ellipse at 50% 30%, #1e2d29 0%, #141a19 100%); } | |
| body.dark .card { border: 1px solid #2e3836; box-shadow: 0 4px 32px rgba(0,0,0,0.5); } | |
| h1 { color: var(--text); text-align: center; margin-bottom: 6px; font-size: 28px; letter-spacing: -0.5px; font-weight: 700; } | |
| .subtitle-label { text-align: center; color: var(--subtitle); font-size: 15px; font-weight: 600; margin-bottom: 8px; } | |
| .gold-divider { width: 40px; height: 2px; background: #E9C46A; margin: 0 auto 12px; border-radius: 1px; } | |
| .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); font-family: inherit; } | |
| select:focus { outline: none; border-color: #1F2933; } | |
| .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: #1F2933; background: rgba(31,41,51,0.05); color: #1F2933; } | |
| body.dark .dropzone:hover, body.dark .dropzone.dragover { border-color: #e5e5e5; background: rgba(255,255,255,0.05); color: #e5e5e5; } | |
| .dropzone input { display: none; } | |
| .dropzone strong { color: #1F2933; } | |
| body.dark .dropzone strong { color: #e5e5e5; } | |
| #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: #1F2933; color: white; border: 2px solid #1F2933; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 4px; font-family: inherit; transition: all 0.15s; position: relative; z-index: 10; } | |
| button:hover { background: #374151; border-color: #374151; } | |
| button:disabled { background: #9ca3af; border-color: #9ca3af; 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: #1F2933; } | |
| body.dark .result-value { color: #e5e5e5; } | |
| .result-range { font-size: 12px; color: #6B7280; 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: 4px; background: rgba(0,0,0,0.08); border-radius: 2px; overflow: hidden; } | |
| .conf-fill { height: 100%; transition: width 0.4s; background: #1F2933; } | |
| .conf-fill.high { background: #10b981 !important; } | |
| .conf-fill.medium { background: #f59e0b !important; } | |
| .conf-fill.low { background: #ef4444 !important; } | |
| body.dark .conf-fill:not(.high):not(.medium):not(.low) { background: #e5e5e5; } | |
| .conf-text { font-size: 11px; margin-top: 4px; font-weight: 600; color: #6B7280; } | |
| .error { background: #dc2626; border-color: #991b1b; } | |
| .error .result-value { font-size: 16px; color: #FCD34D; font-weight: 700; } | |
| .disclaimer { margin-top: 20px; padding: 10px 14px; background: #fffbeb; border: 1px solid #E9C46A; border-radius: 8px; font-size: 11px; color: #92400e; text-align: center; line-height: 1.6; } | |
| body.dark .disclaimer { background: #2a2310; border-color: #a07820; color: #E9C46A; } | |
| .label-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; } | |
| .label-row label { margin-bottom: 0; } | |
| .tips-toggle { font-size: 11px; color: var(--subtitle); background: none; border: none; cursor: pointer; width: auto; padding: 0; margin: 0; font-family: inherit; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; } | |
| .tips-toggle:hover { color: var(--text); background: none; } | |
| .tips-box { display: none; margin-top: 8px; padding: 10px 12px; background: var(--input-bg); border: 1px solid var(--input-border); border-radius: 8px; font-size: 11.5px; color: var(--subtitle); line-height: 1.7; } | |
| .tips-box li { margin-left: 14px; } | |
| .conf-info-btn { width: 24px; height: 24px; padding: 0; border: 1px solid var(--input-border); border-radius: 50%; background: var(--input-bg); color: var(--text); font-size: 12px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; } | |
| .conf-info-btn:hover { background: var(--preview-border); } | |
| .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); } | |
| .modal-content { background: var(--card); margin: 10% auto; padding: 24px; border: 1px solid var(--result-border); border-radius: 12px; width: 90%; max-width: 400px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); } | |
| .modal-close { float: right; font-size: 20px; font-weight: 600; color: var(--text); cursor: pointer; } | |
| .modal-close:hover { color: #999; } | |
| .conf-legend { display: grid; gap: 10px; margin-top: 10px; } | |
| .conf-legend-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text); line-height: 1.4; } | |
| .conf-legend-color { width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; } | |
| @media (max-width: 768px) { | |
| html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; } | |
| body { position: fixed; top: 0; left: 0; width: 100%; height: 100vh; margin: 0; padding: 0; display: flex; justify-content: center; align-items: flex-start; overflow: hidden; } | |
| .card { padding: 28px 18px; width: 100%; height: 100vh; margin: 0; border-radius: 0; box-shadow: none; overflow-y: auto; -webkit-overflow-scrolling: touch; } | |
| h1 { font-size: 26px; margin-bottom: 3px; } | |
| .subtitle { font-size: 14px; margin-bottom: 22px; } | |
| label { font-size: 15px; } | |
| select { padding: 14px 16px; font-size: 17px; } | |
| .field { margin-bottom: 18px; } | |
| .dropzone { padding: 36px 18px; font-size: 15px; } | |
| button { padding: 16px; font-size: 17px; } | |
| .result-value { font-size: 36px; } | |
| .result-label { font-size: 15px; } | |
| .result-range { font-size: 13px; } | |
| .result-months { font-size: 14px; } | |
| .tips-box { font-size: 13px; } | |
| #preview { max-height: 400px; } | |
| .disclaimer { font-size: 13px; padding: 14px; } | |
| } | |
| </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> | |
| <div class="gold-divider"></div> | |
| <p class="subtitle">Upload a hand X-ray to estimate skeletal maturity</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"> | |
| <div class="label-row"> | |
| <label>Hand X-Ray</label> | |
| <button type="button" class="tips-toggle" onclick="document.getElementById('tips-box').style.display=document.getElementById('tips-box').style.display==='block'?'none':'block'">Image tips</button> | |
| </div> | |
| <div class="tips-box" id="tips-box"> | |
| <ul> | |
| <li>Use original file (PNG/JPEG from PACS), not a screenshot</li> | |
| <li>Include full hand — wrist to fingertips, nothing cut off</li> | |
| <li>Standard PA view (palm down, fingers pointing up)</li> | |
| <li>Crop out DICOM text, labels, and rulers if possible</li> | |
| <li>PNG preferred over JPEG (lossless)</li> | |
| <li>Avoid inverted images (bones should appear white)</li> | |
| </ul> | |
| </div> | |
| <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:#6B7280;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 style="display: flex; align-items: center; gap: 6px;"> | |
| <div class="conf-bar" style="flex: 1;"><div class="conf-fill" id="conf-fill"></div></div> | |
| <button type="button" class="conf-info-btn" onclick="document.getElementById('conf-modal').style.display='block'" title="What does this mean?">ℹ</button> | |
| </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> | |
| <div id="conf-modal" class="modal"> | |
| <div class="modal-content"> | |
| <span class="modal-close" onclick="document.getElementById('conf-modal').style.display='none'">×</span> | |
| <h3 style="color: var(--text); margin-top: 0; font-size: 16px;">Confidence Indicator</h3> | |
| <p style="font-size: 12px; color: var(--text); margin: 0 0 12px 0; line-height: 1.5;"> | |
| The confidence bar shows how closely the predicted bone age matches Greulich-Pyle reference standards. | |
| </p> | |
| <div class="conf-legend"> | |
| <div class="conf-legend-item"> | |
| <div class="conf-legend-color" style="background: #10b981;"></div> | |
| <div><strong>High (≤6 mo):</strong> Consistent with G&P</div> | |
| </div> | |
| <div class="conf-legend-item"> | |
| <div class="conf-legend-color" style="background: #f59e0b;"></div> | |
| <div><strong>Medium (6-12 mo):</strong> Moderate deviation</div> | |
| </div> | |
| <div class="conf-legend-item"> | |
| <div class="conf-legend-color" style="background: #ef4444;"></div> | |
| <div><strong>Low (>12 mo):</strong> Notable deviation</div> | |
| </div> | |
| </div> | |
| <p style="font-size: 11px; color: var(--text); margin: 10px 0 0 0; line-height: 1.5; background: rgba(0,0,0,0.2); padding: 8px; border-radius: 6px;"> | |
| ⚠️ Model uncertainty: ±8 months. Always interpret clinically. | |
| </p> | |
| </div> | |
| </div> | |
| <script> | |
| window.onclick = function(event) { | |
| const modal = document.getElementById('conf-modal'); | |
| if (event.target === modal) modal.style.display = 'none'; | |
| }; | |
| </script> | |
| <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 = Math.round(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('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.target.matches('button, select')) { | |
| const fileInput = document.querySelector('input[type="file"]'); | |
| const hasFile = clipboardFile || (fileInput && fileInput.files.length > 0); | |
| if (hasFile && !document.getElementById('btn').disabled) { | |
| document.getElementById('form').requestSubmit(); | |
| } | |
| } | |
| }); | |
| 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-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-range').textContent = '\u00b1 8 months (model uncertainty)'; | |
| document.getElementById('result-gp').innerHTML = | |
| 'Closest G&P reference: <strong>' + fmtAge(gpAge) + '</strong>'; | |
| // Color-coded confidence bar | |
| const diff = Math.abs(data.total_months - gpAge); | |
| let conf, label, level; | |
| if (diff <= 6) { conf = 90; label = 'Consistent with G&P standards'; level = 'high'; } | |
| else if (diff <= 12) { conf = 65; label = 'Moderate deviation from G&P'; level = 'medium'; } | |
| else { conf = 35; label = 'Notable deviation — review carefully'; level = 'low'; } | |
| const confFill = document.getElementById('conf-fill'); | |
| confFill.style.width = conf + '%'; | |
| confFill.className = 'conf-fill ' + level; | |
| document.getElementById('conf-text').textContent = label; | |
| 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."} | |
| t_start = time.perf_counter() | |
| all_timings = {} | |
| contents = await file.read() | |
| # Check file size | |
| file_size = len(contents) | |
| if file_size < MIN_FILE_SIZE: | |
| return {"error": f"File too small ({file_size//1024} KB). Minimum: {MIN_FILE_SIZE//1024} KB. Use an image from PACS or scanner."} | |
| if file_size > MAX_FILE_SIZE: | |
| return {"error": f"File too large ({file_size//1024//1024} MB). Maximum: {MAX_FILE_SIZE//1024//1024} MB. Compress or resize the image."} | |
| # Try loading as DICOM first, then standard image format | |
| t0 = time.perf_counter() | |
| image = None | |
| if dicom_available: | |
| image = load_dicom_image(contents) | |
| if image is None: | |
| try: | |
| image = Image.open(io.BytesIO(contents)) | |
| except Exception as e: | |
| return {"error": f"Could not open image file. Supported formats: JPEG, PNG, DICOM. Error: {str(e)[:50]}"} | |
| all_timings['image_loading'] = (time.perf_counter() - t0) * 1000 | |
| # Check if image is grayscale-ish (X-rays have low color saturation) | |
| img_rgb = image.convert("RGB") | |
| 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" | |
| # Skip CLIP validation - model works well without it, and PACS pre-filters images anyway | |
| # is_valid, validation_msg, clip_confidence, clip_timings = validate_hand_xray(image, log_timing=True) | |
| # all_timings.update({f'clip_{k}': v for k, v in clip_timings.items()}) | |
| # if not is_valid: | |
| # return {"error": f"Image validation failed: {validation_msg}"} | |
| # Inference with TTA | |
| predicted_months, inference_timings = predict_bone_age(image, is_male, log_timing=True) | |
| all_timings.update(inference_timings) | |
| predicted_months = max(0.0, min(228.0, predicted_months)) | |
| t_total = (time.perf_counter() - t_start) * 1000 | |
| years = int(predicted_months // 12) | |
| months = int(predicted_months % 12) | |
| # Log complete timing breakdown | |
| print(f"[TOTAL] End-to-end latency: {t_total:.1f}ms | " | |
| f"Load: {all_timings.get('image_loading', 0):.1f}ms | " | |
| f"CLIP: {sum(v for k, v in all_timings.items() if 'clip' in k):.1f}ms | " | |
| f"Inference: {all_timings.get('model_inference_tta', 0):.1f}ms") | |
| return { | |
| "bone_age": f"{years} yrs {months} mo", | |
| "total_months": round(predicted_months, 1) | |
| } | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=7860) | |