MuhammadAdil63 commited on
Commit
fcec417
Β·
1 Parent(s): 9f8a14b

deploy YOLACT+ fracture detection demo

Browse files
Files changed (8) hide show
  1. README.md +157 -11
  2. README_hf.md +58 -0
  3. app.py +231 -0
  4. best.pth +3 -0
  5. dataloader.py +292 -0
  6. model.py +380 -0
  7. requirements.txt +10 -0
  8. requirements_hf.txt +9 -0
README.md CHANGED
@@ -1,14 +1,160 @@
 
 
 
 
1
  ---
2
- title: FracAtlas YOLACT
3
- emoji: πŸƒ
4
- colorFrom: green
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 6.13.0
8
- app_file: app.py
9
- pinned: false
10
- license: apache-2.0
11
- short_description: Fracture detection and segmentation on X-ray images
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # YOLACT+ (ResNet-18) on FracAtlas
2
+
3
+ Instance segmentation pipeline for fracture detection using YOLACT+ with a lightweight ResNet-18 backbone.
4
+
5
  ---
6
+
7
+ ## File Overview
8
+
9
+ ```
10
+ fracatlas_yolact/
11
+ β”œβ”€β”€ prepare_dataset.py # Step 1 β€” split FracAtlas into train/val/test
12
+ β”œβ”€β”€ dataloader.py # Dataset class + DataLoader factory
13
+ β”œβ”€β”€ model.py # YOLACT+ architecture (ResNet-18 backbone)
14
+ β”œβ”€β”€ loss.py # Focal cls + SmoothL1 box + BCE mask loss
15
+ β”œβ”€β”€ train.py # Training loop with CSV logging + checkpointing
16
+ β”œβ”€β”€ inference.py # Single-image / val-set / test-set inference
17
+ └── requirements.txt
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Dataset Splits
23
+
24
+ | Split | Fractured | Non-fractured | Total |
25
+ |-------|-----------|---------------|-------|
26
+ | Train | 500 | 500 | 1000 |
27
+ | Val | 100 | 100 | 200 |
28
+ | Test | 100 | 100 | 200 |
29
+
30
  ---
31
 
32
+ ## Quick Start
33
+
34
+ ### 1. Install dependencies
35
+ ```bash
36
+ pip install -r requirements.txt
37
+ ```
38
+
39
+ ### 2. Prepare dataset
40
+ ```bash
41
+ python prepare_dataset.py \
42
+ --fracatlas_root /path/to/FracAtlas \
43
+ --output_dir data/
44
+ ```
45
+
46
+ Expected FracAtlas layout:
47
+ ```
48
+ FracAtlas/
49
+ β”œβ”€β”€ images/
50
+ β”‚ β”œβ”€β”€ Fractured/
51
+ β”‚ └── Non_fractured/
52
+ └── Annotations/COCO JSON/COCO_fracture_all.json
53
+ ```
54
+
55
+ Output:
56
+ ```
57
+ data/
58
+ β”œβ”€β”€ train/images/ + annotations.json (1000 images)
59
+ β”œβ”€β”€ val/images/ + annotations.json (200 images)
60
+ └── test/images/ + annotations.json (200 images)
61
+ ```
62
+
63
+ ### 3. Train
64
+ ```bash
65
+ python train.py \
66
+ --data_root data/ \
67
+ --epochs 100 \
68
+ --batch_size 8 \
69
+ --lr 1e-4
70
+ ```
71
+
72
+ Checkpoints β†’ `runs/fracatlas_yolact_resnet18/checkpoints/`
73
+ - `best.pth` β€” lowest validation loss
74
+ - `last.pth` β€” most recent epoch
75
+ - `epoch_NNN.pth` β€” every 10 epochs
76
+
77
+ Training log β†’ `runs/fracatlas_yolact_resnet18/train_log.csv`
78
+
79
+ ### 4. Inference
80
+
81
+ #### Single image
82
+ ```bash
83
+ python inference.py \
84
+ --mode single \
85
+ --weights runs/fracatlas_yolact_resnet18/checkpoints/best.pth \
86
+ --image path/to/xray.jpg \
87
+ --save_vis output_vis.jpg
88
+ ```
89
+
90
+ #### Validation set (with metrics)
91
+ ```bash
92
+ python inference.py \
93
+ --mode val \
94
+ --weights runs/fracatlas_yolact_resnet18/checkpoints/best.pth \
95
+ --data_root data/ \
96
+ --save_vis_dir results/vis/val/ \
97
+ --save_results results/val_metrics.json
98
+ ```
99
+
100
+ #### Test set (with metrics)
101
+ ```bash
102
+ python inference.py \
103
+ --mode test \
104
+ --weights runs/fracatlas_yolact_resnet18/checkpoints/best.pth \
105
+ --data_root data/ \
106
+ --save_vis_dir results/vis/test/ \
107
+ --save_results results/test_metrics.json
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Architecture Summary
113
+
114
+ ```
115
+ Input [B, 3, 550, 550]
116
+ β”‚
117
+ β–Ό
118
+ ResNet-18 Backbone
119
+ β”‚ C3 [128, 69, 69]
120
+ β”‚ C4 [256, 35, 35]
121
+ β”‚ C5 [512, 18, 18]
122
+ β”‚
123
+ β–Ό
124
+ FPN (5 levels P3–P7, each 256 channels)
125
+ β”‚
126
+ β”œβ”€β–Ί ProtoNet (on P3) β†’ Prototypes [B, 32, 138, 138]
127
+ β”‚
128
+ └─► PredictionHead (shared across levels)
129
+ β”œβ”€ cls_pred [B, A, num_classes+1]
130
+ β”œβ”€ box_pred [B, A, 4]
131
+ └─ coef_pred [B, A, 32]
132
+
133
+ Final masks = sigmoid(prototypes βŠ— coefficients)
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Key Hyperparameters
139
+
140
+ | Parameter | Default | Notes |
141
+ |------------------|---------|--------------------------------------------|
142
+ | `img_size` | 550 | YOLACT+ standard input resolution |
143
+ | `num_prototypes` | 32 | Prototype masks |
144
+ | `fpn_channels` | 256 | FPN output channels |
145
+ | `lr` | 1e-4 | AdamW learning rate |
146
+ | `lambda_cls` | 1.0 | Classification (focal) loss weight |
147
+ | `lambda_box` | 1.5 | Box regression (SmoothL1) loss weight |
148
+ | `lambda_mask` | 6.125 | Mask (BCE) loss weight (YOLACT+ default) |
149
+ | `score_thresh` | 0.3 | Detection confidence threshold |
150
+ | `nms_thresh` | 0.5 | NMS IoU threshold |
151
+
152
+ ---
153
+
154
+ ## Evaluation Metrics
155
+
156
+ The inference script reports per-class and mean:
157
+ - **Precision** β€” TP / (TP + FP)
158
+ - **Recall** β€” TP / (TP + FN)
159
+ - **F1 Score** β€” harmonic mean of precision and recall
160
+ - **Avg IoU** β€” average box IoU of matched detections (threshold @ 0.5)
README_hf.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: FracAtlas YOLACT+
3
+ emoji: 🦴
4
+ colorFrom: red
5
+ colorTo: gray
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: apache-2.0
11
+ short_description: Fracture detection and segmentation on X-ray images using YOLACT+ with ResNet-18 backbone, trained on the FracAtlas dataset.
12
+ ---
13
+
14
+ # FracAtlas Fracture Detection β€” YOLACT+ (ResNet-18)
15
+
16
+ Instance segmentation model for bone fracture detection on X-ray images.
17
+
18
+ ## Model
19
+
20
+ - **Architecture:** YOLACT+ with ResNet-18 backbone
21
+ - **Neck:** Feature Pyramid Network (FPN, 5 levels)
22
+ - **Prototypes:** 32 mask prototypes
23
+ - **Input size:** 550Γ—550
24
+
25
+ ## Dataset
26
+
27
+ [FracAtlas](https://figshare.com/articles/dataset/The_dataset/22363012)
28
+
29
+ | Split | Fractured | Non-fractured | Total |
30
+ |-------|-----------|---------------|-------|
31
+ | Train | 500 | 500 | 1000 |
32
+ | Val | 100 | 100 | 200 |
33
+ | Test | 100 | 100 | 200 |
34
+
35
+ ## Training
36
+
37
+ - **Epochs:** 200
38
+ - **Optimizer:** AdamW (lr=5e-5, weight_decay=5e-4)
39
+ - **Scheduler:** Cosine decay with 5-epoch warmup
40
+ - **Loss:** Focal classification + SmoothL1 box + BCE mask
41
+
42
+ ## Results (Validation Set)
43
+
44
+ | Metric | Value |
45
+ |--------|-------|
46
+ | Precision | 0.5328 |
47
+ | Recall | 0.5422 |
48
+ | F1 Score | 0.5374 |
49
+ | Avg IoU | 0.9405 |
50
+
51
+ ## Author
52
+
53
+ Muhammad Adil β€” MS Data Science, Information Technology University (ITU) Lahore, Pakistan
54
+ GitHub: [Adil6312](https://github.com/Adil6312)
55
+
56
+ ## License
57
+
58
+ Apache 2.0
app.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FracAtlas YOLACT+ Demo
3
+ ======================
4
+ Gradio app for fracture detection and segmentation on X-ray images.
5
+ Deployed on Hugging Face Spaces: https://huggingface.co/spaces/MuhammadAdil63/FracAtlas-YOLACT
6
+ """
7
+
8
+ import os
9
+ import cv2
10
+ import numpy as np
11
+ import torch
12
+ import torch.nn.functional as F
13
+ import gradio as gr
14
+ from huggingface_hub import hf_hub_download
15
+ import albumentations as A
16
+ from albumentations.pytorch import ToTensorV2
17
+
18
+ from model import YOLACTPlus
19
+
20
+
21
+ # ─── Config ───────────────────────────────────────────────────────────────────
22
+ IMG_SIZE = 550
23
+ NUM_CLASSES = 1
24
+ CLASS_NAMES = ["fracture"]
25
+ SCORE_THRESH = 0.4
26
+ NMS_THRESH = 0.4
27
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
28
+
29
+ # HuggingFace repo where best.pth is hosted
30
+ HF_REPO_ID = "MuhammadAdil63/FracAtlas-YOLACT" # change if model hosted separately
31
+ CKPT_FILE = "best.pth"
32
+
33
+ # Mask overlay colour (red in RGB)
34
+ MASK_COLOR = (220, 50, 50)
35
+ BOX_COLOR = (220, 50, 50)
36
+
37
+
38
+ # ─── Load model (cached after first load) ────────────────────────────────────
39
+
40
+ def load_model():
41
+ print(f"[INFO] Loading model on {DEVICE} ...")
42
+
43
+ # Try local first, then download from HF Hub
44
+ if os.path.isfile(CKPT_FILE):
45
+ ckpt_path = CKPT_FILE
46
+ print(f"[INFO] Using local checkpoint: {ckpt_path}")
47
+ else:
48
+ print(f"[INFO] Downloading checkpoint from HF Hub ...")
49
+ ckpt_path = hf_hub_download(repo_id=HF_REPO_ID, filename=CKPT_FILE)
50
+
51
+ model = YOLACTPlus(num_classes=NUM_CLASSES, img_size=IMG_SIZE, pretrained=False)
52
+ ckpt = torch.load(ckpt_path, map_location=DEVICE)
53
+ model.load_state_dict(ckpt.get("model", ckpt))
54
+ model.to(DEVICE).eval()
55
+ print("[INFO] Model loaded successfully!")
56
+ return model
57
+
58
+
59
+ # Load once at startup
60
+ MODEL = load_model()
61
+
62
+
63
+ # ─── Preprocessing ────────────────────────────────────────────────────────────
64
+
65
+ def preprocess(image_rgb: np.ndarray):
66
+ transform = A.Compose([
67
+ A.LongestMaxSize(max_size=IMG_SIZE),
68
+ A.PadIfNeeded(min_height=IMG_SIZE, min_width=IMG_SIZE, fill=0),
69
+ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
70
+ ToTensorV2(),
71
+ ])
72
+ out = transform(image=image_rgb)
73
+ tensor = out["image"].unsqueeze(0).to(DEVICE)
74
+ return tensor
75
+
76
+
77
+ # ─── Draw predictions ─────────────────────────────────────────────────────────
78
+
79
+ def draw_predictions(image_rgb: np.ndarray, result: dict) -> np.ndarray:
80
+ vis = image_rgb.copy().astype(np.float32)
81
+ H, W = vis.shape[:2]
82
+
83
+ boxes = result["boxes"] # [N, 4] normalised
84
+ scores = result["scores"] # [N]
85
+ masks = result["masks"] # [N, IMG_SIZE, IMG_SIZE]
86
+
87
+ for i in range(len(scores)):
88
+ # Mask overlay
89
+ mask_np = masks[i].numpy()
90
+ mask_rs = cv2.resize(mask_np, (W, H), interpolation=cv2.INTER_LINEAR)
91
+ mask_bin = (mask_rs > 0.5).astype(np.float32)
92
+ colour = np.array(MASK_COLOR, dtype=np.float32)
93
+ for c in range(3):
94
+ vis[:, :, c] = vis[:, :, c] * (1 - 0.45 * mask_bin) + \
95
+ colour[c] * (0.45 * mask_bin)
96
+
97
+ # Bounding box
98
+ x1 = int(boxes[i, 0].item() * W)
99
+ y1 = int(boxes[i, 1].item() * H)
100
+ x2 = int(boxes[i, 2].item() * W)
101
+ y2 = int(boxes[i, 3].item() * H)
102
+ vis_u8 = np.clip(vis, 0, 255).astype(np.uint8)
103
+ cv2.rectangle(vis_u8, (x1, y1), (x2, y2), BOX_COLOR, 2)
104
+
105
+ # Label
106
+ score = scores[i].item()
107
+ tag = f"fracture: {score:.2f}"
108
+ (tw, th), _ = cv2.getTextSize(tag, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 1)
109
+ cv2.rectangle(vis_u8, (x1, y1 - th - 8), (x1 + tw + 4, y1), BOX_COLOR, -1)
110
+ cv2.putText(vis_u8, tag, (x1 + 2, y1 - 4),
111
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1, cv2.LINE_AA)
112
+ vis = vis_u8.astype(np.float32)
113
+
114
+ return np.clip(vis, 0, 255).astype(np.uint8)
115
+
116
+
117
+ # ─── Main inference function ──────────────────────────────────────────────────
118
+
119
+ def predict(image: np.ndarray, score_threshold: float, nms_threshold: float):
120
+ """
121
+ Gradio inference function.
122
+ Args:
123
+ image : RGB numpy array from Gradio
124
+ score_threshold : confidence threshold slider
125
+ nms_threshold : NMS IoU threshold slider
126
+ Returns:
127
+ annotated image, result summary text
128
+ """
129
+ if image is None:
130
+ return None, "No image provided."
131
+
132
+ orig_rgb = image.copy()
133
+ tensor = preprocess(orig_rgb)
134
+
135
+ with torch.no_grad():
136
+ results = MODEL.predict(tensor, score_threshold, nms_threshold)
137
+
138
+ res = results[0]
139
+ n = len(res["scores"])
140
+
141
+ # Draw
142
+ annotated = draw_predictions(orig_rgb, res)
143
+
144
+ # Summary text
145
+ if n == 0:
146
+ summary = "**No fractures detected.**\n\nTry lowering the Score Threshold."
147
+ else:
148
+ lines = [f"**{n} fracture(s) detected:**\n"]
149
+ for i in range(n):
150
+ score = res["scores"][i].item()
151
+ box = res["boxes"][i].tolist()
152
+ lines.append(
153
+ f"- Detection {i+1}: confidence **{score:.3f}** | "
154
+ f"box `[{box[0]:.3f}, {box[1]:.3f}, {box[2]:.3f}, {box[3]:.3f}]`"
155
+ )
156
+ summary = "\n".join(lines)
157
+
158
+ return annotated, summary
159
+
160
+
161
+ # ─── Gradio UI ────────────────────────────────────────────────────────────────
162
+
163
+ DESCRIPTION = """
164
+ ## FracAtlas Fracture Detection β€” YOLACT+ (ResNet-18)
165
+
166
+ Upload an X-ray image to detect and segment bone fractures.
167
+
168
+ **Model:** YOLACT+ with ResNet-18 backbone
169
+ **Dataset:** [FracAtlas](https://figshare.com/articles/dataset/The_dataset/22363012) β€” 717 fractured + 3366 non-fractured X-rays
170
+ **Training:** 200 epochs | AdamW | Cosine LR decay with warmup
171
+ **Val F1:** 0.537 | **Val Avg IoU:** 0.940
172
+ """
173
+
174
+ EXAMPLES = [
175
+ ["examples/fractured_example.jpg", 0.4, 0.4],
176
+ ["examples/nonfractured_example.jpg", 0.4, 0.4],
177
+ ]
178
+
179
+ with gr.Blocks(theme=gr.themes.Soft(), title="FracAtlas YOLACT+") as demo:
180
+
181
+ gr.Markdown(DESCRIPTION)
182
+
183
+ with gr.Row():
184
+ with gr.Column(scale=1):
185
+ input_image = gr.Image(
186
+ label="Input X-ray Image",
187
+ type="numpy",
188
+ image_mode="RGB",
189
+ )
190
+ with gr.Accordion("Detection Settings", open=False):
191
+ score_thresh = gr.Slider(
192
+ minimum=0.1, maximum=0.9, value=0.4, step=0.05,
193
+ label="Score Threshold",
194
+ info="Higher = fewer but more confident detections",
195
+ )
196
+ nms_thresh = gr.Slider(
197
+ minimum=0.1, maximum=0.9, value=0.4, step=0.05,
198
+ label="NMS Threshold",
199
+ info="Lower = suppress more overlapping boxes",
200
+ )
201
+ run_btn = gr.Button("Detect Fractures", variant="primary")
202
+
203
+ with gr.Column(scale=1):
204
+ output_image = gr.Image(
205
+ label="Predicted Segmentation",
206
+ type="numpy",
207
+ )
208
+ output_text = gr.Markdown(label="Detection Summary")
209
+
210
+ run_btn.click(
211
+ fn=predict,
212
+ inputs=[input_image, score_thresh, nms_thresh],
213
+ outputs=[output_image, output_text],
214
+ )
215
+
216
+ # Also run on image upload
217
+ input_image.change(
218
+ fn=predict,
219
+ inputs=[input_image, score_thresh, nms_thresh],
220
+ outputs=[output_image, output_text],
221
+ )
222
+
223
+ gr.Markdown("""
224
+ ---
225
+ **Note:** This is a research demo. Not intended for clinical use.
226
+ **Author:** Muhammad Adil | MS Data Science, ITU Lahore
227
+ **GitHub:** [Adil6312](https://github.com/Adil6312)
228
+ """)
229
+
230
+ if __name__ == "__main__":
231
+ demo.launch()
best.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bac75ce750a77f810c60c9a4a72dd8be8d0b39ebd1969140a643d25de8af578b
3
+ size 229563787
dataloader.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FracAtlas DataLoader for YOLACT+ (ResNet-18 backbone)
3
+ ======================================================
4
+ Provides:
5
+ - FracAtlasDataset : torch.utils.data.Dataset over COCO-format splits
6
+ - detection_collate : custom collate for variable-size masks/boxes
7
+ - get_dataloader : factory function for train / val / test loaders
8
+ """
9
+
10
+ import os
11
+ import os
12
+ import cv2
13
+ import numpy as np
14
+ import torch
15
+ import warnings
16
+ warnings.filterwarnings("ignore", message=".*Premature end.*")
17
+ warnings.filterwarnings("ignore", message=".*Corrupt JPEG.*")
18
+ # Suppress OpenCV JPEG warnings
19
+ try:
20
+ cv2.setLogLevel(0)
21
+ except AttributeError:
22
+ os.environ["OPENCV_LOG_LEVEL"] = "SILENT"
23
+ from torch.utils.data import Dataset, DataLoader
24
+ from pycocotools.coco import COCO
25
+ from pycocotools import mask as coco_mask
26
+ import albumentations as A
27
+ from albumentations.pytorch import ToTensorV2
28
+
29
+
30
+ # ─── Augmentation pipelines ───────────────────────────────────────────────────
31
+
32
+ def get_train_transforms(img_size: int = 550):
33
+ return A.Compose(
34
+ [
35
+ A.LongestMaxSize(max_size=img_size),
36
+ A.PadIfNeeded(
37
+ min_height=img_size,
38
+ min_width=img_size,
39
+ fill=0,
40
+ ),
41
+ A.HorizontalFlip(p=0.5),
42
+ A.VerticalFlip(p=0.2),
43
+ A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5),
44
+ A.GaussNoise(p=0.3),
45
+ A.Affine(
46
+ translate_percent=0.05,
47
+ scale=(0.9, 1.1),
48
+ rotate=(-10, 10),
49
+ p=0.4,
50
+ ),
51
+ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
52
+ ToTensorV2(),
53
+ ],
54
+ bbox_params=A.BboxParams(
55
+ format="pascal_voc",
56
+ label_fields=["class_labels"],
57
+ min_visibility=0.3,
58
+ ),
59
+ )
60
+
61
+
62
+ def get_val_transforms(img_size: int = 550):
63
+ return A.Compose(
64
+ [
65
+ A.LongestMaxSize(max_size=img_size),
66
+ A.PadIfNeeded(
67
+ min_height=img_size,
68
+ min_width=img_size,
69
+ fill=0,
70
+ ),
71
+ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
72
+ ToTensorV2(),
73
+ ],
74
+ bbox_params=A.BboxParams(
75
+ format="pascal_voc",
76
+ label_fields=["class_labels"],
77
+ min_visibility=0.3,
78
+ ),
79
+ )
80
+
81
+
82
+ # ─── Dataset ──────────────────────────────────────────────────────────────────
83
+
84
+ class FracAtlasDataset(Dataset):
85
+ """
86
+ COCO-format dataset for FracAtlas fracture detection.
87
+
88
+ Each item returns:
89
+ image : FloatTensor [3, H, W] (normalised)
90
+ target : dict with keys
91
+ boxes : FloatTensor [N, 4] (x1y1x2y2, normalised 0-1)
92
+ labels : LongTensor [N]
93
+ masks : FloatTensor [N, H, W] (binary, same spatial size as image)
94
+ image_id: int
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ image_dir: str,
100
+ ann_file: str,
101
+ img_size: int = 550,
102
+ split: str = "train",
103
+ ):
104
+ self.image_dir = image_dir
105
+ self.img_size = img_size
106
+ self.split = split
107
+
108
+ self.coco = COCO(ann_file)
109
+ self.image_ids = sorted(self.coco.imgs.keys())
110
+
111
+ # Build category β†’ 0-indexed label map
112
+ # NOTE: FracAtlas uses category_id=0 ('fractured') β€” handle offset
113
+ cats = self.coco.loadCats(self.coco.getCatIds())
114
+ self.cat_id_to_label = {c["id"]: i for i, c in enumerate(cats)}
115
+ # If only one class and its id is 0, map it to label 0
116
+ if len(cats) == 1 and cats[0]["id"] == 0:
117
+ self.cat_id_to_label = {0: 0}
118
+ self.num_classes = len(cats)
119
+ self.class_names = [c["name"] for c in cats]
120
+
121
+ self.transforms = (
122
+ get_train_transforms(img_size)
123
+ if split == "train"
124
+ else get_val_transforms(img_size)
125
+ )
126
+
127
+ print(
128
+ f"[{split}] {len(self.image_ids)} images | "
129
+ f"{self.num_classes} classes: {self.class_names}"
130
+ )
131
+
132
+ def __len__(self):
133
+ return len(self.image_ids)
134
+
135
+ def _decode_mask(self, ann: dict, h: int, w: int) -> np.ndarray:
136
+ """Decode COCO RLE or polygon segmentation to binary mask."""
137
+ seg = ann.get("segmentation", None)
138
+ if seg is None:
139
+ # Fall back: create mask from bbox
140
+ x, y, bw, bh = [int(v) for v in ann["bbox"]]
141
+ m = np.zeros((h, w), dtype=np.uint8)
142
+ m[y : y + bh, x : x + bw] = 1
143
+ return m
144
+ if isinstance(seg, dict): # RLE
145
+ return coco_mask.decode(seg).astype(np.uint8)
146
+ else: # polygon
147
+ rle = coco_mask.frPyObjects(seg, h, w)
148
+ merged = coco_mask.merge(rle)
149
+ return coco_mask.decode(merged).astype(np.uint8)
150
+
151
+ def __getitem__(self, idx: int):
152
+ img_id = self.image_ids[idx]
153
+ img_info = self.coco.imgs[img_id]
154
+
155
+ # ── Load image ────────────────────────────────────────────────────────
156
+ img_path = os.path.join(self.image_dir, img_info["file_name"])
157
+ image = cv2.imread(img_path)
158
+ if image is None:
159
+ raise FileNotFoundError(f"Cannot read image: {img_path}")
160
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
161
+ orig_h, orig_w = image.shape[:2]
162
+
163
+ # ── Load annotations ─────────────────────────────────────────────────
164
+ ann_ids = self.coco.getAnnIds(imgIds=img_id)
165
+ anns = self.coco.loadAnns(ann_ids)
166
+
167
+ boxes, class_labels, raw_masks = [], [], []
168
+ for ann in anns:
169
+ x, y, bw, bh = ann["bbox"]
170
+ x1, y1, x2, y2 = x, y, x + bw, y + bh
171
+ # Clip to image bounds
172
+ x1 = max(0.0, x1)
173
+ y1 = max(0.0, y1)
174
+ x2 = min(float(orig_w), x2)
175
+ y2 = min(float(orig_h), y2)
176
+ if x2 <= x1 or y2 <= y1:
177
+ continue
178
+ boxes.append([x1, y1, x2, y2])
179
+ class_labels.append(self.cat_id_to_label[ann["category_id"]])
180
+ raw_masks.append(self._decode_mask(ann, orig_h, orig_w))
181
+
182
+ # Non-fractured images: create a dummy background instance so the
183
+ # tensor shapes are consistent (YOLACT handles empty targets fine too,
184
+ # but keeping consistent is safer).
185
+ if len(boxes) == 0:
186
+ boxes = [[0.0, 0.0, float(orig_w), float(orig_h)]]
187
+ class_labels = [0] # background / non-fractured
188
+ raw_masks = [np.zeros((orig_h, orig_w), dtype=np.uint8)]
189
+
190
+ # ── Albumentations ───────────────────────────────────────────────────
191
+ transformed = self.transforms(
192
+ image=image,
193
+ masks=raw_masks,
194
+ bboxes=boxes,
195
+ class_labels=class_labels,
196
+ )
197
+ image_t = transformed["image"] # [3, H, W]
198
+ boxes_t = transformed["bboxes"]
199
+ labels_t = transformed["class_labels"]
200
+ masks_t = transformed["masks"] # list of HΓ—W arrays
201
+
202
+ _, H, W = image_t.shape
203
+
204
+ # ── Build target tensors ─────────────────────────────────────────────
205
+ if len(boxes_t) == 0:
206
+ # All boxes removed by augmentation (e.g. min_visibility)
207
+ boxes_out = torch.zeros((0, 4), dtype=torch.float32)
208
+ labels_out = torch.zeros((0,), dtype=torch.long)
209
+ masks_out = torch.zeros((0, H, W), dtype=torch.float32)
210
+ else:
211
+ boxes_np = np.array(boxes_t, dtype=np.float32)
212
+ # Normalise to [0, 1]
213
+ boxes_np[:, [0, 2]] /= W
214
+ boxes_np[:, [1, 3]] /= H
215
+ boxes_np = np.clip(boxes_np, 0.0, 1.0)
216
+
217
+ boxes_out = torch.from_numpy(boxes_np)
218
+ labels_out = torch.tensor(labels_t, dtype=torch.long)
219
+ # Albumentations >=2.x returns masks as tensors; older versions return numpy.
220
+ def to_float_tensor(m):
221
+ if isinstance(m, torch.Tensor):
222
+ return m.float()
223
+ return torch.from_numpy(np.array(m, dtype=np.float32))
224
+ masks_out = torch.stack([to_float_tensor(m) for m in masks_t])
225
+
226
+ target = {
227
+ "boxes": boxes_out,
228
+ "labels": labels_out,
229
+ "masks": masks_out,
230
+ "image_id": img_id,
231
+ }
232
+ return image_t, target
233
+
234
+
235
+ # ─── Collate ──────────────────────────────────────────────────────────────────
236
+
237
+ def detection_collate(batch):
238
+ """
239
+ Custom collate for object detection.
240
+ Images are stacked; targets are kept as a list (variable number of instances).
241
+ """
242
+ images, targets = zip(*batch)
243
+ images = torch.stack(images, dim=0) # [B, 3, H, W]
244
+ return images, list(targets)
245
+
246
+
247
+ # ─── DataLoader factory ───────────────────────────────────────────────────────
248
+
249
+ def get_dataloader(
250
+ image_dir: str,
251
+ ann_file: str,
252
+ split: str = "train",
253
+ img_size: int = 550,
254
+ batch_size: int = 8,
255
+ num_workers: int = 4,
256
+ pin_memory: bool = True,
257
+ ) -> DataLoader:
258
+ """
259
+ Returns a DataLoader for the given split.
260
+
261
+ Args:
262
+ image_dir : path to images/ folder for this split
263
+ ann_file : path to annotations.json for this split
264
+ split : "train" | "val" | "test"
265
+ img_size : input resolution fed to the network (default 550 for YOLACT+)
266
+ batch_size : mini-batch size
267
+ num_workers: parallel data-loading workers
268
+ pin_memory : pin CPU memory for faster GPU transfer
269
+
270
+ Returns:
271
+ torch.utils.data.DataLoader
272
+ """
273
+ dataset = FracAtlasDataset(
274
+ image_dir=image_dir,
275
+ ann_file=ann_file,
276
+ img_size=img_size,
277
+ split=split,
278
+ )
279
+ shuffle = split == "train"
280
+ # pin_memory only works when CUDA is available
281
+ import torch
282
+ use_pin = pin_memory and torch.cuda.is_available()
283
+ loader = DataLoader(
284
+ dataset,
285
+ batch_size=batch_size,
286
+ shuffle=shuffle,
287
+ num_workers=num_workers,
288
+ pin_memory=use_pin,
289
+ collate_fn=detection_collate,
290
+ drop_last=(split == "train"),
291
+ )
292
+ return loader
model.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ YOLACT+ with ResNet-18 Backbone
3
+ =================================
4
+ A faithful implementation of YOLACT+ adapted for a lightweight ResNet-18 backbone.
5
+
6
+ Architecture:
7
+ Backbone : ResNet-18 (torchvision, ImageNet pre-trained)
8
+ Neck : FPN (Feature Pyramid Network)
9
+ Head : PredictionHead (class + box + mask coefficient)
10
+ Proto : ProtoNet (generates prototype masks)
11
+ Mask : linear combination of prototypes Γ— coefficients
12
+ NMS : Fast NMS (YOLACT-style)
13
+ """
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+ from torchvision.models import resnet18, ResNet18_Weights
19
+ from torchvision.ops import nms
20
+
21
+
22
+ # ─── Constants ────────────────────────────────────────────────────────────────
23
+ NUM_PROTOTYPES = 32
24
+ FPN_CHANNELS = 256
25
+ PROTO_CHANNELS = 256
26
+
27
+
28
+ # ─── Backbone ─────────────────────────────────────────────────────────────────
29
+
30
+ class ResNet18Backbone(nn.Module):
31
+ """
32
+ ResNet-18 feature extractor.
33
+ Returns C3, C4, C5 feature maps (strides 8, 16, 32).
34
+ """
35
+
36
+ def __init__(self, pretrained: bool = True):
37
+ super().__init__()
38
+ weights = ResNet18_Weights.IMAGENET1K_V1 if pretrained else None
39
+ base = resnet18(weights=weights)
40
+
41
+ self.layer0 = nn.Sequential(base.conv1, base.bn1, base.relu, base.maxpool)
42
+ self.layer1 = base.layer1 # stride 4, channels 64
43
+ self.layer2 = base.layer2 # stride 8, channels 128 β†’ C3
44
+ self.layer3 = base.layer3 # stride 16, channels 256 β†’ C4
45
+ self.layer4 = base.layer4 # stride 32, channels 512 β†’ C5
46
+
47
+ self.out_channels = [128, 256, 512] # C3, C4, C5
48
+
49
+ def forward(self, x):
50
+ x = self.layer0(x)
51
+ x = self.layer1(x)
52
+ c3 = self.layer2(x)
53
+ c4 = self.layer3(c3)
54
+ c5 = self.layer4(c4)
55
+ return c3, c4, c5
56
+
57
+
58
+ # ─── FPN ──────────────────────────────────────────────────────────────────────
59
+
60
+ class FPN(nn.Module):
61
+ """
62
+ 5-level FPN: P3–P7 (P6, P7 generated by strided convolution on P5).
63
+ """
64
+
65
+ def __init__(self, in_channels: list, out_channels: int = FPN_CHANNELS):
66
+ super().__init__()
67
+ self.lateral_convs = nn.ModuleList(
68
+ [nn.Conv2d(c, out_channels, 1) for c in in_channels]
69
+ )
70
+ self.output_convs = nn.ModuleList(
71
+ [nn.Conv2d(out_channels, out_channels, 3, padding=1) for _ in in_channels]
72
+ )
73
+ # Extra levels P6, P7
74
+ self.p6_conv = nn.Conv2d(out_channels, out_channels, 3, stride=2, padding=1)
75
+ self.p7_conv = nn.Conv2d(out_channels, out_channels, 3, stride=2, padding=1)
76
+
77
+ def forward(self, features):
78
+ c3, c4, c5 = features
79
+ lat = [l(f) for l, f in zip(self.lateral_convs, [c3, c4, c5])]
80
+
81
+ # Top-down pathway
82
+ lat[1] = lat[1] + F.interpolate(lat[2], size=lat[1].shape[-2:], mode="nearest")
83
+ lat[0] = lat[0] + F.interpolate(lat[1], size=lat[0].shape[-2:], mode="nearest")
84
+
85
+ p3 = self.output_convs[0](lat[0])
86
+ p4 = self.output_convs[1](lat[1])
87
+ p5 = self.output_convs[2](lat[2])
88
+ p6 = self.p6_conv(p5)
89
+ p7 = self.p7_conv(F.relu(p6))
90
+
91
+ return [p3, p4, p5, p6, p7]
92
+
93
+
94
+ # ─── ProtoNet ─────────────────────────────────────────────────────────────────
95
+
96
+ class ProtoNet(nn.Module):
97
+ """
98
+ Generates K prototype masks from the P3 feature map.
99
+ Output: [B, K, H/4, W/4]
100
+ """
101
+
102
+ def __init__(self, in_channels: int = FPN_CHANNELS, num_protos: int = NUM_PROTOTYPES):
103
+ super().__init__()
104
+ self.proto_net = nn.Sequential(
105
+ nn.Conv2d(in_channels, PROTO_CHANNELS, 3, padding=1), nn.ReLU(),
106
+ nn.Conv2d(PROTO_CHANNELS, PROTO_CHANNELS, 3, padding=1), nn.ReLU(),
107
+ nn.Conv2d(PROTO_CHANNELS, PROTO_CHANNELS, 3, padding=1), nn.ReLU(),
108
+ nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False),
109
+ nn.Conv2d(PROTO_CHANNELS, PROTO_CHANNELS, 3, padding=1), nn.ReLU(),
110
+ nn.Conv2d(PROTO_CHANNELS, num_protos, 1),
111
+ )
112
+
113
+ def forward(self, p3):
114
+ return self.proto_net(p3) # [B, K, H', W']
115
+
116
+
117
+ # ─── Anchor generator ─────────────────────────────────────────────────────────
118
+
119
+ class AnchorGenerator:
120
+ """
121
+ Pre-computed anchor boxes for each FPN level.
122
+ Scales: [24, 48, 96, 192, 384] (for 550Γ—550 input)
123
+ Aspect ratios: [1.0, 0.5, 2.0]
124
+ """
125
+
126
+ SCALES = [24, 48, 96, 192, 384]
127
+ ASPECT_RATIOS = [1.0, 0.5, 2.0]
128
+
129
+ def __init__(self, img_size: int = 550):
130
+ self.img_size = img_size
131
+ self.num_anchors_per_cell = len(self.ASPECT_RATIOS)
132
+
133
+ def make_anchors(self, feature_sizes: list) -> torch.Tensor:
134
+ """Returns [total_anchors, 4] in cx/cy/w/h format (normalised 0-1)."""
135
+ all_anchors = []
136
+ for lvl, (fh, fw) in enumerate(feature_sizes):
137
+ scale = self.SCALES[lvl]
138
+ for row in range(fh):
139
+ for col in range(fw):
140
+ cx = (col + 0.5) / fw
141
+ cy = (row + 0.5) / fh
142
+ for ar in self.ASPECT_RATIOS:
143
+ w = scale * (ar ** 0.5) / self.img_size
144
+ h = scale / (ar ** 0.5) / self.img_size
145
+ all_anchors.append([cx, cy, w, h])
146
+ return torch.tensor(all_anchors, dtype=torch.float32)
147
+
148
+
149
+ # ─── Prediction Head ──────────────────────────────────────────────────────────
150
+
151
+ class PredictionHead(nn.Module):
152
+ """
153
+ Shared prediction head applied to each FPN level.
154
+ Outputs:
155
+ cls_pred : [B, A, num_classes+1]
156
+ box_pred : [B, A, 4]
157
+ coef_pred : [B, A, K]
158
+ """
159
+
160
+ def __init__(
161
+ self,
162
+ in_channels: int = FPN_CHANNELS,
163
+ num_classes: int = 2,
164
+ num_anchors: int = 3,
165
+ num_protos: int = NUM_PROTOTYPES,
166
+ ):
167
+ super().__init__()
168
+ self.num_classes = num_classes
169
+ self.num_anchors = num_anchors
170
+
171
+ self.shared = nn.Sequential(
172
+ nn.Conv2d(in_channels, in_channels, 3, padding=1), nn.ReLU(),
173
+ nn.Conv2d(in_channels, in_channels, 3, padding=1), nn.ReLU(),
174
+ nn.Conv2d(in_channels, in_channels, 3, padding=1), nn.ReLU(),
175
+ nn.Conv2d(in_channels, in_channels, 3, padding=1), nn.ReLU(),
176
+ )
177
+ self.cls_layer = nn.Conv2d(in_channels, num_anchors * (num_classes + 1), 1)
178
+ self.box_layer = nn.Conv2d(in_channels, num_anchors * 4, 1)
179
+ self.coef_layer = nn.Conv2d(in_channels, num_anchors * num_protos, 1)
180
+
181
+ def forward(self, feat):
182
+ B, _, H, W = feat.shape
183
+ x = self.shared(feat)
184
+
185
+ cls = self.cls_layer(x) # [B, A*(C+1), H, W]
186
+ box = self.box_layer(x) # [B, A*4, H, W]
187
+ coef = self.coef_layer(x) # [B, A*K, H, W]
188
+
189
+ # Reshape to [B, H*W*A, ...]
190
+ A, C, K = self.num_anchors, self.num_classes, NUM_PROTOTYPES
191
+ cls = cls.permute(0, 2, 3, 1).contiguous().view(B, -1, C + 1)
192
+ box = box.permute(0, 2, 3, 1).contiguous().view(B, -1, 4)
193
+ coef = coef.permute(0, 2, 3, 1).contiguous().view(B, -1, K)
194
+ coef = torch.tanh(coef)
195
+
196
+ return cls, box, coef
197
+
198
+
199
+ # ─── YOLACT+ ──────────────────────────────────────────────────────────────────
200
+
201
+ class YOLACTPlus(nn.Module):
202
+ """
203
+ YOLACT+ with ResNet-18 backbone.
204
+
205
+ Args:
206
+ num_classes : number of foreground classes (background added internally)
207
+ img_size : input image resolution (square, default 550)
208
+ pretrained : use ImageNet-pretrained ResNet-18
209
+ """
210
+
211
+ def __init__(
212
+ self,
213
+ num_classes: int = 1,
214
+ img_size: int = 550,
215
+ pretrained: bool = True,
216
+ ):
217
+ super().__init__()
218
+ self.num_classes = num_classes
219
+ self.img_size = img_size
220
+
221
+ self.backbone = ResNet18Backbone(pretrained=pretrained)
222
+ self.fpn = FPN(self.backbone.out_channels)
223
+ self.proto_net = ProtoNet(FPN_CHANNELS, NUM_PROTOTYPES)
224
+ self.head = PredictionHead(
225
+ FPN_CHANNELS, num_classes, len(AnchorGenerator.ASPECT_RATIOS), NUM_PROTOTYPES
226
+ )
227
+ self.anchor_gen = AnchorGenerator(img_size)
228
+ self._anchors = None # cached after first forward pass
229
+
230
+ # ── Forward ───────────────────────────────────────────────────────────────
231
+
232
+ def forward(self, images: torch.Tensor):
233
+ """
234
+ Args:
235
+ images : [B, 3, H, W]
236
+ Returns (training mode):
237
+ {
238
+ "cls_pred" : [B, total_anchors, num_classes+1]
239
+ "box_pred" : [B, total_anchors, 4]
240
+ "coef_pred" : [B, total_anchors, K]
241
+ "proto_out" : [B, K, H', W']
242
+ "anchors" : [total_anchors, 4] (cx/cy/w/h, normalised)
243
+ }
244
+ """
245
+ features = self.backbone(images)
246
+ fpn_feats = self.fpn(features)
247
+
248
+ proto_out = self.proto_net(fpn_feats[0]) # P3 β†’ prototypes
249
+
250
+ # Cache anchors (they depend only on feature map sizes)
251
+ if self._anchors is None or self._anchors.device != images.device:
252
+ feat_sizes = [(f.shape[2], f.shape[3]) for f in fpn_feats]
253
+ self._anchors = self.anchor_gen.make_anchors(feat_sizes).to(images.device)
254
+
255
+ cls_preds, box_preds, coef_preds = [], [], []
256
+ for feat in fpn_feats:
257
+ cls, box, coef = self.head(feat)
258
+ cls_preds.append(cls)
259
+ box_preds.append(box)
260
+ coef_preds.append(coef)
261
+
262
+ return {
263
+ "cls_pred": torch.cat(cls_preds, dim=1),
264
+ "box_pred": torch.cat(box_preds, dim=1),
265
+ "coef_pred": torch.cat(coef_preds, dim=1),
266
+ "proto_out": proto_out,
267
+ "anchors": self._anchors,
268
+ }
269
+
270
+ # ── Post-processing (inference) ───────────────────────────────────────────
271
+
272
+ @torch.no_grad()
273
+ def predict(
274
+ self,
275
+ images: torch.Tensor,
276
+ score_thresh: float = 0.3,
277
+ nms_thresh: float = 0.5,
278
+ top_k: int = 100,
279
+ ) -> list:
280
+ """
281
+ Run inference and return decoded predictions.
282
+
283
+ Returns:
284
+ List (one per image) of dicts:
285
+ boxes : [N, 4] x1y1x2y2 normalised
286
+ scores : [N]
287
+ labels : [N]
288
+ masks : [N, H, W] float binary masks (upsampled to input size)
289
+ """
290
+ self.eval()
291
+ out = self.forward(images)
292
+
293
+ cls_pred = out["cls_pred"] # [B, A, C+1]
294
+ box_pred = out["box_pred"] # [B, A, 4]
295
+ coef_pred = out["coef_pred"] # [B, A, K]
296
+ proto = out["proto_out"] # [B, K, H', W']
297
+ anchors = out["anchors"] # [A, 4]
298
+
299
+ results = []
300
+ B = images.shape[0]
301
+ for i in range(B):
302
+ scores_all = torch.softmax(cls_pred[i], dim=-1) # [A, C+1]
303
+ scores, labels = scores_all[:, 1:].max(dim=-1) # foreground only
304
+
305
+ keep_mask = scores > score_thresh
306
+ if keep_mask.sum() == 0:
307
+ results.append({"boxes": torch.zeros(0, 4), "scores": torch.zeros(0),
308
+ "labels": torch.zeros(0, dtype=torch.long),
309
+ "masks": torch.zeros(0, self.img_size, self.img_size)})
310
+ continue
311
+
312
+ scores = scores[keep_mask]
313
+ labels = labels[keep_mask]
314
+ boxes_d = box_pred[i][keep_mask] # deltas
315
+ coefs = coef_pred[i][keep_mask] # [N, K]
316
+ anch = anchors[keep_mask] # [N, 4]
317
+
318
+ # Decode box deltas β†’ cx/cy/w/h
319
+ pred_cx = boxes_d[:, 0] * anch[:, 2] + anch[:, 0]
320
+ pred_cy = boxes_d[:, 1] * anch[:, 3] + anch[:, 1]
321
+ pred_w = torch.exp(boxes_d[:, 2]) * anch[:, 2]
322
+ pred_h = torch.exp(boxes_d[:, 3]) * anch[:, 3]
323
+
324
+ # β†’ x1y1x2y2
325
+ x1 = torch.clamp(pred_cx - pred_w / 2, 0, 1)
326
+ y1 = torch.clamp(pred_cy - pred_h / 2, 0, 1)
327
+ x2 = torch.clamp(pred_cx + pred_w / 2, 0, 1)
328
+ y2 = torch.clamp(pred_cy + pred_h / 2, 0, 1)
329
+ boxes_xyxy = torch.stack([x1, y1, x2, y2], dim=1)
330
+
331
+ # ── Filter out oversized boxes ────────────────────────────
332
+ # Remove boxes whose area exceeds 50% of the image area.
333
+ # These are almost always spurious full-image anchors.
334
+ box_w = boxes_xyxy[:, 2] - boxes_xyxy[:, 0]
335
+ box_h = boxes_xyxy[:, 3] - boxes_xyxy[:, 1]
336
+ box_area = box_w * box_h
337
+ size_mask = box_area < 0.50 # keep boxes < 50% of image area
338
+ boxes_xyxy = boxes_xyxy[size_mask]
339
+ scores = scores[size_mask]
340
+ labels = labels[size_mask]
341
+ coefs = coefs[size_mask]
342
+
343
+ if boxes_xyxy.shape[0] == 0:
344
+ results.append({"boxes": torch.zeros(0, 4), "scores": torch.zeros(0),
345
+ "labels": torch.zeros(0, dtype=torch.long),
346
+ "masks": torch.zeros(0, self.img_size, self.img_size)})
347
+ continue
348
+
349
+ # NMS (pixel-scale for torchvision nms)
350
+ scale = float(self.img_size)
351
+ keep = nms(boxes_xyxy * scale, scores, nms_thresh)
352
+ keep = keep[:top_k]
353
+
354
+ boxes_xyxy = boxes_xyxy[keep]
355
+ scores = scores[keep]
356
+ labels = labels[keep]
357
+ coefs = coefs[keep] # [N, K]
358
+
359
+ # Decode masks: proto [K, H', W'], coefs [N, K]
360
+ proto_i = proto[i] # [K, H', W']
361
+ K, pH, pW = proto_i.shape
362
+ proto_flat = proto_i.view(K, -1).T # [H'W', K]
363
+ mask_flat = torch.sigmoid(proto_flat @ coefs.T) # [H'W', N]
364
+ masks_raw = mask_flat.T.view(len(keep), pH, pW) # [N, H', W']
365
+
366
+ # Upsample to input resolution
367
+ masks_up = F.interpolate(
368
+ masks_raw.unsqueeze(0), size=(self.img_size, self.img_size),
369
+ mode="bilinear", align_corners=False
370
+ ).squeeze(0)
371
+ masks_bin = (masks_up > 0.5).float()
372
+
373
+ results.append({
374
+ "boxes": boxes_xyxy.cpu(),
375
+ "scores": scores.cpu(),
376
+ "labels": labels.cpu(),
377
+ "masks": masks_bin.cpu(),
378
+ })
379
+
380
+ return results
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # YOLACT+ FracAtlas Requirements
2
+ # Install with: pip install -r requirements.txt
3
+
4
+ torch>=2.0.0
5
+ torchvision>=0.15.0
6
+ albumentations>=1.3.0
7
+ pycocotools>=2.0.6
8
+ opencv-python>=4.7.0
9
+ numpy>=1.24.0
10
+ tqdm>=4.65.0
requirements_hf.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ torch
2
+ torchvision
3
+ albumentations
4
+ pycocotools
5
+ opencv-python-headless
6
+ numpy
7
+ matplotlib
8
+ gradio
9
+ huggingface_hub