"""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_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_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_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_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]
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