From 8c6a4fa6430e89943315835584f35fcae63cd52e Mon Sep 17 00:00:00 2001 From: Harim Kang Date: Wed, 21 Feb 2024 21:15:48 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20Remove=20pl=20dependency=20from?= =?UTF-8?q?=20Anomalib=20CLI=20&=20Add=20install=20subcommand=20(#1748)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove pl dependency from anomalib CLI Signed-off-by: Kang, Harim * Add CHANGELOG.md Signed-off-by: Kang, Harim * Remove blank in requirement txt Signed-off-by: Kang, Harim * Add Missing arguments Signed-off-by: Kang, Harim * Reflect some comments Signed-off-by: Kang, Harim * Fix verbosity level Signed-off-by: Kang, Harim * Add pytest-mock in tox Signed-off-by: Kang, Harim --------- Signed-off-by: Kang, Harim --- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 1 + CONTRIBUTING.md | 3 +- .../markdown/guides/developer/contributing.md | 3 +- requirements/{base.txt => core.txt} | 6 +- requirements/dev.txt | 1 + requirements/installer.txt | 5 + setup.py | 3 +- src/anomalib/cli/cli.py | 344 ++++++++++---- src/anomalib/cli/install.py | 77 ++++ src/anomalib/cli/utils/help_formatter.py | 118 +++-- src/anomalib/cli/utils/installation.py | 423 ++++++++++++++++++ src/anomalib/cli/utils/openvino.py | 4 +- src/anomalib/loggers/__init__.py | 27 +- tests/integration/cli/test_cli.py | 3 +- tests/unit/cli/test_help_formatter.py | 27 +- tests/unit/cli/test_installation.py | 191 ++++++++ tox.ini | 10 +- 18 files changed, 1041 insertions(+), 207 deletions(-) rename requirements/{base.txt => core.txt} (84%) create mode 100644 requirements/installer.txt create mode 100644 src/anomalib/cli/install.py create mode 100644 src/anomalib/cli/utils/installation.py create mode 100644 tests/unit/cli/test_installation.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b3eb4f738..ff0192aeeb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: rev: "v1.7.0" hooks: - id: mypy - additional_dependencies: [types-PyYAML] + additional_dependencies: [types-PyYAML, types-setuptools] exclude: "tests" # add bandit for security checks diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ac2621a1..36a6e43a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 🔒 Address checkmarx issues. by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/1672 - 📚 Update contribution guidelines by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/1677 - 🔨 Refactor Visualisation by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/1693 +- 🔨 Remove Lightning dependencies from the CLI and Add `anomalib install` subcommand by @harimkang in https://github.com/openvinotoolkit/anomalib/pull/1748 ### Deprecated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1987363098..143aab11b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,8 @@ Set up your development environment to start contributing. This involves install 2. Install the base and development requirements: ```bash - pip install -r requirements/base.txt -r requirements/dev.txt + pip install -r requirements/installer.txt -r requirements/dev.txt + anomalib install -v ``` Optionally, for a full installation with all dependencies: diff --git a/docs/source/markdown/guides/developer/contributing.md b/docs/source/markdown/guides/developer/contributing.md index 1e3538e26d..fdebc380a3 100644 --- a/docs/source/markdown/guides/developer/contributing.md +++ b/docs/source/markdown/guides/developer/contributing.md @@ -25,7 +25,8 @@ Set up your development environment to start contributing. This involves install Install the base and development requirements: ```bash - pip install -r requirements/base.txt -r requirements/dev.txt + pip install -r requirements/installer.txt -r requirements/dev.txt + anomalib install -v ``` Optionally, for a full installation with all dependencies: diff --git a/requirements/base.txt b/requirements/core.txt similarity index 84% rename from requirements/base.txt rename to requirements/core.txt index 4ec0d94cb4..75ece55747 100644 --- a/requirements/base.txt +++ b/requirements/core.txt @@ -3,16 +3,12 @@ av>=10.0.0 einops>=0.3.2 freia>=0.2 imgaug==0.4.0 -jsonargparse[signatures]>=4.3 kornia>=0.6.6,<0.6.10 matplotlib>=3.4.3 -omegaconf>=2.1.1 opencv-python>=4.5.3.56 pandas>=1.1.0 -lightning>2,<2.2.0 # We need to sort out the compatibility with the latest version of Lightning -setuptools>=41.0.0 timm>=0.5.4,<=0.6.13 +lightning>2,<2.2.0 # We need to sort out the compatibility with the latest version of Lightning torch>=2,<2.2.0 # rkde export fails even with ONNX 17 (latest) with torch 2.2.0. TODO(ashwinvaidya17): revisit torchmetrics==0.10.3 -rich-argparse open-clip-torch>=2.23.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index 98b81bbd28..fe108d6b6b 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -3,5 +3,6 @@ pytest pytest-cov pytest-sugar pytest-xdist +pytest-mock coverage[toml] tox diff --git a/requirements/installer.txt b/requirements/installer.txt new file mode 100644 index 0000000000..7da45d8afd --- /dev/null +++ b/requirements/installer.txt @@ -0,0 +1,5 @@ +jsonargparse[signatures]>=4.3 +omegaconf>=2.1.1 +rich>=13.5.2 +setuptools>=41.0.0 +rich-argparse diff --git a/setup.py b/setup.py index 3fe51edd29..01ccfe4878 100644 --- a/setup.py +++ b/setup.py @@ -78,9 +78,10 @@ def get_required_packages(requirement_files: list[str]) -> list[str]: VERSION = get_version() LONG_DESCRIPTION = (Path(__file__).parent / "README.md").read_text(encoding="utf8") -INSTALL_REQUIRES = get_required_packages(requirement_files=["base"]) +INSTALL_REQUIRES = get_required_packages(requirement_files=["installer"]) EXTRAS_REQUIRE = { "loggers": get_required_packages(requirement_files=["loggers"]), + "core": get_required_packages(requirement_files=["core"]), "notebooks": get_required_packages(requirement_files=["notebooks"]), "openvino": get_required_packages(requirement_files=["openvino"]), "full": get_required_packages(requirement_files=["loggers", "notebooks", "openvino"]), diff --git a/src/anomalib/cli/cli.py b/src/anomalib/cli/cli.py index a90fed15b8..9184367c40 100644 --- a/src/anomalib/cli/cli.py +++ b/src/anomalib/cli/cli.py @@ -4,38 +4,43 @@ # SPDX-License-Identifier: Apache-2.0 import logging -from collections.abc import Callable +from collections.abc import Callable, Sequence +from functools import partial from inspect import signature from pathlib import Path +from types import MethodType from typing import Any -import lightning.pytorch as pl -from jsonargparse import ActionConfigFile, Namespace -from lightning.pytorch import Trainer -from lightning.pytorch.cli import ArgsType, LightningArgumentParser, LightningCLI, SaveConfigCallback -from lightning.pytorch.utilities.types import _EVALUATE_OUTPUT, _PREDICT_OUTPUT +from jsonargparse import ActionConfigFile, ArgumentParser, Namespace +from jsonargparse._actions import _ActionSubCommands from rich import traceback -from torch.utils.data import DataLoader, Dataset from anomalib import TaskType, __version__ -from anomalib.callbacks import get_callbacks -from anomalib.callbacks.normalization import get_normalization_callback -from anomalib.cli.utils import CustomHelpFormatter +from anomalib.cli.utils.help_formatter import CustomHelpFormatter, get_short_docstring from anomalib.cli.utils.openvino import add_openvino_export_arguments -from anomalib.data import AnomalibDataModule, AnomalibDataset -from anomalib.data.predict import PredictDataset -from anomalib.engine import Engine from anomalib.loggers import configure_logger -from anomalib.metrics.threshold import BaseThreshold -from anomalib.models import AnomalyModule -from anomalib.utils.config import update_config -from anomalib.utils.visualization.base import BaseVisualizer traceback.install() logger = logging.getLogger("anomalib.cli") +_LIGHTNING_AVAILABLE = True +try: + from lightning.pytorch import Trainer + from torch.utils.data import DataLoader, Dataset -class AnomalibCLI(LightningCLI): + from anomalib.data import AnomalibDataModule, AnomalibDataset + from anomalib.data.predict import PredictDataset + from anomalib.engine import Engine + from anomalib.metrics.threshold import BaseThreshold + from anomalib.models import AnomalyModule + from anomalib.utils.config import update_config + from anomalib.utils.visualization.base import BaseVisualizer + +except ImportError: + _LIGHTNING_AVAILABLE = False + + +class AnomalibCLI: """Implementation of a fully configurable CLI tool for anomalib. The advantage of this tool is its flexibility to configure the pipeline @@ -48,39 +53,22 @@ class AnomalibCLI(LightningCLI): ``SaveConfigCallback`` overwrites the config if it already exists. """ - def __init__( - self, - save_config_callback: type[SaveConfigCallback] = SaveConfigCallback, - save_config_kwargs: dict[str, Any] | None = None, - trainer_class: type[Trainer] | Callable[..., Trainer] = Trainer, - trainer_defaults: dict[str, Any] | None = None, - seed_everything_default: bool | int = True, - parser_kwargs: dict[str, Any] | dict[str, dict[str, Any]] | None = None, - args: ArgsType = None, - run: bool = True, - auto_configure_optimizers: bool = True, - ) -> None: - super().__init__( - AnomalyModule, - AnomalibDataModule, - save_config_callback, - {"overwrite": True} if save_config_kwargs is None else save_config_kwargs, - trainer_class, - trainer_defaults, - seed_everything_default, - parser_kwargs, - subclass_mode_model=True, - subclass_mode_data=True, - args=args, - run=run, - auto_configure_optimizers=auto_configure_optimizers, - ) - self.engine: Engine - - def init_parser(self, **kwargs) -> LightningArgumentParser: + def __init__(self, args: Sequence[str] | None = None) -> None: + self.parser = self.init_parser() + self.subcommand_parsers: dict[str, ArgumentParser] = {} + self.subcommand_method_arguments: dict[str, list[str]] = {} + self.add_subcommands() + self.config = self.parser.parse_args(args=args) + self.subcommand = self.config["subcommand"] + if _LIGHTNING_AVAILABLE: + self.before_instantiate_classes() + self.instantiate_classes() + self._run_subcommand() + + def init_parser(self, **kwargs) -> ArgumentParser: """Method that instantiates the argument parser.""" - kwargs.setdefault("dump_header", [f"lightning.pytorch=={pl.__version__},anomalib=={__version__}"]) - parser = LightningArgumentParser(formatter_class=CustomHelpFormatter, **kwargs) + kwargs.setdefault("dump_header", [f"anomalib=={__version__}"]) + parser = ArgumentParser(formatter_class=CustomHelpFormatter, **kwargs) parser.add_argument( "-c", "--config", @@ -92,7 +80,11 @@ def init_parser(self, **kwargs) -> LightningArgumentParser: @staticmethod def subcommands() -> dict[str, set[str]]: """Skip predict subcommand as it is added later.""" - return {key: value for key, value in LightningCLI.subcommands().items() if key != "predict"} + return { + "fit": {"model", "train_dataloaders", "val_dataloaders", "datamodule"}, + "validate": {"model", "dataloaders", "datamodule"}, + "test": {"model", "dataloaders", "datamodule"}, + } @staticmethod def anomalib_subcommands() -> dict[str, dict[str, str]]: @@ -103,16 +95,37 @@ def anomalib_subcommands() -> dict[str, dict[str, str]]: "export": {"description": "Export the model to ONNX or OpenVINO format."}, } - def _add_subcommands(self, parser: LightningArgumentParser, **kwargs) -> None: + def add_subcommands(self, **kwargs) -> None: """Initialize base subcommands and add anomalib specific on top of it.""" - # Initializes fit, validate, test, predict and tune - super()._add_subcommands(parser, **kwargs) - # Add anomalib subcommands + parser_subcommands = self.parser.add_subcommands() + + # Extra subcommand: install + self._set_install_subcommand(parser_subcommands) + + if not _LIGHTNING_AVAILABLE: + # If environment is not configured to use pl, do not add a subcommand for Engine. + return + + # Add Trainer subcommands + for subcommand in self.subcommands(): + sub_parser = self.init_parser(**kwargs) + + fn = getattr(Trainer, subcommand) + # extract the first line description in the docstring for the subcommand help message + description = get_short_docstring(fn) + subparser_kwargs = kwargs.get(subcommand, {}) + subparser_kwargs.setdefault("description", description) + + self.subcommand_parsers[subcommand] = sub_parser + parser_subcommands.add_subcommand(subcommand, sub_parser, help=description) + self.add_trainer_arguments(sub_parser, subcommand) + + # Add anomalib subcommands for subcommand in self.anomalib_subcommands(): sub_parser = self.init_parser(**kwargs) - self._subcommand_parsers[subcommand] = sub_parser - parser._subcommands_action.add_subcommand( # noqa: SLF001 + self.subcommand_parsers[subcommand] = sub_parser + parser_subcommands.add_subcommand( subcommand, sub_parser, help=self.anomalib_subcommands()[subcommand]["description"], @@ -120,13 +133,15 @@ def _add_subcommands(self, parser: LightningArgumentParser, **kwargs) -> None: # add arguments to subcommand getattr(self, f"add_{subcommand}_arguments")(sub_parser) - def add_arguments_to_parser(self, parser: LightningArgumentParser) -> None: + def add_arguments_to_parser(self, parser: ArgumentParser) -> None: """Extend trainer's arguments to add engine arguments. .. note:: Since ``Engine`` parameters are manually added, any change to the ``Engine`` class should be reflected manually. """ + from anomalib.callbacks.normalization import get_normalization_callback + parser.add_function_arguments(get_normalization_callback, "normalization") # visualization takes task from the project parser.add_argument( @@ -156,11 +171,36 @@ def add_arguments_to_parser(self, parser: LightningArgumentParser) -> None: # TODO(ashwinvaidya17): Tiling should also be a category of its own # CVS-122659 - def add_train_arguments(self, parser: LightningArgumentParser) -> None: + def add_trainer_arguments(self, parser: ArgumentParser, subcommand: str) -> None: """Add train arguments to the parser.""" - self.add_default_arguments_to_parser(parser) - self._add_trainer_arguments_to_parser(parser) - parser.add_lightning_class_args(AnomalyModule, "model", subclass_mode=True) + self._add_default_arguments_to_parser(parser) + self._add_trainer_arguments_to_parser(parser, add_optimizer=True, add_scheduler=True) + parser.add_subclass_arguments( + AnomalyModule, + "model", + fail_untyped=False, + required=True, + ) + parser.add_subclass_arguments(AnomalibDataModule, "data") + self.add_arguments_to_parser(parser) + skip: set[str | int] = set(self.subcommands()[subcommand]) + added = parser.add_method_arguments( + Trainer, + subcommand, + skip=skip, + ) + self.subcommand_method_arguments[subcommand] = added + + def add_train_arguments(self, parser: ArgumentParser) -> None: + """Add train arguments to the parser.""" + self._add_default_arguments_to_parser(parser) + self._add_trainer_arguments_to_parser(parser, add_optimizer=True, add_scheduler=True) + parser.add_subclass_arguments( + AnomalyModule, + "model", + fail_untyped=False, + required=True, + ) parser.add_subclass_arguments(AnomalibDataModule, "data") self.add_arguments_to_parser(parser) added = parser.add_method_arguments( @@ -168,13 +208,18 @@ def add_train_arguments(self, parser: LightningArgumentParser) -> None: "train", skip={"model", "datamodule", "val_dataloaders", "test_dataloaders", "train_dataloaders"}, ) - self._subcommand_method_arguments["train"] = added + self.subcommand_method_arguments["train"] = added - def add_predict_arguments(self, parser: LightningArgumentParser) -> None: + def add_predict_arguments(self, parser: ArgumentParser) -> None: """Add predict arguments to the parser.""" - self.add_default_arguments_to_parser(parser) + self._add_default_arguments_to_parser(parser) self._add_trainer_arguments_to_parser(parser) - parser.add_lightning_class_args(AnomalyModule, "model", subclass_mode=True) + parser.add_subclass_arguments( + AnomalyModule, + "model", + fail_untyped=False, + required=True, + ) parser.add_argument( "--data", type=Dataset | AnomalibDataModule | DataLoader | str | Path, @@ -185,24 +230,52 @@ def add_predict_arguments(self, parser: LightningArgumentParser) -> None: "predict", skip={"model", "dataloaders", "datamodule", "dataset"}, ) - self._subcommand_method_arguments["predict"] = added + self.subcommand_method_arguments["predict"] = added self.add_arguments_to_parser(parser) - def add_export_arguments(self, parser: LightningArgumentParser) -> None: + def add_export_arguments(self, parser: ArgumentParser) -> None: """Add export arguments to the parser.""" - self.add_default_arguments_to_parser(parser) + self._add_default_arguments_to_parser(parser) self._add_trainer_arguments_to_parser(parser) - parser.add_lightning_class_args(AnomalyModule, "model", subclass_mode=True) + parser.add_subclass_arguments( + AnomalyModule, + "model", + fail_untyped=False, + required=True, + ) parser.add_subclass_arguments((AnomalibDataModule, AnomalibDataset), "data") added = parser.add_method_arguments( Engine, "export", skip={"mo_args", "datamodule", "dataset", "model"}, ) - self._subcommand_method_arguments["export"] = added + self.subcommand_method_arguments["export"] = added add_openvino_export_arguments(parser) self.add_arguments_to_parser(parser) + def _set_install_subcommand(self, action_subcommand: _ActionSubCommands) -> None: + sub_parser = ArgumentParser(formatter_class=CustomHelpFormatter) + sub_parser.add_argument( + "--option", + help="Install the full or optional-dependencies.", + default="full", + type=str, + choices=["full", "core", "dev", "loggers", "notebooks", "openvino"], + ) + sub_parser.add_argument( + "-v", + "--verbose", + help="Set Logger level to INFO", + action="store_true", + ) + + self.subcommand_parsers["install"] = sub_parser + action_subcommand.add_subcommand( + "install", + sub_parser, + help="Install the full-package for anomalib.", + ) + def before_instantiate_classes(self) -> None: """Modify the configuration to properly instantiate classes and sets up tiler.""" subcommand = self.config["subcommand"] @@ -224,19 +297,19 @@ def instantiate_classes(self) -> None: self.config_init = self.parser.instantiate_classes(self.config) self.datamodule = self._get(self.config_init, "data") self.model = self._get(self.config_init, "model") - self._add_configure_optimizers_method_to_model(self.subcommand) - self.engine = self.instantiate_engine() + self._configure_optimizers_method_to_model() + self.instantiate_engine() else: self.config_init = self.parser.instantiate_classes(self.config) subcommand = self.config["subcommand"] if subcommand in ("train", "export"): - self.engine = self.instantiate_engine() + self.instantiate_engine() if "model" in self.config_init[subcommand]: self.model = self._get(self.config_init, "model") if "data" in self.config_init[subcommand]: self.datamodule = self._get(self.config_init, "data") - def instantiate_engine(self) -> Engine: + def instantiate_engine(self) -> None: """Instantiate the engine. .. note:: @@ -244,7 +317,10 @@ def instantiate_engine(self) -> Engine: ``instantiate_trainer`` method. Refer to that method for more details. """ - extra_callbacks = [self._get(self.config_init, c) for c in self._parser(self.subcommand).callback_keys] + from lightning.pytorch.cli import SaveConfigCallback + + from anomalib.callbacks import get_callbacks + engine_args = { "normalization": self._get(self.config_init, "normalization.normalization_method"), "threshold": self._get(self.config_init, "metrics.threshold"), @@ -260,19 +336,15 @@ def instantiate_engine(self) -> Engine: trainer_config[key] = [] elif not isinstance(trainer_config[key], list): trainer_config[key] = [trainer_config[key]] - trainer_config[key].extend(extra_callbacks) - if key in self.trainer_defaults: - value = self.trainer_defaults[key] - trainer_config[key] += value if isinstance(value, list) else [value] - if self.save_config_callback and not trainer_config.get("fast_dev_run", False): - config_callback = self.save_config_callback( + if not trainer_config.get("fast_dev_run", False): + config_callback = SaveConfigCallback( self._parser(self.subcommand), self.config.get(str(self.subcommand), self.config), - **self.save_config_kwargs, + overwrite=True, ) trainer_config[key].append(config_callback) trainer_config[key].extend(get_callbacks(self.config[self.subcommand])) - return Engine(**trainer_config) + self.engine = Engine(**trainer_config) def _get_visualization_parameters(self) -> dict[str, Any]: """Return visualization parameters.""" @@ -284,55 +356,82 @@ def _get_visualization_parameters(self) -> dict[str, Any]: "show_image": self.config[subcommand].visualization.show, } - def _run_subcommand(self, subcommand: str) -> None: + def _run_subcommand(self) -> None: """Run subcommand depending on the subcommand. This overrides the original ``_run_subcommand`` to run the ``Engine`` method rather than the ``Train`` method. """ - if self.config["subcommand"] in (*self.subcommands(), "train", "export", "predict"): - fn = getattr(self.engine, subcommand) - fn_kwargs = self._prepare_subcommand_kwargs(subcommand) + if self.subcommand == "install": + from anomalib.cli.install import anomalib_install + + install_kwargs = self.config.get("install", {}) + anomalib_install(**install_kwargs) + elif self.config["subcommand"] in (*self.subcommands(), "train", "export", "predict"): + fn = getattr(self.engine, self.subcommand) + fn_kwargs = self._prepare_subcommand_kwargs(self.subcommand) fn(**fn_kwargs) else: self.config_init = self.parser.instantiate_classes(self.config) - getattr(self, f"{subcommand}")() + getattr(self, f"{self.subcommand}")() @property - def fit(self) -> Callable[..., None]: + def fit(self) -> Callable: """Fit the model using engine's fit method.""" return self.engine.fit @property - def validate(self) -> Callable[..., _EVALUATE_OUTPUT | None]: + def validate(self) -> Callable: """Validate the model using engine's validate method.""" return self.engine.validate @property - def test(self) -> Callable[..., _EVALUATE_OUTPUT]: + def test(self) -> Callable: """Test the model using engine's test method.""" return self.engine.test @property - def predict(self) -> Callable[..., _PREDICT_OUTPUT | None]: + def predict(self) -> Callable: """Predict using engine's predict method.""" return self.engine.predict @property - def train(self) -> Callable[..., _EVALUATE_OUTPUT]: + def train(self) -> Callable: """Train the model using engine's train method.""" return self.engine.train @property - def export(self) -> Callable[..., Path | None]: + def export(self) -> Callable: """Export the model using engine's export method.""" return self.engine.export - def _add_trainer_arguments_to_parser(self, parser: LightningArgumentParser) -> None: + def _add_trainer_arguments_to_parser( + self, + parser: ArgumentParser, + add_optimizer: bool = False, + add_scheduler: bool = False, + ) -> None: """Add trainer arguments to the parser.""" - parser.add_lightning_class_args(Trainer, "trainer") - trainer_defaults = {"trainer." + k: v for k, v in self.trainer_defaults.items() if k != "callbacks"} - parser.set_defaults(trainer_defaults) + parser.add_class_arguments(Trainer, "trainer", fail_untyped=False, instantiate=False, sub_configs=True) + + if add_optimizer: + from torch.optim import Optimizer + + optim_kwargs = {"instantiate": False, "fail_untyped": False, "skip": {"params"}} + parser.add_subclass_arguments( + baseclass=(Optimizer,), + nested_key="optimizer", + **optim_kwargs, + ) + if add_scheduler: + from lightning.pytorch.cli import LRSchedulerTypeTuple + + scheduler_kwargs = {"instantiate": False, "fail_untyped": False, "skip": {"optimizer"}} + parser.add_subclass_arguments( + baseclass=LRSchedulerTypeTuple, + nested_key="lr_scheduler", + **scheduler_kwargs, + ) def _set_predict_dataloader_namespace(self, data_path: str | Path | Namespace) -> Namespace: """Set the predict dataloader namespace. @@ -355,6 +454,53 @@ def _set_predict_dataloader_namespace(self, data_path: str | Path | Namespace) - ) return data_path + def _add_default_arguments_to_parser(self, parser: ArgumentParser) -> None: + """Adds default arguments to the parser.""" + parser.add_argument( + "--seed_everything", + type=bool | int, + default=True, + help=( + "Set to an int to run seed_everything with this value before classes instantiation." + "Set to True to use a random seed." + ), + ) + + def _get(self, config: Namespace, key: str, default: Any = None) -> Any: # noqa: ANN401 + """Utility to get a config value which might be inside a subcommand.""" + return config.get(str(self.subcommand), config).get(key, default) + + def _prepare_subcommand_kwargs(self, subcommand: str) -> dict[str, Any]: + """Prepares the keyword arguments to pass to the subcommand to run.""" + fn_kwargs = { + k: v for k, v in self.config_init[subcommand].items() if k in self.subcommand_method_arguments[subcommand] + } + fn_kwargs["model"] = self.model + if self.datamodule is not None: + fn_kwargs["datamodule"] = self.datamodule + return fn_kwargs + + def _parser(self, subcommand: str | None) -> ArgumentParser: + if subcommand is None: + return self.parser + # return the subcommand parser for the subcommand passed + return self.subcommand_parsers[subcommand] + + def _configure_optimizers_method_to_model(self) -> None: + from lightning.pytorch.cli import LightningCLI, instantiate_class + + optimizer_cfg = self._get(self.config_init, "optimizer", None) + if optimizer_cfg is None: + return + lr_scheduler_cfg = self._get(self.config_init, "lr_scheduler", {}) + + optimizer = instantiate_class(self.model.parameters(), optimizer_cfg) + lr_scheduler = instantiate_class(optimizer, lr_scheduler_cfg) if lr_scheduler_cfg else None + fn = partial(LightningCLI.configure_optimizers, optimizer=optimizer, lr_scheduler=lr_scheduler) + + # override the existing method + self.model.configure_optimizers = MethodType(fn, self.model) + def main() -> None: """Trainer via Anomalib CLI.""" diff --git a/src/anomalib/cli/install.py b/src/anomalib/cli/install.py new file mode 100644 index 0000000000..1bd9599f77 --- /dev/null +++ b/src/anomalib/cli/install.py @@ -0,0 +1,77 @@ +"""Anomalib install subcommand code.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from pathlib import Path + +from rich.console import Console +from rich.logging import RichHandler + +from anomalib.cli.utils.installation import ( + get_requirements, + get_torch_install_args, + parse_requirements, +) + +logger = logging.getLogger("pip") +logger.setLevel(logging.WARNING) # setLevel: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET +console = Console() +handler = RichHandler( + console=console, + show_level=False, + show_path=False, +) +logger.addHandler(handler) + + +def anomalib_install(option: str = "full", verbose: bool = False) -> int: + """Install Anomalib requirements. + + Args: + option (str | None): Optional-dependency to install requirements for. + verbose (bool): Set pip logger level to INFO + + Raises: + ValueError: When the task is not supported. + + Returns: + int: Status code of the pip install command. + """ + from pip._internal.commands import create_command + + options = ( + [option] + if option != "full" + else [option.stem for option in Path("requirements").glob("*.txt") if option.stem != "dev"] + ) + requirements = get_requirements(requirement_files=options) + + # Parse requirements into torch and other requirements. + # This is done to parse the correct version of torch (cpu/cuda). + torch_requirement, other_requirements = parse_requirements(requirements, skip_torch="core" not in options) + + # Get install args for torch to install it from a specific index-url + install_args: list[str] = [] + torch_install_args = [] + if "core" in options and torch_requirement is not None: + torch_install_args = get_torch_install_args(torch_requirement) + + # Combine torch and other requirements. + install_args = other_requirements + torch_install_args + + # Install requirements. + with console.status("[bold green]Installing packages... This may take a few minutes.\n") as status: + if verbose: + logger.setLevel(logging.INFO) + status.stop() + console.log(f"Installation list: [yellow]{install_args}[/yellow]") + status_code = create_command("install").main(install_args) + if status_code == 0: + console.log(f"Installation Complete: {install_args}") + + if status_code == 0: + console.print("Anomalib Installation [bold green]Complete.[/bold green]") + + return status_code diff --git a/src/anomalib/cli/utils/help_formatter.py b/src/anomalib/cli/utils/help_formatter.py index 646984f3b5..ea4ef825b6 100644 --- a/src/anomalib/cli/utils/help_formatter.py +++ b/src/anomalib/cli/utils/help_formatter.py @@ -6,30 +6,52 @@ import argparse import re import sys +from typing import TypeVar +import docstring_parser from jsonargparse import DefaultHelpFormatter from rich.markdown import Markdown from rich.panel import Panel from rich_argparse import RichHelpFormatter -from anomalib.engine import Engine - REQUIRED_ARGUMENTS = { + "train": {"model", "model.help", "data", "data.help", "ckpt_path", "config"}, "fit": {"model", "model.help", "data", "data.help", "ckpt_path", "config"}, "validate": {"model", "model.help", "data", "data.help", "ckpt_path", "config"}, "test": {"model", "model.help", "data", "data.help", "ckpt_path", "config"}, "predict": {"model", "model.help", "data", "data.help", "ckpt_path", "config"}, } -DOCSTRING_USAGE = { - "fit": Engine.fit, - "validate": Engine.validate, - "test": Engine.test, - "predict": Engine.predict, -} +try: + from anomalib.engine import Engine + + DOCSTRING_USAGE = { + "train": Engine.train, + "fit": Engine.fit, + "validate": Engine.validate, + "test": Engine.test, + "predict": Engine.predict, + } +except ImportError: + print("To use other subcommand using `anomalib install`") + + +def get_short_docstring(component: TypeVar) -> str: + """Get the short description from the docstring. + + Args: + component (TypeVar): The component to get the docstring from + + Returns: + str: The short description + """ + if component.__doc__ is None: + return "" + docstring = docstring_parser.parse(component.__doc__) + return docstring.short_description -def pre_parse_arguments() -> dict: +def get_verbosity_subcommand() -> dict: """Parse command line arguments and returns a dictionary of key-value pairs. Returns: @@ -37,56 +59,24 @@ def pre_parse_arguments() -> dict: Examples: >>> import sys - >>> sys.argv = ['anomalib', 'fit', '--arg1', 'value1', '-a', 'value2', '-h'] - >>> pre_parse_arguments() - {'subcommand': 'fit', 'arg1': 'value1', 'a': 'value2', 'h': None} + >>> sys.argv = ['anomalib', 'train', '-h', '-v'] + >>> get_verbosity_subcommand() + {'subcommand': 'train', 'help': True, 'verbosity': 1} """ - arguments: dict = {"subcommand": None} - i = 1 - while i < len(sys.argv): - if sys.argv[i].startswith("--"): - key = sys.argv[i][2:] - value = None - if i + 1 < len(sys.argv) and not sys.argv[i + 1].startswith("--"): - value = sys.argv[i + 1] - i += 1 - arguments[key] = value - elif sys.argv[i].startswith("-"): - key = sys.argv[i][1:] - value = None - if i + 1 < len(sys.argv) and not sys.argv[i + 1].startswith("-"): - value = sys.argv[i + 1] - i += 1 - arguments[key] = value - elif i == 1: - arguments["subcommand"] = sys.argv[i] - i += 1 + arguments: dict = {"subcommand": None, "help": False, "verbosity": 2} + if len(sys.argv) >= 2 and sys.argv[1] not in ("--help", "-h"): + arguments["subcommand"] = sys.argv[1] + if "--help" in sys.argv or "-h" in sys.argv: + arguments["help"] = True + if arguments["subcommand"] in REQUIRED_ARGUMENTS: + arguments["verbosity"] = 0 + if "-v" in sys.argv or "--verbose" in sys.argv: + arguments["verbosity"] = 1 + if "-vv" in sys.argv: + arguments["verbosity"] = 2 return arguments -def get_verbosity_subcommand() -> tuple: - """Return a tuple containing the verbosity level and the subcommand name. - - The verbosity level is determined by the command line arguments passed to the script. - If the subcommand requires additional arguments, the verbosity level is only set if the - help option is specified. The verbosity level can be set to 0 (no output), 1 (normal output), - or 2 (verbose output). - - Returns: - A tuple containing the verbosity level (int) and the subcommand name (str). - """ - arguments = pre_parse_arguments() - verbosity = 2 - if arguments["subcommand"] in REQUIRED_ARGUMENTS and ("h" in arguments or "help" in arguments): - if "v" in arguments: - verbosity = 1 - elif "vv" in arguments: - verbosity = 2 - else: - verbosity = 0 - return verbosity, arguments["subcommand"] - - def get_intro() -> Markdown: """Return a Markdown object containing the introduction text for Anomalib CLI Guide. @@ -193,7 +183,7 @@ class CustomHelpFormatter(RichHelpFormatter, DefaultHelpFormatter): a more detailed and customizable help output for Anomalib CLI. Attributes: - verbose_level : int + verbosity_level : int The level of verbosity for the help output. subcommand : str | None The subcommand to render the guide for. @@ -207,7 +197,9 @@ class CustomHelpFormatter(RichHelpFormatter, DefaultHelpFormatter): Format the help output. """ - verbose_level, subcommand = get_verbosity_subcommand() + verbosity_dict = get_verbosity_subcommand() + verbosity_level = verbosity_dict["verbosity"] + subcommand = verbosity_dict["subcommand"] def add_usage(self, usage: str | None, actions: list, *args, **kwargs) -> None: """Add usage information to the formatter. @@ -224,9 +216,9 @@ def add_usage(self, usage: str | None, actions: list, *args, **kwargs) -> None: None """ if self.subcommand in REQUIRED_ARGUMENTS: - if self.verbose_level == 0: + if self.verbosity_level == 0: actions = [] - elif self.verbose_level == 1: + elif self.verbosity_level == 1: actions = [action for action in actions if action.dest in REQUIRED_ARGUMENTS[self.subcommand]] super().add_usage(usage, actions, *args, **kwargs) @@ -242,9 +234,9 @@ def add_argument(self, action: argparse.Action) -> None: action (argparse.Action): The action to add to the help formatter. """ if self.subcommand in REQUIRED_ARGUMENTS: - if self.verbose_level == 0: + if self.verbosity_level == 0: return - if self.verbose_level == 1 and action.dest not in REQUIRED_ARGUMENTS[self.subcommand]: + if self.verbosity_level == 1 and action.dest not in REQUIRED_ARGUMENTS[self.subcommand]: return super().add_argument(action) @@ -259,11 +251,11 @@ def format_help(self) -> str: """ with self.console.capture() as capture: section = self._root_section - if self.subcommand in REQUIRED_ARGUMENTS and self.verbose_level in (0, 1) and len(section.rich_items) > 1: + if self.subcommand in REQUIRED_ARGUMENTS and self.verbosity_level in (0, 1) and len(section.rich_items) > 1: contents = render_guide(self.subcommand) for content in contents: self.console.print(content) - if self.verbose_level > 0: + if self.verbosity_level > 0: if len(section.rich_items) > 1: section = Panel(section, border_style="dim", title="Arguments", title_align="left") self.console.print(section, highlight=False, soft_wrap=True) diff --git a/src/anomalib/cli/utils/installation.py b/src/anomalib/cli/utils/installation.py new file mode 100644 index 0000000000..4915244c8a --- /dev/null +++ b/src/anomalib/cli/utils/installation.py @@ -0,0 +1,423 @@ +"""Anomalib installation util functions.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import os +import platform +import re +from pathlib import Path +from warnings import warn + +from pkg_resources import Requirement + +AVAILABLE_TORCH_VERSIONS = { + "2.0.0": {"torchvision": "0.15.1", "cuda": ("11.7", "11.8")}, + "2.0.1": {"torchvision": "0.15.2", "cuda": ("11.7", "11.8")}, + "2.1.1": {"torchvision": "0.16.1", "cuda": ("11.8", "12.1")}, + "2.1.2": {"torchvision": "0.16.2", "cuda": ("11.8", "12.1")}, + "2.2.0": {"torchvision": "0.16.2", "cuda": ("11.8", "12.1")}, +} + + +def get_requirements(requirement_files: list[str]) -> list[Requirement]: + """Get packages from requirements.txt file. + + This function returns list of required packages from requirement files. + + Args: + requirement_files (list[Requirement]): txt files that contains list of required + packages. + + Example: + >>> get_required_packages(requirement_files=["openvino"]) + [Requirement('onnx>=1.8.1'), Requirement('networkx~=2.5'), Requirement('openvino-dev==2021.4.1'), ...] + + Returns: + list[Requirement]: List of required packages + """ + required_packages: list[Requirement] = [] + + for requirement_file in requirement_files: + with Path(f"requirements/{requirement_file}.txt").open(encoding="utf8") as file: + for line in file: + package = line.strip() + if package and not package.startswith(("#", "-f")): + required_packages.append(Requirement.parse(package)) + + return required_packages + + +def parse_requirements( + requirements: list[Requirement], + skip_torch: bool = False, +) -> tuple[str | None, list[str]]: + """Parse requirements and returns torch and other requirements. + + Args: + requirements (list[Requirement]): List of requirements. + skip_torch (bool): Whether to skip torch requirement. Defaults to False. + + Raises: + ValueError: If torch requirement is not found. + + Examples: + >>> requirements = [ + ... Requirement.parse("torch==1.13.0"), + ... Requirement.parse("onnx>=1.8.1"), + ... ] + >>> parse_requirements(requirements=requirements) + (Requirement.parse("torch==1.13.0"), + Requirement.parse("onnx>=1.8.1")) + + Returns: + tuple[str, list[str], list[str]]: Tuple of torch and other requirements. + """ + torch_requirement: str | None = None + other_requirements: list[str] = [] + + for requirement in requirements: + if requirement.unsafe_name == "torch": + torch_requirement = str(requirement) + if len(requirement.specs) > 1: + warn( + "requirements.txt contains. Please remove other versions of torch from requirements.", + stacklevel=2, + ) + + # Rest of the requirements are task requirements. + # Other torch-related requirements such as `torchvision` are to be excluded. + # This is because torch-related requirements are already handled in torch_requirement. + else: + # if not requirement.unsafe_name.startswith("torch"): + other_requirements.append(str(requirement)) + + if not skip_torch and not torch_requirement: + msg = "Could not find torch requirement. Anoamlib depends on torch. Please add torch to your requirements." + raise ValueError(msg) + + # Get the unique list of the requirements. + other_requirements = list(set(other_requirements)) + + return torch_requirement, other_requirements + + +def get_cuda_version() -> str | None: + """Get CUDA version installed on the system. + + Examples: + >>> # Assume that CUDA version is 11.2 + >>> get_cuda_version() + "11.2" + + >>> # Assume that CUDA is not installed on the system + >>> get_cuda_version() + None + + Returns: + str | None: CUDA version installed on the system. + """ + # 1. Check CUDA_HOME Environment variable + cuda_home = os.environ.get("CUDA_HOME", "/usr/local/cuda") + + if Path(cuda_home).exists(): + # Check $CUDA_HOME/version.json file. + version_file = Path(cuda_home) / "version.json" + if version_file.is_file(): + with Path(version_file).open() as file: + data = json.load(file) + cuda_version = data.get("cuda", {}).get("version", None) + if cuda_version is not None: + cuda_version_parts = cuda_version.split(".") + return ".".join(cuda_version_parts[:2]) + # 2. 'nvcc --version' check & without version.json case + try: + result = os.popen(cmd="nvcc --version") + output = result.read() + + cuda_version_pattern = r"cuda_(\d+\.\d+)" + cuda_version_match = re.search(cuda_version_pattern, output) + + if cuda_version_match is not None: + return cuda_version_match.group(1) + except OSError: + msg = "Could not find cuda-version. Instead, the CPU version of torch will be installed." + warn(msg, stacklevel=2) + return None + + +def update_cuda_version_with_available_torch_cuda_build(cuda_version: str, torch_version: str) -> str: + """Update the installed CUDA version with the highest supported CUDA version by PyTorch. + + Args: + cuda_version (str): The installed CUDA version. + torch_version (str): The PyTorch version. + + Raises: + Warning: If the installed CUDA version is not supported by PyTorch. + + Examples: + >>> update_cuda_version_with_available_torch_cuda_builds("11.1", "1.13.0") + "11.6" + + >>> update_cuda_version_with_available_torch_cuda_builds("11.7", "1.13.0") + "11.7" + + >>> update_cuda_version_with_available_torch_cuda_builds("11.8", "1.13.0") + "11.7" + + >>> update_cuda_version_with_available_torch_cuda_builds("12.1", "2.0.1") + "11.8" + + Returns: + str: The updated CUDA version. + """ + max_supported_cuda = max(AVAILABLE_TORCH_VERSIONS[torch_version]["cuda"]) + min_supported_cuda = min(AVAILABLE_TORCH_VERSIONS[torch_version]["cuda"]) + bounded_cuda_version = max(min(cuda_version, max_supported_cuda), min_supported_cuda) + + if cuda_version != bounded_cuda_version: + warn( + f"Installed CUDA version is v{cuda_version}. \n" + f"v{min_supported_cuda} <= Supported CUDA version <= v{max_supported_cuda}.\n" + f"This script will use CUDA v{bounded_cuda_version}.\n" + f"However, this may not be safe, and you are advised to install the correct version of CUDA.\n" + f"For more details, refer to https://pytorch.org/get-started/locally/", + stacklevel=2, + ) + cuda_version = bounded_cuda_version + + return cuda_version + + +def get_cuda_suffix(cuda_version: str) -> str: + """Get CUDA suffix for PyTorch versions. + + Args: + cuda_version (str): CUDA version installed on the system. + + Note: + The CUDA version of PyTorch is not always the same as the CUDA version + that is installed on the system. For example, the latest PyTorch + version (1.10.0) supports CUDA 11.3, but the latest CUDA version + that is available for download is 11.2. Therefore, we need to use + the latest available CUDA version for PyTorch instead of the CUDA + version that is installed on the system. Therefore, this function + shoudl be regularly updated to reflect the latest available CUDA. + + Examples: + >>> get_cuda_suffix(cuda_version="11.2") + "cu112" + + >>> get_cuda_suffix(cuda_version="11.8") + "cu118" + + Returns: + str: CUDA suffix for PyTorch or mmX version. + """ + return f"cu{cuda_version.replace('.', '')}" + + +def get_hardware_suffix(with_available_torch_build: bool = False, torch_version: str | None = None) -> str: + """Get hardware suffix for PyTorch or mmX versions. + + Args: + with_available_torch_build (bool): Whether to use the latest available + PyTorch build or not. If True, the latest available PyTorch build + will be used. If False, the installed PyTorch build will be used. + Defaults to False. + torch_version (str | None): PyTorch version. This is only used when the + ``with_available_torch_build`` is True. + + Examples: + >>> # Assume that CUDA version is 11.2 + >>> get_hardware_suffix() + "cu112" + + >>> # Assume that CUDA is not installed on the system + >>> get_hardware_suffix() + "cpu" + + Assume that that installed CUDA version is 12.1. + However, the latest available CUDA version for PyTorch v2.0 is 11.8. + Therefore, we use 11.8 instead of 12.1. This is because PyTorch does not + support CUDA 12.1 yet. In this case, we could correct the CUDA version + by setting `with_available_torch_build` to True. + + >>> cuda_version = get_cuda_version() + "12.1" + >>> get_hardware_suffix(with_available_torch_build=True, torch_version="2.0.1") + "cu118" + + Returns: + str: Hardware suffix for PyTorch or mmX version. + """ + cuda_version = get_cuda_version() + if cuda_version: + if with_available_torch_build: + if torch_version is None: + msg = "``torch_version`` must be provided when with_available_torch_build is True." + raise ValueError(msg) + cuda_version = update_cuda_version_with_available_torch_cuda_build(cuda_version, torch_version) + hardware_suffix = get_cuda_suffix(cuda_version) + else: + hardware_suffix = "cpu" + + return hardware_suffix + + +def add_hardware_suffix_to_torch( + requirement: Requirement, + hardware_suffix: str | None = None, + with_available_torch_build: bool = False, +) -> str: + """Add hardware suffix to the torch requirement. + + Args: + requirement (Requirement): Requirement object comprising requirement + details. + hardware_suffix (str | None): Hardware suffix. If None, it will be set + to the correct hardware suffix. Defaults to None. + with_available_torch_build (bool): To check whether the installed + CUDA version is supported by the latest available PyTorch build. + Defaults to False. + + Examples: + >>> from pkg_resources import Requirement + >>> req = "torch>=1.13.0, <=2.0.1" + >>> requirement = Requirement.parse(req) + >>> requirement.name, requirement.specs + ('torch', [('>=', '1.13.0'), ('<=', '2.0.1')]) + + >>> add_hardware_suffix_to_torch(requirement) + 'torch>=1.13.0+cu121, <=2.0.1+cu121' + + ``with_available_torch_build=True`` will use the latest available PyTorch build. + >>> req = "torch==2.0.1" + >>> requirement = Requirement.parse(req) + >>> add_hardware_suffix_to_torch(requirement, with_available_torch_build=True) + 'torch==2.0.1+cu118' + + It is possible to pass the ``hardware_suffix`` manually. + >>> req = "torch==2.0.1" + >>> requirement = Requirement.parse(req) + >>> add_hardware_suffix_to_torch(requirement, hardware_suffix="cu121") + 'torch==2.0.1+cu111' + + Raises: + ValueError: When the requirement has more than two version criterion. + + Returns: + str: Updated torch package with the right cuda suffix. + """ + name = requirement.unsafe_name + updated_specs: list[str] = [] + + for operator, version in requirement.specs: + hardware_suffix = hardware_suffix or get_hardware_suffix(with_available_torch_build, version) + updated_version = version + f"+{hardware_suffix}" if not version.startswith(("2.1", "2.2")) else version + + # ``specs`` contains operators and versions as follows: + # These are to be concatenated again for the updated version. + updated_specs.append(operator + updated_version) + + updated_requirement: str = "" + + if updated_specs: + # This is the case when specs are e.g. ['<=1.9.1+cu111'] + if len(updated_specs) == 1: + updated_requirement = name + updated_specs[0] + # This is the case when specs are e.g., ['<=1.9.1+cu111', '>=1.8.1+cu111'] + elif len(updated_specs) == 2: + updated_requirement = name + updated_specs[0] + ", " + updated_specs[1] + else: + msg = ( + "Requirement version can be a single value or a range. \n" + "For example it could be torch>=1.8.1 " + "or torch>=1.8.1, <=1.9.1\n" + f"Got {updated_specs} instead." + ) + raise ValueError(msg) + return updated_requirement + + +def get_torch_install_args(requirement: str | Requirement) -> list[str]: + """Get the install arguments for Torch requirement. + + This function will return the install arguments for the Torch requirement + and its corresponding torchvision requirement. + + Args: + requirement (str | Requirement): The torch requirement. + + Raises: + RuntimeError: If the OS is not supported. + + Example: + >>> from pkg_resources import Requirement + >>> requriment = "torch>=1.13.0" + >>> get_torch_install_args(requirement) + ['--extra-index-url', 'https://download.pytorch.org/whl/cpu', + 'torch==1.13.0+cpu', 'torchvision==0.14.0+cpu'] + + Returns: + list[str]: The install arguments. + """ + if isinstance(requirement, str): + requirement = Requirement.parse(requirement) + + # NOTE: This does not take into account if the requirement has multiple versions + # such as torch<2.0.1,>=1.13.0 + if len(requirement.specs) < 1: + return [str(requirement)] + select_spec_idx = 0 + for i, spec in enumerate(requirement.specs): + if "=" in spec[0]: + select_spec_idx = i + break + operator, version = requirement.specs[select_spec_idx] + if version not in AVAILABLE_TORCH_VERSIONS: + version = max(AVAILABLE_TORCH_VERSIONS.keys()) + warn( + f"Torch Version will be selected as {version}.", + stacklevel=2, + ) + install_args: list[str] = [] + + if platform.system() in ("Linux", "Windows"): + # Get the hardware suffix (eg., +cpu, +cu116 and +cu118 etc.) + hardware_suffix = get_hardware_suffix(with_available_torch_build=True, torch_version=version) + + # Create the PyTorch Index URL to download the correct wheel. + index_url = f"https://download.pytorch.org/whl/{hardware_suffix}" + + # Create the PyTorch version depending on the CUDA version. For example, + # If CUDA version is 11.2, then the PyTorch version is 1.8.0+cu112. + # If CUDA version is None, then the PyTorch version is 1.8.0+cpu. + torch_version = add_hardware_suffix_to_torch(requirement, hardware_suffix, with_available_torch_build=True) + + # Get the torchvision version depending on the torch version. + torchvision_version = AVAILABLE_TORCH_VERSIONS[version]["torchvision"] + torchvision_requirement = f"torchvision{operator}{torchvision_version}" + if isinstance(torchvision_version, str) and not torchvision_version.startswith("0.16"): + torchvision_requirement += f"+{hardware_suffix}" + + # Return the install arguments. + install_args += [ + "--extra-index-url", + # "--index-url", + index_url, + torch_version, + torchvision_requirement, + ] + elif platform.system() in ("macos", "Darwin"): + torch_version = str(requirement) + install_args += [torch_version] + else: + msg = f"Unsupported OS: {platform.system()}" + raise RuntimeError(msg) + + return install_args diff --git a/src/anomalib/cli/utils/openvino.py b/src/anomalib/cli/utils/openvino.py index 7ad1c5cd0b..70e329f6b4 100644 --- a/src/anomalib/cli/utils/openvino.py +++ b/src/anomalib/cli/utils/openvino.py @@ -5,7 +5,7 @@ import logging -from lightning.pytorch.cli import LightningArgumentParser +from jsonargparse import ArgumentParser from anomalib.utils.exceptions import try_import @@ -18,7 +18,7 @@ get_common_cli_parser = None -def add_openvino_export_arguments(parser: LightningArgumentParser) -> None: +def add_openvino_export_arguments(parser: ArgumentParser) -> None: """Add OpenVINO arguments to parser under --mo key.""" if get_common_cli_parser is not None: group = parser.add_argument_group("OpenVINO Model Optimizer arguments (optional)") diff --git a/src/anomalib/loggers/__init__.py b/src/anomalib/loggers/__init__.py index 6fd3c2d1bb..2d35ed398c 100644 --- a/src/anomalib/loggers/__init__.py +++ b/src/anomalib/loggers/__init__.py @@ -7,23 +7,32 @@ import logging from pathlib import Path -from lightning.pytorch.loggers import CSVLogger, Logger from omegaconf.dictconfig import DictConfig from omegaconf.listconfig import ListConfig from rich.logging import RichHandler -from .comet import AnomalibCometLogger -from .tensorboard import AnomalibTensorBoardLogger -from .wandb import AnomalibWandbLogger - __all__ = [ - "AnomalibCometLogger", - "AnomalibTensorBoardLogger", - "AnomalibWandbLogger", "configure_logger", "get_experiment_logger", ] +try: + from lightning.pytorch.loggers import CSVLogger, Logger + + from .comet import AnomalibCometLogger + from .tensorboard import AnomalibTensorBoardLogger + from .wandb import AnomalibWandbLogger + + __all__.extend( + [ + "AnomalibCometLogger", + "AnomalibTensorBoardLogger", + "AnomalibWandbLogger", + ], + ) +except ImportError: + print("To use any logger install it using `anomalib install -v`") + AVAILABLE_LOGGERS = ["tensorboard", "wandb", "csv", "comet"] @@ -60,7 +69,7 @@ def configure_logger(level: int | str = logging.INFO) -> None: def get_experiment_logger( config: DictConfig | ListConfig, -) -> list[Logger] | bool: +) -> list | bool: """Return a logger based on the choice of logger in the config file. Args: diff --git a/tests/integration/cli/test_cli.py b/tests/integration/cli/test_cli.py index bb1046c22d..8917794345 100644 --- a/tests/integration/cli/test_cli.py +++ b/tests/integration/cli/test_cli.py @@ -12,7 +12,6 @@ import pytest import torch -from anomalib import TaskType from anomalib.cli import AnomalibCLI from anomalib.deploy.export import ExportType @@ -210,7 +209,7 @@ def _get_common_cli_args(dataset_path: Path | None, project_path: Path) -> list[ "--results_dir.unique", "false", "--task", - TaskType.SEGMENTATION, + "SEGMENTATION", "--trainer.max_epochs", "1", "--trainer.callbacks+=anomalib.callbacks.ModelCheckpoint", diff --git a/tests/unit/cli/test_help_formatter.py b/tests/unit/cli/test_help_formatter.py index 11331b1818..83278903fa 100644 --- a/tests/unit/cli/test_help_formatter.py +++ b/tests/unit/cli/test_help_formatter.py @@ -13,40 +13,27 @@ get_cli_usage_docstring, get_verbose_usage, get_verbosity_subcommand, - pre_parse_arguments, render_guide, ) -def test_pre_parse_arguments() -> None: - """Test pre_parse_arguments.""" - argv = ["anomalib", "subcommand", "--arg1", "value1", "-a2", "value2"] - expected_output: dict = { - "subcommand": "subcommand", - "arg1": "value1", - "a2": "value2", - } - with patch.object(sys, "argv", argv): - assert pre_parse_arguments() == expected_output - - def test_get_verbosity_subcommand() -> None: """Test if the verbosity level and subcommand are correctly parsed.""" argv = ["anomalib", "fit", "-h"] with patch.object(sys, "argv", argv): - assert get_verbosity_subcommand() == (0, "fit") + assert get_verbosity_subcommand() == {"help": True, "verbosity": 0, "subcommand": "fit"} argv = ["anomalib", "fit", "-h", "-v"] with patch.object(sys, "argv", argv): - assert get_verbosity_subcommand() == (1, "fit") + assert get_verbosity_subcommand() == {"help": True, "verbosity": 1, "subcommand": "fit"} argv = ["anomalib", "fit", "-h", "-vv"] with patch.object(sys, "argv", argv): - assert get_verbosity_subcommand() == (2, "fit") + assert get_verbosity_subcommand() == {"help": True, "verbosity": 2, "subcommand": "fit"} argv = ["anomalib", "-h"] with patch.object(sys, "argv", argv): - assert get_verbosity_subcommand() == (2, None) + assert get_verbosity_subcommand() == {"help": True, "verbosity": 2, "subcommand": None} def test_get_verbose_usage() -> None: @@ -120,7 +107,7 @@ def test_verbose_0(self, capfd: "pytest.CaptureFixture", mock_parser: ArgumentPa """Test verbose level 0.""" argv = ["anomalib", "fit", "-h"] assert mock_parser.formatter_class == CustomHelpFormatter - mock_parser.formatter_class.verbose_level = 0 + mock_parser.formatter_class.verbosity_level = 0 with pytest.raises(SystemExit, match="0"): mock_parser.parse_args(argv) out, _ = capfd.readouterr() @@ -131,7 +118,7 @@ def test_verbose_1(self, capfd: "pytest.CaptureFixture", mock_parser: ArgumentPa """Test verbose level 1.""" argv = ["anomalib", "fit", "-h", "-v"] assert mock_parser.formatter_class == CustomHelpFormatter - mock_parser.formatter_class.verbose_level = 1 + mock_parser.formatter_class.verbosity_level = 1 with pytest.raises(SystemExit, match="0"): mock_parser.parse_args(argv) out, _ = capfd.readouterr() @@ -142,7 +129,7 @@ def test_verbose_2(self, capfd: "pytest.CaptureFixture", mock_parser: ArgumentPa """Test verbose level 2.""" argv = ["anomalib", "fit", "-h", "-vv"] assert mock_parser.formatter_class == CustomHelpFormatter - mock_parser.formatter_class.verbose_level = 2 + mock_parser.formatter_class.verbosity_level = 2 with pytest.raises(SystemExit, match="0"): mock_parser.parse_args(argv) out, _ = capfd.readouterr() diff --git a/tests/unit/cli/test_installation.py b/tests/unit/cli/test_installation.py new file mode 100644 index 0000000000..2459cf6473 --- /dev/null +++ b/tests/unit/cli/test_installation.py @@ -0,0 +1,191 @@ +"""Tests for installation utils.""" + +# Copyright (C) 2023 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import os +import tempfile +from pathlib import Path + +import pytest +from pkg_resources import Requirement +from pytest_mock import MockerFixture + +from anomalib.cli.utils.installation import ( + add_hardware_suffix_to_torch, + get_cuda_suffix, + get_cuda_version, + get_hardware_suffix, + get_requirements, + get_torch_install_args, + parse_requirements, + update_cuda_version_with_available_torch_cuda_build, +) + + +@pytest.fixture() +def requirements_file() -> Path: + """Create a temporary requirements file with some example requirements.""" + requirements = ["numpy==1.19.5", "opencv-python-headless>=4.5.1.48"] + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("\n".join(requirements)) + return Path(f.name) + + +def test_get_requirements() -> None: + """Test that get_requirements returns the expected dictionary of requirements.""" + options = [option.stem for option in Path("requirements").glob("*.txt") if option.stem != "dev"] + requirements = get_requirements(requirement_files=options) + assert isinstance(requirements, list) + assert len(requirements) > 0 + for req in requirements: + assert isinstance(req, Requirement) + + +def test_parse_requirements() -> None: + """Test that parse_requirements returns the expected tuple of requirements.""" + requirements = [ + Requirement.parse("torch==2.0.0"), + Requirement.parse("onnx>=1.8.1"), + ] + torch_req, other_reqs = parse_requirements(requirements) + assert isinstance(torch_req, str) + assert isinstance(other_reqs, list) + assert torch_req == "torch==2.0.0" + assert other_reqs == ["onnx>=1.8.1"] + + requirements = [ + Requirement.parse("torch<=2.0.1, >=1.8.1"), + ] + torch_req, other_reqs = parse_requirements(requirements) + assert torch_req == "torch<=2.0.1,>=1.8.1" + assert other_reqs == [] + + requirements = [ + Requirement.parse("onnx>=1.8.1"), + ] + with pytest.raises(ValueError, match="Could not find torch requirement."): + parse_requirements(requirements) + + +def test_get_cuda_version_with_version_file(mocker: MockerFixture, tmp_path: Path) -> None: + """Test that get_cuda_version returns the expected CUDA version when version file exists.""" + tmp_path = tmp_path / "cuda" + tmp_path.mkdir() + mocker.patch.dict(os.environ, {"CUDA_HOME": str(tmp_path)}) + version_file = tmp_path / "version.json" + version_file.write_text('{"cuda": {"version": "11.2.0"}}') + assert get_cuda_version() == "11.2" + + +def test_get_cuda_version_with_nvcc(mocker: MockerFixture) -> None: + """Test that get_cuda_version returns the expected CUDA version when nvcc is available.""" + mock_run = mocker.patch("anomalib.cli.utils.installation.Path.exists", return_value=False) + mock_run = mocker.patch("os.popen") + mock_run.return_value.read.return_value = "Build cuda_11.2.r11.2/compiler.00000_0" + assert get_cuda_version() == "11.2" + + mock_run = mocker.patch("os.popen") + mock_run.side_effect = FileNotFoundError + assert get_cuda_version() is None + + +def test_update_cuda_version_with_available_torch_cuda_build() -> None: + """Test that update_cuda_version_with_available_torch_cuda_build returns the expected CUDA version.""" + assert update_cuda_version_with_available_torch_cuda_build("11.1", "2.0.1") == "11.7" + assert update_cuda_version_with_available_torch_cuda_build("11.7", "2.0.1") == "11.7" + assert update_cuda_version_with_available_torch_cuda_build("11.8", "2.0.1") == "11.8" + assert update_cuda_version_with_available_torch_cuda_build("12.1", "2.1.1") == "12.1" + + +def test_get_cuda_suffix() -> None: + """Test the get_cuda_suffix function.""" + assert get_cuda_suffix(cuda_version="11.2") == "cu112" + assert get_cuda_suffix(cuda_version="11.8") == "cu118" + + +def test_get_hardware_suffix(mocker: MockerFixture) -> None: + """Test the behavior of the get_hardware_suffix function.""" + mocker.patch("anomalib.cli.utils.installation.get_cuda_version", return_value="11.2") + assert get_hardware_suffix() == "cu112" + + mocker.patch("anomalib.cli.utils.installation.get_cuda_version", return_value="12.1") + assert get_hardware_suffix(with_available_torch_build=True, torch_version="2.0.1") == "cu118" + + with pytest.raises(ValueError, match="``torch_version`` must be provided"): + get_hardware_suffix(with_available_torch_build=True) + + mocker.patch("anomalib.cli.utils.installation.get_cuda_version", return_value=None) + assert get_hardware_suffix() == "cpu" + + +def test_add_hardware_suffix_to_torch(mocker: MockerFixture) -> None: + """Test that add_hardware_suffix_to_torch returns the expected updated requirement.""" + mocker.patch("anomalib.cli.utils.installation.get_hardware_suffix", return_value="cu121") + requirement = Requirement.parse("torch>=1.13.0, <=2.0.1") + updated_requirement = add_hardware_suffix_to_torch(requirement) + assert "torch" in updated_requirement + assert ">=1.13.0+cu121" in updated_requirement + assert "<=2.0.1+cu121" in updated_requirement + + requirement = Requirement.parse("torch==2.0.1") + mocker.patch("anomalib.cli.utils.installation.get_hardware_suffix", return_value="cu118") + updated_requirement = add_hardware_suffix_to_torch(requirement, with_available_torch_build=True) + assert updated_requirement == "torch==2.0.1+cu118" + + requirement = Requirement.parse("torch==2.0.1") + updated_requirement = add_hardware_suffix_to_torch(requirement, hardware_suffix="cu111") + assert updated_requirement == "torch==2.0.1+cu111" + + requirement = Requirement.parse("torch>=1.13.0, <=2.0.1, !=1.14.0") + with pytest.raises(ValueError, match="Requirement version can be a single value or a range."): + add_hardware_suffix_to_torch(requirement) + + +def test_get_torch_install_args(mocker: MockerFixture) -> None: + """Test that get_torch_install_args returns the expected install arguments.""" + requirement = Requirement.parse("torch>=2.1.1") + mocker.patch("anomalib.cli.utils.installation.platform.system", return_value="Linux") + mocker.patch("anomalib.cli.utils.installation.get_hardware_suffix", return_value="cpu") + install_args = get_torch_install_args(requirement) + expected_args = [ + "--extra-index-url", + "https://download.pytorch.org/whl/cpu", + "torch>=2.1.1", + "torchvision>=0.16.1", + ] + for arg in expected_args: + assert arg in install_args + + requirement = Requirement.parse("torch>=1.13.0,<=2.0.1") + mocker.patch("anomalib.cli.utils.installation.get_hardware_suffix", return_value="cu111") + install_args = get_torch_install_args(requirement) + expected_args = [ + "--extra-index-url", + "https://download.pytorch.org/whl/cu111", + ] + for arg in expected_args: + assert arg in install_args + + requirement = Requirement.parse("torch==2.0.1") + expected_args = [ + "--extra-index-url", + "https://download.pytorch.org/whl/cu111", + "torch==2.0.1+cu111", + "torchvision==0.15.2+cu111", + ] + install_args = get_torch_install_args(requirement) + for arg in expected_args: + assert arg in install_args + + install_args = get_torch_install_args("torch") + assert install_args == ["torch"] + + mocker.patch("anomalib.cli.utils.installation.platform.system", return_value="Darwin") + requirement = Requirement.parse("torch==2.0.1") + install_args = get_torch_install_args(requirement) + assert install_args == ["torch==2.0.1"] + + mocker.patch("anomalib.cli.utils.installation.platform.system", return_value="Unknown") + with pytest.raises(RuntimeError, match="Unsupported OS: Unknown"): + get_torch_install_args(requirement) diff --git a/tox.ini b/tox.ini index 81d7ab20f0..a81fa9410d 100644 --- a/tox.ini +++ b/tox.ini @@ -26,10 +26,12 @@ deps = coverage[toml] pytest pytest-cov + pytest-mock pytest-order flaky nbmake - -r{toxinidir}/requirements/base.txt + -r{toxinidir}/requirements/installer.txt + -r{toxinidir}/requirements/core.txt -r{toxinidir}/requirements/openvino.txt -r{toxinidir}/requirements/loggers.txt -r{toxinidir}/requirements/notebooks.txt @@ -56,7 +58,8 @@ deps = coverage pytest flaky - -r{toxinidir}/requirements/base.txt + -r{toxinidir}/requirements/installer.txt + -r{toxinidir}/requirements/core.txt -r{toxinidir}/requirements/openvino.txt -r{toxinidir}/requirements/loggers.txt -r{toxinidir}/requirements/notebooks.txt @@ -78,7 +81,8 @@ allowlist_externals = cat install_command = pip install --no-cache-dir {opts} {packages} deps = - -r{toxinidir}/requirements/base.txt + -r{toxinidir}/requirements/installer.txt + -r{toxinidir}/requirements/core.txt -r{toxinidir}/requirements/openvino.txt -r{toxinidir}/requirements/loggers.txt -r{toxinidir}/requirements/notebooks.txt