caideface 0.1.3__py3-none-any.whl

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.
caideface/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """caideface - MRI defacing pipeline from cai4cai.
2
+
3
+ A three-step pipeline for anonymising head MRI scans:
4
+ 1. Reorientation to MNI152 atlas reference (nibabel)
5
+ 2. Skull-stripping with HD-BET and dynamic dilation
6
+ 3. Affine registration and defacing (BRAINSFit)
7
+ """
8
+
9
+ __version__ = "0.1.3"
10
+
11
+ from .pipeline import DefacePipeline
12
+ from .reorient import reorient_batch, reorient_single
13
+ from .skull_strip import skull_strip_batch, skull_strip_single
14
+ from .register import deface_batch, deface_single
15
+
16
+ __all__ = [
17
+ "DefacePipeline",
18
+ "reorient_batch",
19
+ "reorient_single",
20
+ "skull_strip_batch",
21
+ "skull_strip_single",
22
+ "deface_batch",
23
+ "deface_single",
24
+ ]
caideface/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ """Command-line interface for caideface."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+
7
+ from .pipeline import DefacePipeline
8
+ from .reorient import reorient_batch
9
+ from .skull_strip import skull_strip_batch, get_default_device
10
+ from .register import deface_batch
11
+
12
+
13
+ def _setup_logging(verbose: bool):
14
+ level = logging.DEBUG if verbose else logging.INFO
15
+ logging.basicConfig(
16
+ level=level,
17
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
18
+ datefmt="%H:%M:%S",
19
+ )
20
+
21
+
22
+ def main():
23
+ # Shared parent so -v works on any subcommand
24
+ parent = argparse.ArgumentParser(add_help=False)
25
+ parent.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
26
+
27
+ parser = argparse.ArgumentParser(
28
+ prog="caideface",
29
+ description="MRI defacing pipeline from cai4cai: reorientation, skull-stripping, and affine-based defacing.",
30
+ parents=[parent],
31
+ )
32
+
33
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
34
+
35
+ # --- run: full pipeline ---
36
+ run_parser = subparsers.add_parser("run", help="Run the full defacing pipeline", parents=[parent])
37
+ run_parser.add_argument("input_dir", help="Directory containing raw NIfTI files")
38
+ run_parser.add_argument("output_dir", help="Root output directory")
39
+ run_parser.add_argument("--brainsfit", required=True, help="Path to BRAINSFit executable")
40
+ run_parser.add_argument("--brainsresample", required=True, help="Path to BRAINSResample executable")
41
+ run_parser.add_argument("--device", default=None, choices=["cpu", "cuda"], help="Device for HD-BET (auto-detected if omitted)")
42
+ run_parser.add_argument("--no-tta", action="store_true", default=True, help="Disable HD-BET test-time augmentation (default: disabled)")
43
+ run_parser.add_argument("--dilation-mm", type=float, default=14.0, help="Brain mask dilation in mm (default: 14)")
44
+ run_parser.add_argument("--background", type=float, default=0, help="Background value for defaced voxels (default: 0 for MRI, use -1024 for CT)")
45
+ run_parser.add_argument("--template", default=None, help="Custom MNI152 skull-stripped template (uses bundled if omitted)")
46
+ run_parser.add_argument("--face-mask", default=None, help="Custom face mask in MNI152 space (uses bundled if omitted)")
47
+ run_parser.add_argument("--steps", default="all", help="Steps to run: all, or comma-separated: reorient,skull_strip,deface")
48
+
49
+ # --- reorient ---
50
+ reorient_parser = subparsers.add_parser("reorient", help="Step 1: Reorient NIfTI scans to MNI152", parents=[parent])
51
+ reorient_parser.add_argument("input_dir", help="Directory with NIfTI files")
52
+ reorient_parser.add_argument("output_dir", help="Output directory for reoriented files")
53
+
54
+ # --- skull-strip ---
55
+ ss_parser = subparsers.add_parser("skull-strip", help="Step 2: Skull-strip with HD-BET", parents=[parent])
56
+ ss_parser.add_argument("input_dir", help="Directory with reoriented NIfTI files")
57
+ ss_parser.add_argument("output_dir", help="Output directory for HD-BET results")
58
+ ss_parser.add_argument("--device", default=None, choices=["cpu", "cuda"], help="Device for HD-BET")
59
+ ss_parser.add_argument("--no-tta", action="store_true", default=True, help="Disable test-time augmentation")
60
+ ss_parser.add_argument("--dilation-mm", type=float, default=14.0, help="Dilation in mm")
61
+
62
+ # --- deface ---
63
+ deface_parser = subparsers.add_parser("deface", help="Step 3: Register and deface", parents=[parent])
64
+ deface_parser.add_argument("reoriented_dir", help="Directory with reoriented scans (Step 1 output)")
65
+ deface_parser.add_argument("hdbet_dir", help="Directory with HD-BET results (Step 2 output)")
66
+ deface_parser.add_argument("output_dir", help="Output directory for defaced scans")
67
+ deface_parser.add_argument("--brainsfit", required=True, help="Path to BRAINSFit executable")
68
+ deface_parser.add_argument("--brainsresample", required=True, help="Path to BRAINSResample executable")
69
+ deface_parser.add_argument("--template", default=None, help="Custom MNI152 skull-stripped template")
70
+ deface_parser.add_argument("--face-mask", default=None, help="Custom face mask in MNI152 space")
71
+ deface_parser.add_argument("--background", type=float, default=0, help="Background value (default: 0 for MRI, use -1024 for CT)")
72
+
73
+ args = parser.parse_args()
74
+
75
+ if not args.command:
76
+ parser.print_help()
77
+ sys.exit(1)
78
+
79
+ _setup_logging(args.verbose)
80
+
81
+ if args.command == "run":
82
+ pipeline = DefacePipeline(
83
+ brainsfit_path=args.brainsfit,
84
+ brainsresample_path=args.brainsresample,
85
+ device=args.device,
86
+ disable_tta=args.no_tta,
87
+ desired_dilation_mm=args.dilation_mm,
88
+ background_value=args.background,
89
+ target_path=args.template,
90
+ face_mask_path=args.face_mask,
91
+ )
92
+ results = pipeline.run(args.input_dir, args.output_dir, steps=args.steps)
93
+ failed = results.get("failed_defacing", [])
94
+ if failed:
95
+ print(f"\n{len(failed)} scan(s) failed to deface. See output log for details.")
96
+ sys.exit(1)
97
+
98
+ elif args.command == "reorient":
99
+ reorient_batch(args.input_dir, args.output_dir)
100
+
101
+ elif args.command == "skull-strip":
102
+ skull_strip_batch(
103
+ args.input_dir,
104
+ args.output_dir,
105
+ device=args.device,
106
+ disable_tta=args.no_tta,
107
+ desired_dilation_mm=args.dilation_mm,
108
+ )
109
+
110
+ elif args.command == "deface":
111
+ failed = deface_batch(
112
+ reoriented_dir=args.reoriented_dir,
113
+ hdbet_dir=args.hdbet_dir,
114
+ output_dir=args.output_dir,
115
+ brainsfit_path=args.brainsfit,
116
+ brainsresample_path=args.brainsresample,
117
+ target_path=args.template,
118
+ face_mask_path=args.face_mask,
119
+ background_value=args.background,
120
+ )
121
+ if failed:
122
+ print(f"\n{len(failed)} scan(s) failed. See output log.")
123
+ sys.exit(1)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
Binary file
caideface/pipeline.py ADDED
@@ -0,0 +1,139 @@
1
+ """Full defacing pipeline orchestrator: reorient -> skull-strip -> register & deface."""
2
+
3
+ import logging
4
+ import os
5
+
6
+ import pandas as pd
7
+
8
+ from .reorient import reorient_batch
9
+ from .skull_strip import skull_strip_batch
10
+ from .register import deface_batch
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DefacePipeline:
16
+ """Orchestrates the three-step MRI defacing pipeline.
17
+
18
+ Parameters
19
+ ----------
20
+ brainsfit_path : str
21
+ Path to the BRAINSFit executable (from 3D Slicer).
22
+ brainsresample_path : str
23
+ Path to the BRAINSResample executable (from 3D Slicer).
24
+ device : str or None
25
+ 'cpu' or 'cuda' for HD-BET. Auto-detected if None.
26
+ disable_tta : bool
27
+ Disable HD-BET test-time augmentation for faster processing.
28
+ desired_dilation_mm : float
29
+ Physical dilation in mm for brain mask expansion.
30
+ background_value : float
31
+ Value for defaced voxels (default 0 for MRI; use -1024 for CT).
32
+ target_path : str or None
33
+ Custom MNI152 skull-stripped template. Uses bundled if None.
34
+ face_mask_path : str or None
35
+ Custom face mask in MNI152 space. Uses bundled if None.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ brainsfit_path: str,
41
+ brainsresample_path: str,
42
+ device: str | None = None,
43
+ disable_tta: bool = True,
44
+ desired_dilation_mm: float = 14.0,
45
+ background_value: float = 0,
46
+ target_path: str | None = None,
47
+ face_mask_path: str | None = None,
48
+ ):
49
+ self.brainsfit_path = brainsfit_path
50
+ self.brainsresample_path = brainsresample_path
51
+ self.device = device
52
+ self.disable_tta = disable_tta
53
+ self.desired_dilation_mm = desired_dilation_mm
54
+ self.background_value = background_value
55
+ self.target_path = target_path
56
+ self.face_mask_path = face_mask_path
57
+
58
+ def run(
59
+ self,
60
+ input_dir: str,
61
+ output_dir: str,
62
+ steps: str = "all",
63
+ ) -> dict:
64
+ """Run the defacing pipeline.
65
+
66
+ Parameters
67
+ ----------
68
+ input_dir : str
69
+ Directory containing raw NIfTI (.nii.gz) files.
70
+ output_dir : str
71
+ Root output directory. Subdirectories will be created:
72
+ ``reoriented/``, ``hdbet/``, ``defaced/``.
73
+ steps : str
74
+ Which steps to run: 'all', 'reorient', 'skull_strip', 'deface',
75
+ or comma-separated combination like 'reorient,skull_strip'.
76
+
77
+ Returns
78
+ -------
79
+ dict
80
+ Summary with keys: reorient_log, skull_strip_log, failed_defacing.
81
+ """
82
+ input_dir = os.path.abspath(input_dir)
83
+ output_dir = os.path.abspath(output_dir)
84
+
85
+ reoriented_dir = os.path.join(output_dir, "reoriented")
86
+ hdbet_dir = os.path.join(output_dir, "hdbet")
87
+ defaced_dir = os.path.join(output_dir, "defaced")
88
+
89
+ run_steps = set(s.strip() for s in steps.split(",")) if steps != "all" else {"reorient", "skull_strip", "deface"}
90
+
91
+ results = {}
92
+
93
+ # Step 1: Reorientation
94
+ if "reorient" in run_steps:
95
+ logger.info("=" * 60)
96
+ logger.info("STEP 1: Reorientation to MNI152")
97
+ logger.info("=" * 60)
98
+ reorient_log = reorient_batch(input_dir, reoriented_dir)
99
+ results["reorient_log"] = reorient_log
100
+ else:
101
+ logger.info("Skipping Step 1 (reorientation)")
102
+
103
+ # Step 2: Skull-stripping
104
+ if "skull_strip" in run_steps:
105
+ logger.info("=" * 60)
106
+ logger.info("STEP 2: Skull-stripping with HD-BET")
107
+ logger.info("=" * 60)
108
+ skull_strip_log = skull_strip_batch(
109
+ input_dir=reoriented_dir,
110
+ output_dir=hdbet_dir,
111
+ device=self.device,
112
+ disable_tta=self.disable_tta,
113
+ desired_dilation_mm=self.desired_dilation_mm,
114
+ )
115
+ results["skull_strip_log"] = skull_strip_log
116
+ else:
117
+ logger.info("Skipping Step 2 (skull-stripping)")
118
+
119
+ # Step 3: Registration & Defacing
120
+ if "deface" in run_steps:
121
+ logger.info("=" * 60)
122
+ logger.info("STEP 3: Registration & Defacing")
123
+ logger.info("=" * 60)
124
+ failed = deface_batch(
125
+ reoriented_dir=reoriented_dir,
126
+ hdbet_dir=hdbet_dir,
127
+ output_dir=defaced_dir,
128
+ brainsfit_path=self.brainsfit_path,
129
+ brainsresample_path=self.brainsresample_path,
130
+ target_path=self.target_path,
131
+ face_mask_path=self.face_mask_path,
132
+ background_value=self.background_value,
133
+ )
134
+ results["failed_defacing"] = failed
135
+ else:
136
+ logger.info("Skipping Step 3 (registration & defacing)")
137
+
138
+ logger.info("Pipeline finished.")
139
+ return results
caideface/register.py ADDED
@@ -0,0 +1,395 @@
1
+ """Step 3: Affine registration with BRAINSFit, face mask warping, and defacing."""
2
+
3
+ import os
4
+ import csv
5
+ import logging
6
+ from glob import glob
7
+ from pathlib import Path
8
+
9
+ import nibabel as nib
10
+ import numpy as np
11
+ from numpy.linalg import inv
12
+ from natsort import natsorted
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Bundled data helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def _data_dir() -> str:
22
+ """Return the path to the bundled data directory."""
23
+ return os.path.join(os.path.dirname(__file__), "data")
24
+
25
+
26
+ def default_template_path() -> str:
27
+ return os.path.join(_data_dir(), "mni_icbm152_t1_tal_nlin_sym_55_ext_brain_only.nii.gz")
28
+
29
+
30
+ def default_face_mask_path() -> str:
31
+ return os.path.join(_data_dir(), "t1_mask.nii.gz")
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Transform I/O (supports both plain 4x4 text and ITK/Slicer formats)
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def read_slicer_transform_as_forward_in_RAS(txtfile_path: str) -> np.ndarray:
39
+ """Read an ITK/3D-Slicer affine transform file and return it as a
40
+ forward-direction 4x4 matrix in RAS coordinates."""
41
+ import SimpleITK as sitk
42
+
43
+ sitk_tfm = sitk.AffineTransform(sitk.ReadTransform(txtfile_path))
44
+
45
+ A = np.array(sitk_tfm.GetMatrix()).reshape(3, 3)
46
+ t = np.array(sitk_tfm.GetTranslation())
47
+ c = np.array(sitk_tfm.GetCenter())
48
+
49
+ # T(x) = A(x-c) + t + c => translation = -A@c + t + c
50
+ t_aff = -A @ c + t + c
51
+
52
+ mat = np.eye(4)
53
+ mat[:3, :3] = A
54
+ mat[:3, 3] = t_aff
55
+
56
+ # LPS -> RAS conversion
57
+ RAS_to_LPS = np.diag([-1, -1, 1, 1])
58
+ mat_RAS = RAS_to_LPS @ mat @ RAS_to_LPS
59
+
60
+ # Return forward (inverse of resampling direction)
61
+ return inv(mat_RAS)
62
+
63
+
64
+ def load_transform(path: str) -> np.ndarray:
65
+ """Load a transform from a text file, auto-detecting ITK vs plain format."""
66
+ with open(path, "r") as f:
67
+ first_line = f.readline()
68
+
69
+ if "#Insight Transform File" in first_line:
70
+ return read_slicer_transform_as_forward_in_RAS(path)
71
+ return np.loadtxt(path)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Apply affine: warp floating image sform using a registration transform
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def apply_affine_to_sform(
79
+ floating_path: str,
80
+ reference_path: str,
81
+ transform_path: str,
82
+ output_dir: str,
83
+ ) -> str:
84
+ """Apply a registration transform to the sform of a floating image.
85
+
86
+ Saves a warped NIfTI whose data is unchanged but whose affine encodes
87
+ the registration to the reference.
88
+
89
+ Returns the path to the warped image.
90
+ """
91
+ flo_nii = nib.load(floating_path)
92
+ reg_transform = load_transform(transform_path)
93
+
94
+ warped_affine = inv(reg_transform) @ flo_nii.affine
95
+
96
+ flo_name = os.path.basename(floating_path).replace(".nii.gz", "")
97
+ warped_path = os.path.join(output_dir, flo_name + "_warped.nii.gz")
98
+
99
+ warped_nii = nib.Nifti1Image(flo_nii.get_fdata(), warped_affine)
100
+ warped_nii.header["qform_code"] = 0
101
+ nib.save(warped_nii, warped_path)
102
+
103
+ return warped_path
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # BRAINSFit registration
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def run_brainsfit(
111
+ fixed_path: str,
112
+ moving_path: str,
113
+ output_volume_path: str,
114
+ output_transform_path: str,
115
+ brainsfit_path: str,
116
+ ) -> int:
117
+ """Run BRAINSFit affine registration and return the exit code."""
118
+ cmd = (
119
+ f'"{brainsfit_path}" '
120
+ f'--fixedVolume "{fixed_path}" '
121
+ f'--movingVolume "{moving_path}" '
122
+ f'--outputVolume "{output_volume_path}" '
123
+ f'--outputTransform "{output_transform_path}" '
124
+ f'--samplingPercentage 0.1 '
125
+ f'--splineGridSize 14,10,12 '
126
+ f'--initializeTransformMode useMomentsAlign '
127
+ f'--useRigid --useAffine '
128
+ f'--maskProcessingMode NOMASK '
129
+ f'--medianFilterSize 0,0,0 '
130
+ f'--removeIntensityOutliers 0 '
131
+ f'--outputVolumePixelType float '
132
+ f'--backgroundFillValue 0 '
133
+ f'--interpolationMode Linear '
134
+ f'--numberOfIterations 1500 '
135
+ f'--maximumStepLength 0.05 '
136
+ f'--minimumStepLength 0.001 '
137
+ f'--relaxationFactor 0.5 '
138
+ f'--translationScale 1000 '
139
+ f'--reproportionScale 1 '
140
+ f'--skewScale 1 '
141
+ f'--maxBSplineDisplacement 0 '
142
+ f'--fixedVolumeTimeIndex 0 '
143
+ f'--movingVolumeTimeIndex 0 '
144
+ f'--numberOfHistogramBins 50 '
145
+ f'--numberOfMatchPoints 10 '
146
+ f'--costMetric MMI '
147
+ f'--maskInferiorCutOffFromCenter 1000 '
148
+ f'--ROIAutoDilateSize 0 '
149
+ f'--ROIAutoClosingSize 9 '
150
+ f'--numberOfSamples 0 '
151
+ f'--failureExitCode -1 '
152
+ f'--numberOfThreads -1 '
153
+ f'--debugLevel 0 '
154
+ f'--costFunctionConvergenceFactor 2e+13 '
155
+ f'--projectedGradientTolerance 1e-05 '
156
+ f'--maximumNumberOfEvaluations 900 '
157
+ f'--maximumNumberOfCorrections 25 '
158
+ f'--metricSamplingStrategy Random '
159
+ f'>> /dev/null'
160
+ )
161
+ return os.system(cmd)
162
+
163
+
164
+ def run_brainsresample(
165
+ input_volume: str,
166
+ reference_volume: str,
167
+ output_volume: str,
168
+ transform_path: str,
169
+ brainsresample_path: str,
170
+ inverse: bool = True,
171
+ ) -> int:
172
+ """Run BRAINSResample and return the exit code."""
173
+ cmd = (
174
+ f'"{brainsresample_path}" '
175
+ f'--inputVolume "{input_volume}" '
176
+ f'--referenceVolume "{reference_volume}" '
177
+ f'--outputVolume "{output_volume}" '
178
+ f'--warpTransform "{transform_path}" '
179
+ f'{"--inverseTransform " if inverse else ""}'
180
+ f'--interpolationMode NearestNeighbor'
181
+ )
182
+ return os.system(cmd)
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Defacing logic
187
+ # ---------------------------------------------------------------------------
188
+
189
+ def _build_scan_mapping(
190
+ floating_imgs: list[str],
191
+ reoriented_dir: str,
192
+ hdbet_dir: str,
193
+ ) -> dict:
194
+ """Map dilated skull-stripped scans to their reoriented originals and brain masks.
195
+
196
+ Returns a dict: {floating_path: (reoriented_path, brain_mask_path)}
197
+ """
198
+ reoriented_files = natsorted(glob(os.path.join(reoriented_dir, "**", "*.nii.gz"), recursive=True))
199
+ brain_masks = natsorted(glob(os.path.join(hdbet_dir, "**", "*_mask.nii.gz"), recursive=True))
200
+
201
+ mapping = {}
202
+ for fimg in floating_imgs:
203
+ stem = os.path.basename(fimg).replace("_dilated.nii.gz", "")
204
+ reoriented = next((r for r in reoriented_files if os.path.basename(r) == f"{stem}.nii.gz"), None)
205
+ mask = next((m for m in brain_masks if os.path.basename(m) == f"{stem}_mask.nii.gz"), None)
206
+ if reoriented and mask:
207
+ mapping[fimg] = (reoriented, mask)
208
+ else:
209
+ logger.warning("No match for %s (reoriented=%s, mask=%s)", fimg, reoriented, mask)
210
+ return mapping
211
+
212
+
213
+ def deface_single(
214
+ floating_path: str,
215
+ reoriented_path: str,
216
+ brain_mask_path: str,
217
+ results_dir: str,
218
+ target_path: str,
219
+ face_mask_path: str,
220
+ brainsfit_path: str,
221
+ brainsresample_path: str,
222
+ existing_transform: str | None = None,
223
+ background_value: float = 0,
224
+ ) -> bool:
225
+ """Register, warp face mask, and deface a single scan.
226
+
227
+ Returns True on success, False on failure.
228
+ """
229
+ os.makedirs(results_dir, exist_ok=True)
230
+
231
+ base = os.path.basename(floating_path)
232
+ masked_path = os.path.join(results_dir, base.replace(".nii.gz", "_masked.nii.gz"))
233
+
234
+ # Skip if already defaced
235
+ if os.path.isfile(masked_path):
236
+ logger.info("Already defaced, skipping: %s", masked_path)
237
+ return True
238
+
239
+ out_affine = os.path.join(results_dir, base.replace(".nii.gz", ".txt"))
240
+ out_resampled = os.path.join(results_dir, base.replace(".nii.gz", "_resampled.nii.gz"))
241
+
242
+ # --- Registration ---
243
+ if existing_transform and os.path.isfile(existing_transform):
244
+ logger.info("Using existing transform: %s", existing_transform)
245
+ out_affine = existing_transform
246
+ else:
247
+ logger.info("Running BRAINSFit registration...")
248
+ exit_code = run_brainsfit(
249
+ target_path, floating_path, out_resampled, out_affine, brainsfit_path
250
+ )
251
+ if exit_code != 0:
252
+ logger.error("BRAINSFit failed (exit %d) for %s", exit_code, floating_path)
253
+ return False
254
+ logger.info("BRAINSFit completed successfully")
255
+
256
+ # --- Apply affine to warp face mask into scan space ---
257
+ apply_affine_to_sform(face_mask_path, floating_path, out_affine, results_dir)
258
+
259
+ # --- Resample face mask ---
260
+ face_mask_resampled = os.path.join(
261
+ results_dir,
262
+ os.path.basename(face_mask_path).replace(".nii.gz", "_resampled.nii.gz"),
263
+ )
264
+ run_brainsresample(
265
+ face_mask_path, floating_path, face_mask_resampled,
266
+ out_affine, brainsresample_path, inverse=True,
267
+ )
268
+
269
+ # --- Combine masks and deface ---
270
+ floating_nii = nib.load(reoriented_path)
271
+ floating_data = floating_nii.get_fdata()
272
+
273
+ face_data = nib.load(face_mask_resampled).get_fdata()
274
+ brain_data = nib.load(brain_mask_path).get_fdata()
275
+
276
+ mask_data = np.clip(face_data + brain_data, 0, 1)
277
+
278
+ if floating_data.ndim == 4:
279
+ defaced = np.where(mask_data[..., np.newaxis] > 0, floating_data, background_value)
280
+ else:
281
+ defaced = np.where(mask_data > 0, floating_data, background_value)
282
+
283
+ nib.save(nib.Nifti1Image(defaced, floating_nii.affine), masked_path)
284
+ logger.info("Saved defaced scan: %s", masked_path)
285
+ return True
286
+
287
+
288
+ def deface_batch(
289
+ reoriented_dir: str,
290
+ hdbet_dir: str,
291
+ output_dir: str,
292
+ brainsfit_path: str,
293
+ brainsresample_path: str,
294
+ target_path: str | None = None,
295
+ face_mask_path: str | None = None,
296
+ background_value: float = 0,
297
+ ) -> list[str]:
298
+ """Run the full defacing pipeline (Step 3) on all dilated scans.
299
+
300
+ Parameters
301
+ ----------
302
+ reoriented_dir : str
303
+ Directory with reoriented scans from Step 1.
304
+ hdbet_dir : str
305
+ Directory with HD-BET outputs from Step 2.
306
+ output_dir : str
307
+ Where defaced scans will be saved.
308
+ brainsfit_path : str
309
+ Path to the BRAINSFit executable.
310
+ brainsresample_path : str
311
+ Path to the BRAINSResample executable.
312
+ target_path : str or None
313
+ Path to skull-stripped MNI152 template. Uses bundled if None.
314
+ face_mask_path : str or None
315
+ Path to face mask in MNI152 space. Uses bundled if None.
316
+ background_value : float
317
+ Value to fill defaced regions (default 0 for MRI; use -1024 for CT).
318
+
319
+ Returns
320
+ -------
321
+ list[str]
322
+ Paths of scans that failed to deface.
323
+ """
324
+ if target_path is None:
325
+ target_path = default_template_path()
326
+ if face_mask_path is None:
327
+ face_mask_path = default_face_mask_path()
328
+
329
+ hdbet_dir = os.path.abspath(hdbet_dir)
330
+ reoriented_dir = os.path.abspath(reoriented_dir)
331
+ output_dir = os.path.abspath(output_dir)
332
+
333
+ floating_imgs = natsorted(
334
+ glob(os.path.join(hdbet_dir, "**", "*_dilated.nii.gz"), recursive=True)
335
+ )
336
+
337
+ if not floating_imgs:
338
+ logger.warning("No dilated skull-stripped scans found in %s", hdbet_dir)
339
+ return []
340
+
341
+ logger.info("Found %d scans to deface", len(floating_imgs))
342
+
343
+ # Build mapping: floating -> (reoriented, brain_mask)
344
+ mapping = _build_scan_mapping(floating_imgs, reoriented_dir, hdbet_dir)
345
+
346
+ # Check for pre-existing transforms
347
+ existing_transforms = {}
348
+ for fimg in floating_imgs:
349
+ tfm_path = os.path.join(os.path.dirname(fimg), "Transform_to_template.txt")
350
+ if os.path.isfile(tfm_path):
351
+ existing_transforms[fimg] = tfm_path
352
+
353
+ failed = []
354
+ for fimg in floating_imgs:
355
+ if fimg not in mapping:
356
+ logger.warning("Skipping %s: no matching reoriented scan / mask", fimg)
357
+ failed.append(fimg)
358
+ continue
359
+
360
+ reoriented_path, brain_mask_path = mapping[fimg]
361
+ rel = os.path.relpath(os.path.dirname(fimg), hdbet_dir)
362
+ results_dir = os.path.join(output_dir, rel)
363
+
364
+ try:
365
+ ok = deface_single(
366
+ floating_path=fimg,
367
+ reoriented_path=reoriented_path,
368
+ brain_mask_path=brain_mask_path,
369
+ results_dir=results_dir,
370
+ target_path=target_path,
371
+ face_mask_path=face_mask_path,
372
+ brainsfit_path=brainsfit_path,
373
+ brainsresample_path=brainsresample_path,
374
+ existing_transform=existing_transforms.get(fimg),
375
+ background_value=background_value,
376
+ )
377
+ if not ok:
378
+ failed.append(fimg)
379
+ except Exception:
380
+ logger.exception("Error defacing %s", fimg)
381
+ failed.append(fimg)
382
+
383
+ # Write failure log
384
+ if failed:
385
+ csv_path = os.path.join(output_dir, "not_defaced_scans.csv")
386
+ with open(csv_path, "w", newline="") as f:
387
+ writer = csv.writer(f)
388
+ writer.writerow(["scan_path"])
389
+ for p in failed:
390
+ writer.writerow([p])
391
+ logger.warning("%d scans failed. See %s", len(failed), csv_path)
392
+ else:
393
+ logger.info("All scans successfully defaced.")
394
+
395
+ return failed
caideface/reorient.py ADDED
@@ -0,0 +1,98 @@
1
+ """Step 1: Reorientation of NIfTI scans to LAS (MNI152 standard) using nibabel."""
2
+
3
+ import os
4
+ import logging
5
+
6
+ import nibabel as nib
7
+ from nibabel.orientations import axcodes2ornt, ornt_transform, io_orientation
8
+ import pandas as pd
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Target orientation matching fslreorient2std / MNI152 standard
13
+ TARGET_ORIENTATION = ("L", "A", "S")
14
+
15
+
16
+ def reorient_single(input_file: str, output_file: str) -> bool:
17
+ """Reorient a single NIfTI file to LAS orientation (MNI152 standard).
18
+
19
+ This is equivalent to FSL's ``fslreorient2std`` but implemented purely
20
+ in Python using nibabel, removing the FSL dependency.
21
+
22
+ Parameters
23
+ ----------
24
+ input_file : str
25
+ Path to the input NIfTI (.nii.gz) file.
26
+ output_file : str
27
+ Path where the reoriented file will be saved.
28
+
29
+ Returns
30
+ -------
31
+ bool
32
+ True if reorientation succeeded, False otherwise.
33
+ """
34
+ os.makedirs(os.path.dirname(output_file), exist_ok=True)
35
+
36
+ try:
37
+ img = nib.load(input_file)
38
+ orig_ornt = io_orientation(img.affine)
39
+ target_ornt = axcodes2ornt(TARGET_ORIENTATION)
40
+ transform = ornt_transform(orig_ornt, target_ornt)
41
+ reoriented = img.as_reoriented(transform)
42
+ nib.save(reoriented, output_file)
43
+ except Exception as e:
44
+ logger.error("Failed to reorient %s: %s", input_file, e)
45
+ return False
46
+
47
+ return os.path.exists(output_file)
48
+
49
+
50
+ def reorient_batch(input_dir: str, output_dir: str) -> pd.DataFrame:
51
+ """Reorient all NIfTI files found recursively under *input_dir*.
52
+
53
+ The directory structure is mirrored under *output_dir*.
54
+
55
+ Parameters
56
+ ----------
57
+ input_dir : str
58
+ Root directory containing NIfTI files.
59
+ output_dir : str
60
+ Root directory where reoriented files will be saved,
61
+ preserving the subdirectory structure.
62
+
63
+ Returns
64
+ -------
65
+ pd.DataFrame
66
+ Log with columns ``input``, ``output``, ``success``.
67
+ """
68
+ input_dir = os.path.abspath(input_dir)
69
+ output_dir = os.path.abspath(output_dir)
70
+
71
+ log_rows = []
72
+ for root, _dirs, files in os.walk(input_dir):
73
+ for fname in files:
74
+ if not fname.endswith(".nii.gz"):
75
+ continue
76
+
77
+ input_path = os.path.join(root, fname)
78
+ rel = os.path.relpath(root, input_dir)
79
+ out_path = os.path.join(output_dir, rel, fname)
80
+
81
+ logger.info("Reorienting %s", input_path)
82
+ success = reorient_single(input_path, out_path)
83
+ log_rows.append({"input": input_path, "output": out_path, "success": success})
84
+
85
+ if success:
86
+ logger.info("OK: %s", fname)
87
+ else:
88
+ logger.warning("FAILED: %s", fname)
89
+
90
+ df = pd.DataFrame(log_rows)
91
+
92
+ # Save log
93
+ log_path = os.path.join(output_dir, "reorientation_log.csv")
94
+ os.makedirs(output_dir, exist_ok=True)
95
+ df.to_csv(log_path, index=False)
96
+ logger.info("Reorientation log saved at %s", log_path)
97
+
98
+ return df
@@ -0,0 +1,248 @@
1
+ """Step 2: Skull-stripping with HD-BET and dynamic mask dilation."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import logging
7
+
8
+ import nibabel as nib
9
+ import numpy as np
10
+ import pandas as pd
11
+ from scipy.ndimage import binary_dilation
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def get_default_device() -> str:
17
+ """Return 'cuda' if a GPU is available, else 'cpu'."""
18
+ try:
19
+ import torch
20
+ return "cuda" if torch.cuda.is_available() else "cpu"
21
+ except ImportError:
22
+ return "cuda" if os.system("nvidia-smi > /dev/null 2>&1") == 0 else "cpu"
23
+
24
+
25
+ def _hd_bet_executable() -> str:
26
+ """Return the path to hd-bet that belongs to the current Python env."""
27
+ env_bin = os.path.dirname(sys.executable)
28
+ env_hdbet = os.path.join(env_bin, "hd-bet")
29
+ if os.path.isfile(env_hdbet):
30
+ return env_hdbet
31
+ # Fall back to PATH
32
+ result = subprocess.run(["which", "hd-bet"], capture_output=True, text=True)
33
+ if result.returncode == 0:
34
+ return result.stdout.strip()
35
+ raise EnvironmentError(
36
+ "hd-bet not found. Install it with: pip install hd-bet\n"
37
+ "See: https://github.com/MIC-DKFZ/HD-BET"
38
+ )
39
+
40
+
41
+ def get_safe_structuring_element(
42
+ voxel_sizes: tuple,
43
+ desired_dilation_mm: float = 14.0,
44
+ max_kernel_shape: tuple = (30, 30, 30),
45
+ ) -> np.ndarray:
46
+ """Compute a 3D structuring element adapted to voxel sizes.
47
+
48
+ This ensures a consistent physical dilation distance in mm regardless
49
+ of voxel resolution.
50
+
51
+ Parameters
52
+ ----------
53
+ voxel_sizes : tuple of float
54
+ Voxel spacing (mm) along each axis.
55
+ desired_dilation_mm : float
56
+ Desired physical dilation size in mm.
57
+ max_kernel_shape : tuple of int
58
+ Maximum allowed structuring element size per axis.
59
+
60
+ Returns
61
+ -------
62
+ np.ndarray
63
+ Binary structuring element.
64
+ """
65
+ shape = tuple(
66
+ min(mk, max(1, int(round(desired_dilation_mm / vs))))
67
+ for vs, mk in zip(voxel_sizes, max_kernel_shape)
68
+ )
69
+ logger.debug("Structuring element shape: %s", shape)
70
+ return np.ones(shape, dtype=np.uint8)
71
+
72
+
73
+ def is_valid_3d_volume(filepath: str) -> bool:
74
+ """Return True if the NIfTI file is a 3D volume (not 2D or 4D)."""
75
+ if not filepath.endswith(".nii.gz"):
76
+ return False
77
+ try:
78
+ img = nib.load(filepath)
79
+ shape = img.shape
80
+ return len(shape) == 3 and all(d > 1 for d in shape)
81
+ except Exception:
82
+ return False
83
+
84
+
85
+ def skull_strip_single(
86
+ input_file: str,
87
+ input_root: str,
88
+ output_dir: str,
89
+ device: str = "cpu",
90
+ disable_tta: bool = True,
91
+ desired_dilation_mm: float = 14.0,
92
+ ) -> dict:
93
+ """Run HD-BET on a single NIfTI file and produce dilated brain mask.
94
+
95
+ Parameters
96
+ ----------
97
+ input_file : str
98
+ Path to a reoriented NIfTI file.
99
+ input_root : str
100
+ Root input directory (used to compute relative paths).
101
+ output_dir : str
102
+ Root output directory.
103
+ device : str
104
+ 'cpu' or 'cuda'.
105
+ disable_tta : bool
106
+ Disable test-time augmentation for faster processing.
107
+ desired_dilation_mm : float
108
+ Physical dilation size in mm for mask expansion.
109
+
110
+ Returns
111
+ -------
112
+ dict
113
+ Status dict with keys: input, output, hd_bet, mask, dilated.
114
+ """
115
+ filename = os.path.basename(input_file)
116
+ stem = filename.replace(".nii.gz", "")
117
+ subfolder = os.path.relpath(os.path.dirname(input_file), start=input_root)
118
+
119
+ hd_bet_file = os.path.join(output_dir, subfolder, f"{stem}_brain.nii.gz")
120
+ mask_file = os.path.join(output_dir, subfolder, f"{stem}_mask.nii.gz")
121
+ dilated_file = os.path.join(output_dir, subfolder, f"{stem}_dilated.nii.gz")
122
+
123
+ os.makedirs(os.path.dirname(hd_bet_file), exist_ok=True)
124
+
125
+ status = {
126
+ "hd_bet": "skipped" if os.path.exists(hd_bet_file) else "pending",
127
+ "mask": "skipped" if os.path.exists(mask_file) else "pending",
128
+ "dilated": "skipped" if os.path.exists(dilated_file) else "pending",
129
+ }
130
+
131
+ try:
132
+ # --- HD-BET ---
133
+ if not os.path.exists(hd_bet_file):
134
+ hdbet_bin = _hd_bet_executable()
135
+ cmd = [hdbet_bin, "-i", input_file, "-o", hd_bet_file, "-device", device]
136
+ if disable_tta:
137
+ cmd.append("--disable_tta")
138
+ try:
139
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
140
+ status["hd_bet"] = "success"
141
+ except subprocess.CalledProcessError as e:
142
+ logger.error("hd-bet failed for %s: %s", input_file, e.stderr)
143
+ status["hd_bet"] = "failed"
144
+ return {"input": input_file, "output": hd_bet_file, **status, "error": e.stderr}
145
+
146
+ # --- Binary mask + dilation ---
147
+ if not os.path.exists(mask_file):
148
+ img = nib.load(hd_bet_file)
149
+ data = img.get_fdata()
150
+ voxel_sizes = img.header.get_zooms()[:3]
151
+
152
+ struct_elem = get_safe_structuring_element(voxel_sizes, desired_dilation_mm)
153
+
154
+ binary_volume = (data > 0).astype(np.uint8)
155
+ dilated_data = binary_dilation(binary_volume, structure=struct_elem)
156
+
157
+ nib.save(nib.Nifti1Image(dilated_data, img.affine, img.header), mask_file)
158
+ status["mask"] = "success" if os.path.exists(mask_file) else "failed"
159
+
160
+ # --- Apply dilated mask to original scan ---
161
+ if not os.path.exists(dilated_file):
162
+ mask_img = nib.load(mask_file)
163
+ mask_data = (mask_img.get_fdata() > 0).astype(np.uint8)
164
+
165
+ original_img = nib.load(input_file)
166
+ original_data = original_img.get_fdata()
167
+
168
+ dilated_volume = original_data * mask_data
169
+ nib.save(
170
+ nib.Nifti1Image(dilated_volume, original_img.affine, original_img.header),
171
+ dilated_file,
172
+ )
173
+ status["dilated"] = "success" if os.path.exists(dilated_file) else "failed"
174
+
175
+ except Exception as e:
176
+ logger.exception("Error processing %s", input_file)
177
+ for k in status:
178
+ if status[k] == "pending":
179
+ status[k] = "failed"
180
+
181
+ return {"input": input_file, "output": hd_bet_file, **status}
182
+
183
+
184
+ def skull_strip_batch(
185
+ input_dir: str,
186
+ output_dir: str,
187
+ device: str | None = None,
188
+ disable_tta: bool = True,
189
+ desired_dilation_mm: float = 14.0,
190
+ ) -> pd.DataFrame:
191
+ """Run HD-BET skull-stripping on all 3D NIfTI volumes under *input_dir*.
192
+
193
+ Scans that are not 3D volumes (2D slices or 4D time series) are skipped.
194
+
195
+ Parameters
196
+ ----------
197
+ input_dir : str
198
+ Root directory with reoriented NIfTI files.
199
+ output_dir : str
200
+ Root output directory for HD-BET results.
201
+ device : str or None
202
+ 'cpu' or 'cuda'. Auto-detected if None.
203
+ disable_tta : bool
204
+ Disable test-time augmentation.
205
+ desired_dilation_mm : float
206
+ Physical dilation in mm.
207
+
208
+ Returns
209
+ -------
210
+ pd.DataFrame
211
+ Processing log.
212
+ """
213
+ # Validate hd-bet is available (will raise if not found)
214
+ _hd_bet_executable()
215
+
216
+ if device is None:
217
+ device = get_default_device()
218
+
219
+ input_dir = os.path.abspath(input_dir)
220
+ output_dir = os.path.abspath(output_dir)
221
+ os.makedirs(output_dir, exist_ok=True)
222
+
223
+ log_data = []
224
+
225
+ for root, _dirs, files in os.walk(input_dir):
226
+ for fname in files:
227
+ input_file = os.path.join(root, fname)
228
+
229
+ if not is_valid_3d_volume(input_file):
230
+ continue
231
+
232
+ logger.info("Processing: %s", input_file)
233
+ result = skull_strip_single(
234
+ input_file, input_dir, output_dir, device, disable_tta, desired_dilation_mm
235
+ )
236
+ log_data.append(result)
237
+
238
+ df = pd.DataFrame(log_data)
239
+ log_path = os.path.join(output_dir, "hd_bet_log.csv")
240
+
241
+ if os.path.exists(log_path):
242
+ existing = pd.read_csv(log_path)
243
+ df = pd.concat([existing, df], ignore_index=True)
244
+
245
+ df.to_csv(log_path, index=False)
246
+ logger.info("HD-BET log saved at %s", log_path)
247
+
248
+ return df
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: caideface
3
+ Version: 0.1.3
4
+ Summary: MRI defacing pipeline with skull-stripping and affine registration from cai4cai
5
+ Author-email: Lorena Garcia-Foncillas <lorenagarfon00@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/LorenaGarcia-Foncillas/caideface
8
+ Project-URL: Repository, https://github.com/LorenaGarcia-Foncillas/caideface
9
+ Keywords: MRI,defacing,anonymisation,skull-stripping,neuroimaging
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE.md
17
+ Requires-Dist: nibabel>=4.0
18
+ Requires-Dist: numpy<2,>=1.22
19
+ Requires-Dist: scipy>=1.9
20
+ Requires-Dist: SimpleITK>=2.2
21
+ Requires-Dist: pandas>=1.5
22
+ Requires-Dist: natsort>=8.0
23
+ Requires-Dist: tqdm>=4.60
24
+ Requires-Dist: hd-bet
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # caideface
31
+
32
+ **MRI defacing pipeline with skull-stripping and affine registration** from the [cai4cai](https://cai4cai.ml/) research group (Contextual Artificial Intelligence for Computer Assisted Interventions).
33
+
34
+ This pipeline anonymises head MRI scans by removing facial features while preserving brain structures, as described in the paper *"A Generalisable Head MRI Defacing Pipeline: Evaluation on 2,566 Meningioma Scans"* ([arXiv:2505.12999](https://arxiv.org/abs/2505.12999)).
35
+
36
+ ## Pipeline overview
37
+
38
+ The pipeline consists of three steps:
39
+
40
+ 1. **Reorientation** -- Aligns NIfTI scans to LAS canonical orientation (MNI152 standard) using nibabel.
41
+ 2. **Skull-stripping** -- Extracts brain masks using [HD-BET](https://github.com/MIC-DKFZ/HD-BET), then applies dynamic dilation to preserve peripheral brain structures.
42
+ 3. **Registration & Defacing** -- Registers each scan to the MNI152 template using BRAINSFit (affine), warps a face mask into the scan's space, and applies it to remove facial features.
43
+
44
+ The MNI152 skull-stripped template and face mask are **bundled with the package**, so no additional downloads are needed.
45
+
46
+ ## Requirements
47
+
48
+ ### Python
49
+
50
+ - Python >= 3.9
51
+
52
+ ### External tools (not pip-installable)
53
+
54
+ | Tool | Used in | Install |
55
+ |------|---------|---------|
56
+ | **BRAINSFit** & **BRAINSResample** | Step 3 | Bundled with [3D Slicer](https://www.slicer.org/) |
57
+
58
+ > **Note:** Step 1 (reorientation) no longer requires FSL -- it uses nibabel's orientation tools to reorient scans to LAS (equivalent to `fslreorient2std`).
59
+
60
+ #### Finding BRAINSFit and BRAINSResample
61
+
62
+ These executables are included with 3D Slicer. Common locations:
63
+
64
+ - **macOS**: `/Applications/Slicer.app/Contents/lib/Slicer-5.8/cli-modules/BRAINSFit`
65
+ - **Linux**: `/path/to/Slicer/lib/Slicer-5.8/cli-modules/BRAINSFit`
66
+
67
+ Replace `5.8` with your installed Slicer version if different. To verify the executables are found and working:
68
+
69
+ ```bash
70
+ # Check they exist
71
+ ls /Applications/Slicer.app/Contents/lib/Slicer-5.8/cli-modules/BRAINSFit
72
+ ls /Applications/Slicer.app/Contents/lib/Slicer-5.8/cli-modules/BRAINSResample
73
+
74
+ # Check they run (should print usage/help info)
75
+ /Applications/Slicer.app/Contents/lib/Slicer-5.8/cli-modules/BRAINSFit --help
76
+ /Applications/Slicer.app/Contents/lib/Slicer-5.8/cli-modules/BRAINSResample --help
77
+ ```
78
+
79
+ You can also build them from source via [BRAINSTools](https://github.com/BRAINSia/BRAINSTools).
80
+
81
+ ## Installation
82
+
83
+ We recommend using a conda environment:
84
+
85
+ ```bash
86
+ conda create -n caideface python=3.10 -y
87
+ conda activate caideface
88
+ pip install caideface
89
+ ```
90
+
91
+ Or install from source:
92
+
93
+ ```bash
94
+ git clone https://github.com/cai4cai/caideface.git
95
+ cd caideface
96
+ pip install -e .
97
+ ```
98
+
99
+ > **Note:** caideface requires `numpy<2` (enforced automatically). Some dependencies (HD-BET / nnU-Net) are not yet compatible with NumPy 2.x.
100
+
101
+ ## Usage
102
+
103
+ ### CLI -- Full pipeline
104
+
105
+ Run all three steps in one command:
106
+
107
+ ```bash
108
+ caideface run ./input_nifti ./output \
109
+ --brainsfit /path/to/BRAINSFit \
110
+ --brainsresample /path/to/BRAINSResample
111
+ ```
112
+
113
+ This creates three subdirectories under `./output`:
114
+ - `reoriented/` -- Step 1 outputs
115
+ - `hdbet/` -- Step 2 outputs (skull-stripped, masks, dilated)
116
+ - `defaced/` -- Step 3 outputs (final defaced scans)
117
+
118
+ #### Options
119
+
120
+ | Flag | Default | Description |
121
+ |------|---------|-------------|
122
+ | `--device` | auto-detected | `cpu` or `cuda` for HD-BET |
123
+ | `--no-tta` | on | Disable HD-BET test-time augmentation (faster but less accurate) |
124
+ | `--dilation-mm` | `14.0` | Brain mask dilation in mm |
125
+ | `--background` | `0` | Fill value for defaced regions (0 for MRI, -1024 for CT) |
126
+ | `--template` | bundled | Custom MNI152 skull-stripped template |
127
+ | `--face-mask` | bundled | Custom face mask in MNI152 space |
128
+ | `--steps` | `all` | Run specific steps: `reorient`, `skull_strip`, `deface` (comma-separated) |
129
+ | `-v` | off | Verbose/debug logging |
130
+
131
+ ### CLI -- Individual steps
132
+
133
+ Run each step separately for more control:
134
+
135
+ ```bash
136
+ # Step 1: Reorientation
137
+ caideface reorient ./raw_nifti ./reoriented
138
+
139
+ # Step 2: Skull-stripping
140
+ caideface skull-strip ./reoriented ./hdbet --device cpu
141
+
142
+ # Step 3: Registration & Defacing
143
+ caideface deface ./reoriented ./hdbet ./defaced \
144
+ --brainsfit /path/to/BRAINSFit \
145
+ --brainsresample /path/to/BRAINSResample
146
+ ```
147
+
148
+ ## Output structure
149
+
150
+ ```
151
+ output/
152
+ ├── reoriented/
153
+ │ ├── reorientation_log.csv
154
+ │ └── <subject>/<scan>.nii.gz
155
+ ├── hdbet/
156
+ │ ├── hd_bet_log.csv
157
+ │ └── <subject>/
158
+ │ ├── hd_bet_<scan>.nii.gz # Skull-stripped
159
+ │ ├── hd_bet_mask_<scan>.nii.gz # Dilated brain mask
160
+ │ └── hd_bet_dilated_<scan>.nii.gz # Dilated skull-stripped
161
+ └── defaced/
162
+ ├── not_defaced_scans.csv # Only if failures occurred
163
+ └── <subject>/
164
+ └── hd_bet_dilated_<scan>_masked.nii.gz # Final defaced scan
165
+ ```
166
+
167
+ ## Existing transforms
168
+
169
+ If you have pre-computed registration transforms (e.g. from 3D Slicer), place a file named `Transform_to_template.txt` in the same directory as the dilated skull-stripped scan. The pipeline will use it instead of running BRAINSFit. Both plain 4x4 text matrices and ITK/Slicer transform formats are supported.
170
+
171
+ ## Citation
172
+
173
+ If you use this tool, please cite:
174
+
175
+ ```bibtex
176
+ @article{caideface2025,
177
+ title={A Generalisable Head MRI Defacing Pipeline: Evaluation on 2,566 Meningioma Scans},
178
+ year={2025},
179
+ url={https://arxiv.org/abs/2505.12999}
180
+ }
181
+ ```
182
+
183
+ If you use HD-BET (skull-stripping, Step 2), please also cite:
184
+
185
+ ```bibtex
186
+ @article{Isensee2019,
187
+ author={Isensee, F. and Schell, M. and Tursunova, I. and Brugnara, G. and Bonekamp, D. and Neuberger, U. and Wick, A. and Schlemmer, H. P. and Heiland, S. and Wick, W. and Bendszus, M. and Maier-Hein, K. H. and Kickingereder, P.},
188
+ title={Automated brain extraction of multi-sequence MRI using artificial neural networks},
189
+ journal={Human Brain Mapping},
190
+ year={2019},
191
+ pages={1--13},
192
+ doi={10.1002/hbm.24750}
193
+ }
194
+ ```
195
+
196
+ ## License
197
+
198
+ This project is licensed under the MIT License -- see the [LICENSE](LICENSE.md) file for details.
@@ -0,0 +1,14 @@
1
+ caideface/__init__.py,sha256=msDPaEjxupVzd6NlyPE10Nv35BflTJJLgWbfX9pSH4Q,665
2
+ caideface/cli.py,sha256=Ze8ZAzbYPAIAGz54YdQPD4CfXXnQlqP4O2keskhhwws,6085
3
+ caideface/pipeline.py,sha256=7GRKY7MUaj5YfE6gG2lBBmen67JDXiBzf1_ze9K9QK0,4849
4
+ caideface/register.py,sha256=fF0SWI9pEpV70SN2GUOJxR2PfN1yyP-h-whRCIDQ5yg,13438
5
+ caideface/reorient.py,sha256=YbRSE9XmNy-MbVVfukED6I3lbNCcPkF527h1nQRBaj8,3004
6
+ caideface/skull_strip.py,sha256=iGBkpM1q-cV87qOmDDI3DXktAapzH_vq5VE3JhLou9k,8003
7
+ caideface/data/mni_icbm152_t1_tal_nlin_sym_55_ext_brain_only.nii.gz,sha256=z_8nUQneAGj-CF-rSDtStwGXRbpopT3I_Gu1bDCSoTo,7398318
8
+ caideface/data/t1_mask.nii.gz,sha256=fsePOut8Cv1ywZzNn9NRQbYKlrUdEHh7En99wIyB8oo,512923
9
+ caideface-0.1.3.dist-info/licenses/LICENSE.md,sha256=OKBUz68LPq02070_4Swh-7AOVr4U0HemWq2gZTKcpnM,1074
10
+ caideface-0.1.3.dist-info/METADATA,sha256=ytzsxwjzBSUJ_0o9Ly0Ef5AWlExEZLlvNfE3J-RMVEw,7178
11
+ caideface-0.1.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ caideface-0.1.3.dist-info/entry_points.txt,sha256=NUUL-AAOAC7oTaL9AAQKnkHCxdS7od1pzUFe6FtKCeQ,49
13
+ caideface-0.1.3.dist-info/top_level.txt,sha256=dXA98OltCVkbV76cm3x9ZCqSHgtYQO_CVUmHDpBHTq8,10
14
+ caideface-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ caideface = caideface.cli:main
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2025-present cai4cai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ caideface