Source code for bids.ext.reports.parsing

"""Parsing functions for generating BIDSReports."""

from __future__ import annotations

from pathlib import Path
from typing import Any

import nibabel as nib
from bids.layout import BIDSFile, BIDSLayout
from nibabel.filebasedimages import ImageFileError

from . import parameters, templates
from .logger import pybids_reports_logger
from .utils import collect_associated_files

LOGGER = pybids_reports_logger()


[docs] def institution_info(files: list[BIDSFile]): first_file = files[0] metadata = first_file.get_metadata() if metadata.get("InstitutionName"): return templates.institution_info(metadata) else: return ""
[docs] def mri_scanner_info(files: list[BIDSFile]): first_file = files[0] metadata = first_file.get_metadata() return templates.mri_scanner_info(metadata)
[docs] def common_mri_desc( img: None | nib.Nifti1Image, metadata: dict[str, Any], config: dict[str, dict[str, str]], ) -> dict[str, Any]: """Extract common MRI parameters from metadata.""" nb_slices = "UNKNOWN" if "SliceTiming" in metadata: nb_slices = str(len(metadata["SliceTiming"])) if img is not None and not nb_slices: nb_slices = str(img.shape[2]) tr = "UNKNOWN" if "RepetitionTime" in metadata: tr = metadata["RepetitionTime"] * 1000 return { **metadata, "tr": tr, "fov": parameters.field_of_view(img), "matrix_size": parameters.matrix_size(img), "voxel_size": parameters.voxel_size(img), "variants": parameters.variants(metadata, config), "seqs": parameters.sequence(metadata, config), "nb_slices": nb_slices, }
[docs] def func_info(files: list[BIDSFile], config: dict[str, dict[str, str]], layout: BIDSLayout) -> str: """Generate a paragraph describing T2*-weighted functional scans. Parameters ---------- files : :obj:`list` of :obj:`bids.layout.models.BIDSFile` List of nifti files in layout corresponding to DWI scan. config : :obj:`dict` A dictionary with relevant information regarding sequences, sequence variants, phase encoding directions, and task names. layout : :obj:`bids.layout.BIDSLayout` Layout object for a BIDS dataset. Returns ------- desc : :obj:`str` A description of the scan's acquisition information. """ errored_files = [] first_file = files[0] metadata = first_file.get_metadata() img = try_load_nii(first_file.path) if img is None: errored_files.append(Path(first_file.path).relative_to(layout.root)) all_imgs = [] for f in files: img = try_load_nii(f) if img is None: errored_files.append(Path(f).relative_to(layout.root)) else: all_imgs.append(img) if errored_files: files_not_found_warning(list(set(errored_files))) task_name = first_file.get_entities()["task"] all_runs = sorted({f.get_entities().get("run", 1) for f in files}) nb_vols = "UNKNOWN" duration = "UNKNOWN" if all_imgs: nb_vols = parameters.nb_vols(all_imgs) duration = parameters.duration(all_imgs, metadata) desc_data = { **common_mri_desc(img, metadata, config), "echo_time": parameters.echo_time_ms(files), "slice_order": parameters.slice_order(metadata), "nb_runs": parameters.nb_runs(all_runs), "task_name": metadata.get("TaskName", task_name), "multi_echo": parameters.multi_echo(files), "nb_vols": nb_vols, "duration": duration, "scan_type": first_file.get_entities()["suffix"].replace("w", "-weighted"), } return templates.func_info(desc_data)
[docs] def anat_info(files: list[BIDSFile], config: dict[str, dict[str, str]], layout: BIDSLayout) -> str: """Generate a paragraph describing T1- and T2-weighted structural scans. Parameters ---------- files : :obj:`list` of :obj:`bids.layout.models.BIDSFile` List of nifti files in layout corresponding to DWI scan. config : :obj:`dict` A dictionary with relevant information regarding sequences, sequence variants, phase encoding directions, and task names. layout : :obj:`bids.layout.BIDSLayout` Layout object for a BIDS dataset. Returns ------- desc : :obj:`str` A description of the scan's acquisition information. """ first_file = files[0] metadata = first_file.get_metadata() img = try_load_nii(first_file.path) if img is None: files_not_found_warning(Path(first_file.path).relative_to(layout.root)) all_runs = sorted({f.get_entities().get("run", 1) for f in files}) desc_data = { **common_mri_desc(img, metadata, config), "echo_time": parameters.echo_time_ms(files), "slice_order": parameters.slice_order(metadata), "nb_runs": parameters.nb_runs(all_runs), "multi_echo": parameters.multi_echo(files), } return templates.anat_info(desc_data)
[docs] def dwi_info(files: list[BIDSFile], config: dict[str, dict[str, str]], layout: BIDSLayout) -> str: """Generate a paragraph describing DWI scan acquisition information. Parameters ---------- files : :obj:`list` of :obj:`bids.layout.models.BIDSFile` List of nifti files in layout corresponding to DWI scan. config : :obj:`dict` A dictionary with relevant information regarding sequences, sequence variants, phase encoding directions, and task names. layout : :obj:`bids.layout.BIDSLayout` Layout object for a BIDS dataset. Returns ------- desc : :obj:`str` A description of the DWI scan's acquisition information. """ first_file = files[0] metadata = first_file.get_metadata() img = try_load_nii(first_file.path) if img is None: files_not_found_warning(Path(first_file.path).relative_to(layout.root)) bval_file = first_file.path.replace(".nii.gz", ".bval").replace(".nii", ".bval") all_runs = sorted({f.get_entities().get("run", 1) for f in files}) dmri_dir = "UNKNOWN" if img is not None: dmri_dir = img.shape[3] desc_data = { **common_mri_desc(img, metadata, config), "echo_time": parameters.echo_time_ms(files), "nb_runs": parameters.nb_runs(all_runs), "bvals": parameters.bvals(bval_file), "dmri_dir": dmri_dir, } return templates.dwi_info(desc_data)
[docs] def fmap_info(files: list[BIDSFile], config: dict[str, dict[str, str]], layout: BIDSLayout) -> str: """Generate a paragraph describing field map acquisition information. Parameters ---------- files : :obj:`list` of :obj:`bids.layout.models.BIDSFile` List of nifti files in layout corresponding to field map scan. config : :obj:`dict` A dictionary with relevant information regarding sequences, sequence variants, phase encoding directions, and task names. layout : :obj:`bids.layout.BIDSLayout` Layout object for a BIDS dataset. Returns ------- desc : :obj:`str` A description of the field map's acquisition information. """ first_file = files[0] metadata = first_file.get_metadata() img = try_load_nii(first_file.path) if img is None: files_not_found_warning(Path(first_file.path).relative_to(layout.root)) direction = "UNKNOWN PHASE ENCODING" if PhaseEncodingDirection := metadata.get("PhaseEncodingDirection"): direction = config["dir"].get(PhaseEncodingDirection, "UNKNOWN PHASE ENCODING") desc_data = { **common_mri_desc(img, metadata, config), "te_1": parameters.echo_times_fmap(files)[0], "te_2": parameters.echo_times_fmap(files)[1], "slice_order": parameters.slice_order(metadata), "dir": direction, "intended_for": parameters.intendedfor_targets(metadata, layout), } return templates.fmap_info(desc_data)
[docs] def perf_info(files: list[BIDSFile], config: dict[str, dict[str, str]], layout: BIDSLayout) -> str: first_file = files[0] metadata = first_file.get_metadata() img = try_load_nii(first_file.path) if img is None: files_not_found_warning(Path(first_file.path).relative_to(layout.root)) all_runs = sorted({f.get_entities().get("run", 1) for f in files}) desc_data = { **common_mri_desc(img, metadata, config), "echo_time": parameters.echo_time_ms(files), "nb_runs": parameters.nb_runs(all_runs), } return templates.perf_info(desc_data)
[docs] def pet_info(files: list[BIDSFile], layout: BIDSLayout) -> str: first_file = files[0] metadata = first_file.get_metadata() img = try_load_nii(first_file.path) if img is None: files_not_found_warning(Path(first_file.path).relative_to(layout.root)) all_runs = sorted({f.get_entities().get("run", 1) for f in files}) desc_data = { **metadata, "fov": parameters.field_of_view(img), "matrix_size": parameters.matrix_size(img), "voxel_size": parameters.voxel_size(img), "nb_runs": parameters.nb_runs(all_runs), } return templates.pet_info(desc_data)
[docs] def meg_info(files: list[BIDSFile]) -> str: """Generate a paragraph describing meg acquisition information. Parameters ---------- files : :obj:`list` of :obj:`bids.layout.models.BIDSFile` List of nifti files in layout corresponding to meg scan. config : :obj:`dict` A dictionary with relevant information regarding sequences, sequence variants, phase encoding directions, and task names. Returns ------- desc : :obj:`str` A description of the field map's acquisition information. """ first_file = files[0] metadata = first_file.get_metadata() return templates.meg_info(metadata)
[docs] def final_paragraph(metadata: dict[str, Any]) -> str: """Describe dicom-to-nifti conversion process and methods generation. Parameters ---------- metadata : :obj:`dict` The metadata for the scan. Returns ------- desc : :obj:`str` Output string with scanner information. """ if "ConversionSoftware" in metadata: soft = metadata["ConversionSoftware"] vers = metadata["ConversionSoftwareVersion"] software_str = f" using {soft} ({vers})" else: software_str = "" return f"Dicoms were converted to NIfTI-1 format{software_str}."
[docs] def parse_files( layout: BIDSLayout, data_files: list[BIDSFile], config: dict[str, dict[str, str]] ) -> list[str]: """Loop through files in a BIDSLayout and generate appropriate descriptions. Then, compile all of the descriptions into a list. Parameters ---------- layout : :obj:`bids.layout.BIDSLayout` Layout object for a BIDS dataset. data_files : :obj:`list` of :obj:`bids.layout.models.BIDSFile` List of nifti files in layout corresponding to subject/session combo. config : :obj:`dict` Configuration info for methods generation. """ # Group files into individual runs data_files = collect_associated_files(layout, data_files, extra_entities=["run"]) # Will only get institution from the first file. # This assumes that ALL files from ALL datatypes # were acquired in the same institution. description_list = [institution_info(data_files[0])] # %% MRI mri_datatypes = ["anat", "func", "fmap", "perf", "dwi"] mri_scanner_info_done = False for group in data_files: if group[0].entities["datatype"] not in mri_datatypes: continue # assume all MRI data was acquires on the same scanner if not mri_scanner_info_done: description_list.append(mri_scanner_info(group)) mri_scanner_info_done = True group_description = "" if group[0].entities["datatype"] == "func": group_description = func_info(group, config, layout) elif (group[0].entities["datatype"] == "anat") and group[0].entities["suffix"] in ( "T1w", "T2w", "PDw", "T2starw", "FLAIR", "inplaneT1", "inplaneT2", "PDT2", "angio", ): group_description = anat_info(group, config, layout) elif group[0].entities["datatype"] == "dwi": group_description = dwi_info(group, config, layout) elif group[0].entities["datatype"] == "perf": group_description = perf_info(group, config, layout) elif (group[0].entities["datatype"] == "fmap") and group[0].entities[ "suffix" ] == "phasediff": group_description = fmap_info(group, config, layout) description_list.append(group_description) # %% other for group in data_files: if group[0].entities["datatype"] in mri_datatypes: continue group_description = "" if group[0].entities["datatype"] == [ "eeg", "meg", "ieeg", ]: group_description = meg_info(group, config, layout) if group[0].entities["datatype"] == "pet": group_description = pet_info(group, layout) if group[0].entities["datatype"] in [ "beh", "fnirs", "microscopy", "motion", ]: LOGGER.warning(f" '{group[0].entities['datatype']}' not yet supported.") else: LOGGER.warning(f" '{group[0].filename}' not yet supported.") description_list.append(group_description) return description_list
[docs] def try_load_nii(file: BIDSFile) -> None | nib.Nifti1Image: """Try to load a nifti file, return None if it fails.""" try: img = nib.load(file) except (FileNotFoundError, ImageFileError): img = None return img
[docs] def files_not_found_warning(files: list[BIDSFile] | BIDSFile) -> None: """Warn user that files were not found or empty.""" if not isinstance(files, list): files = [files] files = [str(Path(file)) for file in files] LOGGER.warning(f"File not found or empty:\n {files}")