diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..4fd9d05 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,90 @@ +######################################### +Using patch-denoise from the command line +######################################### + +``patch-denoise`` minimally requires a path to a NIfTI file, +but it can take advantage of reconstructed phase data and/or noise volumes. + +.. argparse:: + :ref: patch_denoise.bindings.cli._get_parser + :prog: patch-denoise + :func: _get_parser + + +================================ +Using patch-denoise on BIDS data +================================ + +.. warning:: + These examples assume that the phase data are in radians. + If they are in arbitrary units, + you will need to rescale them before running patch-denoise. + + +Magnitude only +============== + +.. code-block:: bash + + patch-denoise \ + sub-01/func/sub-01_task-rest_part-mag_bold.nii.gz \ + sub-01_task-rest_part-mag_desc-denoised_bold.nii.gz \ + --mask auto \ + --method optimal-fro \ + --patch-shape 11 \ + --patch-overlap 5 \ + --recombination weighted \ + --mask-threshold 1 + + +Magnitude with noise volumes +============================ + +.. code-block:: bash + + patch-denoise \ + sub-01/func/sub-01_task-rest_part-mag_bold.nii.gz \ + sub-01_task-rest_part-mag_desc-denoised_bold.nii.gz \ + --noise-map sub-01/func/sub-01_task-rest_part-mag_noRF.nii.gz \ + --mask auto \ + --method optimal-fro \ + --patch-shape 11 \ + --patch-overlap 5 \ + --recombination weighted \ + --mask-threshold 1 + + +Magnitude and phase +=================== + +.. code-block:: bash + + patch-denoise \ + sub-01/func/sub-01_task-rest_part-mag_bold.nii.gz \ + sub-01_task-rest_part-mag_desc-denoised_bold.nii.gz \ + --input-phase sub-01/func/sub-01_task-rest_part-phase_bold.nii.gz \ + --mask auto \ + --method optimal-fro \ + --patch-shape 11 \ + --patch-overlap 5 \ + --recombination weighted \ + --mask-threshold 1 + + +Magnitude and phase with noise volumes +====================================== + +.. code-block:: bash + + patch-denoise \ + sub-01/func/sub-01_task-rest_part-mag_bold.nii.gz \ + sub-01_task-rest_part-mag_desc-denoised_bold.nii.gz \ + --input-phase sub-01/func/sub-01_task-rest_part-phase_bold.nii.gz \ + --noise-map sub-01/func/sub-01_task-rest_part-mag_noRF.nii.gz \ + --noise-map-phase sub-01/func/sub-01_task-rest_part-phase_noRF.nii.gz \ + --mask auto \ + --method optimal-fro \ + --patch-shape 11 \ + --patch-overlap 5 \ + --recombination weighted \ + --mask-threshold 1 diff --git a/src/patch_denoise/bindings/cli.py b/src/patch_denoise/bindings/cli.py index 1ae23ed..b241df5 100644 --- a/src/patch_denoise/bindings/cli.py +++ b/src/patch_denoise/bindings/cli.py @@ -2,8 +2,9 @@ """Cli interface.""" import argparse -from pathlib import Path import logging +from functools import partial +from pathlib import Path import numpy as np @@ -15,104 +16,257 @@ save_array, load_complex_nifti, ) +from patch_denoise import __version__ DENOISER_NAMES = ", ".join(d for d in DENOISER_MAP if d) -def parse_args(): - """Parse input arguments.""" - parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument("input_file", help="Input (noisy) file.", type=Path) +def _path_exists(path, parser): + """Ensure a given path exists.""" + if path is None or not Path(path).exists(): + raise parser.error(f"Path does not exist: <{path}>.") + return Path(path).absolute() + + +def _is_file(path, parser): + """Ensure a given path exists and it is a file.""" + path = _path_exists(path, parser) + if not path.is_file(): + raise parser.error( + f"Path should point to a file (or symlink of file): <{path}>." + ) + return path + + +def _positive_int(string, is_parser=True): + """Check if argument is an integer >= 0.""" + error = argparse.ArgumentTypeError if is_parser else ValueError + try: + intarg = int(string) + except ValueError: + msg = "Argument must be a nonnegative integer." + raise error(msg) from None + + if intarg < 0: + raise error("Int argument must be nonnegative.") + return intarg + + +class ToDict(argparse.Action): + """A custom argparse "store" action to handle a list of key=value pairs.""" + + def __call__(self, parser, namespace, values, option_string=None): # noqa: U100 + """Call the argument.""" + d = {} + for spec in values: + try: + key, value = spec.split("=") + except ValueError: + raise ValueError( + "Extra arguments must be in the form key=value." + ) from None + + # Convert any float-like values to float + try: + value = float(value) + except ValueError: + pass + + d[key] = value + setattr(namespace, self.dest, d) + + +def _get_parser(): + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + IsFile = partial(_is_file, parser=parser) + PositiveInt = partial(_positive_int, is_parser=True) + + parser.add_argument( + "input_file", + help="Input (noisy) file.", + type=IsFile, + ) parser.add_argument( "output_file", nargs="?", default=None, type=Path, - help=("Output (denoised) file.\n" "default is D"), + help=("Output (denoised) file.\nDefault is D."), ) - parser.add_argument( + parser.add_argument("--version", action="version", version=__version__) + + denoising_group = parser.add_argument_group("Denoising parameters") + + conf_vs_separate = denoising_group.add_mutually_exclusive_group(required=True) + conf_vs_separate.add_argument( + "--method", + help=( + "Denoising method.\n" + f"Available denoising methods:\n {DENOISER_NAMES}.\n" + "This parameter is mutually exclusive with --conf." + ), + choices=DENOISER_MAP, + default="optimal-fro", + ) + + denoising_group.add_argument( + "--patch-shape", + help=( + "Patch shape.\n" + "If int, this is the size of the patch in each dimension.\n" + "If not specified, the default value is used.\n" + "Note: setting a low aspect ratio will increase the number of " + "patches to be processed, " + "and will increase memory usage and computation times.\n" + "This parameter should be used in conjunction with --method and " + "is mutually exclusive with --conf." + ), + default=11, + type=PositiveInt, + metavar="INT", + ) + denoising_group.add_argument( + "--patch-overlap", + help=( + "Patch overlap.\n" + "If int, this is the size of the overlap in each dimension.\n" + "If not specified, the default value is used.\n" + "Note: setting a low overlap will increase the number of patches " + "to be processed, " + "and will increase memory usage and computation times.\n" + "This parameter should be used in conjunction with --method and " + "is mutually exclusive with --conf." + ), + default=5, + type=PositiveInt, + metavar="INT", + ) + denoising_group.add_argument( + "--recombination", + help=( + "Recombination method.\n" + "If 'mean', the mean of the overlapping patches is used.\n" + "If 'weighted', the weighted mean of the overlapping patches is used.\n" + "This parameter should be used in conjunction with --method and " + "is mutually exclusive with --conf." + ), + default="weighted", + choices=["mean", "weighted"], + ) + denoising_group.add_argument( + "--mask-threshold", + help=( + "Mask threshold.\n" + "If int, this is the threshold for the mask.\n" + "If not specified, the default value is used.\n" + "This parameter should be used in conjunction with --method and " + "is mutually exclusive with --conf." + ), + default=10, + type=int, + metavar="INT", + ) + conf_vs_separate.add_argument( "--conf", help=( "Denoising configuration.\n" - "Format should be ___.\n" + "Format should be " + "____.\n" "See Documentation of 'DenoiseParameter.from_str' for full specification.\n" - "Default: optimal-fro_11_5_weighted\n" - "Available denoising methods:\n " + DENOISER_NAMES + f"Available denoising methods:\n {DENOISER_NAMES}.\n" + "This parameter is mutually exclusive with --method." ), - default="optimal-fro_11_5_weighted", + default=None, ) - parser.add_argument( - "--time-slice", - help=( - "Slice across time. \n" - "If x the patch will be N times longer in space than in time \n" - "If int, this is the size of the time dimension patch. \n" - "If not specified, the whole time serie is used. \n" - "Note: setting a low aspect ratio will increase the number of patch to be" - "processed, and will increase memory usage and computation times." - ), + denoising_group.add_argument( + "--extra", + metavar="key=value", default=None, - type=str, + nargs="+", + help="extra key=value arguments for denoising methods.", + action=ToDict, ) - parser.add_argument( + + data_group = parser.add_argument_group("Additional input data") + data_group.add_argument( "--mask", + metavar="FILE|auto", default=None, help=( "mask file, if auto or not provided" " it would be determined from the average image." ), ) - parser.add_argument( + data_group.add_argument( "--noise-map", + metavar="FILE", default=None, + type=IsFile, help="noise map estimation file", ) - parser.add_argument( - "--output-noise-map", + data_group.add_argument( + "--input-phase", + metavar="FILE", default=None, - help="output noise map estimation file", + type=IsFile, + help=( + "Phase of the input data. This MUST be in radians. " + "No conversion would be applied." + ), ) - parser.add_argument( - "--extra", + + misc_group = parser.add_argument_group("Miscellaneous options") + misc_group.add_argument( + "--time-slice", + help=( + "Slice across time. \n" + "If x the patch will be N times longer in space than in time \n" + "If int, this is the size of the time dimension patch. \n" + "If not specified, the whole time series is used. \n" + "Note: setting a low aspect ratio will increase the number of patch to be" + "processed, and will increase memory usage and computation times." + ), default=None, - nargs="*", - help="extra key=value arguments for denoising methods.", + type=str, ) - parser.add_argument( + misc_group.add_argument( + "--output-noise-map", + metavar="FILE", + default=None, + type=Path, + help="Output name for calculated noise map", + ) + misc_group.add_argument( "--nan-to-num", + metavar="VALUE", default=None, type=float, help="Replace NaN by the provided value.", ) - parser.add_argument( - "--input-phase", - default=None, - type=Path, - help=( - "Phase of the input data. This MUST be in radian. " - "No conversion would be applied." - ), + misc_group.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase verbosity level. You can provide multiple times (e.g., -vvv).", ) - parser.add_argument("-v", "--verbose", action="count", default=0) + return parser + +def parse_args(): + """Parse input arguments.""" + parser = _get_parser() args = parser.parse_args() # default value for output. if args.output_file is None: args.output_file = args.input_file.with_stem("D" + args.input_file.stem) - if args.extra: - key_values = [kv.split("=") for kv in args.extra] - args.extra = {} - for k, v in key_values: - try: - v = float(v) - except ValueError: - pass - args.extra[k] = v - else: + if not args.extra: args.extra = {} levels = [logging.WARNING, logging.INFO, logging.DEBUG] @@ -130,6 +284,8 @@ def main(): input_data, affine = load_complex_nifti(args.input_file, args.input_phase) input_data, affine = load_as_array(args.input_file) + kwargs = args.extra + if args.nan_to_num is not None: input_data = np.nan_to_num(input_data, nan=args.nan_to_num) n_nans = np.isnan(input_data).sum() @@ -157,37 +313,44 @@ def main(): "Affine matrix of input and noise map does not match", stacklevel=2 ) - d_par = DenoiseParameters.from_str(args.conf) + # Parse configuration string instead of defining each parameter separately + if args.conf is not None: + d_par = DenoiseParameters.from_str(args.conf) + args.method = d_par.method + args.patch_shape = d_par.patch_shape + args.patch_overlap = d_par.patch_overlap + args.recombination = d_par.recombination + args.mask_threshold = d_par.mask_threshold + if isinstance(args.time_slice, str): if args.time_slice.endswith("x"): t = float(args.time_slice[:-1]) - t = int(d_par ** (input_data.ndim - 1) / t) + t = int(args.patch_shape ** (input_data.ndim - 1) / t) else: t = int(args.time_slice) - d_par.patch_shape = (d_par.patch_shape,) * (input_data.ndim - 1) + (t,) - print(d_par) - denoise_func = DENOISER_MAP[d_par.method] - extra_kwargs = dict() - if d_par.method in [ + args.patch_shape = (args.patch_shape,) * (input_data.ndim - 1) + (t,) + + denoise_func = DENOISER_MAP[args.method] + + if args.method in [ "nordic", "hybrid-pca", "adaptive-qut", "optimal-fro-noise", ]: - extra_kwargs["noise_std"] = noise_map + kwargs["noise_std"] = noise_map if noise_map is None: raise RuntimeError("A noise map must be specified for this method.") denoised_data, patchs_weight, noise_std_map, rank_map = denoise_func( input_data, - patch_shape=d_par.patch_shape, - patch_overlap=d_par.patch_overlap, + patch_shape=args.patch_shape, + patch_overlap=args.patch_overlap, mask=mask, - mask_threshold=d_par.mask_threshold, - recombination=d_par.recombination, - **extra_kwargs, - **args.extra, + mask_threshold=args.mask_threshold, + recombination=args.recombination, + **kwargs, ) save_array(denoised_data, affine, args.output_file)