boneage / server.py
Claude
Update: Add months and years output format for validation
6e9f4e5
Raw
History Blame Contribute Delete
17.8 kB
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()
@app.get("/", response_class=HTMLResponse)
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">&#x1F319;</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 &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:#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()">&#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 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">
&#x26A0;&#xFE0F; 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 ? '&#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('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 =
'&#x1F4D6; 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>
"""
@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."}
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)