caideface 0.3.0__tar.gz → 0.3.2__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.
Files changed (40) hide show
  1. {caideface-0.3.0/src/caideface.egg-info → caideface-0.3.2}/PKG-INFO +5 -5
  2. {caideface-0.3.0 → caideface-0.3.2}/README.md +1 -1
  3. {caideface-0.3.0 → caideface-0.3.2}/pyproject.toml +5 -2
  4. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/__init__.py +3 -1
  5. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/cli.py +11 -4
  6. caideface-0.3.2/src/caideface/data/ct_face_mask.nii.gz +0 -0
  7. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/pipeline.py +15 -7
  8. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/register.py +54 -13
  9. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/reorient.py +22 -10
  10. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/skull_strip.py +135 -34
  11. {caideface-0.3.0 → caideface-0.3.2/src/caideface.egg-info}/PKG-INFO +5 -5
  12. {caideface-0.3.0 → caideface-0.3.2}/src/caideface.egg-info/SOURCES.txt +1 -1
  13. {caideface-0.3.0 → caideface-0.3.2}/src/caideface.egg-info/requires.txt +3 -0
  14. caideface-0.3.0/LICENSE.md +0 -21
  15. {caideface-0.3.0 → caideface-0.3.2}/setup.cfg +0 -0
  16. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/anonymize.py +0 -0
  17. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/background.py +0 -0
  18. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/mni_icbm152_t1_tal_nlin_sym_55_ext_brain_only.nii.gz +0 -0
  19. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/config.cfg +0 -0
  20. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/meta.json +0 -0
  21. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/ner/cfg +0 -0
  22. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/ner/model +0 -0
  23. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/ner/moves +0 -0
  24. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/tok2vec/cfg +0 -0
  25. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/tok2vec/model +0 -0
  26. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/tokenizer +0 -0
  27. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/vocab/key2row +0 -0
  28. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/vocab/lookups.bin +0 -0
  29. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/vocab/strings.json +0 -0
  30. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/vocab/vectors +0 -0
  31. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/ner_model/vocab/vectors.cfg +0 -0
  32. {caideface-0.3.0 → caideface-0.3.2}/src/caideface/data/t1_mask.nii.gz +0 -0
  33. {caideface-0.3.0 → caideface-0.3.2}/src/caideface.egg-info/dependency_links.txt +0 -0
  34. {caideface-0.3.0 → caideface-0.3.2}/src/caideface.egg-info/entry_points.txt +0 -0
  35. {caideface-0.3.0 → caideface-0.3.2}/src/caideface.egg-info/top_level.txt +0 -0
  36. {caideface-0.3.0 → caideface-0.3.2}/tests/test_anonymize.py +0 -0
  37. {caideface-0.3.0 → caideface-0.3.2}/tests/test_background.py +0 -0
  38. {caideface-0.3.0 → caideface-0.3.2}/tests/test_register.py +0 -0
  39. {caideface-0.3.0 → caideface-0.3.2}/tests/test_reorient.py +0 -0
  40. {caideface-0.3.0 → caideface-0.3.2}/tests/test_skull_strip.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caideface
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: MRI defacing pipeline with skull-stripping and affine registration from cai4cai
5
5
  Author-email: Lorena Garcia-Foncillas <lorenagarfon00@gmail.com>
6
- License: MIT
6
+ License: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/cai4cai/defacing_pipeline
8
8
  Project-URL: Repository, https://github.com/cai4cai/defacing_pipeline
9
9
  Keywords: MRI,defacing,anonymisation,skull-stripping,neuroimaging,NER,text-anonymization
@@ -13,7 +13,6 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
14
14
  Requires-Python: >=3.9
15
15
  Description-Content-Type: text/markdown
16
- License-File: LICENSE.md
17
16
  Requires-Dist: nibabel>=4.0
18
17
  Requires-Dist: numpy<2,>=1.22
19
18
  Requires-Dist: scipy>=1.9
@@ -27,7 +26,8 @@ Requires-Dist: faker>=18.0
27
26
  Provides-Extra: dev
28
27
  Requires-Dist: pytest; extra == "dev"
29
28
  Requires-Dist: ruff; extra == "dev"
30
- Dynamic: license-file
29
+ Provides-Extra: ct
30
+ Requires-Dist: TotalSegmentator; extra == "ct"
31
31
 
32
32
  # caideface
33
33
 
@@ -330,4 +330,4 @@ If you use the text anonymisation (NER + HIPS), please also cite:
330
330
 
331
331
  ## License
332
332
 
