Source code for tiatoolbox.cli.common

"""Define common code required for cli."""

from __future__ import annotations

import json
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeVar

import click

if TYPE_CHECKING:  # pragma: no cover
    from tiatoolbox.models.engine.io_config import ModelIOConfigABC
    from tiatoolbox.type_hints import IntPair

F = TypeVar("F", bound=Callable[..., Any])


[docs] def add_default_to_usage_help( usage_help: str, *, default: str | float | bool | None, ) -> str: """Adds default value to usage help string. Args: usage_help (str): usage help for click option. default (str or int or float): default value as string for click option. Returns: str: New usage_help value. """ if default is not None: return f"{usage_help} default={default}" return usage_help
[docs] def cli_img_input( usage_help: str = "Path to WSI or directory containing WSIs.", *, multiple: bool | None = None, ) -> Callable: """Enables --img-input option for cli.""" if multiple is None: multiple = False if multiple: usage_help = usage_help + " Multiple instances may be provided." return click.option("--img-input", help=usage_help, type=str, multiple=multiple)
[docs] def cli_name( usage_help: str = "User defined name to be used as an identifier.", *, multiple: bool | None = None, ) -> Callable: """Enable --name option for cli.""" if multiple is None: multiple = False if multiple: usage_help = usage_help + " Multiple instances may be provided." return click.option("--name", help=usage_help, type=str, multiple=multiple)
[docs] def cli_output_path( usage_help: str = "Path to output directory to save the output.", default: str | None = None, ) -> Callable: """Enables --output-path option for cli.""" return click.option( "--output-path", help=add_default_to_usage_help(usage_help, default=default), type=str, default=default, )
[docs] def cli_output_file( usage_help: str = "Filename for saving output (e.g., '.zarr' or '.db').", default: str | None = None, ) -> Callable: """Enables --output-file option for cli.""" return click.option( "--output-file", help=add_default_to_usage_help(usage_help, default=default), type=str, default=default, )
[docs] def cli_class_dict( usage_help: str = ( "Mapping of classification outputs to class names. " 'Example: --class-dict \'{"1": "tumour", "2": "normal"}\'' ), default: dict | None = None, ) -> Callable: """Enables --class-dict option for CLI. The functions parse JSON into a dict with int keys if possible. """ def _parse_json( _ctx: click.Context, _param: click.Parameter, value: str | None ) -> dict[int | str, Any] | None: if value is None: return default try: parsed = json.loads(value) return { int(k) if isinstance(k, str) and k.isdigit() else k: v for k, v in parsed.items() } except json.JSONDecodeError as e: msg = f"Invalid JSON: {e}" raise click.BadParameter(msg) from e return click.option( "--class-dict", help=usage_help, callback=_parse_json, default=default, )
[docs] def cli_input_resolutions( usage_help: str = ( "Resolution settings for input heads. " 'Example: --input-resolutions \'[{"units": "mpp", "resolution": 0.25}]\'' ), default: list[dict[str, Any]] | None = None, ) -> Callable[[F], F]: """Click option for --input-resolutions. Parses a JSON list of dictionaries specifying resolution settings for input heads. Supported units are 'level', 'power', and 'mpp'. Example: --input-resolutions '[{"units": "mpp", "resolution": 0.25}]' """ def _parse_json( _ctx: click.Context, _param: click.Parameter, value: str | None, ) -> list[dict[str, Any]] | None: if value is None: return default try: parsed = json.loads(value) if not isinstance(parsed, list): msg = "Must be a JSON list of dictionaries" raise click.BadParameter(msg) return parsed # noqa: TRY300 except json.JSONDecodeError as e: msg = f"Invalid JSON: {e}" raise click.BadParameter(msg) from e return click.option( "--input-resolutions", help=usage_help, callback=_parse_json, default=default, )
[docs] def cli_output_resolutions( usage_help: str = ( "Resolution used for writing output predictions. " 'Example: --output-resolutions \'[{"units": "mpp", "resolution": 0.25}]\'' ), default: list[dict[str, Any]] | None = None, ) -> Callable[[F], F]: """Click option for --output-resolutions. Parses a JSON list of dictionaries specifying resolution settings for input heads. Supported units are 'level', 'power', and 'mpp'. Example: --output-resolutions '[{"units": "mpp", "resolution": 0.25}]' """ def _parse_json( _ctx: click.Context, _param: click.Parameter, value: str | None, ) -> list[dict[str, Any]] | None: if value is None: return default try: parsed = json.loads(value) if not isinstance(parsed, list): msg = "Must be a JSON list of dictionaries" raise click.BadParameter(msg) return parsed # noqa: TRY300 except json.JSONDecodeError as e: msg = f"Invalid JSON: {e}" raise click.BadParameter(msg) from e return click.option( "--output-resolutions", help=usage_help, callback=_parse_json, default=default, )
[docs] def cli_file_type( usage_help: str = "File types to capture from directory.", default: str = "*.ndpi, *.svs, *.mrxs, *.jp2", ) -> Callable: """Enables --file-types option for cli.""" return click.option( "--file-types", help=add_default_to_usage_help(usage_help, default=default), default=default, type=str, )
[docs] def cli_output_type( usage_help: str = "The format of the output type. " "'output_type' can be 'zarr' or 'AnnotationStore'. " "Default value is 'AnnotationStore'.", default: str = "AnnotationStore", input_type: click.Choice | None = None, ) -> Callable: """Enables --file-types option for cli.""" click_choices = click.Choice( choices=["zarr", "AnnotationStore"], case_sensitive=False ) input_type = click_choices if input_type is None else input_type return click.option( "--output-type", help=add_default_to_usage_help(usage_help, default=default), default=default, type=input_type, )
[docs] def cli_mode( usage_help: str = "Selected mode to show or save the required information.", default: str = "save", input_type: click.Choice | None = None, ) -> Callable: """Enables --mode option for cli.""" if input_type is None: input_type = click.Choice(["show", "save"], case_sensitive=False) return click.option( "--mode", help=add_default_to_usage_help(usage_help, default=default), default=default, type=input_type, )
[docs] def cli_patch_mode( usage_help: str = "Whether to run the model in patch mode or WSI mode.", *, default: bool = False, ) -> Callable: """Enables --return-probabilities option for cli.""" return click.option( "--patch-mode", type=bool, help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_region( usage_help: str = "Image region in the whole slide image to read from. " "default=0 0 2000 2000", ) -> Callable: """Enables --region option for cli.""" return click.option( "--region", type=int, nargs=4, help=usage_help, )
[docs] def cli_patch_input_shape( usage_help: str = "Shape of input patches (height, width). Patches are at " "requested read resolution, not with respect to level 0," "and must be positive. default=None", default: IntPair | None = None, ) -> Callable: """Enables --patch-input-shape option for cli.""" return click.option( "--patch-input-shape", type=int, default=default, nargs=2, help=usage_help, )
[docs] def cli_patch_output_shape( usage_help: str = "Shape of output patches (height, width). default=None", default: IntPair | None = None, ) -> Callable: """Enables --patch-output-shape option for cli.""" return click.option( "--patch-output-shape", type=int, default=default, nargs=2, help=usage_help, )
[docs] def cli_stride_shape( usage_help: str = "Stride used during patch extraction. Stride is" "at requested read resolution, not with respect to" "level 0, and must be positive. If stride_shape is None" "patch_input_shape is used for stride. If not provided," "`stride_shape=None`", default: IntPair | None = None, ) -> Callable: """Enables --stride-shape option for cli.""" return click.option( "--stride-shape", type=int, default=default, nargs=2, help=usage_help, )
[docs] def cli_scale_factor( usage_help: str = "Scale factor for annotations (model_mpp / slide_mpp)." "Used to convert coordinates to baseline resolution.", default: tuple[float, float] | None = None, ) -> Callable: """Enables --scale-factor option for cli.""" return click.option( "--scale-factor", type=float, default=default, nargs=2, help=usage_help, )
[docs] def cli_units( usage_help: str = "Image resolution units to read the image.", default: str = "level", input_type: click.Choice | None = None, ) -> Callable: """Enables --units option for cli.""" if input_type is None: input_type = click.Choice( ["mpp", "power", "level", "baseline"], case_sensitive=False, ) return click.option( "--units", default=default, type=input_type, help=add_default_to_usage_help(usage_help, default=default), )
[docs] def cli_resolution( usage_help: str = "Image resolution to read the image.", default: float = 0, ) -> Callable: """Enables --resolution option for cli.""" return click.option( "--resolution", type=float, default=default, help=add_default_to_usage_help(usage_help, default=default), )
[docs] def cli_tile_objective( usage_help: str = "Objective value for the saved tiles.", default: int = 20, ) -> Callable: """Enables --tile-objective-value option for cli.""" return click.option( "--tile-objective-value", type=int, default=default, help=add_default_to_usage_help(usage_help, default=default), )
[docs] def cli_tile_read_size( usage_help: str = "Width and Height of saved tiles. default=5000 5000", ) -> Callable: """Enables --tile-read-size option for cli.""" return click.option( "--tile-read-size", type=int, nargs=2, default=[5000, 5000], help=usage_help, )
[docs] def cli_tile_format( usage_help: str = "File format to save image tiles, defaults = '.jpg'", ) -> Callable: """Enables --tile-format option for cli.""" return click.option( "--tile-format", type=str, default=".jpg", help=usage_help, )
[docs] def cli_method( usage_help: str = "Select method of for tissue masking.", default: str = "Otsu", input_type: click.Choice | None = None, ) -> Callable: """Enables --method option for cli.""" if input_type is None: input_type = click.Choice(["Otsu", "Morphological"], case_sensitive=True) return click.option( "--method", type=input_type, default=default, help=add_default_to_usage_help(usage_help, default=default), )
[docs] def cli_model( usage_help: str = "Name of the predefined model used to process the data. " "The format is <model_name>_<dataset_trained_on>. For example, " "`resnet18-kather100K` is a resnet18 model trained on the Kather dataset. " "Please see " "https://tia-toolbox.readthedocs.io/en/latest/usage.html#deep-learning-models " "for a detailed list of available pretrained models." "By default, the corresponding pretrained weights will also be" "downloaded. However, you can override with your own set of weights" "via the `pretrained_weights` argument. Argument is case insensitive.", default: str = "resnet18-kather100k", ) -> Callable: """Enables --pretrained-model option for cli.""" return click.option( "--model", help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_weights( usage_help: str = "Path to the model weight file. If not supplied, the default " "pretrained weight will be used.", default: str | None = None, ) -> Callable: """Enables --pretrained-weights option for cli.""" return click.option( "--weights", help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_device( usage_help: str = "Select the device (cpu/cuda/mps) to use for inference.", default: str = "cpu", ) -> Callable: """Enables --pretrained-weights option for cli.""" return click.option( "--device", help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_return_probabilities( usage_help: str = "Whether to return raw model probabilities.", *, default: bool = False, ) -> Callable: """Enables --return-probabilities option for cli.""" return click.option( "--return-probabilities", type=bool, help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def parse_bool_list( _ctx: click.Context, _param: click.Parameter, value: str | None, ) -> tuple[bool, ...] | None: """Parse a comma-separated list of boolean values for a Click option. This function is intended for use as a Click callback. It converts a comma-separated string (e.g., ``"true,false,1,0"``) into a tuple of Python booleans. Each item is stripped, lowercased, and validated against a set of accepted truthy and falsy representations. Accepted truthy values: ``"true"``, ``"1"``, ``"yes"``, ``"y"`` Accepted falsy values: ``"false"``, ``"0"``, ``"no"``, ``"n"`` Args: ctx (click.Context): The Click context object (unused but required by Click callback API). param (click.Parameter): The Click parameter object (unused but required by Click callback API). value (str | None): The raw string provided by the user. If ``None``, the function returns ``None`` unchanged. Returns: tuple[bool, ...] | None: A tuple of parsed boolean values, or ``None`` if no value was provided. Raises: click.BadParameter: If any item in the comma-separated list is not a valid boolean string. """ if value is None: return None items = value.split(",") out = [] for item in items: item_ = item.strip().lower() if item_ in ("true", "1", "yes", "y"): out.append(True) elif item_ in ("false", "0", "no", "n"): out.append(False) else: msg = f"Invalid boolean: {item_}" raise click.BadParameter(msg) return tuple(out)
[docs] def cli_return_predictions( usage_help: str = "Whether to return predictions for individual tasks.", *, default: tuple[bool, ...] | None = None, ) -> Callable: """Enables --return-predictions option for cli.""" return click.option( "--return-predictions", callback=parse_bool_list, help=add_default_to_usage_help(usage_help, default=None), default=default, )
[docs] def cli_batch_size( usage_help: str = "Number of image patches to feed into the model each time.", default: int = 1, ) -> Callable: """Enables --batch-size option for cli.""" return click.option( "--batch-size", help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_masks( usage_help: str = "Path to the input directory containing masks to process " "corresponding to image tiles and whole-slide images. " "Patches are only processed if they are within a masked area. " "If masks are not provided, then a tissue mask will be " "automatically generated for whole-slide images or the entire image is " "processed for image tiles. Supported file types are jpg, png and npy.", default: str | None = None, ) -> Callable: """Enables --masks option for cli.""" return click.option( "--masks", help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_memory_threshold( usage_help: str = ( "Memory usage threshold (in percentage) to trigger caching behavior." ), default: int = 80, ) -> Callable: """Enables --batch-size option for cli.""" return click.option( "--memory-threshold", help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_auto_get_mask( usage_help: str = "Automatically generate tile/WSI tissue mask.", *, default: bool = False, ) -> Callable: """Enables --auto-generate-mask option for cli.""" return click.option( "--auto-get-mask", help=add_default_to_usage_help(usage_help, default=default), type=bool, default=default, )
[docs] def cli_yaml_config_path( usage_help: str = "Path to ioconfig file. Sample yaml file can be viewed in " "tiatoolbox.data.pretrained_model.yaml. " "if pretrained_model is used the ioconfig is automatically set.", default: str | None = None, ) -> Callable: """Enables --yaml-config-path option for cli.""" return click.option( "--yaml-config-path", help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_min_distance( usage_help: str = "Minimum distance separating two nuclei (in pixels).", default: int | None = None, ) -> Callable: """Enables --min_distance option for cli.""" return click.option( "--min_distance", type=int, help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_threshold_abs( usage_help: str = "Absolute detection threshold applied to model outputs.", default: float | None = None, ) -> Callable: """Enables --threshold_abs option for cli.""" return click.option( "--threshold_abs", type=float, help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_threshold_rel( usage_help: str = "Relative detection threshold" " (e.g., with respect to local maxima).", default: float | None = None, ) -> Callable: """Enables --threshold_rel option for cli.""" return click.option( "--threshold_rel", type=float, help=add_default_to_usage_help(usage_help, default=default), default=default, )
[docs] def cli_postproc_tile_shape( usage_help: str = " Tile shape (height, width) used during post-processing " "(in pixels) to control rechunking behavior.", default: IntPair | None = None, ) -> Callable: """Enables --postproc_tile_shape option for cli.""" return click.option( "--postproc_tile_shape", type=int, default=default, nargs=2, help=usage_help, )
[docs] def cli_num_workers( usage_help: str = "Number of workers to load the data. Please note that they will " "also perform preprocessing.", default: int = 0, ) -> Callable: """Enables --num-loader-workers option for cli.""" return click.option( "--num-workers", help=add_default_to_usage_help(usage_help, default=default), type=int, default=default, )
[docs] def cli_verbose( usage_help: str = "Prints the console output.", *, default: bool = True, ) -> Callable: """Enables --verbose option for cli.""" return click.option( "--verbose", type=bool, help=add_default_to_usage_help(usage_help, default=str(default)), default=default, )
[docs] def cli_overwrite( usage_help: str = "Whether to overwrite existing output files. Default is False.", *, default: bool = False, ) -> Callable: """Enables --overwrite option for cli.""" return click.option( "--overwrite", type=bool, help=add_default_to_usage_help(usage_help, default=str(default)), default=default, )
[docs] class TIAToolboxCLI(click.Group): """Define TIAToolbox Commandline Interface Click group.""" def __init__( self: TIAToolboxCLI, *args: tuple[Any, ...], **kwargs: dict[str, Any], ) -> None: """Initialize TIAToolboxCLI.""" super().__init__(*args, **kwargs) # type: ignore[arg-type] self.help = "Computational pathology toolbox by TIA Centre." self.help_option_names = ["-h", "--help"]
[docs] def no_input_message( input_file: str | Path | None = None, message: str = "No image input provided.\n", ) -> Path: """This function is called if no input is provided. Args: input_file (str or Path): Path to input file. message (str): Error message to display. Returns: Path: Return input path as :class:`Path`. """ if input_file is None: ctx = click.get_current_context() return ctx.fail(message=message) return Path(input_file)
[docs] def prepare_file_dir_cli( img_input: str | Path, output_path: str | Path, file_types: str, mode: str, sub_dirname: str, ) -> tuple[list, Path]: """Prepares CLI for running code on multiple files or a directory. Checks for existing directories to run tests. Converts file path to list of file paths or creates list of file paths if input is a directory. Args: img_input (str or Path): File path to images. output_path (str or Path): Output directory path. file_types (str): File types to process using cli. mode (str): wsi or tile mode. sub_dirname (str): Name of subdirectory to save output. Returns: list: list of file paths to process. pathlib.Path: updated output path. """ from tiatoolbox.utils.misc import ( # noqa: PLC0415 grab_files_from_dir, string_to_tuple, ) img_input = no_input_message(input_file=img_input) file_types_tuple = string_to_tuple(in_str=file_types) if isinstance(output_path, str): output_path = Path(output_path) if not Path.exists(img_input): raise FileNotFoundError files_all = [ img_input, ] if Path.is_dir(img_input): files_all = grab_files_from_dir( input_path=img_input, file_types=file_types_tuple ) if output_path is None and mode == "save": input_dir = Path(img_input).parent output_path = input_dir / sub_dirname if mode == "save": output_path.mkdir(parents=True, exist_ok=True) return (files_all, output_path)
[docs] def prepare_model_cli( img_input: str | Path, output_path: str | Path, masks: str | Path, file_types: str, ) -> tuple[list, list | None, Path]: """Prepares cli for running models. Checks for existing directories to run tests. Converts file path to list of file paths or creates list of file paths if input is a directory. Args: img_input (str or Path): File path to images. output_path (str or Path): Output directory path. masks (str or Path): File path to masks. file_types (str): File types to process using cli. Returns: list: List of file paths to process. list: List of masks corresponding to input files. Path: Output path. """ from tiatoolbox.utils.misc import ( # noqa: PLC0415 grab_files_from_dir, string_to_tuple, ) img_input = no_input_message(input_file=img_input) output_path = Path(output_path) file_types_tuple = string_to_tuple(in_str=file_types) if output_path.exists(): msg = "Path already exists." raise FileExistsError(msg) if not Path.exists(img_input): raise FileNotFoundError files_all = [ img_input, ] masks_all = None if masks is not None: masks = Path(masks) if masks.is_file(): masks_all = [masks] if masks.is_dir(): masks_all = grab_files_from_dir( input_path=masks, file_types=("*.jpg", "*.png"), ) if Path.is_dir(img_input): files_all = grab_files_from_dir( input_path=img_input, file_types=file_types_tuple ) return (files_all, masks_all, output_path)
tiatoolbox_cli = TIAToolboxCLI()
[docs] def prepare_ioconfig( config_class: type[ModelIOConfigABC], pretrained_weights: str | Path | None, yaml_config_path: str | Path, ) -> ModelIOConfigABC | None: """Prepare ioconfig for CLI.""" import yaml # noqa: PLC0415 if pretrained_weights is not None: with Path(yaml_config_path).open() as registry_handle: ioconfig = yaml.safe_load(registry_handle) return config_class(**ioconfig) return None