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 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"
@@ -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()