333
- This project is licensed under the MIT License -- see the [LICENSE](LICENSE.md) file for details.
333
+ This project is licensed under the Apache License 2.0 -- see the [LICENSE](https://github.com/cai4cai/defacing_pipeline/blob/main/LICENSE) file for details.
@@ -299,4 +299,4 @@ If you use the text anonymisation (NER + HIPS), please also cite:
299
299
 
300
300
  ## License
301
301
 
302
- This project is licensed under the MIT License -- see the [LICENSE](LICENSE.md) file for details.
302
+ This project is licensed under the Apache License 2.0 -- see the [LICENSE](https://github.com/cai4cai/defacing_pipeline/blob/main/LICENSE) file for details.
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "caideface"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "MRI defacing pipeline with skull-stripping and affine registration from cai4cai"
9
9
  readme = "README.md"
10
- license = {text = "MIT"}
10
+ license = {text = "Apache-2.0"}
11
11
  requires-python = ">=3.9"
12
12
  authors = [
13
13
  {name = "Lorena Garcia-Foncillas", email = "lorenagarfon00@gmail.com"},
@@ -38,6 +38,9 @@ dev = [
38
38
  "pytest",
39
39
  "ruff",
40
40
  ]
41
+ ct = [
42
+ "TotalSegmentator",
43
+ ]
41
44
 
42
45
  [project.scripts]
43
46
  caideface = "caideface.cli:main"
@@ -8,13 +8,14 @@ A three-step pipeline for anonymising head MRI scans:
8
8
  Plus standalone text anonymisation via NER + HIPS (Hiding in Plain Sight).
9
9
  """
10
10
 
11
- __version__ = "0.3.0"
11
+ __version__ = "0.3.2"
12
12
 
13
13
  from .pipeline import DefacePipeline
14
14
  from .reorient import reorient_batch, reorient_single
15
15
  from .skull_strip import skull_strip_batch, skull_strip_single
16
16
  from .register import deface_batch, deface_single
17
17
  from .anonymize import anonymize_batch, anonymize_single, default_ner_model_path
18
+ from .background import detect_background_value
18
19
 
19
20
  __all__ = [
20
21
  "DefacePipeline",
@@ -27,4 +28,5 @@ __all__ = [
27
28
  "anonymize_batch",
28
29
  "anonymize_single",
29
30
  "default_ner_model_path",
31
+ "detect_background_value",
30
32
  ]
@@ -37,25 +37,28 @@ def main():
37
37
  run_parser = subparsers.add_parser("run", help="Run the full defacing pipeline", parents=[parent])
38
38
  run_parser.add_argument("input_dir", help="Directory containing raw NIfTI files")
39
39
  run_parser.add_argument("output_dir", help="Root output directory")
40
+ run_parser.add_argument("--modality", required=True, choices=["mri", "ct"], help="Image modality (required)")
40
41
  run_parser.add_argument("--brainsfit", required=True, help="Path to BRAINSFit executable")
41
42
  run_parser.add_argument("--brainsresample", required=True, help="Path to BRAINSResample executable")
42
43
  run_parser.add_argument("--device", default=None, choices=["cpu", "cuda"], help="Device for HD-BET (auto-detected if omitted)")
43
44
  run_parser.add_argument("--no-tta", action="store_true", default=True, help="Disable HD-BET test-time augmentation (default: disabled)")
44
45
  run_parser.add_argument("--dilation-mm", type=float, default=14.0, help="Brain mask dilation in mm (default: 14)")
45
- run_parser.add_argument("--background", type=float, default=0, help="Background value for defaced voxels (default: 0 for MRI, use -1024 for CT)")
46
+ run_parser.add_argument("--background", type=float, default=None, help="Background value for defaced voxels (auto-detected per volume if omitted)")
46
47
  run_parser.add_argument("--template", default=None, help="Custom MNI152 skull-stripped template (uses bundled if omitted)")
47
48
  run_parser.add_argument("--face-mask", default=None, help="Custom face mask in MNI152 space (uses bundled if omitted)")
48
49
  run_parser.add_argument("--steps", default="all", help="Steps to run: all, or comma-separated: reorient,skull_strip,deface")
49
50
 
50
51
  # --- reorient ---
51
- reorient_parser = subparsers.add_parser("reorient", help="Step 1: Reorient NIfTI scans to MNI152", parents=[parent])
52
+ reorient_parser = subparsers.add_parser("reorient", help="Step 1: Reorient NIfTI scans", parents=[parent])
52
53
  reorient_parser.add_argument("input_dir", help="Directory with NIfTI files")
53
54
  reorient_parser.add_argument("output_dir", help="Output directory for reoriented files")
55
+ reorient_parser.add_argument("--modality", required=True, choices=["mri", "ct"], help="Image modality (mri→LAS, ct→RAS)")
54
56
 
55
57
  # --- skull-strip ---
56
58
  ss_parser = subparsers.add_parser("skull-strip", help="Step 2: Skull-strip with HD-BET", parents=[parent])
57
59
  ss_parser.add_argument("input_dir", help="Directory with reoriented NIfTI files")
58
60
  ss_parser.add_argument("output_dir", help="Output directory for HD-BET results")
61
+ ss_parser.add_argument("--modality", required=True, choices=["mri", "ct"], help="Image modality (required)")
59
62
  ss_parser.add_argument("--device", default=None, choices=["cpu", "cuda"], help="Device for HD-BET")
60
63
  ss_parser.add_argument("--no-tta", action="store_true", default=True, help="Disable test-time augmentation")
61
64
  ss_parser.add_argument("--dilation-mm", type=float, default=14.0, help="Dilation in mm")
@@ -65,11 +68,12 @@ def main():
65
68
  deface_parser.add_argument("reoriented_dir", help="Directory with reoriented scans (Step 1 output)")
66
69
  deface_parser.add_argument("hdbet_dir", help="Directory with HD-BET results (Step 2 output)")
67
70
  deface_parser.add_argument("output_dir", help="Output directory for defaced scans")
71
+ deface_parser.add_argument("--modality", required=True, choices=["mri", "ct"], help="Image modality (required)")
68
72
  deface_parser.add_argument("--brainsfit", required=True, help="Path to BRAINSFit executable")
69
73
  deface_parser.add_argument("--brainsresample", required=True, help="Path to BRAINSResample executable")
70
74
  deface_parser.add_argument("--template", default=None, help="Custom MNI152 skull-stripped template")
71
75
  deface_parser.add_argument("--face-mask", default=None, help="Custom face mask in MNI152 space")
72
- deface_parser.add_argument("--background", type=float, default=0, help="Background value (default: 0 for MRI, use -1024 for CT)")
76
+ deface_parser.add_argument("--background", type=float, default=None, help="Background value (auto-detected per volume if omitted)")
73
77
 
74
78
  # --- anonymize (batch) ---
75
79
  anon_parser = subparsers.add_parser("anonymize", help="Anonymize personal names in all .txt files in a directory", parents=[parent])
@@ -99,6 +103,7 @@ def main():
99
103
  pipeline = DefacePipeline(
100
104
  brainsfit_path=args.brainsfit,
101
105
  brainsresample_path=args.brainsresample,
106
+ modality=args.modality,
102
107
  device=args.device,
103
108
  disable_tta=args.no_tta,
104
109
  desired_dilation_mm=args.dilation_mm,
@@ -113,12 +118,13 @@ def main():
113
118
  sys.exit(1)
114
119
 
115
120
  elif args.command == "reorient":
116
- reorient_batch(args.input_dir, args.output_dir)
121
+ reorient_batch(args.input_dir, args.output_dir, modality=args.modality)
117
122
 
118
123
  elif args.command == "skull-strip":
119
124
  skull_strip_batch(
120
125
  args.input_dir,
121
126
  args.output_dir,
127
+ modality=args.modality,
122
128
  device=args.device,
123
129
  disable_tta=args.no_tta,
124
130
  desired_dilation_mm=args.dilation_mm,
@@ -131,6 +137,7 @@ def main():
131
137
  output_dir=args.output_dir,
132
138
  brainsfit_path=args.brainsfit,
133
139
  brainsresample_path=args.brainsresample,
140
+ modality=args.modality,
134
141
  target_path=args.template,
135
142
  face_mask_path=args.face_mask,
136
143
  background_value=args.background,
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
13
13
 
14
14
 
15
15
  class DefacePipeline:
16
- """Orchestrates the three-step MRI defacing pipeline.
16
+ """Orchestrates the three-step defacing pipeline.
17
17
 
18
18
  Parameters
19
19
  ----------
@@ -21,14 +21,16 @@ class DefacePipeline:
21
21
  Path to the BRAINSFit executable (from 3D Slicer).
22
22
  brainsresample_path : str
23
23
  Path to the BRAINSResample executable (from 3D Slicer).
24
+ modality : str
25
+ Image modality: ``'mri'`` or ``'ct'``.
24
26
  device : str or None
25
27
  'cpu' or 'cuda' for HD-BET. Auto-detected if None.
26
28
  disable_tta : bool
27
29
  Disable HD-BET test-time augmentation for faster processing.
28
30
  desired_dilation_mm : float
29
31
  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
+ background_value : float or None
33
+ Value for defaced voxels. Auto-detected per volume when None.
32
34
  target_path : str or None
33
35
  Custom MNI152 skull-stripped template. Uses bundled if None.
34
36
  face_mask_path : str or None
@@ -39,15 +41,17 @@ class DefacePipeline:
39
41
  self,
40
42
  brainsfit_path: str,
41
43
  brainsresample_path: str,
44
+ modality: str = "mri",
42
45
  device: str | None = None,
43
46
  disable_tta: bool = True,
44
47
  desired_dilation_mm: float = 14.0,
45
- background_value: float = 0,
48
+ background_value: float | None = None,
46
49
  target_path: str | None = None,
47
50
  face_mask_path: str | None = None,
48
51
  ):
49
52
  self.brainsfit_path = brainsfit_path
50
53
  self.brainsresample_path = brainsresample_path
54
+ self.modality = modality
51
55
  self.device = device
52
56
  self.disable_tta = disable_tta
53
57
  self.desired_dilation_mm = desired_dilation_mm
@@ -93,9 +97,10 @@ class DefacePipeline:
93
97
  # Step 1: Reorientation
94
98
  if "reorient" in run_steps:
95
99
  logger.info("=" * 60)
96
- logger.info("STEP 1: Reorientation to MNI152")
100
+ target = "RAS" if self.modality == "ct" else "LAS"
101
+ logger.info("STEP 1: Reorientation to %s", target)
97
102
  logger.info("=" * 60)
98
- reorient_log = reorient_batch(input_dir, reoriented_dir)
103
+ reorient_log = reorient_batch(input_dir, reoriented_dir, modality=self.modality)
99
104
  results["reorient_log"] = reorient_log
100
105
  else:
101
106
  logger.info("Skipping Step 1 (reorientation)")
@@ -103,11 +108,13 @@ class DefacePipeline:
103
108
  # Step 2: Skull-stripping
104
109
  if "skull_strip" in run_steps:
105
110
  logger.info("=" * 60)
106
- logger.info("STEP 2: Skull-stripping with HD-BET")
111
+ backend = "TotalSegmentator" if self.modality == "ct" else "HD-BET"
112
+ logger.info("STEP 2: Skull-stripping with %s", backend)
107
113
  logger.info("=" * 60)
108
114
  skull_strip_log = skull_strip_batch(
109
115
  input_dir=reoriented_dir,
110
116
  output_dir=hdbet_dir,
117
+ modality=self.modality,
111
118
  device=self.device,
112
119
  disable_tta=self.disable_tta,
113
120
  desired_dilation_mm=self.desired_dilation_mm,
@@ -127,6 +134,7 @@ class DefacePipeline:
127
134
  output_dir=defaced_dir,
128
135
  brainsfit_path=self.brainsfit_path,
129
136
  brainsresample_path=self.brainsresample_path,
137
+ modality=self.modality,
130
138
  target_path=self.target_path,
131
139
  face_mask_path=self.face_mask_path,
132
140
  background_value=self.background_value,
@@ -11,6 +11,8 @@ import numpy as np
11
11
  from numpy.linalg import inv
12
12
  from natsort import natsorted
13
13
 
14
+ from .background import detect_background_value
15
+
14
16
  logger = logging.getLogger(__name__)
15
17
 
16
18
 
@@ -23,14 +25,35 @@ def _data_dir() -> str:
23
25
  return os.path.join(os.path.dirname(__file__), "data")
24
26
 
25
27
 
26
- def default_template_path() -> str:
28
+ def default_template_path(modality: str = "mri") -> str:
29
+ """Return the default template path for the given modality."""
30
+ if modality == "ct":
31
+ return default_ct_template_path()
27
32
  return os.path.join(_data_dir(), "mni_icbm152_t1_tal_nlin_sym_55_ext_brain_only.nii.gz")
28
33
 
29
34
 
30
- def default_face_mask_path() -> str:
35
+ def default_face_mask_path(modality: str = "mri") -> str:
36
+ """Return the default face mask path for the given modality."""
37
+ if modality == "ct":
38
+ return os.path.join(_data_dir(), "ct_face_mask.nii.gz")
31
39
  return os.path.join(_data_dir(), "t1_mask.nii.gz")
32
40
 
33
41
 
42
+ def default_ct_template_path() -> str:
43
+ """Return the CT brain atlas from the TotalSegmentator installation."""
44
+ try:
45
+ import totalsegmentator
46
+ ts_dir = os.path.dirname(totalsegmentator.__file__)
47
+ ct_atlas = os.path.join(ts_dir, "resources", "ct_brain_atlas_1mm.nii.gz")
48
+ if os.path.isfile(ct_atlas):
49
+ return ct_atlas
50
+ except ImportError:
51
+ pass
52
+ raise FileNotFoundError(
53
+ "CT brain atlas not found. Install TotalSegmentator with: pip install caideface[ct]"
54
+ )
55
+
56
+
34
57
  # ---------------------------------------------------------------------------
35
58
  # Transform I/O (supports both plain 4x4 text and ITK/Slicer formats)
36
59
  # ---------------------------------------------------------------------------
@@ -113,6 +136,7 @@ def run_brainsfit(
113
136
  output_volume_path: str,
114
137
  output_transform_path: str,
115
138
  brainsfit_path: str,
139
+ background_value: float = 0,
116
140
  ) -> int:
117
141
  """Run BRAINSFit affine registration and return the exit code."""
118
142
  cmd = (
@@ -129,7 +153,7 @@ def run_brainsfit(
129
153
  f'--medianFilterSize 0,0,0 '
130
154
  f'--removeIntensityOutliers 0 '
131
155
  f'--outputVolumePixelType float '
132
- f'--backgroundFillValue 0 '
156
+ f'--backgroundFillValue {background_value} '
133
157
  f'--interpolationMode Linear '
134
158
  f'--numberOfIterations 1500 '
135
159
  f'--maximumStepLength 0.05 '
@@ -219,8 +243,9 @@ def deface_single(
219
243
  face_mask_path: str,
220
244
  brainsfit_path: str,
221
245
  brainsresample_path: str,
246
+ modality: str = "mri",
222
247
  existing_transform: str | None = None,
223
- background_value: float = 0,
248
+ background_value: float | None = None,
224
249
  ) -> bool:
225
250
  """Register, warp face mask, and deface a single scan.
226
251
 
@@ -236,6 +261,20 @@ def deface_single(
236
261
  logger.info("Already defaced, skipping: %s", masked_path)
237
262
  return True
238
263
 
264
+ # --- Background value detection (always runs per volume) ---
265
+ floating_nii = nib.load(reoriented_path)
266
+ floating_data = floating_nii.get_fdata()
267
+
268
+ detected_bg = detect_background_value(floating_data, modality)
269
+ if background_value is not None:
270
+ logger.info(
271
+ "Detected background: %.1f, using override: %.1f",
272
+ detected_bg, background_value,
273
+ )
274
+ else:
275
+ background_value = detected_bg
276
+ logger.info("Using detected background: %.1f", background_value)
277
+
239
278
  out_affine = os.path.join(results_dir, base.replace(".nii.gz", ".txt"))
240
279
  out_resampled = os.path.join(results_dir, base.replace(".nii.gz", "_resampled.nii.gz"))
241
280
 
@@ -246,7 +285,8 @@ def deface_single(
246
285
  else:
247
286
  logger.info("Running BRAINSFit registration...")
248
287
  exit_code = run_brainsfit(
249
- target_path, floating_path, out_resampled, out_affine, brainsfit_path
288
+ target_path, floating_path, out_resampled, out_affine, brainsfit_path,
289
+ background_value=background_value,
250
290
  )
251
291
  if exit_code != 0:
252
292
  logger.error("BRAINSFit failed (exit %d) for %s", exit_code, floating_path)
@@ -267,9 +307,6 @@ def deface_single(
267
307
  )
268
308
 
269
309
  # --- Combine masks and deface ---
270
- floating_nii = nib.load(reoriented_path)
271
- floating_data = floating_nii.get_fdata()
272
-
273
310
  face_data = nib.load(face_mask_resampled).get_fdata()
274
311
  brain_data = nib.load(brain_mask_path).get_fdata()
275
312
 
@@ -291,9 +328,10 @@ def deface_batch(
291
328
  output_dir: str,
292
329
  brainsfit_path: str,
293
330
  brainsresample_path: str,
331
+ modality: str = "mri",
294
332
  target_path: str | None = None,
295
333
  face_mask_path: str | None = None,
296
- background_value: float = 0,
334
+ background_value: float | None = None,
297
335
  ) -> list[str]:
298
336
  """Run the full defacing pipeline (Step 3) on all dilated scans.
299
337
 
@@ -309,12 +347,14 @@ def deface_batch(
309
347
  Path to the BRAINSFit executable.
310
348
  brainsresample_path : str
311
349
  Path to the BRAINSResample executable.
350
+ modality : str
351
+ Image modality: ``'mri'`` or ``'ct'``.
312
352
  target_path : str or None
313
353
  Path to skull-stripped MNI152 template. Uses bundled if None.
314
354
  face_mask_path : str or None
315
355
  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).
356
+ background_value : float or None
357
+ Value to fill defaced regions. Auto-detected per volume when None.
318
358
 
319
359
  Returns
320
360
  -------
@@ -322,9 +362,9 @@ def deface_batch(
322
362
  Paths of scans that failed to deface.
323
363
  """
324
364
  if target_path is None:
325
- target_path = default_template_path()
365
+ target_path = default_template_path(modality)
326
366
  if face_mask_path is None:
327
- face_mask_path = default_face_mask_path()
367
+ face_mask_path = default_face_mask_path(modality)
328
368
 
329
369
  hdbet_dir = os.path.abspath(hdbet_dir)
330
370
  reoriented_dir = os.path.abspath(reoriented_dir)
@@ -371,6 +411,7 @@ def deface_batch(
371
411
  face_mask_path=face_mask_path,
372
412
  brainsfit_path=brainsfit_path,
373
413
  brainsresample_path=brainsresample_path,
414
+ modality=modality,
374
415
  existing_transform=existing_transforms.get(fimg),
375
416
  background_value=background_value,
376
417
  )
@@ -1,4 +1,4 @@
1
- """Step 1: Reorientation of NIfTI scans to LAS (MNI152 standard) using nibabel."""
1
+ """Step 1: Reorientation of NIfTI scans using nibabel."""
2
2
 
3
3
  import os
4
4
  import logging
@@ -9,15 +9,18 @@ import pandas as pd
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
12
- # Target orientation matching fslreorient2std / MNI152 standard
13
- TARGET_ORIENTATION = ("L", "A", "S")
12
+ # Target orientations per modality
13
+ TARGET_ORIENTATIONS = {
14
+ "mri": ("L", "A", "S"), # LAS — matches fslreorient2std
15
+ "ct": ("R", "A", "S"), # RAS — matches CT brain atlas
16
+ }
14
17
 
15
18
 
16
- def reorient_single(input_file: str, output_file: str) -> bool:
17
- """Reorient a single NIfTI file to LAS orientation (MNI152 standard).
19
+ def reorient_single(input_file: str, output_file: str, modality: str = "mri") -> bool:
20
+ """Reorient a single NIfTI file to the target orientation for *modality*.
18
21
 
19
- This is equivalent to FSL's ``fslreorient2std`` but implemented purely
20
- in Python using nibabel, removing the FSL dependency.
22
+ MRI is reoriented to LAS (matching ``fslreorient2std`` / MNI152).
23
+ CT is reoriented to RAS (matching the CT brain atlas).
21
24
 
22
25
  Parameters
23
26
  ----------
@@ -25,6 +28,8 @@ def reorient_single(input_file: str, output_file: str) -> bool:
25
28
  Path to the input NIfTI (.nii.gz) file.
26
29
  output_file : str
27
30
  Path where the reoriented file will be saved.
31
+ modality : str
32
+ ``'mri'`` (target LAS) or ``'ct'`` (target RAS).
28
33
 
29
34
  Returns
30
35
  -------
@@ -33,10 +38,12 @@ def reorient_single(input_file: str, output_file: str) -> bool:
33
38
  """
34
39
  os.makedirs(os.path.dirname(output_file), exist_ok=True)
35
40
 
41
+ target = TARGET_ORIENTATIONS.get(modality, TARGET_ORIENTATIONS["mri"])
42
+
36
43
  try:
37
44
  img = nib.load(input_file)
38
45
  orig_ornt = io_orientation(img.affine)
39
- target_ornt = axcodes2ornt(TARGET_ORIENTATION)
46
+ target_ornt = axcodes2ornt(target)
40
47
  transform = ornt_transform(orig_ornt, target_ornt)
41
48
  reoriented = img.as_reoriented(transform)
42
49
  nib.save(reoriented, output_file)
@@ -47,7 +54,7 @@ def reorient_single(input_file: str, output_file: str) -> bool:
47
54
  return os.path.exists(output_file)
48
55
 
49
56
 
50
- def reorient_batch(input_dir: str, output_dir: str) -> pd.DataFrame:
57
+ def reorient_batch(input_dir: str, output_dir: str, modality: str = "mri") -> pd.DataFrame:
51
58
  """Reorient all NIfTI files found recursively under *input_dir*.
52
59
 
53
60
  The directory structure is mirrored under *output_dir*.
@@ -59,6 +66,8 @@ def reorient_batch(input_dir: str, output_dir: str) -> pd.DataFrame:
59
66
  output_dir : str
60
67
  Root directory where reoriented files will be saved,
61
68
  preserving the subdirectory structure.
69
+ modality : str
70
+ ``'mri'`` (target LAS) or ``'ct'`` (target RAS).
62
71
 
63
72
  Returns
64
73
  -------
@@ -68,6 +77,9 @@ def reorient_batch(input_dir: str, output_dir: str) -> pd.DataFrame:
68
77
  input_dir = os.path.abspath(input_dir)
69
78
  output_dir = os.path.abspath(output_dir)
70
79
 
80
+ target = TARGET_ORIENTATIONS.get(modality, TARGET_ORIENTATIONS["mri"])
81
+ logger.info("Target orientation: %s (%s)", "".join(target), modality)
82
+
71
83
  log_rows = []
72
84
  for root, _dirs, files in os.walk(input_dir):
73
85
  for fname in files:
@@ -79,7 +91,7 @@ def reorient_batch(input_dir: str, output_dir: str) -> pd.DataFrame:
79
91
  out_path = os.path.join(output_dir, rel, fname)
80
92
 
81
93
  logger.info("Reorienting %s", input_path)
82
- success = reorient_single(input_path, out_path)
94
+ success = reorient_single(input_path, out_path, modality=modality)
83
95
  log_rows.append({"input": input_path, "output": out_path, "success": success})
84
96
 
85
97
  if success:
@@ -82,15 +82,81 @@ def is_valid_3d_volume(filepath: str) -> bool:
82
82
  return False
83
83
 
84
84
 
85
+ # ---------------------------------------------------------------------------
86
+ # Brain extraction backends
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def _extract_brain_hdbet(
90
+ input_file: str, output_file: str, device: str = "cpu", disable_tta: bool = True
91
+ ) -> None:
92
+ """Run HD-BET brain extraction (MRI)."""
93
+ hdbet_bin = _hd_bet_executable()
94
+ cmd = [hdbet_bin, "-i", input_file, "-o", output_file, "-device", device]
95
+ if disable_tta:
96
+ cmd.append("--disable_tta")
97
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
98
+
99
+
100
+ def _extract_brain_totalseg(
101
+ input_file: str, output_file: str, binary_mask_file: str, device: str = "cpu"
102
+ ) -> None:
103
+ """Run TotalSegmentator brain extraction (CT).
104
+
105
+ Saves both a brain-extracted volume (*output_file*) and the raw binary
106
+ brain mask (*binary_mask_file*). The mask is needed because CT brain-
107
+ extracted volumes have negative values, so ``data > 0`` cannot be used
108
+ to recover the mask (unlike MRI).
109
+
110
+ Requires ``pip install caideface[ct]``.
111
+ """
112
+ try:
113
+ from totalsegmentator.python_api import totalsegmentator
114
+ except ImportError:
115
+ raise ImportError(
116
+ "TotalSegmentator is required for CT skull-stripping.\n"
117
+ "Install it with: pip install caideface[ct]"
118
+ )
119
+
120
+ ts_device = "cpu" if device == "cpu" else "gpu"
121
+
122
+ input_img = nib.load(input_file)
123
+ brain_mask = totalsegmentator(
124
+ input=input_img,
125
+ task="total",
126
+ roi_subset=["brain"],
127
+ device=ts_device,
128
+ )
129
+
130
+ # Save the raw binary brain mask
131
+ mask_data = brain_mask.get_fdata().astype(np.uint8)
132
+ nib.save(
133
+ nib.Nifti1Image(mask_data, input_img.affine, input_img.header),
134
+ binary_mask_file,
135
+ )
136
+
137
+ # Apply brain mask to get brain-extracted volume
138
+ data = input_img.get_fdata()
139
+ brain_data = data * mask_data
140
+ nib.save(
141
+ nib.Nifti1Image(brain_data, input_img.affine, input_img.header),
142
+ output_file,
143
+ )
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Single / batch skull-stripping
148
+ # ---------------------------------------------------------------------------
149
+
85
150
  def skull_strip_single(
86
151
  input_file: str,
87
152
  input_root: str,
88
153
  output_dir: str,
154
+ modality: str = "mri",
89
155
  device: str = "cpu",
90
156
  disable_tta: bool = True,
91
157
  desired_dilation_mm: float = 14.0,
92
158
  ) -> dict:
93
- """Run HD-BET on a single NIfTI file and produce dilated brain mask.
159
+ """Extract brain, create dilated mask, and apply it to a single NIfTI file.
94
160
 
95
161
  Parameters
96
162
  ----------
@@ -100,58 +166,71 @@ def skull_strip_single(
100
166
  Root input directory (used to compute relative paths).
101
167
  output_dir : str
102
168
  Root output directory.
169
+ modality : str
170
+ ``'mri'`` (uses HD-BET) or ``'ct'`` (uses TotalSegmentator).
103
171
  device : str
104
172
  'cpu' or 'cuda'.
105
173
  disable_tta : bool
106
- Disable test-time augmentation for faster processing.
174
+ Disable HD-BET test-time augmentation (MRI only).
107
175
  desired_dilation_mm : float
108
176
  Physical dilation size in mm for mask expansion.
109
177
 
110
178
  Returns
111
179
  -------
112
180
  dict
113
- Status dict with keys: input, output, hd_bet, mask, dilated.
181
+ Status dict with keys: input, output, brain_extract, mask, dilated.
114
182
  """
115
183
  filename = os.path.basename(input_file)
116
184
  stem = filename.replace(".nii.gz", "")
117
185
  subfolder = os.path.relpath(os.path.dirname(input_file), start=input_root)
118
186
 
119
- hd_bet_file = os.path.join(output_dir, subfolder, f"{stem}_brain.nii.gz")
187
+ brain_file = os.path.join(output_dir, subfolder, f"{stem}_brain.nii.gz")
120
188
  mask_file = os.path.join(output_dir, subfolder, f"{stem}_mask.nii.gz")
121
189
  dilated_file = os.path.join(output_dir, subfolder, f"{stem}_dilated.nii.gz")
122
190
 
123
- os.makedirs(os.path.dirname(hd_bet_file), exist_ok=True)
191
+ os.makedirs(os.path.dirname(brain_file), exist_ok=True)
124
192
 
125
193
  status = {
126
- "hd_bet": "skipped" if os.path.exists(hd_bet_file) else "pending",
194
+ "brain_extract": "skipped" if os.path.exists(brain_file) else "pending",
127
195
  "mask": "skipped" if os.path.exists(mask_file) else "pending",
128
196
  "dilated": "skipped" if os.path.exists(dilated_file) else "pending",
129
197
  }
130
198
 
131
199
  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}
200
+ # --- Brain extraction (modality-dependent) ---
201
+ # For CT, a separate binary mask file is saved alongside the
202
+ # brain-extracted volume because CT values include negatives,
203
+ # making ``data > 0`` unreliable for mask recovery.
204
+ ct_binary_mask_file = os.path.join(
205
+ output_dir, subfolder, f"{stem}_brain_binary_mask.nii.gz"
206
+ )
207
+ if not os.path.exists(brain_file):
208
+ if modality == "ct":
209
+ logger.info("Running TotalSegmentator brain extraction...")
210
+ _extract_brain_totalseg(input_file, brain_file, ct_binary_mask_file, device)
211
+ else:
212
+ logger.info("Running HD-BET brain extraction...")
213
+ try:
214
+ _extract_brain_hdbet(input_file, brain_file, device, disable_tta)
215
+ except subprocess.CalledProcessError as e:
216
+ logger.error("HD-BET failed for %s: %s", input_file, e.stderr)
217
+ status["brain_extract"] = "failed"
218
+ return {"input": input_file, "output": brain_file, **status, "error": e.stderr}
219
+ status["brain_extract"] = "success"
145
220
 
146
221
  # --- Binary mask + dilation ---
147
222
  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]
223
+ if modality == "ct" and os.path.exists(ct_binary_mask_file):
224
+ # Use the raw TotalSegmentator binary mask directly
225
+ img = nib.load(ct_binary_mask_file)
226
+ binary_volume = (img.get_fdata() > 0).astype(np.uint8)
227
+ else:
228
+ # MRI: threshold the brain-extracted volume
229
+ img = nib.load(brain_file)
230
+ binary_volume = (img.get_fdata() > 0).astype(np.uint8)
151
231
 
232
+ voxel_sizes = img.header.get_zooms()[:3]
152
233
  struct_elem = get_safe_structuring_element(voxel_sizes, desired_dilation_mm)
153
-
154
- binary_volume = (data > 0).astype(np.uint8)
155
234
  dilated_data = binary_dilation(binary_volume, structure=struct_elem)
156
235
 
157
236
  nib.save(nib.Nifti1Image(dilated_data, img.affine, img.header), mask_file)
@@ -165,7 +244,12 @@ def skull_strip_single(
165
244
  original_img = nib.load(input_file)
166
245
  original_data = original_img.get_fdata()
167
246
 
168
- dilated_volume = original_data * mask_data
247
+ # Detect background so outside-mask voxels keep the correct
248
+ # intensity (e.g. ~-1000 HU for CT instead of 0).
249
+ from .background import detect_background_value
250
+ bg = detect_background_value(original_data, modality)
251
+
252
+ dilated_volume = np.where(mask_data, original_data, bg)
169
253
  nib.save(
170
254
  nib.Nifti1Image(dilated_volume, original_img.affine, original_img.header),
171
255
  dilated_file,
@@ -178,18 +262,20 @@ def skull_strip_single(
178
262
  if status[k] == "pending":
179
263
  status[k] = "failed"
180
264
 
181
- return {"input": input_file, "output": hd_bet_file, **status}
265
+ return {"input": input_file, "output": brain_file, **status}
182
266
 
183
267
 
184
268
  def skull_strip_batch(
185
269
  input_dir: str,
186
270
  output_dir: str,
271
+ modality: str = "mri",
187
272
  device: str | None = None,
188
273
  disable_tta: bool = True,
189
274
  desired_dilation_mm: float = 14.0,
190
275
  ) -> pd.DataFrame:
191
- """Run HD-BET skull-stripping on all 3D NIfTI volumes under *input_dir*.
276
+ """Run skull-stripping on all 3D NIfTI volumes under *input_dir*.
192
277
 
278
+ Uses HD-BET for MRI or TotalSegmentator for CT, based on *modality*.
193
279
  Scans that are not 3D volumes (2D slices or 4D time series) are skipped.
194
280
 
195
281
  Parameters
@@ -197,11 +283,13 @@ def skull_strip_batch(
197
283
  input_dir : str
198
284
  Root directory with reoriented NIfTI files.
199
285
  output_dir : str
200
- Root output directory for HD-BET results.
286
+ Root output directory for skull-stripping results.
287
+ modality : str
288
+ ``'mri'`` (HD-BET) or ``'ct'`` (TotalSegmentator).
201
289
  device : str or None
202
290
  'cpu' or 'cuda'. Auto-detected if None.
203
291
  disable_tta : bool
204
- Disable test-time augmentation.
292
+ Disable test-time augmentation (MRI/HD-BET only).
205
293
  desired_dilation_mm : float
206
294
  Physical dilation in mm.
207
295
 
@@ -210,8 +298,17 @@ def skull_strip_batch(
210
298
  pd.DataFrame
211
299
  Processing log.
212
300
  """
213
- # Validate hd-bet is available (will raise if not found)
214
- _hd_bet_executable()
301
+ # Validate backend is available
302
+ if modality == "mri":
303
+ _hd_bet_executable()
304
+ elif modality == "ct":
305
+ try:
306
+ import totalsegmentator # noqa: F401
307
+ except ImportError:
308
+ raise ImportError(
309
+ "TotalSegmentator is required for CT skull-stripping.\n"
310
+ "Install it with: pip install caideface[ct]"
311
+ )
215
312
 
216
313
  if device is None:
217
314
  device = get_default_device()
@@ -231,18 +328,22 @@ def skull_strip_batch(
231
328
 
232
329
  logger.info("Processing: %s", input_file)
233
330
  result = skull_strip_single(
234
- input_file, input_dir, output_dir, device, disable_tta, desired_dilation_mm
331
+ input_file, input_dir, output_dir,
332
+ modality=modality,
333
+ device=device,
334
+ disable_tta=disable_tta,
335
+ desired_dilation_mm=desired_dilation_mm,
235
336
  )
236
337
  log_data.append(result)
237
338
 
238
339
  df = pd.DataFrame(log_data)
239
- log_path = os.path.join(output_dir, "hd_bet_log.csv")
340
+ log_path = os.path.join(output_dir, "skull_strip_log.csv")
240
341
 
241
342
  if os.path.exists(log_path):
242
343
  existing = pd.read_csv(log_path)
243
344
  df = pd.concat([existing, df], ignore_index=True)
244
345
 
245
346
  df.to_csv(log_path, index=False)
246
- logger.info("HD-BET log saved at %s", log_path)
347
+ logger.info("Skull-stripping log saved at %s", log_path)
247
348
 
248
349
  return df
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caideface
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: MRI defacing pipeline with skull-stripping and affine registration from cai4cai
5
5
  Author-email: Lorena Garcia-Foncillas <lorenagarfon00@gmail.com>
6
- License: MIT
6
+ License: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/cai4cai/defacing_pipeline
8
8
  Project-URL: Repository, https://github.com/cai4cai/defacing_pipeline
9
9
  Keywords: MRI,defacing,anonymisation,skull-stripping,neuroimaging,NER,text-anonymization
@@ -13,7 +13,6 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
14
14
  Requires-Python: >=3.9
15
15
  Description-Content-Type: text/markdown
16
- License-File: LICENSE.md
17
16
  Requires-Dist: nibabel>=4.0
18
17
  Requires-Dist: numpy<2,>=1.22
19
18
  Requires-Dist: scipy>=1.9
@@ -27,7 +26,8 @@ Requires-Dist: faker>=18.0
27
26
  Provides-Extra: dev
28
27
  Requires-Dist: pytest; extra == "dev"
29
28
  Requires-Dist: ruff; extra == "dev"
30
- Dynamic: license-file
29
+ Provides-Extra: ct
30
+ Requires-Dist: TotalSegmentator; extra == "ct"
31
31
 
32
32
  # caideface
33
33
 
@@ -330,4 +330,4 @@ If you use the text anonymisation (NER + HIPS), please also cite:
330
330
 
331
331
  ## License
332
332
 
333
- This project is licensed under the MIT License -- see the [LICENSE](LICENSE.md) file for details.
333
+ This project is licensed under the Apache License 2.0 -- see the [LICENSE](https://github.com/cai4cai/defacing_pipeline/blob/main/LICENSE) file for details.
@@ -1,4 +1,3 @@
1
- LICENSE.md
2
1
  README.md
3
2
  pyproject.toml
4
3
  src/caideface/__init__.py
@@ -15,6 +14,7 @@ src/caideface.egg-info/dependency_links.txt
15
14
  src/caideface.egg-info/entry_points.txt
16
15
  src/caideface.egg-info/requires.txt
17
16
  src/caideface.egg-info/top_level.txt
17
+ src/caideface/data/ct_face_mask.nii.gz
18
18
  src/caideface/data/mni_icbm152_t1_tal_nlin_sym_55_ext_brain_only.nii.gz
19
19
  src/caideface/data/t1_mask.nii.gz
20
20
  src/caideface/data/ner_model/config.cfg
@@ -9,6 +9,9 @@ hd-bet
9
9
  spacy>=3.5
10
10
  faker>=18.0
11
11
 
12
+ [ct]
13
+ TotalSegmentator
14
+
12
15
  [dev]
13
16
  pytest
14
17
  ruff
@@ -1,21 +0,0 @@
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.
File without changes