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 +24 -0
- caideface/cli.py +127 -0
- caideface/data/mni_icbm152_t1_tal_nlin_sym_55_ext_brain_only.nii.gz +0 -0
- caideface/data/t1_mask.nii.gz +0 -0
- caideface/pipeline.py +139 -0
- caideface/register.py +395 -0
- caideface/reorient.py +98 -0
- caideface/skull_strip.py +248 -0
- caideface-0.1.3.dist-info/METADATA +198 -0
- caideface-0.1.3.dist-info/RECORD +14 -0
- caideface-0.1.3.dist-info/WHEEL +5 -0
- caideface-0.1.3.dist-info/entry_points.txt +2 -0
- caideface-0.1.3.dist-info/licenses/LICENSE.md +21 -0
- caideface-0.1.3.dist-info/top_level.txt +1 -0
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
|
|
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
|
caideface/skull_strip.py
ADDED
|
@@ -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,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
|