sc-crop 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sc_crop-0.6.0/PKG-INFO +14 -0
- sc_crop-0.6.0/README.md +358 -0
- sc_crop-0.6.0/examples/api_examples.py +229 -0
- sc_crop-0.6.0/examples/demo.py +89 -0
- sc_crop-0.6.0/examples/infer_with_sc_crop.py +150 -0
- sc_crop-0.6.0/examples/quickstart.py +72 -0
- sc_crop-0.6.0/pyproject.toml +32 -0
- sc_crop-0.6.0/sc_crop/__init__.py +35 -0
- sc_crop-0.6.0/sc_crop/cli.py +240 -0
- sc_crop-0.6.0/sc_crop/config.yaml +8 -0
- sc_crop-0.6.0/sc_crop/crop.py +728 -0
- sc_crop-0.6.0/sc_crop/download.py +74 -0
- sc_crop-0.6.0/sc_crop/models/cls_model.pt +0 -0
- sc_crop-0.6.0/sc_crop/models/model.onnx +0 -0
- sc_crop-0.6.0/sc_crop/models/model.pt +0 -0
- sc_crop-0.6.0/sc_crop/qc.py +236 -0
- sc_crop-0.6.0/sc_crop/segment.py +146 -0
- sc_crop-0.6.0/sc_crop.egg-info/PKG-INFO +14 -0
- sc_crop-0.6.0/sc_crop.egg-info/SOURCES.txt +24 -0
- sc_crop-0.6.0/sc_crop.egg-info/dependency_links.txt +1 -0
- sc_crop-0.6.0/sc_crop.egg-info/entry_points.txt +4 -0
- sc_crop-0.6.0/sc_crop.egg-info/requires.txt +10 -0
- sc_crop-0.6.0/sc_crop.egg-info/top_level.txt +6 -0
- sc_crop-0.6.0/scripts/compare_cls_inference.py +131 -0
- sc_crop-0.6.0/scripts/compare_inference.py +132 -0
- sc_crop-0.6.0/setup.cfg +4 -0
sc_crop-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sc-crop
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Spinal cord detection and volume cropping
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
Requires-Dist: nibabel>=5.0.0
|
|
7
|
+
Requires-Dist: numpy>=1.24.0
|
|
8
|
+
Requires-Dist: onnxruntime>=1.18.0
|
|
9
|
+
Requires-Dist: pillow>=10.0.0
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
Requires-Dist: scipy>=1.10.0
|
|
12
|
+
Requires-Dist: ultralytics>=8.0.0
|
|
13
|
+
Provides-Extra: segment
|
|
14
|
+
Requires-Dist: nnunet-onnx; extra == "segment"
|
sc_crop-0.6.0/README.md
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# sc-crop — Spinal cord detection and cropping
|
|
2
|
+
|
|
3
|
+
<img width="1635" height="977" alt="image" src="https://github.com/user-attachments/assets/882bb621-685d-4efa-bc45-f484cfe78ab0" />
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Segmentation models for spinal cord pathologies (tumors, lesions, SC itself) are typically trained and run on full volumes where the spinal cord occupies only a small fraction of the volume. **sc-crop** solves this by automatically detecting the spinal cord and cropping the volume tightly around it, so your segmentation model only ever sees the relevant region.
|
|
9
|
+
|
|
10
|
+
This reduces memory usage, speeds up inference, and often improves model accuracy by removing irrelevant background. The recommended workflow is:
|
|
11
|
+
|
|
12
|
+
1. **Preprocessing** — crop all training images and labels around the detected spinal cord, adding a fixed security margin
|
|
13
|
+
2. **Training** — train your segmentation model on the cropped volumes
|
|
14
|
+
3. **Inference** — apply the same sc-crop preprocessing to new images, run your model, then optionally restore the segmentation to the original space
|
|
15
|
+
|
|
16
|
+
Works on both **MRI and CT**, across contrasts (T1, T2, MP2RAGE, DWI…), field strengths, and pathologies. Based on a YOLO26n model trained on multiple datasets covering cervical and lumbar spine. Available as a **CLI tool** and as an **importable Python package**.
|
|
17
|
+
|
|
18
|
+
<img width="1713" height="727" alt="image" src="https://github.com/user-attachments/assets/d8958227-06b6-4430-9378-4a6f91e9741d" />
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Fresh dedicated environment (for testing):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
conda create -n sc_crop python=3.13 && conda activate sc_crop
|
|
28
|
+
pip install sc-crop
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or into an existing environment:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install sc-crop
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
*To pin in a `requirements.txt`: add `sc-crop>=0.6.0`*
|
|
38
|
+
|
|
39
|
+
To install from source (latest development version):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install git+https://github.com/ivadomed/sc-crop.git
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Command-line interface: detect and crop spinal cord volumes
|
|
48
|
+
|
|
49
|
+
> The environment where sc-crop was installed must be active for the `sc_crop` command to be available.
|
|
50
|
+
|
|
51
|
+
### Quick start — download the test data
|
|
52
|
+
|
|
53
|
+
Image and label are available from the same release:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
mkdir ~/sc-crop-test && cd ~/sc-crop-test
|
|
57
|
+
curl -L https://github.com/ivadomed/sc-crop/releases/download/test-data/t2.nii.gz -o t2.nii.gz
|
|
58
|
+
curl -L https://github.com/ivadomed/sc-crop/releases/download/test-data/t2_seg.nii.gz -o t2_seg.nii.gz
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Crop the image
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
sc_crop -i t2.nii.gz
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Three files are written:
|
|
68
|
+
|
|
69
|
+
| File | Content |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `t2_crop.nii.gz` | Cropped image (affine origin updated) |
|
|
72
|
+
| `t2_cropbox.nii.gz` | Binary mask of the bounding box used (FSLeyes overlay) |
|
|
73
|
+
| `t2_bbox.txt` | Bounding box coordinates in voxel space (human-readable) |
|
|
74
|
+
|
|
75
|
+
The command also prints a ready-to-use FSLeyes command to visualise the crop and its bounding box:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
fsleyes t2.nii.gz t2_crop.nii.gz t2_cropbox.nii.gz -ot mask -mc 1 0 0 --outline -w 3 &
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
<table><tr>
|
|
82
|
+
<td><img width="560" alt="FSLeyes bounding box overlay" src="https://github.com/user-attachments/assets/510fff6e-5436-47d1-89b3-ebe549e38449" /></td>
|
|
83
|
+
<td><img width="200" alt="FSLeyes overlay panel" src="https://github.com/user-attachments/assets/4d83d93d-efc5-4695-97f3-0535aec30fce" /></td>
|
|
84
|
+
</tr></table>
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
### Crop a label with the same bounding box
|
|
89
|
+
|
|
90
|
+
Use the `t2_cropbox.nii.gz` (or `t2_bbox.txt`) produced above to crop any other volume — label, atlas, or additional contrast — with the exact same boundaries:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
sc_crop -i t2_seg.nii.gz --bbox t2_cropbox.nii.gz -o t2_seg_crop.nii.gz
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Adjust the bounding box margin
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
sc_crop -i t2.nii.gz --pad-sup 50 --pad-inf 80 --pad-left 10 --pad-right 10 --pad-ant 15 --pad-post 15
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
sc_crop -i t2.nii.gz --pad-si 30 --pad-rl 10 --pad-ap 15
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Priority: individual (e.g. `--pad-sup`) > symmetric (e.g. `--pad-si`) > default.
|
|
107
|
+
|
|
108
|
+
Use `--detect` to run detection only (outputs cropbox + bbox.txt, skips the crop step).
|
|
109
|
+
|
|
110
|
+
Run `sc_crop --help` for all options.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Python API
|
|
115
|
+
|
|
116
|
+
Three functions cover all use cases:
|
|
117
|
+
|
|
118
|
+
| Function | Description |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `detect(img_path)` | Runs the SC detector and returns the bounding box coordinates |
|
|
121
|
+
| `crop(img, bbox)` | Crops any NIfTI volume (image or label) to the bounding box |
|
|
122
|
+
| `uncrop(seg, bbox)` | Restores a segmentation from the cropped space back to the original full image space |
|
|
123
|
+
| `check_label_crop(label, bbox)` | Checks the crop preserves every label voxel; reports the extra padding (mm) needed per face if not |
|
|
124
|
+
| `CropReport` | Accumulates per-volume `check_label_crop` results and writes a CSV report + JSON summary |
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from sc_crop import detect, crop, uncrop
|
|
128
|
+
import nibabel as nib
|
|
129
|
+
|
|
130
|
+
img = nib.load("t2.nii.gz")
|
|
131
|
+
bbox = detect(img) # detect the spinal cord, return bounding box
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from sc_crop import crop
|
|
136
|
+
import nibabel as nib
|
|
137
|
+
|
|
138
|
+
crop_img = crop(nib.load("t2.nii.gz"), bbox) # also works on t2_seg.nii.gz
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from sc_crop import uncrop
|
|
143
|
+
|
|
144
|
+
full_img = uncrop(crop_img, bbox) # restore to the original space
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Bounding box padding
|
|
148
|
+
|
|
149
|
+
The margin around the detected spinal cord is adjustable per face (in mm).
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# Adjust SI only (most common)
|
|
153
|
+
bbox = detect(img, pad_superior=50, pad_inferior=80)
|
|
154
|
+
|
|
155
|
+
# All 3 symmetric
|
|
156
|
+
bbox = detect(img, pad_si=30, pad_rl=10, pad_ap=15)
|
|
157
|
+
|
|
158
|
+
# Symmetric + override one face
|
|
159
|
+
bbox = detect(img, pad_si=30, pad_inferior=60)
|
|
160
|
+
|
|
161
|
+
# Full per-face control
|
|
162
|
+
bbox = detect(img, pad_superior=40, pad_inferior=100,
|
|
163
|
+
pad_left=15, pad_right=15,
|
|
164
|
+
pad_anterior=15, pad_posterior=22)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Padding is always clamped to the image boundaries. Priority per face: **individual > symmetric > default** (sup=40, inf=100, left=right=15, ant=15, post=22).
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Use in a training + inference pipeline
|
|
172
|
+
|
|
173
|
+
### ⚠️ Critical: use the same padding at training and inference
|
|
174
|
+
|
|
175
|
+
sc-crop must be applied with **identical padding around the detected spinal cord** at both training and inference time. Different padding changes the crop boundaries and the distribution of what the model sees, which degrades performance. Pick your padding values once and keep them fixed throughout.
|
|
176
|
+
|
|
177
|
+
### Step 1 — Preprocess training data
|
|
178
|
+
|
|
179
|
+
Crop all images and their labels with the same padding. Use `check_label_crop`
|
|
180
|
+
and `CropReport` to verify no label voxel is cut by the crop — any loss means the
|
|
181
|
+
detected box (plus padding) does not fully contain the cord, which would silently
|
|
182
|
+
remove ground-truth voxels from training:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from sc_crop import detect, crop, check_label_crop, CropReport
|
|
186
|
+
import nibabel as nib
|
|
187
|
+
|
|
188
|
+
# Use the same padding for every subject
|
|
189
|
+
PAD = dict(pad_superior=40, pad_inferior=100, pad_left=15, pad_right=15,
|
|
190
|
+
pad_anterior=15, pad_posterior=22)
|
|
191
|
+
|
|
192
|
+
report = CropReport() # accumulates per-volume QC
|
|
193
|
+
|
|
194
|
+
for subject in subjects:
|
|
195
|
+
bbox = detect(subject.image, **PAD)
|
|
196
|
+
label_nii = nib.load(subject.label)
|
|
197
|
+
|
|
198
|
+
qc = check_label_crop(label_nii, bbox) # check BEFORE cropping
|
|
199
|
+
report.add(subject.label, qc) # qc["ok"], voxels_before/after,
|
|
200
|
+
# extra_pad_<face>_mm if a face is short
|
|
201
|
+
|
|
202
|
+
crop_img = crop(nib.load(subject.image), bbox)
|
|
203
|
+
crop_label = crop(label_nii, bbox)
|
|
204
|
+
nib.save(crop_img, subject.image_crop)
|
|
205
|
+
nib.save(crop_label, subject.label_crop)
|
|
206
|
+
|
|
207
|
+
report.save("crop_qc_report.csv") # one row per volume
|
|
208
|
+
report.save_summary("crop_qc_summary.json") # totals + max extra padding needed per face
|
|
209
|
+
print(f"{report.n_failed()} / {len(report)} crops lost label voxels")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
If `report.n_failed() > 0`, inspect `crop_qc_summary.json`: its `max_extra_padding_mm`
|
|
213
|
+
field tells you how many mm to add on each face (e.g. raise `pad_posterior`) so every
|
|
214
|
+
cord is fully contained. The defaults above were tuned this way.
|
|
215
|
+
|
|
216
|
+
### Step 2 — Train your model on the cropped volumes
|
|
217
|
+
|
|
218
|
+
Train normally on `*_crop.nii.gz` images and labels.
|
|
219
|
+
|
|
220
|
+
### Step 3 — Inference on a new image
|
|
221
|
+
|
|
222
|
+
Apply the **same padding**, run your model on the crop, then restore the segmentation to the original space:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from sc_crop import detect, crop, uncrop
|
|
226
|
+
import nibabel as nib
|
|
227
|
+
|
|
228
|
+
PAD = dict(pad_superior=40, pad_inferior=100, pad_left=15, pad_right=15,
|
|
229
|
+
pad_anterior=15, pad_posterior=22) # identical to training
|
|
230
|
+
|
|
231
|
+
bbox = detect("new_subject.nii.gz", **PAD)
|
|
232
|
+
crop_img = crop(nib.load("new_subject.nii.gz"), bbox)
|
|
233
|
+
|
|
234
|
+
seg_crop = my_model(crop_img) # run your segmentation model
|
|
235
|
+
seg_full = uncrop(seg_crop, bbox) # back to original space + affine
|
|
236
|
+
nib.save(seg_full, "new_subject_seg.nii.gz")
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### nnUNet integration
|
|
240
|
+
|
|
241
|
+
sc-crop is applied **before** nnUNet's own preprocessing. The cropped images go directly into the nnUNet raw dataset folder; nnUNet never sees the original full volumes.
|
|
242
|
+
|
|
243
|
+
**Step 1 — Populate `nnUNet_raw/Dataset{ID}_{Name}/`**
|
|
244
|
+
|
|
245
|
+
nnUNet expects images in `imagesTr/` named `{case}_{0000}.nii.gz` and labels in `labelsTr/` named `{case}.nii.gz`. Crop image and label with the same bbox before saving there:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from sc_crop import detect, crop
|
|
249
|
+
import nibabel as nib
|
|
250
|
+
from pathlib import Path
|
|
251
|
+
|
|
252
|
+
RAW = Path("nnUNet_raw/Dataset001_MyTask")
|
|
253
|
+
PAD = dict(pad_superior=40, pad_inferior=100, pad_left=15, pad_right=15,
|
|
254
|
+
pad_anterior=15, pad_posterior=22)
|
|
255
|
+
|
|
256
|
+
for case_id, img_path, lbl_path in subjects:
|
|
257
|
+
bbox = detect(img_path, **PAD) # detect once
|
|
258
|
+
nib.save(crop(nib.load(img_path), bbox),
|
|
259
|
+
RAW / "imagesTr" / f"{case_id}_0000.nii.gz") # cropped image
|
|
260
|
+
nib.save(crop(nib.load(lbl_path), bbox),
|
|
261
|
+
RAW / "labelsTr" / f"{case_id}.nii.gz") # same bbox
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Step 2 — Run the standard nnUNet pipeline**
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
nnUNetv2_plan_and_preprocess -d 001 --verify_dataset_integrity
|
|
268
|
+
nnUNetv2_train 001 3d_fullres 0
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Step 3 — Inference on a new image**
|
|
272
|
+
|
|
273
|
+
The simplest approach uses the built-in `segment_onnx` / `segment_pt` functions (or their CLI equivalents) which handle detect → crop → infer → uncrop in one call. Install [nnunet-onnx](https://github.com/quentinRevillon/nnunet-onnx) for the inference backend:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
pip install "nnunet-onnx @ git+https://github.com/quentinRevillon/nnunet-onnx.git" torch nnunetv2 onnxscript onnx
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Via CLI:**
|
|
280
|
+
```bash
|
|
281
|
+
# PyTorch checkpoint
|
|
282
|
+
sc-segment-pt -i new_subject.nii.gz -o seg.nii.gz \
|
|
283
|
+
--checkpoint /path/to/fold_0/checkpoint_best.pth
|
|
284
|
+
|
|
285
|
+
# ONNX model (no nnunetv2 needed at runtime)
|
|
286
|
+
sc-segment-onnx -i new_subject.nii.gz -o seg.nii.gz \
|
|
287
|
+
--model /path/to/model.onnx
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Via Python API:**
|
|
291
|
+
```python
|
|
292
|
+
from sc_crop import segment_pt, segment_onnx
|
|
293
|
+
import nibabel as nib
|
|
294
|
+
|
|
295
|
+
seg = segment_pt("new_subject.nii.gz", "/path/to/fold_0/checkpoint_best.pth")
|
|
296
|
+
seg = segment_onnx("new_subject.nii.gz", "/path/to/model.onnx")
|
|
297
|
+
nib.save(seg, "seg.nii.gz")
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
To convert a checkpoint to ONNX (plans are embedded in the file — no external json needed):
|
|
301
|
+
```bash
|
|
302
|
+
python -m nnunet_onnx.export \
|
|
303
|
+
--checkpoint /path/to/fold_0/checkpoint_best.pth \
|
|
304
|
+
--output model.onnx
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Examples
|
|
310
|
+
|
|
311
|
+
The example scripts are part of the repository — clone it to run them:
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
git clone https://github.com/ivadomed/sc-crop.git
|
|
315
|
+
cd sc-crop
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**`examples/api_examples.py`** — Python API cookbook covering all usage patterns. If no image is provided, the SCT tutorial T2 is downloaded automatically:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
python examples/api_examples.py # auto-download tutorial data
|
|
322
|
+
python examples/api_examples.py t2.nii.gz # use your own image
|
|
323
|
+
python examples/api_examples.py t2.nii.gz --ex 3 # run example 3 only (padding)
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
| # | Pattern |
|
|
327
|
+
|---|---------|
|
|
328
|
+
| 1 | Basic `detect()` + `crop()` |
|
|
329
|
+
| 2 | Multi-volume: detect once, crop image + label with the same bbox |
|
|
330
|
+
| 3 | Padding variants: default / symmetric / mixed / full individual |
|
|
331
|
+
| 4 | `detect_and_crop()` one-liner |
|
|
332
|
+
| 5 | Fake segmentation model + `uncrop()` round-trip |
|
|
333
|
+
| 6 | GPU inference (`device="cuda"`) |
|
|
334
|
+
|
|
335
|
+
**`examples/infer_with_sc_crop.py`** — Full inference pipeline template with a built-in fake model (center-cylinder segmentation). If no input is provided, the SCT tutorial T2 is downloaded automatically:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
python examples/infer_with_sc_crop.py # auto-download tutorial data
|
|
339
|
+
python examples/infer_with_sc_crop.py -i t2.nii.gz -o seg.nii.gz
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Replace `fake_sc_segmentation()` with your own model to use in production.
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Requirements
|
|
347
|
+
|
|
348
|
+
Python ≥ 3.8. Dependencies installed automatically: `nibabel`, `numpy`, `onnxruntime`, `pillow`, `pyyaml`, `scipy`, `ultralytics` (includes `opencv`).
|
|
349
|
+
|
|
350
|
+
Optional: `nnunet-onnx` for the `segment_onnx` / `segment_pt` functions — install with `pip install "sc-crop[segment]"`.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Training the detector
|
|
355
|
+
|
|
356
|
+
The detection model was trained using [ivadomed/model_cropping_sc_contrast-agnostic_yolo](https://github.com/ivadomed/model_cropping_sc_contrast-agnostic_yolo).
|
|
357
|
+
The link between package versions, model versions, and training runs is documented in [VERSIONS.md](VERSIONS.md).
|
|
358
|
+
To publish a new model version, see the release procedure in [VERSIONS.md](VERSIONS.md#procédure-de-release).
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sc-crop API examples — runnable cookbook.
|
|
3
|
+
|
|
4
|
+
Shows all major usage patterns for the Python API, from the simplest
|
|
5
|
+
to the most advanced. Each example is self-contained and can be run
|
|
6
|
+
independently by calling it directly.
|
|
7
|
+
|
|
8
|
+
If no image is provided, a T2 image and its SC segmentation are downloaded
|
|
9
|
+
automatically (~5 MB) and cached in ~/.cache/sc_crop/tutorial/.
|
|
10
|
+
|
|
11
|
+
Requirements:
|
|
12
|
+
pip install "sc-crop @ git+https://github.com/ivadomed/sc-crop.git"
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python examples/api_examples.py # auto-download tutorial data
|
|
16
|
+
python examples/api_examples.py image.nii.gz # use your own image
|
|
17
|
+
python examples/api_examples.py image.nii.gz --ex 3 # run example 3 only
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import nibabel as nib
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from sc_crop import detect, crop, detect_and_crop, uncrop
|
|
28
|
+
|
|
29
|
+
_BASE_URL = "https://github.com/ivadomed/sc-crop/releases/download/test-data"
|
|
30
|
+
_TUTORIAL_CACHE = Path.home() / ".cache" / "sc_crop" / "tutorial"
|
|
31
|
+
_TUTORIAL_IMG = _TUTORIAL_CACHE / "t2.nii.gz"
|
|
32
|
+
_TUTORIAL_SEG = _TUTORIAL_CACHE / "t2_seg.nii.gz"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_tutorial_data() -> tuple[str, str]:
|
|
36
|
+
"""Download and cache the tutorial T2 image and SC segmentation. Returns (img_path, seg_path)."""
|
|
37
|
+
_TUTORIAL_CACHE.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
for fname in ("t2.nii.gz", "t2_seg.nii.gz"):
|
|
39
|
+
out = _TUTORIAL_CACHE / fname
|
|
40
|
+
if not out.exists():
|
|
41
|
+
url = f"{_BASE_URL}/{fname}"
|
|
42
|
+
print(f"Downloading {fname} → {out}")
|
|
43
|
+
urllib.request.urlretrieve(url, out)
|
|
44
|
+
return str(_TUTORIAL_IMG), str(_TUTORIAL_SEG)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─── Example 1 — Basic: detect + crop ────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def example_basic(img_path: str, _seg_path: str) -> None:
|
|
50
|
+
"""Detect the SC bbox, then crop the image."""
|
|
51
|
+
print("\n── Example 1: Basic detect + crop ──────────────────────────────")
|
|
52
|
+
|
|
53
|
+
bbox = detect(img_path)
|
|
54
|
+
print(f" bbox: x[{bbox['xmin']}:{bbox['xmax']}] "
|
|
55
|
+
f"y[{bbox['ymin']}:{bbox['ymax']}] "
|
|
56
|
+
f"z[{bbox['zmin']}:{bbox['zmax']}]")
|
|
57
|
+
print(f" orientation: {bbox['original_axcodes']}")
|
|
58
|
+
|
|
59
|
+
crop_img = crop(nib.load(img_path), bbox)
|
|
60
|
+
print(f" original shape : {nib.load(img_path).shape}")
|
|
61
|
+
print(f" cropped shape : {crop_img.shape}")
|
|
62
|
+
|
|
63
|
+
out = Path(img_path).with_name(Path(img_path).stem.replace(".nii", "") + "_ex1_crop.nii.gz")
|
|
64
|
+
nib.save(crop_img, out)
|
|
65
|
+
print(f" → {out}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ─── Example 2 — Multi-volume: image + label with the same bbox ──────────────
|
|
69
|
+
|
|
70
|
+
def example_multi_volume(img_path: str, seg_path: str) -> None:
|
|
71
|
+
"""Detect once, crop both image and label — they share the same bbox."""
|
|
72
|
+
print("\n── Example 2: Multi-volume (image + label) ──────────────────────")
|
|
73
|
+
|
|
74
|
+
img = nib.load(img_path)
|
|
75
|
+
label = nib.load(seg_path)
|
|
76
|
+
|
|
77
|
+
bbox = detect(img_path)
|
|
78
|
+
crop_img = crop(img, bbox)
|
|
79
|
+
crop_label = crop(label, bbox)
|
|
80
|
+
|
|
81
|
+
assert crop_img.shape == crop_label.shape, "image and label must have the same cropped shape"
|
|
82
|
+
print(f" cropped image shape : {crop_img.shape}")
|
|
83
|
+
print(f" cropped label shape : {crop_label.shape} (identical bbox ✓)")
|
|
84
|
+
|
|
85
|
+
stem = Path(img_path).name.replace(".nii.gz", "").replace(".nii", "")
|
|
86
|
+
parent = Path(img_path).parent
|
|
87
|
+
nib.save(crop_img, parent / f"{stem}_ex2_crop.nii.gz")
|
|
88
|
+
nib.save(crop_label, parent / f"{stem}_ex2_label_crop.nii.gz")
|
|
89
|
+
print(f" → {parent / f'{stem}_ex2_crop.nii.gz'}")
|
|
90
|
+
print(f" → {parent / f'{stem}_ex2_label_crop.nii.gz'}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ─── Example 3 — Padding variants ────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def example_padding(img_path: str, _seg_path: str) -> None:
|
|
96
|
+
"""Show the 3 padding styles and how priority works."""
|
|
97
|
+
print("\n── Example 3: Padding variants ──────────────────────────────────")
|
|
98
|
+
|
|
99
|
+
# Defaults: superior=40, inferior=60, left=right=10, anterior=posterior=15
|
|
100
|
+
bbox_default = detect(img_path)
|
|
101
|
+
|
|
102
|
+
# Symmetric SI (common for focused cervical datasets)
|
|
103
|
+
bbox_si = detect(img_path, pad_si=30)
|
|
104
|
+
|
|
105
|
+
# Symmetric + override one face (highest priority)
|
|
106
|
+
bbox_mix = detect(img_path, pad_si=30, pad_inferior=60)
|
|
107
|
+
|
|
108
|
+
# Full individual control (all 6 faces)
|
|
109
|
+
bbox_full = detect(img_path,
|
|
110
|
+
pad_superior=40, pad_inferior=60,
|
|
111
|
+
pad_left=10, pad_right=10,
|
|
112
|
+
pad_anterior=15, pad_posterior=15)
|
|
113
|
+
|
|
114
|
+
def _z(bbox): return bbox["zmax"] - bbox["zmin"]
|
|
115
|
+
def _x(bbox): return bbox["xmax"] - bbox["xmin"]
|
|
116
|
+
|
|
117
|
+
print(f" {'style':<30} {'z_range':>8} {'x_range':>8}")
|
|
118
|
+
print(f" {'─'*50}")
|
|
119
|
+
print(f" {'default (sup=40, inf=60)':<30} {_z(bbox_default):>8} {_x(bbox_default):>8}")
|
|
120
|
+
print(f" {'pad_si=30 (symmetric)':<30} {_z(bbox_si):>8} {_x(bbox_si):>8}")
|
|
121
|
+
print(f" {'pad_si=30 + pad_inf=60':<30} {_z(bbox_mix):>8} {_x(bbox_mix):>8}")
|
|
122
|
+
print(f" {'full individual':<30} {_z(bbox_full):>8} {_x(bbox_full):>8}")
|
|
123
|
+
|
|
124
|
+
assert _z(bbox_mix) >= _z(bbox_si), "pad_inferior=60 should give >= padding than pad_si=30"
|
|
125
|
+
print(" priority rule verified: individual > symmetric ✓")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ─── Example 4 — detect_and_crop convenience wrapper ─────────────────────────
|
|
129
|
+
|
|
130
|
+
def example_detect_and_crop(img_path: str, _seg_path: str) -> None:
|
|
131
|
+
"""Single-call convenience when only cropping one volume."""
|
|
132
|
+
print("\n── Example 4: detect_and_crop() one-liner ───────────────────────")
|
|
133
|
+
|
|
134
|
+
crop_nii, bbox = detect_and_crop(img_path)
|
|
135
|
+
print(f" cropped shape : {crop_nii.shape}")
|
|
136
|
+
print(f" bbox keys : {[k for k in bbox if not k.startswith('_')]}")
|
|
137
|
+
|
|
138
|
+
out = Path(img_path).with_name(Path(img_path).stem.replace(".nii", "") + "_ex4_crop.nii.gz")
|
|
139
|
+
nib.save(crop_nii, out)
|
|
140
|
+
print(f" → {out}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ─── Example 5 — uncrop ────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def example_restore(img_path: str, seg_path: str) -> None:
|
|
146
|
+
"""Crop the SC segmentation with the same bbox, restore it to the original space.
|
|
147
|
+
|
|
148
|
+
Uses the real SC segmentation (t2_seg.nii.gz) to demonstrate that
|
|
149
|
+
uncrop() correctly places it back at the right anatomical
|
|
150
|
+
location. Open the output in FSLeyes alongside the original to verify.
|
|
151
|
+
"""
|
|
152
|
+
print("\n── Example 5: crop label + uncrop() ──────────────")
|
|
153
|
+
|
|
154
|
+
crop_nii, bbox = detect_and_crop(img_path)
|
|
155
|
+
crop_seg = crop(nib.load(seg_path), bbox)
|
|
156
|
+
|
|
157
|
+
# Simulate a model output: use the cropped real segmentation as-is
|
|
158
|
+
seg_full = uncrop(crop_seg, bbox)
|
|
159
|
+
orig_shape = nib.load(img_path).shape[:3]
|
|
160
|
+
assert seg_full.shape == orig_shape, f"expected {orig_shape}, got {seg_full.shape}"
|
|
161
|
+
|
|
162
|
+
n = int(np.asarray(seg_full.dataobj).sum())
|
|
163
|
+
print(f" crop shape : {crop_nii.shape}")
|
|
164
|
+
print(f" restored seg shape : {seg_full.shape} ({n} non-zero voxels)")
|
|
165
|
+
|
|
166
|
+
out = Path(img_path).with_name(Path(img_path).stem.replace(".nii", "") + "_ex5_seg.nii.gz")
|
|
167
|
+
nib.save(seg_full, out)
|
|
168
|
+
print(f" → {out}")
|
|
169
|
+
print(f" verify: fsleyes {img_path} {out} -cm red")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ─── Example 6 — GPU inference ───────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def example_gpu(img_path: str, _seg_path: str, device: str = "cuda") -> None:
|
|
175
|
+
"""Run inference on GPU by passing device='cuda' (or 'mps' on Apple Silicon)."""
|
|
176
|
+
print("\n── Example 6: GPU inference ─────────────────────────────────────")
|
|
177
|
+
try:
|
|
178
|
+
bbox = detect(img_path, device=device)
|
|
179
|
+
print(f" GPU ({device}) inference bbox: x[{bbox['xmin']}:{bbox['xmax']}]")
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
print(f" skipped — {exc}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ─── Runner ───────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
EXAMPLES = {
|
|
187
|
+
1: ("Basic detect + crop", example_basic),
|
|
188
|
+
2: ("Multi-volume: image + label", example_multi_volume),
|
|
189
|
+
3: ("Padding variants", example_padding),
|
|
190
|
+
4: ("detect_and_crop() one-liner", example_detect_and_crop),
|
|
191
|
+
5: ("crop label + uncrop()", example_restore),
|
|
192
|
+
6: ("GPU inference (skip if no GPU)", example_gpu),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main():
|
|
197
|
+
p = argparse.ArgumentParser(
|
|
198
|
+
description="sc-crop API cookbook — runnable examples",
|
|
199
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
200
|
+
)
|
|
201
|
+
p.add_argument("input", nargs="?", default=None,
|
|
202
|
+
help="Input NIfTI volume (.nii or .nii.gz) — downloads tutorial T2 if omitted")
|
|
203
|
+
p.add_argument("--seg", default=None,
|
|
204
|
+
help="SC segmentation label — downloads tutorial t2_seg.nii.gz if omitted")
|
|
205
|
+
g = p.add_mutually_exclusive_group()
|
|
206
|
+
g.add_argument("--all", action="store_true", help="Run all examples (default)")
|
|
207
|
+
g.add_argument("--ex", type=int, choices=list(EXAMPLES), metavar="N",
|
|
208
|
+
help=f"Run only example N ({', '.join(str(k) for k in EXAMPLES)})")
|
|
209
|
+
args = p.parse_args()
|
|
210
|
+
|
|
211
|
+
if args.input is None:
|
|
212
|
+
img_path, seg_path = _get_tutorial_data()
|
|
213
|
+
else:
|
|
214
|
+
img_path = args.input
|
|
215
|
+
seg_path = args.seg or str(Path(img_path).with_name(
|
|
216
|
+
Path(img_path).name.replace(".nii.gz", "").replace(".nii", "") + "_seg.nii.gz"
|
|
217
|
+
))
|
|
218
|
+
|
|
219
|
+
print(f"\nsc-crop API examples — {img_path}")
|
|
220
|
+
for idx, (label, fn) in EXAMPLES.items():
|
|
221
|
+
if args.ex and args.ex != idx:
|
|
222
|
+
continue
|
|
223
|
+
fn(img_path, seg_path)
|
|
224
|
+
|
|
225
|
+
print("\n✅ Done.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sc-crop demo — spinal cord detection and cropping.
|
|
3
|
+
|
|
4
|
+
Detects the spinal cord bounding box, prints coordinates, and optionally saves
|
|
5
|
+
the cropped volume. No segmentation model required — only the SC detector.
|
|
6
|
+
detect() is pure and writes no files; cropping and saving are done here explicitly.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python examples/demo.py t2.nii.gz
|
|
10
|
+
python examples/demo.py t2.nii.gz --crop
|
|
11
|
+
python examples/demo.py t2.nii.gz --crop --pad-sup 50 --pad-inf 80
|
|
12
|
+
python examples/demo.py t2.nii.gz --crop --pad-si 30
|
|
13
|
+
python examples/demo.py t2.nii.gz --crop --time
|
|
14
|
+
|
|
15
|
+
Requirements:
|
|
16
|
+
pip install git+https://github.com/ivadomed/sc-crop.git
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import nibabel as nib
|
|
23
|
+
|
|
24
|
+
from sc_crop import detect, crop
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
p = argparse.ArgumentParser(
|
|
29
|
+
description="sc-crop demo: detect SC bbox and optionally crop the volume.",
|
|
30
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
31
|
+
)
|
|
32
|
+
p.add_argument("input", help="Input NIfTI volume (.nii or .nii.gz)")
|
|
33
|
+
p.add_argument("--crop", action="store_true", help="Save the cropped volume")
|
|
34
|
+
p.add_argument("--pad-sup", type=float, default=None, dest="pad_superior",
|
|
35
|
+
metavar="MM", help="Superior padding mm (default 40)")
|
|
36
|
+
p.add_argument("--pad-inf", type=float, default=None, dest="pad_inferior",
|
|
37
|
+
metavar="MM", help="Inferior padding mm (default 60)")
|
|
38
|
+
p.add_argument("--pad-si", type=float, default=None, dest="pad_si",
|
|
39
|
+
metavar="MM", help="Symmetric SI")
|
|
40
|
+
p.add_argument("--pad-rl", type=float, default=None, dest="pad_rl",
|
|
41
|
+
metavar="MM", help="Symmetric RL padding mm (default 10)")
|
|
42
|
+
p.add_argument("--pad-ap", type=float, default=None, dest="pad_ap",
|
|
43
|
+
metavar="MM", help="Symmetric AP padding mm (default 15)")
|
|
44
|
+
p.add_argument("--time", action="store_true", help="Print elapsed time per step")
|
|
45
|
+
args = p.parse_args()
|
|
46
|
+
|
|
47
|
+
print(f"\n{'─'*60}")
|
|
48
|
+
print(f" sc-crop demo")
|
|
49
|
+
print(f"{'─'*60}")
|
|
50
|
+
|
|
51
|
+
bbox = detect(
|
|
52
|
+
args.input,
|
|
53
|
+
pad_superior=args.pad_superior,
|
|
54
|
+
pad_inferior=args.pad_inferior,
|
|
55
|
+
pad_si=args.pad_si,
|
|
56
|
+
pad_rl=args.pad_rl,
|
|
57
|
+
pad_ap=args.pad_ap,
|
|
58
|
+
time_steps=args.time,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
print(f"\n{'─'*60}")
|
|
62
|
+
print(f" Bounding box (inclusive voxel indices, native space)")
|
|
63
|
+
print(f"{'─'*60}")
|
|
64
|
+
print(f" x [{bbox['xmin']:4d} : {bbox['xmax']:4d}] ({bbox['xmax'] - bbox['xmin'] + 1} voxels)")
|
|
65
|
+
print(f" y [{bbox['ymin']:4d} : {bbox['ymax']:4d}] ({bbox['ymax'] - bbox['ymin'] + 1} voxels)")
|
|
66
|
+
print(f" z [{bbox['zmin']:4d} : {bbox['zmax']:4d}] ({bbox['zmax'] - bbox['zmin'] + 1} voxels)")
|
|
67
|
+
|
|
68
|
+
if args.crop:
|
|
69
|
+
orig = nib.load(args.input)
|
|
70
|
+
crop_nii = crop(orig, bbox)
|
|
71
|
+
|
|
72
|
+
inp = Path(args.input)
|
|
73
|
+
stem = inp.name.replace(".nii.gz", "").replace(".nii", "")
|
|
74
|
+
out_path = inp.parent / f"{stem}_crop.nii.gz"
|
|
75
|
+
nib.save(crop_nii, out_path)
|
|
76
|
+
|
|
77
|
+
orig_mm = [round(float(v), 2) for v in orig.header.get_zooms()[:3]]
|
|
78
|
+
print(f"\n{'─'*60}")
|
|
79
|
+
print(f" Crop")
|
|
80
|
+
print(f"{'─'*60}")
|
|
81
|
+
print(f" Original : {orig.shape} {orig_mm} mm")
|
|
82
|
+
print(f" Cropped : {crop_nii.shape}")
|
|
83
|
+
print(f" → {out_path}")
|
|
84
|
+
|
|
85
|
+
print()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|