From 5bdb93e18dabd9f3d1803a14451117c1c3f5b2bb Mon Sep 17 00:00:00 2001 From: Samet Date: Tue, 13 Feb 2024 18:29:53 +0000 Subject: [PATCH 1/9] Create migration tool --- src/anomalib/models/__init__.py | 9 +- tools/upgrade/__init__.py | 4 + tools/upgrade/config.py | 350 ++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 tools/upgrade/__init__.py create mode 100644 tools/upgrade/config.py diff --git a/src/anomalib/models/__init__.py b/src/anomalib/models/__init__.py index 613c06e357..bcd4c84a8d 100644 --- a/src/anomalib/models/__init__.py +++ b/src/anomalib/models/__init__.py @@ -20,6 +20,7 @@ Dfkde, Dfm, Draem, + Dsr, EfficientAd, Fastflow, Ganomaly, @@ -62,7 +63,7 @@ class UnknownModelError(ModuleNotFoundError): logger = logging.getLogger(__name__) -def _convert_pascal_to_snake_case(pascal_case: str) -> str: +def convert_pascal_to_snake_case(pascal_case: str) -> str: """Convert PascalCase to snake_case. Args: @@ -81,7 +82,7 @@ def _convert_pascal_to_snake_case(pascal_case: str) -> str: return re.sub(r"(? str: +def convert_snake_to_pascal_case(snake_case: str) -> str: """Convert snake_case to PascalCase. Args: @@ -110,7 +111,7 @@ def get_available_models() -> set[str]: >>> get_available_models() ['ai_vad', 'cfa', 'cflow', 'csflow', 'dfkde', 'dfm', 'draem', 'efficient_ad', 'fastflow', ...] """ - return {_convert_pascal_to_snake_case(cls.__name__) for cls in AnomalyModule.__subclasses__()} + return {convert_pascal_to_snake_case(cls.__name__) for cls in AnomalyModule.__subclasses__()} def _get_model_class_by_name(name: str) -> type[AnomalyModule]: @@ -128,7 +129,7 @@ def _get_model_class_by_name(name: str) -> type[AnomalyModule]: logger.info("Loading the model.") model_class: type[AnomalyModule] | None = None - name = _convert_snake_to_pascal_case(name).lower() + name = convert_snake_to_pascal_case(name).lower() for model in AnomalyModule.__subclasses__(): if name == model.__name__.lower(): model_class = model diff --git a/tools/upgrade/__init__.py b/tools/upgrade/__init__.py new file mode 100644 index 0000000000..971da28389 --- /dev/null +++ b/tools/upgrade/__init__.py @@ -0,0 +1,4 @@ +"""Upgrade tool.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 diff --git a/tools/upgrade/config.py b/tools/upgrade/config.py new file mode 100644 index 0000000000..1bc8ba9291 --- /dev/null +++ b/tools/upgrade/config.py @@ -0,0 +1,350 @@ +"""Config upgrade tool. + +This module provides a tool for migrating Anomalib configuration files from +v0.* format to v1.* format. The `ConfigAdapter` class in this module is +responsible for migrating different sections of the configuration file. + +Example: + # Create a ConfigAdapter instance with the path to the old config file + adapter = ConfigAdapter("/path/to/old_config.yaml") + + # Upgrade the configuration to v1 format + upgraded_config = adapter.upgrade_all() + + # Save the upgraded configuration to a new file + adapter.save_config(upgraded_config, "/path/to/new_config.yaml") +""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + + +import argparse +import importlib +import inspect +from pathlib import Path +from typing import Any + +import yaml + +from anomalib.models import convert_snake_to_pascal_case +from anomalib.utils.config import to_tuple + + +def get_class_signature(module_path: str, class_name: str) -> inspect.Signature: + """Get the signature of a class constructor. + + Args: + module_path (str): The path to the module containing the class. + class_name (str): The name of the class. + + Returns: + inspect.Signature: The signature of the class constructor. + + Examples: + >>> get_class_signature('my_module', 'MyClass') + + + >>> get_class_signature('other_module', 'OtherClass') + + """ + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + return inspect.signature(cls.__init__) + + +def get_class_init_args(module_path: str, class_name: str) -> dict[str, Any | None]: + """Get the initialization arguments of a class. + + Args: + module_path (str): The path of the module containing the class. + class_name (str): The name of the class. + + Returns: + dict[str, Any | None]: A dictionary containing the initialization arguments + of the class, with argument names as keys and default values as values. + + Example: + >>> get_class_init_args("my_module", "MyClass") + {'arg1': None, 'arg2': 0, 'arg3': 'default'} + """ + init_signature = get_class_signature(module_path, class_name) + return { + k: v.default if v.default is not inspect.Parameter.empty else None + for k, v in init_signature.parameters.items() + if k != "self" + } + + +def overwrite_args( + default_args: dict[str, Any], + new_args: dict[str, Any], + excluded_keys: list[str] | None = None, +) -> dict[str, Any]: + """Overwrite the default arguments with the new arguments. + + Args: + default_args (dict[str, Any]): The default arguments. + new_args (dict[str, Any]): The new arguments. + excluded_keys (list[str] | None, optional): A list of keys to exclude + from the new arguments. + Defaults to ``None``. + + Returns: + dict[str, Any]: The updated arguments. + + Example: + Overwrite the default arguments with the new arguments + >>> default_args = {"a": 1, "b": 2, "c": 3} + >>> new_args = {"b": 4, "c": 5} + >>> updated_args = overwrite_args(default_args, new_args) + >>> print(updated_args) + Output: {"a": 1, "b": 4, "c": 5} + """ + if excluded_keys is None: + excluded_keys = [] + + for key, value in new_args.items(): + if key in default_args and key not in excluded_keys: + default_args[key] = value + + return default_args + + +class ConfigAdapter: + """Class responsible for migrating configuration data.""" + + def __init__(self, config_path: str | Path) -> None: + self.old_config = self.safe_load(config_path) + + def safe_load(self, path: str | Path) -> dict: + """Load a yaml file and return the content as a dictionary.""" + with Path(path).open("r") as f: + return yaml.safe_load(f) + + def upgrade_data_config(self) -> dict[str, Any]: + """Upgrade data config.""" + # Get the dataset class name based on the format in the old config + dataset_class_name = convert_snake_to_pascal_case(self.old_config["dataset"]["format"]) + + # mvtec has an exception and is written as MVTec. Convert all Mvtec datasets to MVTec + dataset_class_name = dataset_class_name.replace("Mvtec", "MVTec") + + # Get the class path and init args. + class_path = f"anomalib.data.{dataset_class_name}" + init_args = get_class_init_args("anomalib.data", dataset_class_name) + + # Replace the old config key ``path`` with ``root`` + if "path" in self.old_config["dataset"]: + self.old_config["dataset"]["root"] = self.old_config["dataset"].pop("path") + + # Overwrite the init_args with the old config + init_args = overwrite_args( + init_args, + self.old_config["dataset"], + excluded_keys=["name", "early_stopping", "normalization_method"], + ) + + # Input size is a list in the old config, convert it to a tuple + init_args["image_size"] = to_tuple(init_args["image_size"]) + + # Enum-based configs are to be converted to uppercase + init_args["task"] = init_args["task"].upper() + init_args["test_split_mode"] = init_args["test_split_mode"].upper() + init_args["val_split_mode"] = init_args["val_split_mode"].upper() + + return { + "data": { + "class_path": class_path, + "init_args": init_args, + }, + } + + def upgrade_model_config(self) -> dict[str, Any]: + """Upgrade the model config to v1 format.""" + # Get the model class name + model_name = convert_snake_to_pascal_case(self.old_config["model"]["name"]) + + # Get the models args. + init_args = get_class_init_args("anomalib.models", model_name) + + # Overwrite the init_args with the old config + init_args = overwrite_args( + init_args, + self.old_config["model"], + excluded_keys=["name", "early_stopping", "normalization_method"], + ) + + return { + "model": { + "class_path": f"anomalib.models.{model_name}", + "init_args": init_args, + }, + } + + def upgrade_normalization_config(self) -> dict[str, Any]: + """Upgrade the normalization config to v1 format.""" + return {"normalization": {"normalization_method": self.old_config["model"]["normalization_method"].upper()}} + + def upgrade_metrics_config(self) -> dict[str, Any]: + """Upgrade the metrics config to v1 format, with streamlined logic.""" + # Define a direct mapping for threshold methods to class names + threshold_class_map = { + "adaptive": "F1AdaptiveThreshold", + "manual": "ManualThreshold", + } + + threshold_method = self.old_config.get("metrics", {}).get("threshold", {}).get("method") + class_name = threshold_class_map.get(threshold_method) + + if not class_name: + msg = f"Unknown threshold method {threshold_method}. Available methods are 'adaptive' or 'manual'." + raise ValueError(msg) + + new_config: dict[str, Any] = { + "metrics": { + "image": self.old_config.get("metrics", {}).get("image"), + "pixel": self.old_config.get("metrics", {}).get("pixel"), + "threshold": { + "class_path": f"anomalib.metrics.{class_name}", + "init_args": {"default_value": 0.5}, + }, + }, + } + + return new_config + + def upgrade_visualization_config(self) -> dict[str, Any]: + """Upgrade the visualization config to v1 format.""" + # Initialize the new configuration with default values from the new format + new_config = { + "visualization": { + "visualizers": None, + "save": False, + "log": False, + "show": False, + }, + } + + # Map old configuration values to the new format + if "visualization" in self.old_config: + old_config = self.old_config["visualization"] + + # Set new configuration values based on the old configuration + new_config["visualization"]["save"] = old_config.get("save_images", False) + new_config["visualization"]["log"] = old_config.get("log_images", False) + new_config["visualization"]["show"] = old_config.get("show_images", False) + + return new_config + + def upgrade_logging_config(self) -> dict[str, Any]: + """Upgrade logging config to v1 format.""" + return {"logging": {"log_graph": self.old_config["logging"]["log_graph"]}} + + def add_results_dir_config(self) -> dict[str, Any]: + """Create results_dir field in v1 config.""" + return { + "results_dir": { + "path": self.old_config["project"]["path"], + "unique": False, + }, + } + + def add_seed_config(self) -> dict[str, Any]: + """Create seed everything field in v1 config.""" + return {"seed_everything": bool(self.old_config["project"]["seed"])} + + def add_ckpt_path_config(self) -> dict[str, Any]: + """Create checkpoint path directory in v1 config.""" + return {"ckpt_path": None} + + def add_task_config(self) -> dict[str, str]: + """Create task field in v1 config.""" + return {"task": self.old_config["dataset"]["task"].upper()} + + def upgrade_trainer_config(self) -> dict[str, Any]: + """Upgrade Trainer config to v1 format.""" + # Get the signature of the Trainer class's __init__ method + init_args = get_class_init_args("lightning.pytorch", "Trainer") + + # Overwrite the init_args with the old config + init_args = overwrite_args(init_args, self.old_config["trainer"], excluded_keys=["strategy"]) + + # Early stopping callback was passed to model config in v0.* + if "early_stopping" in self.old_config.get("model", {}): + early_stopping_config = { + "class_path": "lightning.pytorch.callbacks.EarlyStopping", + "init_args": self.old_config["model"]["early_stopping"], + } + + # Rename metric to monitor + if "metric" in early_stopping_config["init_args"]: + early_stopping_config["init_args"]["monitor"] = early_stopping_config["init_args"].pop("metric") + + if init_args["callbacks"] is None: + init_args["callbacks"] = [early_stopping_config] + else: + init_args["callbacks"].append(early_stopping_config) + + return {"trainer": init_args} + + def upgrade_all(self) -> dict[str, Any]: + """Upgrade Anomalib v0.* config to v1 config format.""" + new_config = {} + + new_config.update(self.upgrade_data_config()) + new_config.update(self.upgrade_model_config()) + new_config.update(self.upgrade_normalization_config()) + new_config.update(self.upgrade_metrics_config()) + new_config.update(self.upgrade_visualization_config()) + new_config.update(self.upgrade_logging_config()) + new_config.update(self.add_seed_config()) + new_config.update(self.add_task_config()) + new_config.update(self.add_results_dir_config()) + new_config.update(self.add_ckpt_path_config()) + new_config.update(self.upgrade_trainer_config()) + + return new_config + + def save_config(self, config: dict, path: str | Path) -> None: + """Save the given configuration dictionary to a YAML file. + + Args: + config (dict): The configuration dictionary to be saved. + path (str | Path): The path to the output file. + + Returns: + None + """ + with Path(path).open("w") as file: + yaml.safe_dump(config, file, sort_keys=False) + + +def main(old_config_path: Path, new_config_path: Path) -> None: + """Upgrade Anomalib configuration file from v0.* to v1.* format. + + Args: + old_config_path (Path): Path to the old configuration file. + new_config_path (Path): Path to the new configuration file. + """ + config_adapter = ConfigAdapter(config_path=old_config_path) + new_config = config_adapter.upgrade_all() + config_adapter.save_config(new_config, new_config_path) + + +if __name__ == "__main__": + # Set up the argument parser + parser = argparse.ArgumentParser(description="Upgrade configuration files from v0.* format to v1.* format.") + parser.add_argument("-i", "--input_config", type=Path, required=True, help="Path to the old configuration file.") + parser.add_argument("-o", "--output_config", type=Path, required=True, help="Path to the new configuration file.") + + # Parse arguments + args = parser.parse_args() + + # Ensure the provided paths are valid + if not args.input_config.exists(): + msg = f"The specified old configuration file does not exist: {args.input_config}" + raise FileNotFoundError(msg) + + # Upgrade the configuration file + main(args.input_config, args.output_config) From 50e0558967886cfe84345f530440a917454c3b58 Mon Sep 17 00:00:00 2001 From: Samet Date: Tue, 13 Feb 2024 19:44:59 +0000 Subject: [PATCH 2/9] Remove excluded keys from data arg overwrites --- tools/upgrade/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/upgrade/config.py b/tools/upgrade/config.py index 1bc8ba9291..92c5b5d7cc 100644 --- a/tools/upgrade/config.py +++ b/tools/upgrade/config.py @@ -142,7 +142,6 @@ def upgrade_data_config(self) -> dict[str, Any]: init_args = overwrite_args( init_args, self.old_config["dataset"], - excluded_keys=["name", "early_stopping", "normalization_method"], ) # Input size is a list in the old config, convert it to a tuple From a172e2642f938103c7824b807d7ed0c57e9eef29 Mon Sep 17 00:00:00 2001 From: Samet Date: Thu, 15 Feb 2024 15:30:55 +0000 Subject: [PATCH 3/9] Fix CLI arg to ensure the enum vs str type --- src/anomalib/cli/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anomalib/cli/cli.py b/src/anomalib/cli/cli.py index aca109d0dc..b2cd90464d 100644 --- a/src/anomalib/cli/cli.py +++ b/src/anomalib/cli/cli.py @@ -141,7 +141,7 @@ def add_arguments_to_parser(self, parser: LightningArgumentParser) -> None: parser.add_argument("--visualization.save", type=bool, default=False) parser.add_argument("--visualization.log", type=bool, default=False) parser.add_argument("--visualization.show", type=bool, default=False) - parser.add_argument("--task", type=TaskType, default=TaskType.SEGMENTATION) + parser.add_argument("--task", type=TaskType | str, default=TaskType.SEGMENTATION) parser.add_argument("--metrics.image", type=list[str] | str | None, default=["F1Score", "AUROC"]) parser.add_argument("--metrics.pixel", type=list[str] | str | None, default=None, required=False) parser.add_argument("--metrics.threshold", type=BaseThreshold | str, default="F1AdaptiveThreshold") From ab200477f506c188d8e56d1e3a8f292eec9f968b Mon Sep 17 00:00:00 2001 From: Samet Date: Thu, 15 Feb 2024 15:31:12 +0000 Subject: [PATCH 4/9] Update the config script --- tools/upgrade/config.py | 62 +++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/tools/upgrade/config.py b/tools/upgrade/config.py index 92c5b5d7cc..1cfb20aa00 100644 --- a/tools/upgrade/config.py +++ b/tools/upgrade/config.py @@ -114,10 +114,11 @@ def overwrite_args( class ConfigAdapter: """Class responsible for migrating configuration data.""" - def __init__(self, config_path: str | Path) -> None: - self.old_config = self.safe_load(config_path) + def __init__(self, config: str | Path | dict[str, Any]) -> None: + self.old_config = self.safe_load(config) if isinstance(config, str | Path) else config - def safe_load(self, path: str | Path) -> dict: + @staticmethod + def safe_load(path: str | Path) -> dict: """Load a yaml file and return the content as a dictionary.""" with Path(path).open("r") as f: return yaml.safe_load(f) @@ -147,11 +148,6 @@ def upgrade_data_config(self) -> dict[str, Any]: # Input size is a list in the old config, convert it to a tuple init_args["image_size"] = to_tuple(init_args["image_size"]) - # Enum-based configs are to be converted to uppercase - init_args["task"] = init_args["task"].upper() - init_args["test_split_mode"] = init_args["test_split_mode"].upper() - init_args["val_split_mode"] = init_args["val_split_mode"].upper() - return { "data": { "class_path": class_path, @@ -183,7 +179,7 @@ def upgrade_model_config(self) -> dict[str, Any]: def upgrade_normalization_config(self) -> dict[str, Any]: """Upgrade the normalization config to v1 format.""" - return {"normalization": {"normalization_method": self.old_config["model"]["normalization_method"].upper()}} + return {"normalization": {"normalization_method": self.old_config["model"]["normalization_method"]}} def upgrade_metrics_config(self) -> dict[str, Any]: """Upgrade the metrics config to v1 format, with streamlined logic.""" @@ -259,7 +255,7 @@ def add_ckpt_path_config(self) -> dict[str, Any]: def add_task_config(self) -> dict[str, str]: """Create task field in v1 config.""" - return {"task": self.old_config["dataset"]["task"].upper()} + return {"task": self.old_config["dataset"]["task"]} def upgrade_trainer_config(self) -> dict[str, Any]: """Upgrade Trainer config to v1 format.""" @@ -280,10 +276,10 @@ def upgrade_trainer_config(self) -> dict[str, Any]: if "metric" in early_stopping_config["init_args"]: early_stopping_config["init_args"]["monitor"] = early_stopping_config["init_args"].pop("metric") - if init_args["callbacks"] is None: - init_args["callbacks"] = [early_stopping_config] - else: - init_args["callbacks"].append(early_stopping_config) + if init_args["callbacks"] is None: + init_args["callbacks"] = [early_stopping_config] + else: + init_args["callbacks"].append(early_stopping_config) return {"trainer": init_args} @@ -305,7 +301,8 @@ def upgrade_all(self) -> dict[str, Any]: return new_config - def save_config(self, config: dict, path: str | Path) -> None: + @staticmethod + def save_config(config: dict, path: str | Path) -> None: """Save the given configuration dictionary to a YAML file. Args: @@ -319,19 +316,8 @@ def save_config(self, config: dict, path: str | Path) -> None: yaml.safe_dump(config, file, sort_keys=False) -def main(old_config_path: Path, new_config_path: Path) -> None: - """Upgrade Anomalib configuration file from v0.* to v1.* format. - - Args: - old_config_path (Path): Path to the old configuration file. - new_config_path (Path): Path to the new configuration file. - """ - config_adapter = ConfigAdapter(config_path=old_config_path) - new_config = config_adapter.upgrade_all() - config_adapter.save_config(new_config, new_config_path) - - -if __name__ == "__main__": +def get_args() -> argparse.Namespace: + """Get the command line arguments.""" # Set up the argument parser parser = argparse.ArgumentParser(description="Upgrade configuration files from v0.* format to v1.* format.") parser.add_argument("-i", "--input_config", type=Path, required=True, help="Path to the old configuration file.") @@ -345,5 +331,21 @@ def main(old_config_path: Path, new_config_path: Path) -> None: msg = f"The specified old configuration file does not exist: {args.input_config}" raise FileNotFoundError(msg) - # Upgrade the configuration file - main(args.input_config, args.output_config) + return args + + +def upgrade(old_config_path: Path, new_config_path: Path) -> None: + """Upgrade Anomalib configuration file from v0.* to v1.* format. + + Args: + old_config_path (Path): Path to the old configuration file. + new_config_path (Path): Path to the new configuration file. + """ + config_adapter = ConfigAdapter(config=old_config_path) + new_config = config_adapter.upgrade_all() + config_adapter.save_config(new_config, new_config_path) + + +if __name__ == "__main__": + args = get_args() + upgrade(args.input_config, args.output_config) From e6eec2dcc36779d135c00e159a49e613fa116c24 Mon Sep 17 00:00:00 2001 From: Samet Date: Thu, 15 Feb 2024 15:37:14 +0000 Subject: [PATCH 5/9] Add tests --- .../tools/upgrade/expected_draem_v1.yaml | 101 ++++++++++++++++ .../tools/upgrade/original_draem_v0.yaml | 110 ++++++++++++++++++ .../integration/tools/upgrade/test_config.py | 46 ++++++++ 3 files changed, 257 insertions(+) create mode 100644 tests/integration/tools/upgrade/expected_draem_v1.yaml create mode 100644 tests/integration/tools/upgrade/original_draem_v0.yaml create mode 100644 tests/integration/tools/upgrade/test_config.py diff --git a/tests/integration/tools/upgrade/expected_draem_v1.yaml b/tests/integration/tools/upgrade/expected_draem_v1.yaml new file mode 100644 index 0000000000..599779ff00 --- /dev/null +++ b/tests/integration/tools/upgrade/expected_draem_v1.yaml @@ -0,0 +1,101 @@ +data: + class_path: anomalib.data.MVTec + init_args: + root: ./datasets/MVTec + category: bottle + image_size: + - 256 + - 256 + center_crop: null + normalization: imagenet + train_batch_size: 72 + eval_batch_size: 32 + num_workers: 8 + task: segmentation + transform_config_train: null + transform_config_eval: null + test_split_mode: from_dir + test_split_ratio: 0.2 + val_split_mode: same_as_test + val_split_ratio: 0.5 + seed: null +model: + class_path: anomalib.models.Draem + init_args: + enable_sspcab: false + sspcab_lambda: 0.1 + anomaly_source_path: null + beta: + - 0.1 + - 1.0 +normalization: + normalization_method: min_max +metrics: + image: + - F1Score + - AUROC + pixel: + - F1Score + - AUROC + threshold: + class_path: anomalib.metrics.F1AdaptiveThreshold + init_args: + default_value: 0.5 +visualization: + visualizers: null + save: true + log: true + show: false +logging: + log_graph: false +seed_everything: true +task: segmentation +results_dir: + path: ./results + unique: false +ckpt_path: null +trainer: + accelerator: auto + strategy: auto + devices: 1 + num_nodes: 1 + precision: 32 + logger: null + callbacks: + - class_path: lightning.pytorch.callbacks.EarlyStopping + init_args: + patience: 20 + mode: max + monitor: pixel_AUROC + fast_dev_run: false + max_epochs: 1 + min_epochs: null + max_steps: -1 + min_steps: null + max_time: null + limit_train_batches: 1.0 + limit_val_batches: 1.0 + limit_test_batches: 1.0 + limit_predict_batches: 1.0 + overfit_batches: 0.0 + val_check_interval: 1.0 + check_val_every_n_epoch: 1 + num_sanity_val_steps: 0 + log_every_n_steps: 50 + enable_checkpointing: true + enable_progress_bar: true + enable_model_summary: true + accumulate_grad_batches: 1 + gradient_clip_val: 0 + gradient_clip_algorithm: norm + deterministic: false + benchmark: false + inference_mode: true + use_distributed_sampler: true + profiler: null + detect_anomaly: false + barebones: false + plugins: null + sync_batchnorm: false + reload_dataloaders_every_n_epochs: 0 + default_root_dir: null diff --git a/tests/integration/tools/upgrade/original_draem_v0.yaml b/tests/integration/tools/upgrade/original_draem_v0.yaml new file mode 100644 index 0000000000..0f98ca7d99 --- /dev/null +++ b/tests/integration/tools/upgrade/original_draem_v0.yaml @@ -0,0 +1,110 @@ +dataset: + name: mvtec + format: mvtec + path: ./datasets/MVTec + category: bottle + task: segmentation + train_batch_size: 72 + eval_batch_size: 32 + num_workers: 8 + image_size: 256 # dimensions to which images are resized (mandatory) + center_crop: null # dimensions to which images are center-cropped after resizing (optional) + normalization: imagenet # data distribution to which the images will be normalized: [none, imagenet] + transform_config: + train: null + eval: null + test_split_mode: from_dir # options: [from_dir, synthetic] + test_split_ratio: 0.2 # fraction of train images held out testing (usage depends on test_split_mode) + val_split_mode: same_as_test # options: [same_as_test, from_test, synthetic] + val_split_ratio: 0.5 # fraction of train/test images held out for validation (usage depends on val_split_mode) + tiling: + apply: false + tile_size: null + stride: null + remove_border_count: 0 + use_random_tiling: False + random_tile_count: 16 + +model: + name: draem + anomaly_source_path: null # optional, e.g. ./datasets/dtd + lr: 0.0001 + enable_sspcab: false + sspcab_lambda: 0.1 + early_stopping: + patience: 20 + metric: pixel_AUROC + mode: max + normalization_method: min_max # options: [none, min_max, cdf] + +metrics: + image: + - F1Score + - AUROC + pixel: + - F1Score + - AUROC + threshold: + method: adaptive #options: [adaptive, manual] + manual_image: null + manual_pixel: null + +visualization: + show_images: False # show images on the screen + save_images: True # save images to the file system + log_images: True # log images to the available loggers (if any) + image_save_path: null # path to which images will be saved + mode: full # options: ["full", "simple"] + +project: + seed: 42 + path: ./results + +logging: + logger: [] # options: [comet, tensorboard, wandb, csv] or combinations. + log_graph: false # Logs the model graph to respective logger. + +optimization: + export_mode: null # options: torch, onnx, openvino +# PL Trainer Args. Don't add extra parameter here. +trainer: + enable_checkpointing: true + default_root_dir: null + gradient_clip_val: 0 + gradient_clip_algorithm: norm + num_nodes: 1 + devices: 1 + enable_progress_bar: true + overfit_batches: 0.0 + track_grad_norm: -1 + check_val_every_n_epoch: 1 # Don't validate before extracting features. + fast_dev_run: false + accumulate_grad_batches: 1 + max_epochs: 1 + min_epochs: null + max_steps: -1 + min_steps: null + max_time: null + limit_train_batches: 1.0 + limit_val_batches: 1.0 + limit_test_batches: 1.0 + limit_predict_batches: 1.0 + val_check_interval: 1.0 # Don't validate before extracting features. + log_every_n_steps: 50 + accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto"> + strategy: null + sync_batchnorm: false + precision: 32 + enable_model_summary: true + num_sanity_val_steps: 0 + profiler: null + benchmark: false + deterministic: false + reload_dataloaders_every_n_epochs: 0 + auto_lr_find: false + replace_sampler_ddp: true + detect_anomaly: false + auto_scale_batch_size: false + plugins: null + move_metrics_to_cpu: false + multiple_trainloader_mode: max_size_cycle diff --git a/tests/integration/tools/upgrade/test_config.py b/tests/integration/tools/upgrade/test_config.py new file mode 100644 index 0000000000..a7a8dc5569 --- /dev/null +++ b/tests/integration/tools/upgrade/test_config.py @@ -0,0 +1,46 @@ +"""Test config upgrade entrypoint script.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + +from tools.upgrade.config import ConfigAdapter + + +class TestConfigAdapter: + """This class contains test cases for the ConfigAdapter class. + + Methods: + - test_config_adapter: + Test case for upgrading and saving the original v0config to v1, + and comparing it to the expected v1 config. + """ + + def test_config_adapter(self, project_path: Path) -> None: + """Test the ConfigAdapter upgrade_all method. + + Test the ConfigAdapter class by upgrading and saving a v0 config to v1, + and then comparing the upgraded config to the expected v1 config. + + Args: + project_path (Path): The path to the project. + + Raises: + AssertionError: If the upgraded config does not match the expected config. + """ + original_config_path = Path(__file__).parent / "original_draem_v0.yaml" + expected_config_path = Path(__file__).parent / "expected_draem_v1.yaml" + upgraded_config_path = project_path / "upgraded_draem_v1.yaml" + + config_adapter = ConfigAdapter(original_config_path) + + # Upgrade and save the original v0 config to v1 + upgraded_config = config_adapter.upgrade_all() + config_adapter.save_config(upgraded_config, upgraded_config_path) + + # Compare the upgraded config to the expected v1 config + # Re-load the upgraded config from the saved file to ensure it is correctly saved + upgraded_config = ConfigAdapter.safe_load(upgraded_config_path) + expected_config = ConfigAdapter.safe_load(expected_config_path) + assert upgraded_config == expected_config From e60f971e692ac86c1c2cf1b9abc79195761e8c00 Mon Sep 17 00:00:00 2001 From: Samet Date: Thu, 15 Feb 2024 15:50:06 +0000 Subject: [PATCH 6/9] Create a migration guide in the docs --- docs/source/index.md | 1 + docs/source/markdown/get_started/migration.md | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 docs/source/markdown/get_started/migration.md diff --git a/docs/source/index.md b/docs/source/index.md index 6803379e4b..b53269e4d0 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -89,6 +89,7 @@ Learn how to develop and contribute to anomalib. :hidden: markdown/get_started/anomalib +markdown/get_started/migration ``` ```{toctree} diff --git a/docs/source/markdown/get_started/migration.md b/docs/source/markdown/get_started/migration.md new file mode 100644 index 0000000000..d558cc2746 --- /dev/null +++ b/docs/source/markdown/get_started/migration.md @@ -0,0 +1,38 @@ +# Migrating from 0.\* to 1.0 + +## Overview + +The 1.0 release of the Anomaly Detection Library (AnomalyLib) introduces several +changes to the library. This guide provides an overview of the changes and how +to migrate from 0.\* to 1.0. + +## Installation + +For installation instructions, refer to the [installation guide](anomalib.md). + +## Changes to the CLI + +### Upgrading the Configuration + +There are several changes to the configuration of Anomalib. The configuration +file has been updated to include new parameters and remove deprecated parameters. +In addition, some parameters have been moved to different sections of the +configuration. + +Anomalib provides a python script to update the configuration file from 0.\* to 1.0. +To update the configuration file, run the following command: + +```bash +python tools/upgrade/config.py \ + --input_config \ + --output_config +``` + +This script will ensure that the configuration file is updated to the 1.0 format. + +In the following sections, we will discuss the changes to the configuration file +in more detail. + +### Changes to the Configuration File + +🚧 To be updated. From 0e911cd8db1844b55ba4f8e789824a79e4395d26 Mon Sep 17 00:00:00 2001 From: Samet Date: Thu, 15 Feb 2024 19:24:26 +0000 Subject: [PATCH 7/9] update migration documentation --- docs/source/markdown/get_started/migration.md | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/docs/source/markdown/get_started/migration.md b/docs/source/markdown/get_started/migration.md index d558cc2746..8380e23a3c 100644 --- a/docs/source/markdown/get_started/migration.md +++ b/docs/source/markdown/get_started/migration.md @@ -35,4 +35,130 @@ in more detail. ### Changes to the Configuration File -🚧 To be updated. +#### Data + +The `data` section of the configuration file has been updated such that the args +can be directly used to instantiate the data object. Below are the differences +between the old and new configuration files highlighted in a markdown diff format. + +```diff +-dataset: ++data: +- name: mvtec +- format: mvtec ++ class_path: anomalib.data.MVTec ++ init_args: +- path: ./datasets/MVTec ++ root: ./datasets/MVTec + category: bottle + image_size: 256 + center_crop: null + normalization: imagenet + train_batch_size: 72 + eval_batch_size: 32 + num_workers: 8 + task: segmentation + test_split_mode: from_dir # options: [from_dir, synthetic] + test_split_ratio: 0.2 # fraction of train images held out testing (usage depends on test_split_mode) + val_split_mode: same_as_test # options: [same_as_test, from_test, synthetic] + val_split_ratio: 0.5 # fraction of train/test images held out for validation (usage depends on val_split_mode) + seed: null +- transform_config: +- train: null +- eval: null ++ transform_config_train: null ++ transform_config_eval: null +- tiling: +- apply: false +- tile_size: null +- stride: null +- remove_border_count: 0 +- use_random_tiling: False +- random_tile_count: 16+data: +``` + +Here is the summary of the changes to the configuration file: + +- The `name` and `format keys` from the old configuration are absent in the new + configuration, possibly integrated into the design of the class at `class_path`. +- Introduction of a `class_path` key in the new configuration specifies the Python + class path for data handling. +- The structure has been streamlined in the new configuration, moving everything + under `data` and `init_args` keys, simplifying the hierarchy. +- `transform_config` keys were split into `transform_config_train` and + `transform_config_eval` to clearly separate training and evaluation configurations. +- The `tiling` section present in the old configuration has been completely + removed in the new configuration. v1.0.0 does not support tiling. This feature + will be added back in a future release. + +#### Model + +Similar to data configuration, the `model` section of the configuration file has +been updated such that the args can be directly used to instantiate the model object. +Below are the differences between the old and new configuration files highlighted +in a markdown diff format. + +```diff + model: +- name: patchcore +- backbone: wide_resnet50_2 +- pre_trained: true +- layers: ++ class_path: anomalib.models.Patchcore ++ init_args: ++ backbone: wide_resnet50_2 ++ pre_trained: true ++ layers: + - layer2 + - layer3 +- coreset_sampling_ratio: 0.1 +- num_neighbors: 9 ++ coreset_sampling_ratio: 0.1 ++ num_neighbors: 9 +- normalization_method: min_max # options: [null, min_max, cdf] ++normalization: ++ normalization_method: min_max +``` + +Here is the summary of the changes to the configuration file: + +- Model Identification: Transition from `name` to `class_path` for specifying + the model, indicating a more explicit reference to the model's implementation. +- Initialization Structure: Introduction of `init_args` to encapsulate model + initialization parameters, suggesting a move towards a more structured and + possibly dynamically loaded configuration system. +- Normalization Method: The `normalization_method` key is removed from the `model` + section and moved to a separate `normalization` section in the new configuration. + +#### Metrics + +The `metrics` section of the configuration file has been updated such that the +args can be directly used to instantiate the metrics object. Below are the differences +between the old and new configuration files highlighted in a markdown diff format. + +```diff +metrics: + image: + - F1Score + - AUROC + pixel: + - F1Score + - AUROC + threshold: +- method: adaptive #options: [adaptive, manual] +- manual_image: null +- manual_pixel: null ++ class_path: anomalib.metrics.F1AdaptiveThreshold ++ init_args: ++ default_value: 0.5 +``` + +Here is the summary of the changes to the configuration file: + +- Metric Identification: Transition from `method` to `class_path` for specifying + the metric, indicating a more explicit reference to the metric's implementation. +- Initialization Structure: Introduction of `init_args` to encapsulate metric initialization + parameters, suggesting a move towards a more structured and possibly dynamically + loaded configuration system. +- Threshold Method: The `method` key is removed from the `threshold` section and + moved to a separate `class_path` section in the new configuration. From b63b49ce82645e1c86c4a95f3057c0adb54b5098 Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 19 Feb 2024 17:07:58 +0000 Subject: [PATCH 8/9] Fix albumentation tests --- tests/unit/data/utils/test_transforms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/data/utils/test_transforms.py b/tests/unit/data/utils/test_transforms.py index f9f0834dcf..b28d18d60e 100644 --- a/tests/unit/data/utils/test_transforms.py +++ b/tests/unit/data/utils/test_transforms.py @@ -84,7 +84,7 @@ def test_load_transforms_from_string() -> None: A.Resize(224, 224, always_apply=True), ], ) - A.save(transform=transforms, filepath=config_path, data_format="yaml") + A.save(transform=transforms, filepath_or_buffer=config_path, data_format="yaml") # Pass a path to config transform = get_transforms(config=config_path) From 4c0d787e8b8aed161d03ccbb483c94a868855036 Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 19 Feb 2024 20:09:48 +0000 Subject: [PATCH 9/9] Fix albumentation tests --- src/anomalib/data/utils/transforms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anomalib/data/utils/transforms.py b/src/anomalib/data/utils/transforms.py index 930f3710bd..be40c99d99 100644 --- a/src/anomalib/data/utils/transforms.py +++ b/src/anomalib/data/utils/transforms.py @@ -120,7 +120,7 @@ def get_transforms( # load transforms from config file elif isinstance(config, str): logger.info("Reading transforms from Albumentations config file: %s.", config) - transforms = A.load(filepath=config, data_format="yaml") + transforms = A.load(filepath_or_buffer=config, data_format="yaml") elif isinstance(config, A.Compose): logger.info("Transforms loaded from Albumentations Compose object") transforms = config