1import torch
2import torch.nn as nn
3import torch.nn.functional as F
4import cv2
5import json
6import numpy as np
7from transformers import SiglipVisionModel
8from peft import PeftModel
9from huggingface_hub import hf_hub_download, snapshot_download
10
11
12# ── Model Components ──
13
14class GlobalAveragePooling(nn.Module):
15 def __init__(self, hidden_size):
16 super().__init__()
17 self.norm = nn.LayerNorm(hidden_size)
18
19 def forward(self, x):
20 return self.norm(x.mean(dim=1))
21
22
23def letterbox_resize(image, size=448):
24 """Resize preserving aspect ratio with zero-padding."""
25 h, w = image.shape[:2]
26 scale = size / max(h, w)
27 nh, nw = int(h * scale), int(w * scale)
28 resized = cv2.resize(image, (nw, nh))
29 padded = np.zeros((size, size, 3), dtype=np.uint8)
30 top, left = (size - nh) // 2, (size - nw) // 2
31 padded[top:top + nh, left:left + nw] = resized
32 return padded
33
34
35# ── Load Model ──
36
37def load_regressor(repo_id="rohitrajesh/chronos-msk-regressor", device="cuda"):
38 """Load the complete bone age regressor from HuggingFace."""
39
40 # Download all files
41 config_path = hf_hub_download(repo_id, "config.json")
42 heads_path = hf_hub_download(repo_id, "heads.pth")
43 adapter_dir = snapshot_download(repo_id, allow_patterns=["adapter/*"])
44
45 # Load config
46 with open(config_path) as f:
47 config = json.load(f)
48
49 # Load MedSigLIP base model with LoRA adapter
50 base_model = SiglipVisionModel.from_pretrained(
51 config["model_id"], torch_dtype=torch.float32
52 )
53 backbone = PeftModel.from_pretrained(base_model, f"{adapter_dir}/adapter")
54
55 hidden = config["hidden_size"]
56 num_bins = config["num_bins"]
57
58 # Build prediction heads
59 pooler = GlobalAveragePooling(hidden)
60 gender_embed = nn.Sequential(
61 nn.Linear(1, 64), nn.GELU(), nn.Linear(64, 128)
62 )
63 classifier = nn.Sequential(
64 nn.LayerNorm(hidden + 128),
65 nn.Linear(hidden + 128, 512),
66 nn.GELU(),
67 nn.Dropout(0.1),
68 nn.Linear(512, num_bins),
69 )
70
71 # Load trained head weights
72 state = torch.load(heads_path, map_location=device)
73 pooler.load_state_dict(state["pooler"])
74 gender_embed.load_state_dict(state["gender_embed"])
75 classifier.load_state_dict(state["classifier"])
76
77 # Move to device and set eval mode
78 backbone.to(device).eval()
79 pooler.to(device).eval()
80 gender_embed.to(device).eval()
81 classifier.to(device).eval()
82
83 return backbone, pooler, gender_embed, classifier, config
84
85
86# ── Predict ──
87
88def predict_bone_age(image_path, is_male, backbone, pooler,
89 gender_embed, classifier, config, device="cuda"):
90 """
91 Predict bone age from a hand/wrist X-ray.
92
93 Args:
94 image_path: Path to X-ray image (any common format)
95 is_male: Boolean, biological sex
96
97 Returns:
98 age_months: Predicted bone age in months (float)
99 """
100 size = config["image_size"]
101
102 # Load and preprocess
103 img = cv2.imread(image_path)
104 if img is None:
105 raise ValueError(f"Cannot read image: {image_path}")
106 img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
107 img = letterbox_resize(img, size)
108
109 # Normalize: (pixel / 255 - 0.5) / 0.5
110 norm = (img.astype(np.float32) / 255.0 - 0.5) / 0.5
111 t_img = torch.from_numpy(norm.transpose(2, 0, 1)).unsqueeze(0).to(device)
112 t_sex = torch.tensor([[1.0 if is_male else 0.0]], device=device)
113
114 with torch.no_grad():
115 # Forward pass
116 out = backbone(pixel_values=t_img)
117 vis = pooler(out.last_hidden_state)
118 gen = gender_embed(t_sex)
119 logits = classifier(torch.cat([vis, gen], dim=-1))
120
121 # Expected value from probability distribution
122 probs = F.softmax(logits, dim=-1)
123 ages = torch.arange(probs.shape[-1], device=device, dtype=torch.float32)
124 predicted_months = torch.sum(probs * ages).item()
125
126 return predicted_months
127
128
129def predict_with_tta(image_path, is_male, backbone, pooler,
130 gender_embed, classifier, config, device="cuda"):
131 """
132 Predict with Test-Time Augmentation (original + horizontal flip).
133 This is the recommended inference method.
134 """
135 # Original prediction
136 pred_orig = predict_bone_age(
137 image_path, is_male, backbone, pooler,
138 gender_embed, classifier, config, device
139 )
140
141 # Flipped prediction
142 img = cv2.imread(image_path)
143 img_flipped = cv2.flip(img, 1) # Horizontal flip
144 tmp_path = "/tmp/chronos_flip.png"
145 cv2.imwrite(tmp_path, img_flipped)
146
147 pred_flip = predict_bone_age(
148 tmp_path, is_male, backbone, pooler,
149 gender_embed, classifier, config, device
150 )
151
152 # Average
153 return (pred_orig + pred_flip) / 2.0
154
155
156# ── Example ──
157
158if __name__ == "__main__":
159 device = "cuda" if torch.cuda.is_available() else "cpu"
160 print(f"Using device: {device}")
161
162 # Load model (downloads from HuggingFace on first run)
163 print("Loading model...")
164 backbone, pooler, gender_embed, classifier, config = load_regressor(device=device)
165 print("Model loaded!")
166
167 # Single prediction
168 age = predict_bone_age(
169 "hand_xray.png",
170 is_male=True,
171 backbone=backbone,
172 pooler=pooler,
173 gender_embed=gender_embed,
174 classifier=classifier,
175 config=config,
176 device=device,
177 )
178 print(f"Predicted bone age: {age:.1f} months ({age/12:.1f} years)")
179
180 # With TTA (recommended)
181 age_tta = predict_with_tta(
182 "hand_xray.png",
183 is_male=True,
184 backbone=backbone,
185 pooler=pooler,
186 gender_embed=gender_embed,
187 classifier=classifier,
188 config=config,
189 device=device,
190 )
191 print(f"Predicted bone age (TTA): {age_tta:.1f} months ({age_tta/12:.1f} years)")