From 027b72108d4dcc1d7ca9e2f3cec2ba293d2ce167 Mon Sep 17 00:00:00 2001 From: nasironeeb Date: Fri, 9 Oct 2026 17:53:16 +0200 Subject: [PATCH 1/2] brainprep/workflow/defacing.py: keep BIDS entities and fix T2w/FLAIR defacing reference --- brainprep/utils/__init__.py | 2 + brainprep/utils/utils.py | 151 +++++++++++++++++++++++++++++++++ brainprep/workflow/defacing.py | 19 +---- 3 files changed, 157 insertions(+), 15 deletions(-) diff --git a/brainprep/utils/__init__.py b/brainprep/utils/__init__.py index 069feff0..afb64aed 100644 --- a/brainprep/utils/__init__.py +++ b/brainprep/utils/__init__.py @@ -24,6 +24,7 @@ print_warn, ) from .utils import ( + bids_basename, bvecbval_from_file, coerce_to_list, coerce_to_path, @@ -32,6 +33,7 @@ make_run_id, parse_bids_keys, sbref_from_file, + select_defaced_t1w, sidecar_from_file, ) diff --git a/brainprep/utils/utils.py b/brainprep/utils/utils.py index f8aea780..7a19088b 100644 --- a/brainprep/utils/utils.py +++ b/brainprep/utils/utils.py @@ -33,6 +33,38 @@ print_warn, ) +def bids_basename( + entities: dict[str], +) -> str: + """ + Build a BIDS-like file basename from parsed BIDS entities. + + The recognized entities are kept in canonical BIDS order, so that + input files differing only by entities such as ``acq`` or ``rec`` + yield distinct derivative names. The ``mod`` and ``modality`` keys + are ignored: the modality is handled by the callers through the + file suffix. + + Parameters + ---------- + entities : dict[str] + A dictionary of parsed BIDS entities, as returned by + :func:`parse_bids_keys`. + + Returns + ------- + basename : str + The BIDS-like basename, e.g. ``sub-01_ses-M00_acq-3DT1_run-01``. + """ + order = ( + "sub", "ses", "task", "acq", "ce", "rec", "dir", + "echo", "part", "run", "space", "res", "recording", + ) + return "_".join( + f"{key}-{entities[key]}" + for key in order + if key in entities and entities[key] != "" + ) def coerce_to_list( value: Any, @@ -566,3 +598,122 @@ def find_first_occurrence( raise ValueError( f"Unable to find target '{target}' in parents of {input_file}" ) + +def input_t1w_runs( + anatomical_file: File, + sub: str, + ses: str, + acq: str | None, + ) -> set[str]: + """ + Collect the run identifiers of the T1w inputs matching an acquisition. + + The sibling T1w images of the input session are parsed so that their + run identifier can be recovered, including the deterministic one + generated when the file name carries no ``run`` entity. + + Parameters + ---------- + anatomical_file : File + Path to the input image whose sibling T1w images are inspected. + sub : str + Subject identifier. + ses : str + Session identifier. + acq : str | None + Acquisition identifier to match, or None to keep every T1w. + + Returns + ------- + runs : set[str] + The matching run identifiers. + """ + runs = set() + for t1_file in anatomical_file.parent.glob( + f"sub-{sub}_ses-{ses}*_T1w.nii.gz" + ): + t1_entities = parse_bids_keys(t1_file) + if acq is None or t1_entities.get("acq") == acq: + runs.add(t1_entities["run"]) + return runs + +def select_defaced_t1w( + output_dir: Directory, + entities: dict, + anatomical_file: File, + ) -> tuple[File, File]: + """ + Select the defaced T1w image used to deface a T2w or FLAIR image. + + Every candidate image is paired with its defacing mask. When the + input carries an ``acq`` entity, candidates are first restricted to + the same acquisition (from the output names), then, for derivatives + named without ``acq``, to the run identifiers of the matching T1w + inputs. When several candidates remain, the first one in + alphabetical order is used and a warning is displayed. + + Parameters + ---------- + output_dir : Directory + Session output directory containing the defaced T1w outputs. + entities : dict + Parsed BIDS entities of the T2w/FLAIR input image. + anatomical_file : File + Path to the T2w/FLAIR input image, used to inspect the sibling + T1w inputs of the same session. + + Returns + ------- + t1_file : File + Path to the selected defaced T1w image. + mask_file : File + Path to the associated defacing mask. + + Raises + ------ + ValueError + If no defaced T1w image is available in the session. + """ + sub = entities["sub"] + ses = entities["ses"] + acq = entities.get("acq") + candidates = [] + for t1_file in sorted( + output_dir.glob(f"sub-{sub}_ses-{ses}*_T1w.nii.gz") + ): + mask_file = t1_file.with_name( + t1_file.name.removesuffix("_T1w.nii.gz") + + "_mod-T1w_defacemask.nii.gz" + ) + if mask_file.is_file(): + candidates.append((t1_file, mask_file)) + if not candidates: + raise ValueError( + f"No defaced T1w image found in {output_dir}: the T1w " + f"image of sub-{sub}_ses-{ses} must be defaced first." + ) + matched = list(candidates) + if acq is not None and len(matched) > 1: + by_acq = [ + item for item in matched + if f"_acq-{acq}_" in item[0].name + ] + if by_acq: + matched = by_acq + else: + runs = input_t1w_runs(anatomical_file, sub, ses, acq) + by_run = [ + item for item in matched + if any(f"_run-{run}_" in item[0].name for run in runs) + ] + if by_run: + matched = by_run + if len(matched) > 1: + acq_part = f"_acq-{acq}" if acq is not None else "" + print_warn( + f"Several defaced T1w images found for " + f"sub-{sub}_ses-{ses}{acq_part}: " + f"{[item[0].name for item in matched]}; using " + f"{matched[0][0].name}." + ) + return matched[0] \ No newline at end of file diff --git a/brainprep/workflow/defacing.py b/brainprep/workflow/defacing.py index 254e6abb..70a36756 100644 --- a/brainprep/workflow/defacing.py +++ b/brainprep/workflow/defacing.py @@ -28,8 +28,10 @@ File, ) from ..utils import ( + bids_basename, Bunch, print_info, + select_defaced_t1w, ) @@ -186,22 +188,9 @@ def brainprep_defacing( interpolation="spline", ) else: - t1_file = list( - output_dir.glob( - f"sub-{entities['sub']}_ses-{entities['ses']}_run-*_T1w.nii.gz" - ) + t1_file, mask_t1_file = select_defaced_t1w( + output_dir, entities, anatomical_file ) - mask_t1_file = list( - output_dir.glob( - f"sub-{entities['sub']}_ses-{entities['ses']}_run-*_mod-T1w_" - "defacemask.nii.gz" - ) - ) - if len(t1_file) != 1 or len(mask_t1_file) != 1: - raise ValueError( - f"No T1w defaced image found: {t1_file}, {mask_t1_file}" - ) - t1_file, mask_t1_file = t1_file[0], mask_t1_file[0] print_info(f"using T1w: {t1_file}") print_info(f"using defacing mask: {mask_t1_file}") aligned_anatomical_file = reoriented_anatomical_file From 4ccfb9596bdfb4888692135319c669cd8a0d5922 Mon Sep 17 00:00:00 2001 From: nasironeeb Date: Tue, 13 Oct 2026 13:45:59 +0200 Subject: [PATCH 2/2] brainprep/workflow/defacing: add t1w_run_id parameter to select specific T1w for T2w/FLAIR --- brainprep/utils/utils.py | 119 --------------------------------- brainprep/workflow/defacing.py | 33 +++++++-- 2 files changed, 29 insertions(+), 123 deletions(-) diff --git a/brainprep/utils/utils.py b/brainprep/utils/utils.py index 7a19088b..f4f7f4b8 100644 --- a/brainprep/utils/utils.py +++ b/brainprep/utils/utils.py @@ -598,122 +598,3 @@ def find_first_occurrence( raise ValueError( f"Unable to find target '{target}' in parents of {input_file}" ) - -def input_t1w_runs( - anatomical_file: File, - sub: str, - ses: str, - acq: str | None, - ) -> set[str]: - """ - Collect the run identifiers of the T1w inputs matching an acquisition. - - The sibling T1w images of the input session are parsed so that their - run identifier can be recovered, including the deterministic one - generated when the file name carries no ``run`` entity. - - Parameters - ---------- - anatomical_file : File - Path to the input image whose sibling T1w images are inspected. - sub : str - Subject identifier. - ses : str - Session identifier. - acq : str | None - Acquisition identifier to match, or None to keep every T1w. - - Returns - ------- - runs : set[str] - The matching run identifiers. - """ - runs = set() - for t1_file in anatomical_file.parent.glob( - f"sub-{sub}_ses-{ses}*_T1w.nii.gz" - ): - t1_entities = parse_bids_keys(t1_file) - if acq is None or t1_entities.get("acq") == acq: - runs.add(t1_entities["run"]) - return runs - -def select_defaced_t1w( - output_dir: Directory, - entities: dict, - anatomical_file: File, - ) -> tuple[File, File]: - """ - Select the defaced T1w image used to deface a T2w or FLAIR image. - - Every candidate image is paired with its defacing mask. When the - input carries an ``acq`` entity, candidates are first restricted to - the same acquisition (from the output names), then, for derivatives - named without ``acq``, to the run identifiers of the matching T1w - inputs. When several candidates remain, the first one in - alphabetical order is used and a warning is displayed. - - Parameters - ---------- - output_dir : Directory - Session output directory containing the defaced T1w outputs. - entities : dict - Parsed BIDS entities of the T2w/FLAIR input image. - anatomical_file : File - Path to the T2w/FLAIR input image, used to inspect the sibling - T1w inputs of the same session. - - Returns - ------- - t1_file : File - Path to the selected defaced T1w image. - mask_file : File - Path to the associated defacing mask. - - Raises - ------ - ValueError - If no defaced T1w image is available in the session. - """ - sub = entities["sub"] - ses = entities["ses"] - acq = entities.get("acq") - candidates = [] - for t1_file in sorted( - output_dir.glob(f"sub-{sub}_ses-{ses}*_T1w.nii.gz") - ): - mask_file = t1_file.with_name( - t1_file.name.removesuffix("_T1w.nii.gz") - + "_mod-T1w_defacemask.nii.gz" - ) - if mask_file.is_file(): - candidates.append((t1_file, mask_file)) - if not candidates: - raise ValueError( - f"No defaced T1w image found in {output_dir}: the T1w " - f"image of sub-{sub}_ses-{ses} must be defaced first." - ) - matched = list(candidates) - if acq is not None and len(matched) > 1: - by_acq = [ - item for item in matched - if f"_acq-{acq}_" in item[0].name - ] - if by_acq: - matched = by_acq - else: - runs = input_t1w_runs(anatomical_file, sub, ses, acq) - by_run = [ - item for item in matched - if any(f"_run-{run}_" in item[0].name for run in runs) - ] - if by_run: - matched = by_run - if len(matched) > 1: - acq_part = f"_acq-{acq}" if acq is not None else "" - print_warn( - f"Several defaced T1w images found for " - f"sub-{sub}_ses-{ses}{acq_part}: " - f"{[item[0].name for item in matched]}; using " - f"{matched[0][0].name}." - ) - return matched[0] \ No newline at end of file diff --git a/brainprep/workflow/defacing.py b/brainprep/workflow/defacing.py index 70a36756..57165505 100644 --- a/brainprep/workflow/defacing.py +++ b/brainprep/workflow/defacing.py @@ -28,10 +28,8 @@ File, ) from ..utils import ( - bids_basename, Bunch, print_info, - select_defaced_t1w, ) @@ -56,6 +54,7 @@ def brainprep_defacing( anatomical_file: File, output_dir: Directory, keep_intermediate: bool = False, + t1w_run_id: str | None = None, **kwargs: dict, ) -> Bunch: """ @@ -84,6 +83,10 @@ def brainprep_defacing( If True, retains intermediate results (e.g., reoriented image); useful for debugging. Default False. + t1w_run_id : str | None + Optional run identifier to select a specific defaced T1w image when + defacing T2w or FLAIR images. Required when multiple T1w images exist + in the same session. Default None. **kwargs : dict entities: dict Dictionary of parsed BIDS entities. @@ -188,9 +191,31 @@ def brainprep_defacing( interpolation="spline", ) else: - t1_file, mask_t1_file = select_defaced_t1w( - output_dir, entities, anatomical_file + t1_file = list( + output_dir.glob( + f"sub-{entities['sub']}_ses-{entities['ses']}_run-*_T1w.nii.gz" + ) ) + mask_t1_file = list( + output_dir.glob( + f"sub-{entities['sub']}_ses-{entities['ses']}_run-*_mod-T1w_" + "defacemask.nii.gz" + ) + ) + if (len(t1_file) > 1 or len(mask_t1_file) > 1): + if t1w_run_id is None: + raise ValueError( + f"Multiple T1w defaced images found: {t1_file}, {mask_t1_file}. " + "Please specify a run identifier run- using the 't1w_run_id' parameter." + ) + t1_file = [f for f in t1_file if f"run-{t1w_run_id}_" in str(f)] + mask_t1_file = [f for f in mask_t1_file if f"run-{t1w_run_id}_" in str(f)] + if len(t1_file) == 0 or len(mask_t1_file) == 0: + raise ValueError( + f"No T1w defaced image found: {t1_file}, {mask_t1_file}" + ) + + t1_file, mask_t1_file = t1_file[0], mask_t1_file[0] print_info(f"using T1w: {t1_file}") print_info(f"using defacing mask: {mask_t1_file}") aligned_anatomical_file = reoriented_anatomical_file