boneage / app.py
Prashanth Mamidipalli
Restore research disclaimer to UI
5ee03e2
Raw
History Blame Contribute Delete
35.9 kB
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
@app.get("/", response_class=HTMLResponse)
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">&#x1F319;</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 &amp; 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()">&#x21BA; 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'">&times;</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 ? '&#x2600;&#xFE0F;' : '&#x1F319;';
}
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>
"""
@app.post("/assess")
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)