From 7556dd8ca7e4be5761e75a5a2994898766d030a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:21:28 +0100 Subject: [PATCH 01/32] Update timm requirement from <=1.0.7,>=1.0.7 to >=1.0.7,<=1.0.9 (#2274) * Update timm requirement from <=1.0.7,>=1.0.7 to >=1.0.7,<=1.0.9 Updates the requirements on [timm](https://github.com/huggingface/pytorch-image-models) to permit the latest version. - [Release notes](https://github.com/huggingface/pytorch-image-models/releases) - [Commits](https://github.com/huggingface/pytorch-image-models/compare/v1.0.7...v1.0.9) --- updated-dependencies: - dependency-name: timm dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Update pyproject.toml --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Samet Akcay --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9709c1a112..e23a4a92b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ core = [ "matplotlib>=3.4.3", "opencv-python>=4.5.3.56", "pandas>=1.1.0", - "timm<=1.0.7,>=1.0.7", + "timm", "lightning>=2.2", "torch>=2", "torchmetrics>=1.3.2", From 3768bb35d7d1ffae4e8ee801585caa5434ea99cc Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Wed, 25 Sep 2024 10:37:45 +0100 Subject: [PATCH 02/32] =?UTF-8?q?=F0=9F=90=9EUpdate=20`setuptools`=20requi?= =?UTF-8?q?rement=20for=20PEP=20660=20support=20(#2320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update setup tools Signed-off-by: Samet Akcay --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e23a4a92b4..2893ad20c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # SETUP CONFIGURATION. # [build-system] -requires = ["setuptools>=42", "wheel"] +requires = ["setuptools>=64.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] From 983ec58e4a861a8f221222913b45e8ae28e5d44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Rolih?= <61357777+blaz-r@users.noreply.github.com> Date: Thu, 26 Sep 2024 08:53:38 +0200 Subject: [PATCH 03/32] Fix transforms for draem, dsr and rkde (#2324) Signed-off-by: Blaz Rolih --- src/anomalib/models/image/draem/lightning_model.py | 11 +++++++++++ src/anomalib/models/image/dsr/lightning_model.py | 11 +++++++++++ src/anomalib/models/image/rkde/lightning_model.py | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/anomalib/models/image/draem/lightning_model.py b/src/anomalib/models/image/draem/lightning_model.py index f33bff6538..6eb0e197fc 100644 --- a/src/anomalib/models/image/draem/lightning_model.py +++ b/src/anomalib/models/image/draem/lightning_model.py @@ -12,6 +12,7 @@ import torch from lightning.pytorch.utilities.types import STEP_OUTPUT from torch import nn +from torchvision.transforms.v2 import Compose, Resize, Transform from anomalib import LearningType from anomalib.data.utils import Augmenter @@ -150,3 +151,13 @@ def learning_type(self) -> LearningType: LearningType: Learning type of the model. """ return LearningType.ONE_CLASS + + @staticmethod + def configure_transforms(image_size: tuple[int, int] | None = None) -> Transform: + """Default transform for DRAEM. Normalization is not needed as the images are scaled to [0, 1] in Dataset.""" + image_size = image_size or (256, 256) + return Compose( + [ + Resize(image_size, antialias=True), + ], + ) diff --git a/src/anomalib/models/image/dsr/lightning_model.py b/src/anomalib/models/image/dsr/lightning_model.py index b9a1136fd3..8381fce73d 100644 --- a/src/anomalib/models/image/dsr/lightning_model.py +++ b/src/anomalib/models/image/dsr/lightning_model.py @@ -13,6 +13,7 @@ import torch from lightning.pytorch.utilities.types import STEP_OUTPUT, OptimizerLRScheduler from torch import Tensor +from torchvision.transforms.v2 import Compose, Resize, Transform from anomalib import LearningType from anomalib.data.utils import DownloadInfo, download_and_extract @@ -191,3 +192,13 @@ def learning_type(self) -> LearningType: LearningType: Learning type of the model. """ return LearningType.ONE_CLASS + + @staticmethod + def configure_transforms(image_size: tuple[int, int] | None = None) -> Transform: + """Default transform for DSR. Normalization is not needed as the images are scaled to [0, 1] in Dataset.""" + image_size = image_size or (256, 256) + return Compose( + [ + Resize(image_size, antialias=True), + ], + ) diff --git a/src/anomalib/models/image/rkde/lightning_model.py b/src/anomalib/models/image/rkde/lightning_model.py index 02ad6c2564..f8b6af6d7a 100644 --- a/src/anomalib/models/image/rkde/lightning_model.py +++ b/src/anomalib/models/image/rkde/lightning_model.py @@ -11,6 +11,7 @@ import torch from lightning.pytorch.utilities.types import STEP_OUTPUT +from torchvision.transforms.v2 import Compose, Resize, Transform from anomalib import LearningType from anomalib.models.components import AnomalyModule, MemoryBankMixin @@ -143,3 +144,13 @@ def learning_type(self) -> LearningType: LearningType: Learning type of the model. """ return LearningType.ONE_CLASS + + @staticmethod + def configure_transforms(image_size: tuple[int, int] | None = None) -> Transform: + """Default transform for RKDE.""" + image_size = image_size or (240, 360) + return Compose( + [ + Resize(image_size, antialias=True), + ], + ) From f473df85e54e72824cba028a04fc210c57c4c30b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Rolih?= <61357777+blaz-r@users.noreply.github.com> Date: Thu, 26 Sep 2024 09:54:12 +0200 Subject: [PATCH 04/32] Add check before loading metrics data from checkpoint (#2323) Add check before loading from checkpoint Signed-off-by: Blaz Rolih Co-authored-by: Samet Akcay --- .../models/components/base/anomaly_module.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/anomalib/models/components/base/anomaly_module.py b/src/anomalib/models/components/base/anomaly_module.py index 7751818e63..963ce485a3 100644 --- a/src/anomalib/models/components/base/anomaly_module.py +++ b/src/anomalib/models/components/base/anomaly_module.py @@ -168,20 +168,19 @@ def load_state_dict(self, state_dict: OrderedDict[str, Any], strict: bool = True if "pixel_threshold_class" in state_dict: self.pixel_threshold = self._get_instance(state_dict, "pixel_threshold_class") - if "anomaly_maps_normalization_class" in state_dict: - self.anomaly_maps_normalization_metrics = self._get_instance(state_dict, "anomaly_maps_normalization_class") - if "box_scores_normalization_class" in state_dict: - self.box_scores_normalization_metrics = self._get_instance(state_dict, "box_scores_normalization_class") + # check only for pred score normalization metrics, because if this one is present, all others are too if "pred_scores_normalization_class" in state_dict: + self.box_scores_normalization_metrics = self._get_instance(state_dict, "box_scores_normalization_class") + self.anomaly_maps_normalization_metrics = self._get_instance(state_dict, "anomaly_maps_normalization_class") self.pred_scores_normalization_metrics = self._get_instance(state_dict, "pred_scores_normalization_class") - self.normalization_metrics = MetricCollection( - { - "anomaly_maps": self.anomaly_maps_normalization_metrics, - "box_scores": self.box_scores_normalization_metrics, - "pred_scores": self.pred_scores_normalization_metrics, - }, - ) + self.normalization_metrics = MetricCollection( + { + "anomaly_maps": self.anomaly_maps_normalization_metrics, + "box_scores": self.box_scores_normalization_metrics, + "pred_scores": self.pred_scores_normalization_metrics, + }, + ) # Used to load metrics if there is any related data in state_dict self._load_metrics(state_dict) From 191e21f424faad0c7cae0a1d9e588f380f991c5a Mon Sep 17 00:00:00 2001 From: Ashwin Vaidya Date: Wed, 2 Oct 2024 13:46:23 +0200 Subject: [PATCH 05/32] Add PIMO (#2329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PIMO (#1726) * update Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * test binclf curves numpy and numba and fixes Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct som docstrings Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * torch interface and tests Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * torch interface and tests Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * constants regrouped in dataclass as class vars Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * result class was unneccesary for per_image_binclf_curve Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * factorize function _get_threshs_minmax_linspace Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * small docs fixes Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add pimo numpy version and test Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * move validation Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add `shared_fpr_metric` option Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add pimo torch functional version and test Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add torchmetrics interface and test Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * renames and put things in init Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * validate inputs in result objects Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * result objects to from dict and tests Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add save and load methods to result objects and test Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor validations and minor changes Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * test result objects' properties Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * minor refactors Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add missing docstrings Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * minore vocabulary fix for consistency Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add per image scores statistics and test it Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor constants notation Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add stats tests and test it Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * change the meaning of AUPIMO.num_thresh Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * interface to format pairwise test results Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * improve doc Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add optional `paths` to result objects and some minor fixes and refactors Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * remove frozen from dataclasses and some done todos Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * review headers Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * doc modifs Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor `score_less_than_thresh` in `_binclf_one_curve_python` Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct license comments Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * fix doc Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * numba as extra requirement Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor copyrights from jpcbertoldo Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * remove from __future__ import annotations Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor validations names Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * dedupe file path validation Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * fix tests Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * Add todo Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor enums Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * only logger.warning Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor test imports Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor docs Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * refactor some docs Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct pre commit errors Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * remove author tag Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add thrid party program Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * Update src/anomalib/metrics/per_image/pimo.py * move HAS_NUMBA Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * remove PIMOSharedFPRMetric Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * make torchmetrics compute avg by dft Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * pre-commit hooks corrections Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct numpy.trapezoid Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --------- Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> Co-authored-by: Samet Akcay * πŸ—‘οΈ Remove numba (#2313) * remove numba Signed-off-by: Ashwin Vaidya * fix pre-commit checks Signed-off-by: Ashwin Vaidya * add third-party-programs.txt Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * πŸ—‘οΈ Remove unused methods (#2315) * remove numba Signed-off-by: Ashwin Vaidya * fix pre-commit checks Signed-off-by: Ashwin Vaidya * remove all unused methods Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * PIMO: Port Numpy β†’ Torch (#2316) * remove numba Signed-off-by: Ashwin Vaidya * fix pre-commit checks Signed-off-by: Ashwin Vaidya * remove all unused methods Signed-off-by: Ashwin Vaidya * replace numpy with torch Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * πŸ”¨Refactor methods across files (#2321) * remove numba Signed-off-by: Ashwin Vaidya * fix pre-commit checks Signed-off-by: Ashwin Vaidya * remove all unused methods Signed-off-by: Ashwin Vaidya * replace numpy with torch Signed-off-by: Ashwin Vaidya * refactor code Signed-off-by: Ashwin Vaidya * refactor move functional inside update remove path from the metric * Add changes from comments Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * Remove model to model comparison (#2325) * rename to pimo Signed-off-by: Ashwin Vaidya * minor refactor Signed-off-by: Ashwin Vaidya * remove model to model comparison Signed-off-by: Ashwin Vaidya * fix test Signed-off-by: Ashwin Vaidya * PR comments Signed-off-by: Ashwin Vaidya * Minor refactor Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * PR comments Signed-off-by: Ashwin Vaidya * Remove unused enums Signed-off-by: Ashwin Vaidya * update doc strings Signed-off-by: Ashwin Vaidya * update param names Signed-off-by: Ashwin Vaidya * add aupimo basic usage tutorial notebook (#2330) * add aupimo basic usage tutorial notebook Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * update scipy import Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add cite us Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * minor Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * modify texts and add illustration Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * udpate working dir Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --------- Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --------- Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> Signed-off-by: Ashwin Vaidya Co-authored-by: Joao P C Bertoldo <24547377+jpcbertoldo@users.noreply.github.com> Co-authored-by: Samet Akcay --- notebooks/700_metrics/701a_aupimo.ipynb | 549 ++++++++++++++ notebooks/700_metrics/roc_pro_pimo.svg | 690 ++++++++++++++++++ src/anomalib/data/utils/path.py | 14 +- src/anomalib/metrics/__init__.py | 3 + src/anomalib/metrics/pimo/__init__.py | 23 + src/anomalib/metrics/pimo/_validate.py | 427 +++++++++++ .../pimo/binary_classification_curve.py | 334 +++++++++ src/anomalib/metrics/pimo/dataclasses.py | 226 ++++++ src/anomalib/metrics/pimo/functional.py | 355 +++++++++ src/anomalib/metrics/pimo/pimo.py | 296 ++++++++ src/anomalib/metrics/pimo/utils.py | 19 + tests/unit/data/utils/test_path.py | 6 + tests/unit/metrics/pimo/__init__.py | 8 + .../pimo/test_binary_classification_curve.py | 423 +++++++++++ tests/unit/metrics/pimo/test_pimo.py | 368 ++++++++++ third-party-programs.txt | 4 + 16 files changed, 3744 insertions(+), 1 deletion(-) create mode 100644 notebooks/700_metrics/701a_aupimo.ipynb create mode 100644 notebooks/700_metrics/roc_pro_pimo.svg create mode 100644 src/anomalib/metrics/pimo/__init__.py create mode 100644 src/anomalib/metrics/pimo/_validate.py create mode 100644 src/anomalib/metrics/pimo/binary_classification_curve.py create mode 100644 src/anomalib/metrics/pimo/dataclasses.py create mode 100644 src/anomalib/metrics/pimo/functional.py create mode 100644 src/anomalib/metrics/pimo/pimo.py create mode 100644 src/anomalib/metrics/pimo/utils.py create mode 100644 tests/unit/metrics/pimo/__init__.py create mode 100644 tests/unit/metrics/pimo/test_binary_classification_curve.py create mode 100644 tests/unit/metrics/pimo/test_pimo.py diff --git a/notebooks/700_metrics/701a_aupimo.ipynb b/notebooks/700_metrics/701a_aupimo.ipynb new file mode 100644 index 0000000000..e6333df6df --- /dev/null +++ b/notebooks/700_metrics/701a_aupimo.ipynb @@ -0,0 +1,549 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# AUPIMO\n", + "\n", + "Basic usage of the metric AUPIMO (pronounced \"a-u-pee-mo\")." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "# What is AUPIMO?\n", + "\n", + "The `Area Under the Per-Image Overlap [curve]` (AUPIMO) is a metric of recall (higher is better) designed for visual anomaly detection.\n", + "\n", + "Inspired by the [ROC](https://en.wikipedia.org/wiki/Receiver_operating_characteristic) and [PRO](https://link.springer.com/article/10.1007/s11263-020-01400-4) curves, \n", + "\n", + "> AUPIMO is the area under a curve of True Positive Rate (TPR or _recall_) as a function of False Positive Rate (FPR) restricted to a fixed range. \n", + "\n", + "But:\n", + "- the TPR (Y-axis) is *per-image* (1 image = 1 curve/score);\n", + "- the FPR (X-axis) considers the (average of) **normal** images only; \n", + "- the FPR (X-axis) is in log scale and its range is [1e-5, 1e-4]\\* (harder detection task!).\n", + "\n", + "\\* The score (the area under the curve) is normalized to be in [0, 1].\n", + "\n", + "AUPIMO can be interpreted as\n", + "\n", + "> average segmentation recall in an image given that the model (nearly) does not yield false positives in normal images.\n", + "\n", + "References in the last cell.\n", + "\n", + "![AUROC vs. AUPRO vs. AUPIMO](./roc_pro_pimo.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Setup" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install `anomalib` using `pip`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO(jpcbertoldo): replace by `pip install anomalib` when AUPIMO is released # noqa: TD003\n", + "%pip install ../.." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Change the directory to have access to the datasets." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "# NOTE: Provide the path to the dataset root directory.\n", + "# If the datasets is not downloaded, it will be downloaded\n", + "# to this directory.\n", + "dataset_root = Path.cwd().parent.parent / \"datasets\" / \"MVTec\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import torch\n", + "from matplotlib import pyplot as plt\n", + "from matplotlib.ticker import MaxNLocator, PercentFormatter\n", + "from scipy import stats\n", + "\n", + "from anomalib import TaskType\n", + "from anomalib.data import MVTec\n", + "from anomalib.engine import Engine\n", + "from anomalib.metrics import AUPIMO\n", + "from anomalib.models import Padim" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Data Module\n", + "\n", + "We will use dataset Leather from MVTec AD. \n", + "\n", + "> See the notebooks below for more details on datamodules. \n", + "> [github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules]((https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules))" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "task = TaskType.SEGMENTATION\n", + "datamodule = MVTec(\n", + " root=dataset_root,\n", + " category=\"leather\",\n", + " image_size=256,\n", + " train_batch_size=32,\n", + " eval_batch_size=32,\n", + " num_workers=8,\n", + " task=task,\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Model\n", + "\n", + "We will use `PaDiM` (performance is not the best, but it is fast to train).\n", + "\n", + "> See the notebooks below for more details on models. \n", + "> [github.com/openvinotoolkit/anomalib/tree/main/notebooks/200_models](https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/200_models)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Instantiate the model." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "model = Padim(\n", + " # only use one layer to speed it up\n", + " layers=[\"layer1\"],\n", + " n_features=32,\n", + " backbone=\"resnet18\",\n", + " pre_trained=True,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Average AUPIMO (Basic)\n", + "\n", + "The easiest way to use AUPIMO is via the collection of pixel metrics in the engine.\n", + "\n", + "By default, the average AUPIMO is calculated." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "engine = Engine(\n", + " pixel_metrics=\"AUPIMO\", # others can be added\n", + " accelerator=\"auto\", # \\<\"cpu\", \"gpu\", \"tpu\", \"ipu\", \"hpu\", \"auto\">,\n", + " devices=1,\n", + " logger=False,\n", + ")\n", + "engine.fit(datamodule=datamodule, model=model)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "F1Score class exists for backwards compatibility. It will be removed in v1.1. Please use BinaryF1Score from torchmetrics instead\n", + "Metric `AUPIMO` will save all targets and predictions in buffer. For large datasets this may lead to large memory footprint.\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "58335955473a43dab43e586caf66aa11", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Testing: | | 0/? [00:00┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n", + "┃ Test metric ┃ DataLoader 0 ┃\n", + "┑━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n", + "β”‚ image_AUROC β”‚ 0.9735053777694702 β”‚\n", + "β”‚ image_F1Score β”‚ 0.9518716335296631 β”‚\n", + "β”‚ pixel_AUPIMO β”‚ 0.6273086756193275 β”‚\n", + "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n", + "\n" + ], + "text/plain": [ + "┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n", + "┃\u001b[1m \u001b[0m\u001b[1m Test metric \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1m DataLoader 0 \u001b[0m\u001b[1m \u001b[0m┃\n", + "┑━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n", + "β”‚\u001b[36m \u001b[0m\u001b[36m image_AUROC \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.9735053777694702 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", + "β”‚\u001b[36m \u001b[0m\u001b[36m image_F1Score \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.9518716335296631 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", + "β”‚\u001b[36m \u001b[0m\u001b[36m pixel_AUPIMO \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.6273086756193275 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", + "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "[{'pixel_AUPIMO': 0.6273086756193275,\n", + " 'image_AUROC': 0.9735053777694702,\n", + " 'image_F1Score': 0.9518716335296631}]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# will output the AUPIMO score on the test set\n", + "engine.test(datamodule=datamodule, model=model)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Individual AUPIMO Scores (Detailed)\n", + "\n", + "AUPIMO assigns one recall score per anomalous image in the dataset.\n", + "\n", + "It is possible to access each of the individual AUPIMO scores and look at the distribution." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Collect the predictions and the ground truth." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "ckpt_path is not provided. Model weights will not be loaded.\n", + "F1Score class exists for backwards compatibility. It will be removed in v1.1. Please use BinaryF1Score from torchmetrics instead\n", + "Metric `AUPIMO` will save all targets and predictions in buffer. For large datasets this may lead to large memory footprint.\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "678cb90805ee4b7bb1dd0c30944edab9", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Predicting: | | 0/? [00:00" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fig, ax = plt.subplots()\n", + "ax.hist(aupimo_result.aupimos.numpy(), bins=np.linspace(0, 1, 11), edgecolor=\"black\")\n", + "ax.set_ylabel(\"Count (number of images)\")\n", + "ax.yaxis.set_major_locator(MaxNLocator(5, integer=True))\n", + "ax.set_xlim(0, 1)\n", + "ax.set_xlabel(\"AUPIMO [%]\")\n", + "ax.xaxis.set_major_formatter(PercentFormatter(1))\n", + "ax.grid()\n", + "ax.set_title(\"AUPIMO distribution\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cite Us\n", + "\n", + "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "\n", + "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "\n", + "```bibtex\n", + "@misc{bertoldo2024aupimo,\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", + " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " year={2024},\n", + " eprint={2401.01984},\n", + " archivePrefix={arXiv},\n", + " primaryClass={cs.CV},\n", + " url={https://arxiv.org/abs/2401.01984}, \n", + "}\n", + "```\n", + "\n", + "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", + "\n", + "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "anomalib-dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/700_metrics/roc_pro_pimo.svg b/notebooks/700_metrics/roc_pro_pimo.svg new file mode 100644 index 0000000000..b580e89d17 --- /dev/null +++ b/notebooks/700_metrics/roc_pro_pimo.svg @@ -0,0 +1,690 @@ + + + +image/svg+xmlEach curve summarizesthe test set with di + + +ff + + +erent aggregations. + + +ROC + + +PRO + + +One per image! + + +AUROC + + +AUPRO + + +AUPIMO + + +PIMO + + +i + + +i + + +Recall + + diff --git a/src/anomalib/data/utils/path.py b/src/anomalib/data/utils/path.py index 9c3f56273b..7bc61b27fe 100644 --- a/src/anomalib/data/utils/path.py +++ b/src/anomalib/data/utils/path.py @@ -142,13 +142,20 @@ def contains_non_printable_characters(path: str | Path) -> bool: return not printable_pattern.match(str(path)) -def validate_path(path: str | Path, base_dir: str | Path | None = None, should_exist: bool = True) -> Path: +def validate_path( + path: str | Path, + base_dir: str | Path | None = None, + should_exist: bool = True, + extensions: tuple[str, ...] | None = None, +) -> Path: """Validate the path. Args: path (str | Path): Path to validate. base_dir (str | Path): Base directory to restrict file access. should_exist (bool): If True, do not raise an exception if the path does not exist. + extensions (tuple[str, ...] | None): Accepted extensions for the path. An exception is raised if the + path does not have one of the accepted extensions. If None, no check is performed. Defaults to None. Returns: Path: Validated path. @@ -213,6 +220,11 @@ def validate_path(path: str | Path, base_dir: str | Path | None = None, should_e msg = f"Read or execute permissions denied for the path: {path}" raise PermissionError(msg) + # Check if the path has one of the accepted extensions + if extensions is not None and path.suffix not in extensions: + msg = f"Path extension is not accepted. Accepted extensions: {extensions}. Path: {path}" + raise ValueError(msg) + return path diff --git a/src/anomalib/metrics/__init__.py b/src/anomalib/metrics/__init__.py index 4c3eafa811..81bab3c93f 100644 --- a/src/anomalib/metrics/__init__.py +++ b/src/anomalib/metrics/__init__.py @@ -19,6 +19,7 @@ from .f1_max import F1Max from .f1_score import F1Score from .min_max import MinMax +from .pimo import AUPIMO, PIMO from .precision_recall_curve import BinaryPrecisionRecallCurve from .pro import PRO from .threshold import F1AdaptiveThreshold, ManualThreshold @@ -35,6 +36,8 @@ "ManualThreshold", "MinMax", "PRO", + "PIMO", + "AUPIMO", ] logger = logging.getLogger(__name__) diff --git a/src/anomalib/metrics/pimo/__init__.py b/src/anomalib/metrics/pimo/__init__.py new file mode 100644 index 0000000000..174f546e4d --- /dev/null +++ b/src/anomalib/metrics/pimo/__init__.py @@ -0,0 +1,23 @@ +"""Per-Image Metrics.""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from .binary_classification_curve import ThresholdMethod +from .pimo import AUPIMO, PIMO, AUPIMOResult, PIMOResult + +__all__ = [ + # constants + "ThresholdMethod", + # result classes + "PIMOResult", + "AUPIMOResult", + # torchmetrics interfaces + "PIMO", + "AUPIMO", + "StatsOutliersPolicy", +] diff --git a/src/anomalib/metrics/pimo/_validate.py b/src/anomalib/metrics/pimo/_validate.py new file mode 100644 index 0000000000..f0ba7af4bf --- /dev/null +++ b/src/anomalib/metrics/pimo/_validate.py @@ -0,0 +1,427 @@ +"""Utils for validating arguments and results. + +TODO(jpcbertoldo): Move validations to a common place and reuse them across the codebase. +https://github.com/openvinotoolkit/anomalib/issues/2093 +""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import torch +from torch import Tensor + +from .utils import images_classes_from_masks + +logger = logging.getLogger(__name__) + + +def is_num_thresholds_gte2(num_thresholds: int) -> None: + """Validate the number of thresholds is a positive integer >= 2.""" + if not isinstance(num_thresholds, int): + msg = f"Expected the number of thresholds to be an integer, but got {type(num_thresholds)}" + raise TypeError(msg) + + if num_thresholds < 2: + msg = f"Expected the number of thresholds to be larger than 1, but got {num_thresholds}" + raise ValueError(msg) + + +def is_same_shape(*args) -> None: + """Works both for tensors and ndarrays.""" + assert len(args) > 0 + shapes = sorted({tuple(arg.shape) for arg in args}) + if len(shapes) > 1: + msg = f"Expected arguments to have the same shape, but got {shapes}" + raise ValueError(msg) + + +def is_rate(rate: float | int, zero_ok: bool, one_ok: bool) -> None: + """Validates a rate parameter. + + Args: + rate (float | int): The rate to be validated. + zero_ok (bool): Flag indicating if rate can be 0. + one_ok (bool): Flag indicating if rate can be 1. + """ + if not isinstance(rate, float | int): + msg = f"Expected rate to be a float or int, but got {type(rate)}." + raise TypeError(msg) + + if rate < 0.0 or rate > 1.0: + msg = f"Expected rate to be in [0, 1], but got {rate}." + raise ValueError(msg) + + if not zero_ok and rate == 0.0: + msg = "Rate cannot be 0." + raise ValueError(msg) + + if not one_ok and rate == 1.0: + msg = "Rate cannot be 1." + raise ValueError(msg) + + +def is_rate_range(bounds: tuple[float, float]) -> None: + """Validates the range of rates within the bounds. + + Args: + bounds (tuple[float, float]): The lower and upper bounds of the rates. + """ + if not isinstance(bounds, tuple): + msg = f"Expected the bounds to be a tuple, but got {type(bounds)}" + raise TypeError(msg) + + if len(bounds) != 2: + msg = f"Expected the bounds to be a tuple of length 2, but got {len(bounds)}" + raise ValueError(msg) + + lower, upper = bounds + is_rate(lower, zero_ok=False, one_ok=False) + is_rate(upper, zero_ok=False, one_ok=True) + + if lower >= upper: + msg = f"Expected the upper bound to be larger than the lower bound, but got {upper=} <= {lower=}" + raise ValueError(msg) + + +def is_valid_threshold(thresholds: Tensor) -> None: + """Validate that the thresholds are valid and monotonically increasing.""" + if not isinstance(thresholds, Tensor): + msg = f"Expected thresholds to be an Tensor, but got {type(thresholds)}" + raise TypeError(msg) + + if thresholds.ndim != 1: + msg = f"Expected thresholds to be 1D, but got {thresholds.ndim}" + raise ValueError(msg) + + if not thresholds.dtype.is_floating_point: + msg = f"Expected thresholds to be of float type, but got Tensor with dtype {thresholds.dtype}" + raise TypeError(msg) + + # make sure they are strictly increasing + if not torch.all(torch.diff(thresholds) > 0): + msg = "Expected thresholds to be strictly increasing, but it is not." + raise ValueError(msg) + + +def validate_threshold_bounds(threshold_bounds: tuple[float, float]) -> None: + if not isinstance(threshold_bounds, tuple): + msg = f"Expected threshold bounds to be a tuple, but got {type(threshold_bounds)}." + raise TypeError(msg) + + if len(threshold_bounds) != 2: + msg = f"Expected threshold bounds to be a tuple of length 2, but got {len(threshold_bounds)}." + raise ValueError(msg) + + lower, upper = threshold_bounds + + if not isinstance(lower, float): + msg = f"Expected lower threshold bound to be a float, but got {type(lower)}." + raise TypeError(msg) + + if not isinstance(upper, float): + msg = f"Expected upper threshold bound to be a float, but got {type(upper)}." + raise TypeError(msg) + + if upper <= lower: + msg = f"Expected the upper bound to be greater than the lower bound, but got {upper} <= {lower}." + raise ValueError(msg) + + +def is_anomaly_maps(anomaly_maps: Tensor) -> None: + if anomaly_maps.ndim != 3: + msg = f"Expected anomaly maps have 3 dimensions (N, H, W), but got {anomaly_maps.ndim} dimensions" + raise ValueError(msg) + + if not anomaly_maps.dtype.is_floating_point: + msg = ( + "Expected anomaly maps to be an floating Tensor with anomaly scores," + f" but got Tensor with dtype {anomaly_maps.dtype}" + ) + raise TypeError(msg) + + +def is_masks(masks: Tensor) -> None: + if masks.ndim != 3: + msg = f"Expected masks have 3 dimensions (N, H, W), but got {masks.ndim} dimensions" + raise ValueError(msg) + + if masks.dtype == torch.bool: + pass + elif masks.dtype.is_floating_point: + msg = ( + "Expected masks to be an integer or boolean Tensor with ground truth labels, " + f"but got Tensor with dtype {masks.dtype}" + ) + raise TypeError(msg) + else: + # assumes the type to be (signed or unsigned) integer + # this will change with the dataclass refactor + masks_unique_vals = torch.unique(masks) + if torch.any((masks_unique_vals != 0) & (masks_unique_vals != 1)): + msg = ( + "Expected masks to be a *binary* Tensor with ground truth labels, " + f"but got Tensor with unique values {sorted(masks_unique_vals)}" + ) + raise ValueError(msg) + + +def is_binclf_curves(binclf_curves: Tensor, valid_thresholds: Tensor | None) -> None: + if binclf_curves.ndim != 4: + msg = f"Expected binclf curves to be 4D, but got {binclf_curves.ndim}D" + raise ValueError(msg) + + if binclf_curves.shape[-2:] != (2, 2): + msg = f"Expected binclf curves to have shape (..., 2, 2), but got {binclf_curves.shape}" + raise ValueError(msg) + + if binclf_curves.dtype != torch.int64: + msg = f"Expected binclf curves to have dtype int64, but got {binclf_curves.dtype}." + raise TypeError(msg) + + if (binclf_curves < 0).any(): + msg = "Expected binclf curves to have non-negative values, but got negative values." + raise ValueError(msg) + + neg = binclf_curves[:, :, 0, :].sum(axis=-1) # (num_images, num_thresholds) + + if (neg != neg[:, :1]).any(): + msg = "Expected binclf curves to have the same number of negatives per image for every thresh." + raise ValueError(msg) + + pos = binclf_curves[:, :, 1, :].sum(axis=-1) # (num_images, num_thresholds) + + if (pos != pos[:, :1]).any(): + msg = "Expected binclf curves to have the same number of positives per image for every thresh." + raise ValueError(msg) + + if valid_thresholds is None: + return + + if binclf_curves.shape[1] != valid_thresholds.shape[0]: + msg = ( + "Expected the binclf curves to have as many confusion matrices as the thresholds sequence, " + f"but got {binclf_curves.shape[1]} and {valid_thresholds.shape[0]}" + ) + raise RuntimeError(msg) + + +def is_images_classes(images_classes: Tensor) -> None: + if images_classes.ndim != 1: + msg = f"Expected image classes to be 1D, but got {images_classes.ndim}D." + raise ValueError(msg) + + if images_classes.dtype == torch.bool: + pass + elif images_classes.dtype.is_floating_point: + msg = ( + "Expected image classes to be an integer or boolean Tensor with ground truth labels, " + f"but got Tensor with dtype {images_classes.dtype}" + ) + raise TypeError(msg) + else: + # assumes the type to be (signed or unsigned) integer + # this will change with the dataclass refactor + unique_vals = torch.unique(images_classes) + if torch.any((unique_vals != 0) & (unique_vals != 1)): + msg = ( + "Expected image classes to be a *binary* Tensor with ground truth labels, " + f"but got Tensor with unique values {sorted(unique_vals)}" + ) + raise ValueError(msg) + + +def is_rates(rates: Tensor, nan_allowed: bool) -> None: + if rates.ndim != 1: + msg = f"Expected rates to be 1D, but got {rates.ndim}D." + raise ValueError(msg) + + if not rates.dtype.is_floating_point: + msg = f"Expected rates to have dtype of float type, but got {rates.dtype}." + raise ValueError(msg) + + isnan_mask = torch.isnan(rates) + if nan_allowed: + # if they are all nan, then there is nothing to validate + if isnan_mask.all(): + return + valid_values = rates[~isnan_mask] + elif isnan_mask.any(): + msg = "Expected rates to not contain NaN values, but got NaN values." + raise ValueError(msg) + else: + valid_values = rates + + if (valid_values < 0).any(): + msg = "Expected rates to have values in the interval [0, 1], but got values < 0." + raise ValueError(msg) + + if (valid_values > 1).any(): + msg = "Expected rates to have values in the interval [0, 1], but got values > 1." + raise ValueError(msg) + + +def is_rate_curve(rate_curve: Tensor, nan_allowed: bool, decreasing: bool) -> None: + is_rates(rate_curve, nan_allowed=nan_allowed) + + diffs = torch.diff(rate_curve) + diffs_valid = diffs[~torch.isnan(diffs)] if nan_allowed else diffs + + if decreasing and (diffs_valid > 0).any(): + msg = "Expected rate curve to be monotonically decreasing, but got non-monotonically decreasing values." + raise ValueError(msg) + + if not decreasing and (diffs_valid < 0).any(): + msg = "Expected rate curve to be monotonically increasing, but got non-monotonically increasing values." + raise ValueError(msg) + + +def is_per_image_rate_curves(rate_curves: Tensor, nan_allowed: bool, decreasing: bool | None) -> None: + if rate_curves.ndim != 2: + msg = f"Expected per-image rate curves to be 2D, but got {rate_curves.ndim}D." + raise ValueError(msg) + + if not rate_curves.dtype.is_floating_point: + msg = f"Expected per-image rate curves to have dtype of float type, but got {rate_curves.dtype}." + raise ValueError(msg) + + isnan_mask = torch.isnan(rate_curves) + if nan_allowed: + # if they are all nan, then there is nothing to validate + if isnan_mask.all(): + return + valid_values = rate_curves[~isnan_mask] + elif isnan_mask.any(): + msg = "Expected per-image rate curves to not contain NaN values, but got NaN values." + raise ValueError(msg) + else: + valid_values = rate_curves + + if (valid_values < 0).any(): + msg = "Expected per-image rate curves to have values in the interval [0, 1], but got values < 0." + raise ValueError(msg) + + if (valid_values > 1).any(): + msg = "Expected per-image rate curves to have values in the interval [0, 1], but got values > 1." + raise ValueError(msg) + + if decreasing is None: + return + + diffs = torch.diff(rate_curves, axis=1) + diffs_valid = diffs[~torch.isnan(diffs)] if nan_allowed else diffs + + if decreasing and (diffs_valid > 0).any(): + msg = ( + "Expected per-image rate curves to be monotonically decreasing, " + "but got non-monotonically decreasing values." + ) + raise ValueError(msg) + + if not decreasing and (diffs_valid < 0).any(): + msg = ( + "Expected per-image rate curves to be monotonically increasing, " + "but got non-monotonically increasing values." + ) + raise ValueError(msg) + + +def is_scores_batch(scores_batch: torch.Tensor) -> None: + """scores_batch (torch.Tensor): floating (N, D).""" + if not isinstance(scores_batch, torch.Tensor): + msg = f"Expected `scores_batch` to be an torch.Tensor, but got {type(scores_batch)}" + raise TypeError(msg) + + if not scores_batch.dtype.is_floating_point: + msg = ( + "Expected `scores_batch` to be an floating torch.Tensor with anomaly scores_batch," + f" but got torch.Tensor with dtype {scores_batch.dtype}" + ) + raise TypeError(msg) + + if scores_batch.ndim != 2: + msg = f"Expected `scores_batch` to be 2D, but got {scores_batch.ndim}" + raise ValueError(msg) + + +def is_gts_batch(gts_batch: torch.Tensor) -> None: + """gts_batch (torch.Tensor): boolean (N, D).""" + if not isinstance(gts_batch, torch.Tensor): + msg = f"Expected `gts_batch` to be an torch.Tensor, but got {type(gts_batch)}" + raise TypeError(msg) + + if gts_batch.dtype != torch.bool: + msg = ( + "Expected `gts_batch` to be an boolean torch.Tensor with anomaly scores_batch," + f" but got torch.Tensor with dtype {gts_batch.dtype}" + ) + raise TypeError(msg) + + if gts_batch.ndim != 2: + msg = f"Expected `gts_batch` to be 2D, but got {gts_batch.ndim}" + raise ValueError(msg) + + +def has_at_least_one_anomalous_image(masks: torch.Tensor) -> None: + is_masks(masks) + image_classes = images_classes_from_masks(masks) + if (image_classes == 1).sum() == 0: + msg = "Expected at least one ANOMALOUS image, but found none." + raise ValueError(msg) + + +def has_at_least_one_normal_image(masks: torch.Tensor) -> None: + is_masks(masks) + image_classes = images_classes_from_masks(masks) + if (image_classes == 0).sum() == 0: + msg = "Expected at least one NORMAL image, but found none." + raise ValueError(msg) + + +def joint_validate_thresholds_shared_fpr(thresholds: torch.Tensor, shared_fpr: torch.Tensor) -> None: + if thresholds.shape[0] != shared_fpr.shape[0]: + msg = ( + "Expected `thresholds` and `shared_fpr` to have the same number of elements, " + f"but got {thresholds.shape[0]} != {shared_fpr.shape[0]}" + ) + raise ValueError(msg) + + +def is_per_image_tprs(per_image_tprs: torch.Tensor, image_classes: torch.Tensor) -> None: + is_images_classes(image_classes) + # general validations + is_per_image_rate_curves( + per_image_tprs, + nan_allowed=True, # normal images have NaN TPRs + decreasing=None, # not checked here + ) + + # specific to anomalous images + is_per_image_rate_curves( + per_image_tprs[image_classes == 1], + nan_allowed=False, + decreasing=True, + ) + + # specific to normal images + normal_images_tprs = per_image_tprs[image_classes == 0] + if not normal_images_tprs.isnan().all(): + msg = "Expected all normal images to have NaN TPRs, but some have non-NaN values." + raise ValueError(msg) + + +def is_per_image_scores(per_image_scores: torch.Tensor) -> None: + if per_image_scores.ndim != 1: + msg = f"Expected per-image scores to be 1D, but got {per_image_scores.ndim}D." + raise ValueError(msg) + + +def is_image_class(image_class: int) -> None: + if image_class not in {0, 1}: + msg = f"Expected image class to be either 0 for 'normal' or 1 for 'anomalous', but got {image_class}." + raise ValueError(msg) diff --git a/src/anomalib/metrics/pimo/binary_classification_curve.py b/src/anomalib/metrics/pimo/binary_classification_curve.py new file mode 100644 index 0000000000..1a80944041 --- /dev/null +++ b/src/anomalib/metrics/pimo/binary_classification_curve.py @@ -0,0 +1,334 @@ +"""Binary classification curve (numpy-only implementation). + +A binary classification (binclf) matrix (TP, FP, FN, TN) is evaluated at multiple thresholds. + +The thresholds are shared by all instances/images, but their binclf are computed independently for each instance/image. +""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import itertools +import logging +from enum import Enum +from functools import partial + +import numpy as np +import torch + +from . import _validate + +logger = logging.getLogger(__name__) + + +class ThresholdMethod(Enum): + """Sequence of thresholds to use.""" + + GIVEN: str = "given" + MINMAX_LINSPACE: str = "minmax-linspace" + MEAN_FPR_OPTIMIZED: str = "mean-fpr-optimized" + + +def _binary_classification_curve(scores: np.ndarray, gts: np.ndarray, thresholds: np.ndarray) -> np.ndarray: + """One binary classification matrix at each threshold. + + In the case where the thresholds are given (i.e. not considering all possible thresholds based on the scores), + this weird-looking function is faster than the two options in `torchmetrics` on the CPU: + - `_binary_precision_recall_curve_update_vectorized` + - `_binary_precision_recall_curve_update_loop` + (both in module `torchmetrics.functional.classification.precision_recall_curve` in `torchmetrics==1.1.0`). + Note: VALIDATION IS NOT DONE HERE. Make sure to validate the arguments before calling this function. + + Args: + scores (np.ndarray): Anomaly scores (D,). + gts (np.ndarray): Binary (bool) ground truth of shape (D,). + thresholds (np.ndarray): Sequence of thresholds in ascending order (K,). + + Returns: + np.ndarray: Binary classification matrix curve (K, 2, 2) + Details: `anomalib.metrics.per_image.binclf_curve_numpy.binclf_multiple_curves`. + """ + num_th = len(thresholds) + + # POSITIVES + scores_positives = scores[gts] + # the sorting is very important for the algorithm to work and the speedup + scores_positives = np.sort(scores_positives) + # variable updated in the loop; start counting with lowest thresh ==> everything is predicted as positive + num_pos = current_count_tp = scores_positives.size + tps = np.empty((num_th,), dtype=np.int64) + + # NEGATIVES + # same thing but for the negative samples + scores_negatives = scores[~gts] + scores_negatives = np.sort(scores_negatives) + num_neg = current_count_fp = scores_negatives.size + fps = np.empty((num_th,), dtype=np.int64) + + def score_less_than_thresh(score: float, thresh: float) -> bool: + return score < thresh + + # it will progressively drop the scores that are below the current thresh + for thresh_idx, thresh in enumerate(thresholds): + # UPDATE POSITIVES + # < becasue it is the same as ~(>=) + num_drop = sum(1 for _ in itertools.takewhile(partial(score_less_than_thresh, thresh=thresh), scores_positives)) + scores_positives = scores_positives[num_drop:] + current_count_tp -= num_drop + tps[thresh_idx] = current_count_tp + + # UPDATE NEGATIVES + # same with the negatives + num_drop = sum(1 for _ in itertools.takewhile(partial(score_less_than_thresh, thresh=thresh), scores_negatives)) + scores_negatives = scores_negatives[num_drop:] + current_count_fp -= num_drop + fps[thresh_idx] = current_count_fp + + # deduce the rest of the matrix counts + fns = num_pos * np.ones((num_th,), dtype=np.int64) - tps + tns = num_neg * np.ones((num_th,), dtype=np.int64) - fps + + # sequence of dimensions is (thresholds, true class, predicted class) (see docstring) + return np.stack( + [ + np.stack([tns, fps], axis=-1), + np.stack([fns, tps], axis=-1), + ], + axis=-1, + ).transpose(0, 2, 1) + + +def binary_classification_curve( + scores_batch: torch.Tensor, + gts_batch: torch.Tensor, + thresholds: torch.Tensor, +) -> torch.Tensor: + """Returns a binary classification matrix at each threshold for each image in the batch. + + This is a wrapper around `_binary_classification_curve`. + Validation of the arguments is done here (not in the actual implementation functions). + + Note: predicted as positive condition is `score >= thresh`. + + Args: + scores_batch (torch.Tensor): Anomaly scores (N, D,). + gts_batch (torch.Tensor): Binary (bool) ground truth of shape (N, D,). + thresholds (torch.Tensor): Sequence of thresholds in ascending order (K,). + + Returns: + torch.Tensor: Binary classification matrix curves (N, K, 2, 2) + + The last two dimensions are the confusion matrix (ground truth, predictions) + So for each thresh it gives: + - `tp`: `[... , 1, 1]` + - `fp`: `[... , 0, 1]` + - `fn`: `[... , 1, 0]` + - `tn`: `[... , 0, 0]` + + `t` is for `true` and `f` is for `false`, `p` is for `positive` and `n` is for `negative`, so: + - `tp` stands for `true positive` + - `fp` stands for `false positive` + - `fn` stands for `false negative` + - `tn` stands for `true negative` + + The numbers in each confusion matrix are the counts (not the ratios). + + Counts are relative to each instance (i.e. from 0 to D, e.g. the total is the number of pixels in the image). + + Thresholds are shared across all instances, so all confusion matrices, for instance, + at position [:, 0, :, :] are relative to the 1st threshold in `thresholds`. + + Thresholds are sorted in ascending order. + """ + _validate.is_scores_batch(scores_batch) + _validate.is_gts_batch(gts_batch) + _validate.is_same_shape(scores_batch, gts_batch) + _validate.is_valid_threshold(thresholds) + # TODO(ashwinvaidya17): this is kept as numpy for now because it is much faster. + # TEMP-0 + result = np.vectorize(_binary_classification_curve, signature="(n),(n),(k)->(k,2,2)")( + scores_batch.detach().cpu().numpy(), + gts_batch.detach().cpu().numpy(), + thresholds.detach().cpu().numpy(), + ) + return torch.from_numpy(result).to(scores_batch.device) + + +def _get_linspaced_thresholds(anomaly_maps: torch.Tensor, num_thresholds: int) -> torch.Tensor: + """Get thresholds linearly spaced between the min and max of the anomaly maps.""" + _validate.is_num_thresholds_gte2(num_thresholds) + # this operation can be a bit expensive + thresh_low, thresh_high = thresh_bounds = (anomaly_maps.min().item(), anomaly_maps.max().item()) + try: + _validate.validate_threshold_bounds(thresh_bounds) + except ValueError as ex: + msg = f"Invalid threshold bounds computed from the given anomaly maps. Cause: {ex}" + raise ValueError(msg) from ex + return torch.linspace(thresh_low, thresh_high, num_thresholds, dtype=anomaly_maps.dtype) + + +def threshold_and_binary_classification_curve( + anomaly_maps: torch.Tensor, + masks: torch.Tensor, + threshold_choice: ThresholdMethod | str = ThresholdMethod.MINMAX_LINSPACE, + thresholds: torch.Tensor | None = None, + num_thresholds: int | None = None, +) -> tuple[torch.Tensor, torch.Tensor]: + """Return thresholds and binary classification matrix at each threshold for each image in the batch. + + Args: + anomaly_maps (torch.Tensor): Anomaly score maps of shape (N, H, W) + masks (torch.Tensor): Binary ground truth masks of shape (N, H, W) + threshold_choice (str, optional): Sequence of thresholds to use. Defaults to THRESH_SEQUENCE_MINMAX_LINSPACE. + thresholds (torch.Tensor, optional): Sequence of thresholds to use. + Only applicable when threshold_choice is THRESH_SEQUENCE_GIVEN. + num_thresholds (int, optional): Number of thresholds between the min and max of the anomaly maps. + Only applicable when threshold_choice is THRESH_SEQUENCE_MINMAX_LINSPACE. + + Returns: + tuple[torch.Tensor, torch.Tensor]: + [0] Thresholds of shape (K,) and dtype is the same as `anomaly_maps.dtype`. + + [1] Binary classification matrices of shape (N, K, 2, 2) + + N: number of images/instances + K: number of thresholds + + The last two dimensions are the confusion matrix (ground truth, predictions) + So for each thresh it gives: + - `tp`: `[... , 1, 1]` + - `fp`: `[... , 0, 1]` + - `fn`: `[... , 1, 0]` + - `tn`: `[... , 0, 0]` + + `t` is for `true` and `f` is for `false`, `p` is for `positive` and `n` is for `negative`, so: + - `tp` stands for `true positive` + - `fp` stands for `false positive` + - `fn` stands for `false negative` + - `tn` stands for `true negative` + + The numbers in each confusion matrix are the counts of pixels in the image (not the ratios). + + Thresholds are shared across all images, so all confusion matrices, for instance, + at position [:, 0, :, :] are relative to the 1st threshold in `thresholds`. + + Thresholds are sorted in ascending order. + """ + threshold_choice = ThresholdMethod(threshold_choice) + _validate.is_anomaly_maps(anomaly_maps) + _validate.is_masks(masks) + _validate.is_same_shape(anomaly_maps, masks) + + if threshold_choice == ThresholdMethod.GIVEN: + assert thresholds is not None + _validate.is_valid_threshold(thresholds) + if num_thresholds is not None: + logger.warning( + "Argument `num_thresholds` was given, " + f"but it is ignored because `thresholds_choice` is '{threshold_choice.value}'.", + ) + thresholds = thresholds.to(anomaly_maps.dtype) + + elif threshold_choice == ThresholdMethod.MINMAX_LINSPACE: + assert num_thresholds is not None + if thresholds is not None: + logger.warning( + "Argument `thresholds_given` was given, " + f"but it is ignored because `thresholds_choice` is '{threshold_choice.value}'.", + ) + # `num_thresholds` is validated in the function below + thresholds = _get_linspaced_thresholds(anomaly_maps, num_thresholds) + + elif threshold_choice == ThresholdMethod.MEAN_FPR_OPTIMIZED: + raise NotImplementedError(f"TODO implement {threshold_choice.value}") # noqa: EM102 + + else: + msg = ( + f"Expected `threshs_choice` to be from {list(ThresholdMethod.__members__)}," + f" but got '{threshold_choice.value}'" + ) + raise NotImplementedError(msg) + + # keep the batch dimension and flatten the rest + scores_batch = anomaly_maps.reshape(anomaly_maps.shape[0], -1) + gts_batch = masks.reshape(masks.shape[0], -1).to(bool) # make sure it is boolean + + binclf_curves = binary_classification_curve(scores_batch, gts_batch, thresholds) + + num_images = anomaly_maps.shape[0] + + try: + _validate.is_binclf_curves(binclf_curves, valid_thresholds=thresholds) + + # these two validations cannot be done in `_validate.binclf_curves` because it does not have access to the + # original shapes of `anomaly_maps` + if binclf_curves.shape[0] != num_images: + msg = ( + "Expected `binclf_curves` to have the same number of images as `anomaly_maps`, " + f"but got {binclf_curves.shape[0]} and {anomaly_maps.shape[0]}" + ) + raise RuntimeError(msg) + + except (TypeError, ValueError) as ex: + msg = f"Invalid `binclf_curves` was computed. Cause: {ex}" + raise RuntimeError(msg) from ex + + return thresholds, binclf_curves + + +def per_image_tpr(binclf_curves: torch.Tensor) -> torch.Tensor: + """True positive rates (TPR) for image for each thresh. + + TPR = TP / P = TP / (TP + FN) + + TP: true positives + FM: false negatives + P: positives (TP + FN) + + Args: + binclf_curves (torch.Tensor): Binary classification matrix curves (N, K, 2, 2). See `per_image_binclf_curve`. + + Returns: + torch.Tensor: shape (N, K), dtype float64 + N: number of images + K: number of thresholds + + Thresholds are sorted in ascending order, so TPR is in descending order. + """ + # shape: (num images, num thresholds) + tps = binclf_curves[..., 1, 1] + pos = binclf_curves[..., 1, :].sum(axis=2) # 2 was the 3 originally + + # tprs will be nan if pos == 0 (normal image), which is expected + return tps.to(torch.float64) / pos.to(torch.float64) + + +def per_image_fpr(binclf_curves: torch.Tensor) -> torch.Tensor: + """False positive rates (TPR) for image for each thresh. + + FPR = FP / N = FP / (FP + TN) + + FP: false positives + TN: true negatives + N: negatives (FP + TN) + + Args: + binclf_curves (torch.Tensor): Binary classification matrix curves (N, K, 2, 2). See `per_image_binclf_curve`. + + Returns: + torch.Tensor: shape (N, K), dtype float64 + N: number of images + K: number of thresholds + + Thresholds are sorted in ascending order, so FPR is in descending order. + """ + # shape: (num images, num thresholds) + fps = binclf_curves[..., 0, 1] + neg = binclf_curves[..., 0, :].sum(axis=2) # 2 was the 3 originally + + # it can be `nan` if an anomalous image is fully covered by the mask + return fps.to(torch.float64) / neg.to(torch.float64) diff --git a/src/anomalib/metrics/pimo/dataclasses.py b/src/anomalib/metrics/pimo/dataclasses.py new file mode 100644 index 0000000000..0c5aeb025d --- /dev/null +++ b/src/anomalib/metrics/pimo/dataclasses.py @@ -0,0 +1,226 @@ +"""Dataclasses for PIMO metrics.""" + +# Based on the code: https://github.com/jpcbertoldo/aupimo +# +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass, field + +import torch + +from . import _validate, functional + + +@dataclass +class PIMOResult: + """Per-Image Overlap (PIMO, pronounced pee-mo) curve. + + This interface gathers the PIMO curve data and metadata and provides several utility methods. + + Notation: + - N: number of images + - K: number of thresholds + - FPR: False Positive Rate + - TPR: True Positive Rate + + Attributes: + thresholds (torch.Tensor): sequence of K (monotonically increasing) thresholds used to compute the PIMO curve + shared_fpr (torch.Tensor): K values of the shared FPR metric at the corresponding thresholds + per_image_tprs (torch.Tensor): for each of the N images, the K values of in-image TPR at the corresponding + thresholds + """ + + # data + thresholds: torch.Tensor = field(repr=False) # shape => (K,) + shared_fpr: torch.Tensor = field(repr=False) # shape => (K,) + per_image_tprs: torch.Tensor = field(repr=False) # shape => (N, K) + + @property + def num_threshsholds(self) -> int: + """Number of thresholds.""" + return self.thresholds.shape[0] + + @property + def num_images(self) -> int: + """Number of images.""" + return self.per_image_tprs.shape[0] + + @property + def image_classes(self) -> torch.Tensor: + """Image classes (0: normal, 1: anomalous). + + Deduced from the per-image TPRs. + If any TPR value is not NaN, the image is considered anomalous. + """ + return (~torch.isnan(self.per_image_tprs)).any(dim=1).to(torch.int32) + + def __post_init__(self) -> None: + """Validate the inputs for the result object are consistent.""" + try: + _validate.is_valid_threshold(self.thresholds) + _validate.is_rate_curve(self.shared_fpr, nan_allowed=False, decreasing=True) # is_shared_apr + _validate.is_per_image_tprs(self.per_image_tprs, self.image_classes) + + except (TypeError, ValueError) as ex: + msg = f"Invalid inputs for {self.__class__.__name__} object. Cause: {ex}." + raise TypeError(msg) from ex + + if self.thresholds.shape != self.shared_fpr.shape: + msg = ( + f"Invalid {self.__class__.__name__} object. Attributes have inconsistent shapes: " + f"{self.thresholds.shape=} != {self.shared_fpr.shape=}." + ) + raise TypeError(msg) + + if self.thresholds.shape[0] != self.per_image_tprs.shape[1]: + msg = ( + f"Invalid {self.__class__.__name__} object. Attributes have inconsistent shapes: " + f"{self.thresholds.shape[0]=} != {self.per_image_tprs.shape[1]=}." + ) + raise TypeError(msg) + + def thresh_at(self, fpr_level: float) -> tuple[int, float, float]: + """Return the threshold at the given shared FPR. + + See `anomalib.metrics.per_image.pimo_numpy.thresh_at_shared_fpr_level` for details. + + Args: + fpr_level (float): shared FPR level + + Returns: + tuple[int, float, float]: + [0] index of the threshold + [1] threshold + [2] the actual shared FPR value at the returned threshold + """ + return functional.thresh_at_shared_fpr_level( + self.thresholds, + self.shared_fpr, + fpr_level, + ) + + +@dataclass +class AUPIMOResult: + """Area Under the Per-Image Overlap (AUPIMO, pronounced a-u-pee-mo) curve. + + This interface gathers the AUPIMO data and metadata and provides several utility methods. + + Attributes: + fpr_lower_bound (float): [metadata] LOWER bound of the FPR integration range + fpr_upper_bound (float): [metadata] UPPER bound of the FPR integration range + num_thresholds (int): [metadata] number of thresholds used to effectively compute AUPIMO; + should not be confused with the number of thresholds used to compute the PIMO curve + thresh_lower_bound (float): LOWER threshold bound --> corresponds to the UPPER FPR bound + thresh_upper_bound (float): UPPER threshold bound --> corresponds to the LOWER FPR bound + aupimos (torch.Tensor): values of AUPIMO scores (1 per image) + """ + + # metadata + fpr_lower_bound: float + fpr_upper_bound: float + num_thresholds: int + + # data + thresh_lower_bound: float = field(repr=False) + thresh_upper_bound: float = field(repr=False) + aupimos: torch.Tensor = field(repr=False) # shape => (N,) + + @property + def num_images(self) -> int: + """Number of images.""" + return self.aupimos.shape[0] + + @property + def num_normal_images(self) -> int: + """Number of normal images.""" + return int((self.image_classes == 0).sum()) + + @property + def num_anomalous_images(self) -> int: + """Number of anomalous images.""" + return int((self.image_classes == 1).sum()) + + @property + def image_classes(self) -> torch.Tensor: + """Image classes (0: normal, 1: anomalous).""" + # if an instance has `nan` aupimo it's because it's a normal image + return self.aupimos.isnan().to(torch.int32) + + @property + def fpr_bounds(self) -> tuple[float, float]: + """Lower and upper bounds of the FPR integration range.""" + return self.fpr_lower_bound, self.fpr_upper_bound + + @property + def thresh_bounds(self) -> tuple[float, float]: + """Lower and upper bounds of the threshold integration range. + + Recall: they correspond to the FPR bounds in reverse order. + I.e.: + fpr_lower_bound --> thresh_upper_bound + fpr_upper_bound --> thresh_lower_bound + """ + return self.thresh_lower_bound, self.thresh_upper_bound + + def __post_init__(self) -> None: + """Validate the inputs for the result object are consistent.""" + try: + _validate.is_rate_range((self.fpr_lower_bound, self.fpr_upper_bound)) + # TODO(jpcbertoldo): warn when it's too low (use parameters from the numpy code) # noqa: TD003 + _validate.is_num_thresholds_gte2(self.num_thresholds) + _validate.is_rates(self.aupimos, nan_allowed=True) # validate is_aupimos + + _validate.validate_threshold_bounds((self.thresh_lower_bound, self.thresh_upper_bound)) + + except (TypeError, ValueError) as ex: + msg = f"Invalid inputs for {self.__class__.__name__} object. Cause: {ex}." + raise TypeError(msg) from ex + + @classmethod + def from_pimo_result( + cls: type["AUPIMOResult"], + pimo_result: PIMOResult, + fpr_bounds: tuple[float, float], + num_thresholds_auc: int, + aupimos: torch.Tensor, + ) -> "AUPIMOResult": + """Return an AUPIMO result object from a PIMO result object. + + Args: + pimo_result: PIMO result object + fpr_bounds: lower and upper bounds of the FPR integration range + num_thresholds_auc: number of thresholds used to effectively compute AUPIMO; + NOT the number of thresholds used to compute the PIMO curve! + aupimos: AUPIMO scores + paths: paths to the source images to which the AUPIMO scores correspond. + """ + if pimo_result.per_image_tprs.shape[0] != aupimos.shape[0]: + msg = ( + f"Invalid {cls.__name__} object. Attributes have inconsistent shapes: " + f"there are {pimo_result.per_image_tprs.shape[0]} PIMO curves but {aupimos.shape[0]} AUPIMO scores." + ) + raise TypeError(msg) + + if not torch.isnan(aupimos[pimo_result.image_classes == 0]).all(): + msg = "Expected all normal images to have NaN AUPIMOs, but some have non-NaN values." + raise TypeError(msg) + + if torch.isnan(aupimos[pimo_result.image_classes == 1]).any(): + msg = "Expected all anomalous images to have valid AUPIMOs (not nan), but some have NaN values." + raise TypeError(msg) + + fpr_lower_bound, fpr_upper_bound = fpr_bounds + # recall: fpr upper/lower bounds are the same as the thresh lower/upper bounds + _, thresh_lower_bound, __ = pimo_result.thresh_at(fpr_upper_bound) + _, thresh_upper_bound, __ = pimo_result.thresh_at(fpr_lower_bound) + # `_` is the threshold's index, `__` is the actual fpr value + return cls( + fpr_lower_bound=fpr_lower_bound, + fpr_upper_bound=fpr_upper_bound, + num_thresholds=num_thresholds_auc, + thresh_lower_bound=float(thresh_lower_bound), + thresh_upper_bound=float(thresh_upper_bound), + aupimos=aupimos, + ) diff --git a/src/anomalib/metrics/pimo/functional.py b/src/anomalib/metrics/pimo/functional.py new file mode 100644 index 0000000000..7eac07b1bd --- /dev/null +++ b/src/anomalib/metrics/pimo/functional.py @@ -0,0 +1,355 @@ +"""Per-Image Overlap curve (PIMO, pronounced pee-mo) and its area under the curve (AUPIMO). + +Details: `anomalib.metrics.per_image.pimo`. +""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import numpy as np +import torch + +from . import _validate +from .binary_classification_curve import ( + ThresholdMethod, + _get_linspaced_thresholds, + per_image_fpr, + per_image_tpr, + threshold_and_binary_classification_curve, +) +from .utils import images_classes_from_masks + +logger = logging.getLogger(__name__) + + +def pimo_curves( + anomaly_maps: torch.Tensor, + masks: torch.Tensor, + num_thresholds: int, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Compute the Per-IMage Overlap (PIMO, pronounced pee-mo) curves. + + PIMO is a curve of True Positive Rate (TPR) values on each image across multiple anomaly score thresholds. + The anomaly score thresholds are indexed by a (cross-image shared) value of False Positive Rate (FPR) measure on + the normal images. + + Details: `anomalib.metrics.per_image.pimo`. + + Args' notation: + N: number of images + H: image height + W: image width + K: number of thresholds + + Args: + anomaly_maps: floating point anomaly score maps of shape (N, H, W) + masks: binary (bool or int) ground truth masks of shape (N, H, W) + num_thresholds: number of thresholds to compute (K) + + Returns: + tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + [0] thresholds of shape (K,) in ascending order + [1] shared FPR values of shape (K,) in descending order (indices correspond to the thresholds) + [2] per-image TPR curves of shape (N, K), axis 1 in descending order (indices correspond to the thresholds) + [3] image classes of shape (N,) with values 0 (normal) or 1 (anomalous) + """ + # validate the strings are valid + _validate.is_num_thresholds_gte2(num_thresholds) + _validate.is_anomaly_maps(anomaly_maps) + _validate.is_masks(masks) + _validate.is_same_shape(anomaly_maps, masks) + _validate.has_at_least_one_anomalous_image(masks) + _validate.has_at_least_one_normal_image(masks) + + image_classes = images_classes_from_masks(masks) + + # the thresholds are computed here so that they can be restrained to the normal images + # therefore getting a better resolution in terms of FPR quantization + # otherwise the function `binclf_curve_numpy.per_image_binclf_curve` would have the range of thresholds + # computed from all the images (normal + anomalous) + thresholds = _get_linspaced_thresholds( + anomaly_maps[image_classes == 0], + num_thresholds, + ) + + # N: number of images, K: number of thresholds + # shapes are (K,) and (N, K, 2, 2) + thresholds, binclf_curves = threshold_and_binary_classification_curve( + anomaly_maps=anomaly_maps, + masks=masks, + threshold_choice=ThresholdMethod.GIVEN.value, + thresholds=thresholds, + num_thresholds=None, + ) + + shared_fpr: torch.Tensor + # mean-per-image-fpr on normal images + # shape -> (N, K) + per_image_fprs_normals = per_image_fpr(binclf_curves[image_classes == 0]) + try: + _validate.is_per_image_rate_curves(per_image_fprs_normals, nan_allowed=False, decreasing=True) + except ValueError as ex: + msg = f"Cannot compute PIMO because the per-image FPR curves from normal images are invalid. Cause: {ex}" + raise RuntimeError(msg) from ex + + # shape -> (K,) + # this is the only shared FPR metric implemented so far, + # see note about shared FPR in Details: `anomalib.metrics.per_image.pimo`. + shared_fpr = per_image_fprs_normals.mean(axis=0) + + # shape -> (N, K) + per_image_tprs = per_image_tpr(binclf_curves) + + return thresholds, shared_fpr, per_image_tprs, image_classes + + +# =========================================== AUPIMO =========================================== + + +def aupimo_scores( + anomaly_maps: torch.Tensor, + masks: torch.Tensor, + num_thresholds: int = 300_000, + fpr_bounds: tuple[float, float] = (1e-5, 1e-4), + force: bool = False, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, int]: + """Compute the PIMO curves and their Area Under the Curve (i.e. AUPIMO) scores. + + Scores are computed from the integration of the PIMO curves within the given FPR bounds, then normalized to [0, 1]. + It can be thought of as the average TPR of the PIMO curves within the given FPR bounds. + + Details: `anomalib.metrics.per_image.pimo`. + + Args' notation: + N: number of images + H: image height + W: image width + K: number of thresholds + + Args: + anomaly_maps: floating point anomaly score maps of shape (N, H, W) + masks: binary (bool or int) ground truth masks of shape (N, H, W) + num_thresholds: number of thresholds to compute (K) + fpr_bounds: lower and upper bounds of the FPR integration range + force: whether to force the computation despite bad conditions + + Returns: + tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + [0] thresholds of shape (K,) in ascending order + [1] shared FPR values of shape (K,) in descending order (indices correspond to the thresholds) + [2] per-image TPR curves of shape (N, K), axis 1 in descending order (indices correspond to the thresholds) + [3] image classes of shape (N,) with values 0 (normal) or 1 (anomalous) + [4] AUPIMO scores of shape (N,) in [0, 1] + [5] number of points used in the AUC integration + """ + _validate.is_rate_range(fpr_bounds) + + # other validations are done in the `pimo` function + thresholds, shared_fpr, per_image_tprs, image_classes = pimo_curves( + anomaly_maps=anomaly_maps, + masks=masks, + num_thresholds=num_thresholds, + ) + try: + _validate.is_valid_threshold(thresholds) + _validate.is_rate_curve(shared_fpr, nan_allowed=False, decreasing=True) + _validate.is_images_classes(image_classes) + _validate.is_per_image_rate_curves(per_image_tprs[image_classes == 1], nan_allowed=False, decreasing=True) + + except ValueError as ex: + msg = f"Cannot compute AUPIMO because the PIMO curves are invalid. Cause: {ex}" + raise RuntimeError(msg) from ex + + fpr_lower_bound, fpr_upper_bound = fpr_bounds + + # get the threshold indices where the fpr bounds are achieved + fpr_lower_bound_thresh_idx, _, fpr_lower_bound_defacto = thresh_at_shared_fpr_level( + thresholds, + shared_fpr, + fpr_lower_bound, + ) + fpr_upper_bound_thresh_idx, _, fpr_upper_bound_defacto = thresh_at_shared_fpr_level( + thresholds, + shared_fpr, + fpr_upper_bound, + ) + + if not torch.isclose( + fpr_lower_bound_defacto, + torch.tensor(fpr_lower_bound, dtype=fpr_lower_bound_defacto.dtype, device=fpr_lower_bound_defacto.device), + rtol=(rtol := 1e-2), + ): + logger.warning( + "The lower bound of the shared FPR integration range is not exactly achieved. " + f"Expected {fpr_lower_bound} but got {fpr_lower_bound_defacto}, which is not within {rtol=}.", + ) + + if not torch.isclose( + fpr_upper_bound_defacto, + torch.tensor(fpr_upper_bound, dtype=fpr_upper_bound_defacto.dtype, device=fpr_upper_bound_defacto.device), + rtol=rtol, + ): + logger.warning( + "The upper bound of the shared FPR integration range is not exactly achieved. " + f"Expected {fpr_upper_bound} but got {fpr_upper_bound_defacto}, which is not within {rtol=}.", + ) + + # reminder: fpr lower/upper bound is threshold upper/lower bound (reversed) + thresh_lower_bound_idx = fpr_upper_bound_thresh_idx + thresh_upper_bound_idx = fpr_lower_bound_thresh_idx + + # deal with edge cases + if thresh_lower_bound_idx >= thresh_upper_bound_idx: + msg = ( + "The thresholds corresponding to the given `fpr_bounds` are not valid because " + "they matched the same threshold or the are in the wrong order. " + f"FPR upper/lower = threshold lower/upper = {thresh_lower_bound_idx} and {thresh_upper_bound_idx}." + ) + raise RuntimeError(msg) + + # limit the curves to the integration range [lbound, ubound] + shared_fpr_bounded: torch.Tensor = shared_fpr[thresh_lower_bound_idx : (thresh_upper_bound_idx + 1)] + per_image_tprs_bounded: torch.Tensor = per_image_tprs[:, thresh_lower_bound_idx : (thresh_upper_bound_idx + 1)] + + # `shared_fpr` and `tprs` are in descending order; `flip()` reverts to ascending order + shared_fpr_bounded = torch.flip(shared_fpr_bounded, dims=[0]) + per_image_tprs_bounded = torch.flip(per_image_tprs_bounded, dims=[1]) + + # the log's base does not matter because it's a constant factor canceled by normalization factor + shared_fpr_bounded_log = torch.log(shared_fpr_bounded) + + # deal with edge cases + invalid_shared_fpr = ~torch.isfinite(shared_fpr_bounded_log) + + if invalid_shared_fpr.all(): + msg = ( + "Cannot compute AUPIMO because the shared fpr integration range is invalid). " + "Try increasing the number of thresholds." + ) + raise RuntimeError(msg) + + if invalid_shared_fpr.any(): + logger.warning( + "Some values in the shared fpr integration range are nan. " + "The AUPIMO will be computed without these values.", + ) + + # get rid of nan values by removing them from the integration range + shared_fpr_bounded_log = shared_fpr_bounded_log[~invalid_shared_fpr] + per_image_tprs_bounded = per_image_tprs_bounded[:, ~invalid_shared_fpr] + + num_points_integral = int(shared_fpr_bounded_log.shape[0]) + + if num_points_integral <= 30: + msg = ( + "Cannot compute AUPIMO because the shared fpr integration range doesn't have enough points. " + f"Found {num_points_integral} points in the integration range. " + "Try increasing `num_thresholds`." + ) + if not force: + raise RuntimeError(msg) + msg += " Computation was forced!" + logger.warning(msg) + + if num_points_integral < 300: + logger.warning( + "The AUPIMO may be inaccurate because the shared fpr integration range doesn't have enough points. " + f"Found {num_points_integral} points in the integration range. " + "Try increasing `num_thresholds`.", + ) + + aucs: torch.Tensor = torch.trapezoid(per_image_tprs_bounded, x=shared_fpr_bounded_log, axis=1) + + # normalize, then clip(0, 1) makes sure that the values are in [0, 1] in case of numerical errors + normalization_factor = aupimo_normalizing_factor(fpr_bounds) + aucs = (aucs / normalization_factor).clip(0, 1) + + return thresholds, shared_fpr, per_image_tprs, image_classes, aucs, num_points_integral + + +# =========================================== AUX =========================================== + + +def thresh_at_shared_fpr_level( + thresholds: torch.Tensor, + shared_fpr: torch.Tensor, + fpr_level: float, +) -> tuple[int, float, torch.Tensor]: + """Return the threshold and its index at the given shared FPR level. + + Three cases are possible: + - fpr_level == 0: the lowest threshold that achieves 0 FPR is returned + - fpr_level == 1: the highest threshold that achieves 1 FPR is returned + - 0 < fpr_level < 1: the threshold that achieves the closest (higher or lower) FPR to `fpr_level` is returned + + Args: + thresholds: thresholds at which the shared FPR was computed. + shared_fpr: shared FPR values. + fpr_level: shared FPR value at which to get the threshold. + + Returns: + tuple[int, float, float]: + [0] index of the threshold + [1] threshold + [2] the actual shared FPR value at the returned threshold + """ + _validate.is_valid_threshold(thresholds) + _validate.is_rate_curve(shared_fpr, nan_allowed=False, decreasing=True) + _validate.joint_validate_thresholds_shared_fpr(thresholds, shared_fpr) + _validate.is_rate(fpr_level, zero_ok=True, one_ok=True) + + shared_fpr_min, shared_fpr_max = shared_fpr.min(), shared_fpr.max() + + if fpr_level < shared_fpr_min: + msg = ( + "Invalid `fpr_level` because it's out of the range of `shared_fpr` = " + f"[{shared_fpr_min}, {shared_fpr_max}], and got {fpr_level}." + ) + raise ValueError(msg) + + if fpr_level > shared_fpr_max: + msg = ( + "Invalid `fpr_level` because it's out of the range of `shared_fpr` = " + f"[{shared_fpr_min}, {shared_fpr_max}], and got {fpr_level}." + ) + raise ValueError(msg) + + # fpr_level == 0 or 1 are special case + # because there may be multiple solutions, and the chosen should their MINIMUM/MAXIMUM respectively + if fpr_level == 0.0: + index = torch.min(torch.where(shared_fpr == fpr_level)[0]) + + elif fpr_level == 1.0: + index = torch.max(torch.where(shared_fpr == fpr_level)[0]) + + else: + index = torch.argmin(torch.abs(shared_fpr - fpr_level)) + + index = int(index) + fpr_level_defacto = shared_fpr[index] + thresh = thresholds[index] + return index, thresh, fpr_level_defacto + + +def aupimo_normalizing_factor(fpr_bounds: tuple[float, float]) -> float: + """Constant that normalizes the AUPIMO integral to 0-1 range. + + It is the maximum possible value from the integral in AUPIMO's definition. + It corresponds to assuming a constant function T_i: thresh --> 1. + + Args: + fpr_bounds: lower and upper bounds of the FPR integration range. + + Returns: + float: the normalization factor (>0). + """ + _validate.is_rate_range(fpr_bounds) + fpr_lower_bound, fpr_upper_bound = fpr_bounds + # the log's base must be the same as the one used in the integration! + return float(np.log(fpr_upper_bound / fpr_lower_bound)) diff --git a/src/anomalib/metrics/pimo/pimo.py b/src/anomalib/metrics/pimo/pimo.py new file mode 100644 index 0000000000..9703b60b59 --- /dev/null +++ b/src/anomalib/metrics/pimo/pimo.py @@ -0,0 +1,296 @@ +"""Per-Image Overlap curve (PIMO, pronounced pee-mo) and its area under the curve (AUPIMO). + +# PIMO + +PIMO is a curve of True Positive Rate (TPR) values on each image across multiple anomaly score thresholds. +The anomaly score thresholds are indexed by a (shared) valued of False Positive Rate (FPR) measure on the normal images. + +Each *anomalous* image has its own curve such that the X-axis is shared by all of them. + +At a given threshold: + X-axis: Shared FPR (may vary) + 1. Log of the Average of per-image FPR on normal images. + SEE NOTE BELOW. + Y-axis: per-image TP Rate (TPR), or "Overlap" between the ground truth and the predicted masks. + +*** Note about other shared FPR alternatives *** +The shared FPR metric can be made harder by using the cross-image max (or high-percentile) FPRs instead of the mean. +Rationale: this will further punish models that have exceptional FPs in normal images. +So far there is only one shared FPR metric implemented but others will be added in the future. + +# AUPIMO + +`AUPIMO` is the area under each `PIMO` curve with bounded integration range in terms of shared FPR. + +# Disclaimer + +This module implements torch interfaces to access the numpy code in `pimo_numpy.py`. +Tensors are converted to numpy arrays and then passed and validated in the numpy code. +The results are converted back to tensors and eventually wrapped in an dataclass object. + +Validations will preferably happen in ndarray so the numpy code can be reused without torch, +so often times the Tensor arguments will be converted to ndarray and then validated. +""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import torch +from torchmetrics import Metric + +from . import _validate, functional +from .dataclasses import AUPIMOResult, PIMOResult + +logger = logging.getLogger(__name__) + + +class PIMO(Metric): + """Per-IMage Overlap (PIMO, pronounced pee-mo) curves. + + This torchmetrics interface is a wrapper around the functional interface, which is a wrapper around the numpy code. + The tensors are converted to numpy arrays and then passed and validated in the numpy code. + The results are converted back to tensors and wrapped in an dataclass object. + + PIMO is a curve of True Positive Rate (TPR) values on each image across multiple anomaly score thresholds. + The anomaly score thresholds are indexed by a (cross-image shared) value of False Positive Rate (FPR) measure on + the normal images. + + Details: `anomalib.metrics.per_image.pimo`. + + Notation: + N: number of images + H: image height + W: image width + K: number of thresholds + + Attributes: + anomaly_maps: floating point anomaly score maps of shape (N, H, W) + masks: binary (bool or int) ground truth masks of shape (N, H, W) + + Args: + num_thresholds: number of thresholds to compute (K) + binclf_algorithm: algorithm to compute the binary classifier curve (see `binclf_curve_numpy.Algorithm`) + + Returns: + PIMOResult: PIMO curves dataclass object. See `PIMOResult` for details. + """ + + is_differentiable: bool = False + higher_is_better: bool | None = None + full_state_update: bool = False + + num_thresholds: int + binclf_algorithm: str + + anomaly_maps: list[torch.Tensor] + masks: list[torch.Tensor] + + @property + def _is_empty(self) -> bool: + """Return True if the metric has not been updated yet.""" + return len(self.anomaly_maps) == 0 + + @property + def num_images(self) -> int: + """Number of images.""" + return sum(am.shape[0] for am in self.anomaly_maps) + + @property + def image_classes(self) -> torch.Tensor: + """Image classes (0: normal, 1: anomalous).""" + return functional.images_classes_from_masks(self.masks) + + def __init__(self, num_thresholds: int) -> None: + """Per-Image Overlap (PIMO) curve. + + Args: + num_thresholds: number of thresholds used to compute the PIMO curve (K) + """ + super().__init__() + + logger.warning( + f"Metric `{self.__class__.__name__}` will save all targets and predictions in buffer." + " For large datasets this may lead to large memory footprint.", + ) + + # the options below are, redundantly, validated here to avoid reaching + # an error later in the execution + + _validate.is_num_thresholds_gte2(num_thresholds) + self.num_thresholds = num_thresholds + + self.add_state("anomaly_maps", default=[], dist_reduce_fx="cat") + self.add_state("masks", default=[], dist_reduce_fx="cat") + + def update(self, anomaly_maps: torch.Tensor, masks: torch.Tensor) -> None: + """Update lists of anomaly maps and masks. + + Args: + anomaly_maps (torch.Tensor): predictions of the model (ndim == 2, float) + masks (torch.Tensor): ground truth masks (ndim == 2, binary) + """ + _validate.is_anomaly_maps(anomaly_maps) + _validate.is_masks(masks) + _validate.is_same_shape(anomaly_maps, masks) + self.anomaly_maps.append(anomaly_maps) + self.masks.append(masks) + + def compute(self) -> PIMOResult: + """Compute the PIMO curves. + + Call the functional interface `pimo_curves()`, which is a wrapper around the numpy code. + + Returns: + PIMOResult: PIMO curves dataclass object. See `PIMOResult` for details. + """ + if self._is_empty: + msg = "No anomaly maps and masks have been added yet. Please call `update()` first." + raise RuntimeError(msg) + anomaly_maps = torch.concat(self.anomaly_maps, dim=0) + masks = torch.concat(self.masks, dim=0) + + thresholds, shared_fpr, per_image_tprs, _ = functional.pimo_curves( + anomaly_maps, + masks, + self.num_thresholds, + ) + return PIMOResult( + thresholds=thresholds, + shared_fpr=shared_fpr, + per_image_tprs=per_image_tprs, + ) + + +class AUPIMO(PIMO): + """Area Under the Per-Image Overlap (PIMO) curve. + + This torchmetrics interface is a wrapper around the functional interface, which is a wrapper around the numpy code. + The tensors are converted to numpy arrays and then passed and validated in the numpy code. + The results are converted back to tensors and wrapped in an dataclass object. + + Scores are computed from the integration of the PIMO curves within the given FPR bounds, then normalized to [0, 1]. + It can be thought of as the average TPR of the PIMO curves within the given FPR bounds. + + Details: `anomalib.metrics.per_image.pimo`. + + Notation: + N: number of images + H: image height + W: image width + K: number of thresholds + + Attributes: + anomaly_maps: floating point anomaly score maps of shape (N, H, W) + masks: binary (bool or int) ground truth masks of shape (N, H, W) + + Args: + num_thresholds: number of thresholds to compute (K) + fpr_bounds: lower and upper bounds of the FPR integration range + force: whether to force the computation despite bad conditions + + Returns: + tuple[PIMOResult, AUPIMOResult]: PIMO and AUPIMO results dataclass objects. See `PIMOResult` and `AUPIMOResult`. + """ + + fpr_bounds: tuple[float, float] + return_average: bool + force: bool + + @staticmethod + def normalizing_factor(fpr_bounds: tuple[float, float]) -> float: + """Constant that normalizes the AUPIMO integral to 0-1 range. + + It is the maximum possible value from the integral in AUPIMO's definition. + It corresponds to assuming a constant function T_i: thresh --> 1. + + Args: + fpr_bounds: lower and upper bounds of the FPR integration range. + + Returns: + float: the normalization factor (>0). + """ + return functional.aupimo_normalizing_factor(fpr_bounds) + + def __repr__(self) -> str: + """Show the metric name and its integration bounds.""" + lower, upper = self.fpr_bounds + return f"{self.__class__.__name__}([{lower:.2g}, {upper:.2g}])" + + def __init__( + self, + num_thresholds: int = 300_000, + fpr_bounds: tuple[float, float] = (1e-5, 1e-4), + return_average: bool = True, + force: bool = False, + ) -> None: + """Area Under the Per-Image Overlap (PIMO) curve. + + Args: + num_thresholds: [passed to parent `PIMO`] number of thresholds used to compute the PIMO curve + fpr_bounds: lower and upper bounds of the FPR integration range + return_average: if True, return the average AUPIMO score; if False, return all the individual AUPIMO scores + force: if True, force the computation of the AUPIMO scores even in bad conditions (e.g. few points) + """ + super().__init__(num_thresholds=num_thresholds) + + # other validations are done in PIMO.__init__() + + _validate.is_rate_range(fpr_bounds) + self.fpr_bounds = fpr_bounds + self.return_average = return_average + self.force = force + + def compute(self, force: bool | None = None) -> tuple[PIMOResult, AUPIMOResult]: # type: ignore[override] + """Compute the PIMO curves and their Area Under the curve (AUPIMO) scores. + + Call the functional interface `aupimo_scores()`, which is a wrapper around the numpy code. + + Args: + force: if given (not None), override the `force` attribute. + + Returns: + tuple[PIMOResult, AUPIMOResult]: PIMO curves and AUPIMO scores dataclass objects. + See `PIMOResult` and `AUPIMOResult` for details. + """ + if self._is_empty: + msg = "No anomaly maps and masks have been added yet. Please call `update()` first." + raise RuntimeError(msg) + anomaly_maps = torch.concat(self.anomaly_maps, dim=0) + masks = torch.concat(self.masks, dim=0) + force = force if force is not None else self.force + + # other validations are done in the numpy code + + thresholds, shared_fpr, per_image_tprs, _, aupimos, num_thresholds_auc = functional.aupimo_scores( + anomaly_maps, + masks, + self.num_thresholds, + fpr_bounds=self.fpr_bounds, + force=force, + ) + + pimo_result = PIMOResult( + thresholds=thresholds, + shared_fpr=shared_fpr, + per_image_tprs=per_image_tprs, + ) + aupimo_result = AUPIMOResult.from_pimo_result( + pimo_result, + fpr_bounds=self.fpr_bounds, + # not `num_thresholds`! + # `num_thresholds` is the number of thresholds used to compute the PIMO curve + # this is the number of thresholds used to compute the AUPIMO integral + num_thresholds_auc=num_thresholds_auc, + aupimos=aupimos, + ) + if self.return_average: + # normal images have NaN AUPIMO scores + is_nan = torch.isnan(aupimo_result.aupimos) + return aupimo_result.aupimos[~is_nan].mean() + return pimo_result, aupimo_result diff --git a/src/anomalib/metrics/pimo/utils.py b/src/anomalib/metrics/pimo/utils.py new file mode 100644 index 0000000000..f0cac45657 --- /dev/null +++ b/src/anomalib/metrics/pimo/utils.py @@ -0,0 +1,19 @@ +"""Torch-oriented interfaces for `utils.py`.""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import torch + +logger = logging.getLogger(__name__) + + +def images_classes_from_masks(masks: torch.Tensor) -> torch.Tensor: + """Deduce the image classes from the masks.""" + return (masks == 1).any(axis=(1, 2)).to(torch.int32) diff --git a/tests/unit/data/utils/test_path.py b/tests/unit/data/utils/test_path.py index c3f134b021..09f88496ad 100644 --- a/tests/unit/data/utils/test_path.py +++ b/tests/unit/data/utils/test_path.py @@ -76,3 +76,9 @@ def test_no_read_execute_permission() -> None: Path(tmp_dir).chmod(0o222) # Remove read and execute permission with pytest.raises(PermissionError, match=r"Read or execute permissions denied for the path:*"): validate_path(tmp_dir, base_dir=Path(tmp_dir)) + + @staticmethod + def test_file_wrongsuffix() -> None: + """Test ``validate_path`` raises ValueError for a file with wrong suffix.""" + with pytest.raises(ValueError, match="Path extension is not accepted."): + validate_path("file.png", should_exist=False, extensions=(".json", ".txt")) diff --git a/tests/unit/metrics/pimo/__init__.py b/tests/unit/metrics/pimo/__init__.py new file mode 100644 index 0000000000..555d67a102 --- /dev/null +++ b/tests/unit/metrics/pimo/__init__.py @@ -0,0 +1,8 @@ +"""Per-Image Metrics Tests.""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/metrics/pimo/test_binary_classification_curve.py b/tests/unit/metrics/pimo/test_binary_classification_curve.py new file mode 100644 index 0000000000..5459d08a14 --- /dev/null +++ b/tests/unit/metrics/pimo/test_binary_classification_curve.py @@ -0,0 +1,423 @@ +"""Tests for per-image binary classification curves using numpy version.""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +# ruff: noqa: SLF001, PT011 + +import pytest +import torch + +from anomalib.metrics.pimo.binary_classification_curve import ( + _binary_classification_curve, + binary_classification_curve, + per_image_fpr, + per_image_tpr, + threshold_and_binary_classification_curve, +) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Generate test cases.""" + pred = torch.arange(1, 5, dtype=torch.float32) + thresholds = torch.arange(1, 5, dtype=torch.float32) + + gt_norm = torch.zeros(4).to(bool) + gt_anom = torch.concatenate([torch.zeros(2), torch.ones(2)]).to(bool) + + # in the case where thresholds are all unique values in the predictions + expected_norm = torch.stack( + [ + torch.tensor([[0, 4], [0, 0]]), + torch.tensor([[1, 3], [0, 0]]), + torch.tensor([[2, 2], [0, 0]]), + torch.tensor([[3, 1], [0, 0]]), + ], + axis=0, + ).to(int) + expected_anom = torch.stack( + [ + torch.tensor([[0, 2], [0, 2]]), + torch.tensor([[1, 1], [0, 2]]), + torch.tensor([[2, 0], [0, 2]]), + torch.tensor([[2, 0], [1, 1]]), + ], + axis=0, + ).to(int) + + expected_tprs_norm = torch.tensor([torch.nan, torch.nan, torch.nan, torch.nan]) + expected_tprs_anom = torch.tensor([1.0, 1.0, 1.0, 0.5]) + expected_tprs = torch.stack([expected_tprs_anom, expected_tprs_norm], axis=0).to(torch.float64) + + expected_fprs_norm = torch.tensor([1.0, 0.75, 0.5, 0.25]) + expected_fprs_anom = torch.tensor([1.0, 0.5, 0.0, 0.0]) + expected_fprs = torch.stack([expected_fprs_anom, expected_fprs_norm], axis=0).to(torch.float64) + + # in the case where all thresholds are higher than the highest prediction + expected_norm_thresholds_too_high = torch.stack( + [ + torch.tensor([[4, 0], [0, 0]]), + torch.tensor([[4, 0], [0, 0]]), + torch.tensor([[4, 0], [0, 0]]), + torch.tensor([[4, 0], [0, 0]]), + ], + axis=0, + ).to(int) + expected_anom_thresholds_too_high = torch.stack( + [ + torch.tensor([[2, 0], [2, 0]]), + torch.tensor([[2, 0], [2, 0]]), + torch.tensor([[2, 0], [2, 0]]), + torch.tensor([[2, 0], [2, 0]]), + ], + axis=0, + ).to(int) + + # in the case where all thresholds are lower than the lowest prediction + expected_norm_thresholds_too_low = torch.stack( + [ + torch.tensor([[0, 4], [0, 0]]), + torch.tensor([[0, 4], [0, 0]]), + torch.tensor([[0, 4], [0, 0]]), + torch.tensor([[0, 4], [0, 0]]), + ], + axis=0, + ).to(int) + expected_anom_thresholds_too_low = torch.stack( + [ + torch.tensor([[0, 2], [0, 2]]), + torch.tensor([[0, 2], [0, 2]]), + torch.tensor([[0, 2], [0, 2]]), + torch.tensor([[0, 2], [0, 2]]), + ], + axis=0, + ).to(int) + + if metafunc.function is test__binclf_one_curve: + metafunc.parametrize( + argnames=("pred", "gt", "thresholds", "expected"), + argvalues=[ + (pred, gt_anom, thresholds[:3], expected_anom[:3]), + (pred, gt_anom, thresholds, expected_anom), + (pred, gt_norm, thresholds, expected_norm), + (pred, gt_norm, 10 * thresholds, expected_norm_thresholds_too_high), + (pred, gt_anom, 10 * thresholds, expected_anom_thresholds_too_high), + (pred, gt_norm, 0.001 * thresholds, expected_norm_thresholds_too_low), + (pred, gt_anom, 0.001 * thresholds, expected_anom_thresholds_too_low), + ], + ) + + preds = torch.stack([pred, pred], axis=0) + gts = torch.stack([gt_anom, gt_norm], axis=0) + binclf_curves = torch.stack([expected_anom, expected_norm], axis=0) + binclf_curves_thresholds_too_high = torch.stack( + [expected_anom_thresholds_too_high, expected_norm_thresholds_too_high], + axis=0, + ) + binclf_curves_thresholds_too_low = torch.stack( + [expected_anom_thresholds_too_low, expected_norm_thresholds_too_low], + axis=0, + ) + + if metafunc.function is test__binclf_multiple_curves: + metafunc.parametrize( + argnames=("preds", "gts", "thresholds", "expecteds"), + argvalues=[ + (preds, gts, thresholds[:3], binclf_curves[:, :3]), + (preds, gts, thresholds, binclf_curves), + ], + ) + + if metafunc.function is test_binclf_multiple_curves: + metafunc.parametrize( + argnames=( + "preds", + "gts", + "thresholds", + "expected_binclf_curves", + ), + argvalues=[ + (preds[:1], gts[:1], thresholds, binclf_curves[:1]), + (preds, gts, thresholds, binclf_curves), + (10 * preds, gts, 10 * thresholds, binclf_curves), + ], + ) + + if metafunc.function is test_binclf_multiple_curves_validations: + metafunc.parametrize( + argnames=("args", "kwargs", "exception"), + argvalues=[ + # `scores` and `gts` must be 2D + ([preds.reshape(2, 2, 2), gts, thresholds], {}, ValueError), + ([preds, gts.flatten(), thresholds], {}, ValueError), + # `thresholds` must be 1D + ([preds, gts, thresholds.reshape(2, 2)], {}, ValueError), + # `scores` and `gts` must have the same shape + ([preds, gts[:1], thresholds], {}, ValueError), + ([preds[:, :2], gts, thresholds], {}, ValueError), + # `scores` be of type float + ([preds.to(int), gts, thresholds], {}, TypeError), + # `gts` be of type bool + ([preds, gts.to(int), thresholds], {}, TypeError), + # `thresholds` be of type float + ([preds, gts, thresholds.to(int)], {}, TypeError), + # `thresholds` must be sorted in ascending order + ([preds, gts, torch.flip(thresholds, dims=[0])], {}, ValueError), + ([preds, gts, torch.concatenate([thresholds[-2:], thresholds[:2]])], {}, ValueError), + # `thresholds` must be unique + ([preds, gts, torch.sort(torch.concatenate([thresholds, thresholds]))[0]], {}, ValueError), + ], + ) + + # the following tests are for `per_image_binclf_curve()`, which expects + # inputs in image spatial format, i.e. (height, width) + preds = preds.reshape(2, 2, 2) + gts = gts.reshape(2, 2, 2) + + per_image_binclf_curves_argvalues = [ + # `thresholds_choice` = "given" + ( + preds, + gts, + "given", + thresholds, + None, + thresholds, + binclf_curves, + ), + ( + preds, + gts, + "given", + 10 * thresholds, + 2, + 10 * thresholds, + binclf_curves_thresholds_too_high, + ), + ( + preds, + gts, + "given", + 0.01 * thresholds, + None, + 0.01 * thresholds, + binclf_curves_thresholds_too_low, + ), + # `thresholds_choice` = 'minmax-linspace'" + ( + preds, + gts, + "minmax-linspace", + None, + len(thresholds), + thresholds, + binclf_curves, + ), + ( + 2 * preds, + gts.to(int), # this is ok + "minmax-linspace", + None, + len(thresholds), + 2 * thresholds, + binclf_curves, + ), + ] + + if metafunc.function is test_per_image_binclf_curve: + metafunc.parametrize( + argnames=( + "anomaly_maps", + "masks", + "threshold_choice", + "thresholds", + "num_thresholds", + "expected_thresholds", + "expected_binclf_curves", + ), + argvalues=per_image_binclf_curves_argvalues, + ) + + if metafunc.function is test_per_image_binclf_curve_validations: + metafunc.parametrize( + argnames=("args", "exception"), + argvalues=[ + # `scores` and `gts` must be 3D + ([preds.reshape(2, 2, 2, 1), gts], ValueError), + ([preds, gts.flatten()], ValueError), + # `scores` and `gts` must have the same shape + ([preds, gts[:1]], ValueError), + ([preds[:, :1], gts], ValueError), + # `scores` be of type float + ([preds.to(int), gts], TypeError), + # `gts` be of type bool or int + ([preds, gts.to(float)], TypeError), + # `thresholds` be of type float + ([preds, gts, thresholds.to(int)], TypeError), + ], + ) + metafunc.parametrize( + argnames=("kwargs",), + argvalues=[ + ( + { + "threshold_choice": "minmax-linspace", + "thresholds": None, + "num_thresholds": len(thresholds), + }, + ), + ], + ) + + # same as above but testing other validations + if metafunc.function is test_per_image_binclf_curve_validations_alt: + metafunc.parametrize( + argnames=("args", "kwargs", "exception"), + argvalues=[ + # invalid `thresholds_choice` + ( + [preds, gts], + {"threshold_choice": "glfrb", "thresholds": thresholds, "num_thresholds": None}, + ValueError, + ), + ], + ) + + if metafunc.function is test_rate_metrics: + metafunc.parametrize( + argnames=("binclf_curves", "expected_fprs", "expected_tprs"), + argvalues=[ + (binclf_curves, expected_fprs, expected_tprs), + (10 * binclf_curves, expected_fprs, expected_tprs), + ], + ) + + +# ================================================================================================== +# LOW-LEVEL FUNCTIONS (PYTHON) + + +def test__binclf_one_curve( + pred: torch.Tensor, + gt: torch.Tensor, + thresholds: torch.Tensor, + expected: torch.Tensor, +) -> None: + """Test if `_binclf_one_curve()` returns the expected values.""" + computed = _binary_classification_curve(pred, gt, thresholds) + assert computed.shape == (thresholds.numel(), 2, 2) + assert (computed == expected.numpy()).all() + + +def test__binclf_multiple_curves( + preds: torch.Tensor, + gts: torch.Tensor, + thresholds: torch.Tensor, + expecteds: torch.Tensor, +) -> None: + """Test if `_binclf_multiple_curves()` returns the expected values.""" + computed = binary_classification_curve(preds, gts, thresholds) + assert computed.shape == (preds.shape[0], thresholds.numel(), 2, 2) + assert (computed == expecteds).all() + + +# ================================================================================================== +# API FUNCTIONS (NUMPY) + + +def test_binclf_multiple_curves( + preds: torch.Tensor, + gts: torch.Tensor, + thresholds: torch.Tensor, + expected_binclf_curves: torch.Tensor, +) -> None: + """Test if `binclf_multiple_curves()` returns the expected values.""" + computed = binary_classification_curve( + preds, + gts, + thresholds, + ) + assert computed.shape == expected_binclf_curves.shape + assert (computed == expected_binclf_curves).all() + + # it's ok to have the threhsholds beyond the range of the preds + binary_classification_curve(preds, gts, 2 * thresholds) + + # or inside the bounds without reaching them + binary_classification_curve(preds, gts, 0.5 * thresholds) + + # it's also ok to have more thresholds than unique values in the preds + # add the values in between the thresholds + thresholds_unncessary = 0.5 * (thresholds[:-1] + thresholds[1:]) + thresholds_unncessary = torch.concatenate([thresholds_unncessary, thresholds]) + thresholds_unncessary = torch.sort(thresholds_unncessary)[0] + binary_classification_curve(preds, gts, thresholds_unncessary) + + # or less + binary_classification_curve(preds, gts, thresholds[1:3]) + + +def test_binclf_multiple_curves_validations(args: list, kwargs: dict, exception: Exception) -> None: + """Test if `_binclf_multiple_curves_python()` raises the expected errors.""" + with pytest.raises(exception): + binary_classification_curve(*args, **kwargs) + + +def test_per_image_binclf_curve( + anomaly_maps: torch.Tensor, + masks: torch.Tensor, + threshold_choice: str, + thresholds: torch.Tensor | None, + num_thresholds: int | None, + expected_thresholds: torch.Tensor, + expected_binclf_curves: torch.Tensor, +) -> None: + """Test if `per_image_binclf_curve()` returns the expected values.""" + computed_thresholds, computed_binclf_curves = threshold_and_binary_classification_curve( + anomaly_maps, + masks, + threshold_choice=threshold_choice, + thresholds=thresholds, + num_thresholds=num_thresholds, + ) + + # thresholds + assert computed_thresholds.shape == expected_thresholds.shape + assert computed_thresholds.dtype == computed_thresholds.dtype + assert (computed_thresholds == expected_thresholds).all() + + # binclf_curves + assert computed_binclf_curves.shape == expected_binclf_curves.shape + assert computed_binclf_curves.dtype == expected_binclf_curves.dtype + assert (computed_binclf_curves == expected_binclf_curves).all() + + +def test_per_image_binclf_curve_validations(args: list, kwargs: dict, exception: Exception) -> None: + """Test if `per_image_binclf_curve()` raises the expected errors.""" + with pytest.raises(exception): + threshold_and_binary_classification_curve(*args, **kwargs) + + +def test_per_image_binclf_curve_validations_alt(args: list, kwargs: dict, exception: Exception) -> None: + """Test if `per_image_binclf_curve()` raises the expected errors.""" + test_per_image_binclf_curve_validations(args, kwargs, exception) + + +def test_rate_metrics( + binclf_curves: torch.Tensor, + expected_fprs: torch.Tensor, + expected_tprs: torch.Tensor, +) -> None: + """Test if rate metrics are computed correctly.""" + tprs = per_image_tpr(binclf_curves) + fprs = per_image_fpr(binclf_curves) + + assert tprs.shape == expected_tprs.shape + assert fprs.shape == expected_fprs.shape + + assert torch.allclose(tprs, expected_tprs, equal_nan=True) + assert torch.allclose(fprs, expected_fprs, equal_nan=True) diff --git a/tests/unit/metrics/pimo/test_pimo.py b/tests/unit/metrics/pimo/test_pimo.py new file mode 100644 index 0000000000..81bafe4c8e --- /dev/null +++ b/tests/unit/metrics/pimo/test_pimo.py @@ -0,0 +1,368 @@ +"""Test `anomalib.metrics.per_image.functional`.""" + +# Original Code +# https://github.com/jpcbertoldo/aupimo +# +# Modified +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import pytest +import torch +from torch import Tensor + +from anomalib.metrics.pimo import AUPIMOResult, PIMOResult, functional, pimo + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Generate tests for all functions in this module. + + All functions are parametrized with the same setting: 1 normal and 2 anomalous images. + The anomaly maps are the same for all functions, but the masks are different. + """ + expected_thresholds = torch.arange(1, 7 + 1, dtype=torch.float32) + shape = (1000, 1000) # (H, W), 1 million pixels + + # --- normal --- + # histogram of scores: + # value: 7 6 5 4 3 2 1 + # count: 1 9 90 900 9k 90k 900k + # cumsum: 1 10 100 1k 10k 100k 1M + pred_norm = torch.ones(1_000_000, dtype=torch.float32) + pred_norm[:100_000] += 1 + pred_norm[:10_000] += 1 + pred_norm[:1_000] += 1 + pred_norm[:100] += 1 + pred_norm[:10] += 1 + pred_norm[:1] += 1 + pred_norm = pred_norm.reshape(shape) + mask_norm = torch.zeros_like(pred_norm, dtype=torch.int32) + + expected_fpr_norm = torch.tensor([1.0, 1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6], dtype=torch.float64) + expected_tpr_norm = torch.full((7,), torch.nan, dtype=torch.float64) + + # --- anomalous --- + pred_anom1 = pred_norm.clone() + mask_anom1 = torch.ones_like(pred_anom1, dtype=torch.int32) + expected_tpr_anom1 = expected_fpr_norm.clone() + + # only the first 100_000 pixels are anomalous + # which corresponds to the first 100_000 highest scores (2 to 7) + pred_anom2 = pred_norm.clone() + mask_anom2 = torch.concatenate([torch.ones(100_000), torch.zeros(900_000)]).reshape(shape).to(torch.int32) + expected_tpr_anom2 = (10 * expected_fpr_norm).clip(0, 1) + + anomaly_maps = torch.stack([pred_norm, pred_anom1, pred_anom2], axis=0) + masks = torch.stack([mask_norm, mask_anom1, mask_anom2], axis=0) + + expected_shared_fpr = expected_fpr_norm + expected_per_image_tprs = torch.stack([expected_tpr_norm, expected_tpr_anom1, expected_tpr_anom2], axis=0) + expected_image_classes = torch.tensor([0, 1, 1], dtype=torch.int32) + + if metafunc.function is test_pimo or metafunc.function is test_aupimo_values: + argvalues_tensors = [ + ( + anomaly_maps, + masks, + expected_thresholds, + expected_shared_fpr, + expected_per_image_tprs, + expected_image_classes, + ), + ( + 10 * anomaly_maps, + masks, + 10 * expected_thresholds, + expected_shared_fpr, + expected_per_image_tprs, + expected_image_classes, + ), + ] + metafunc.parametrize( + argnames=( + "anomaly_maps", + "masks", + "expected_thresholds", + "expected_shared_fpr", + "expected_per_image_tprs", + "expected_image_classes", + ), + argvalues=argvalues_tensors, + ) + + if metafunc.function is test_aupimo_values: + argvalues_tensors = [ + ( + (1e-1, 1.0), + torch.tensor( + [ + torch.nan, + # recall: trapezium area = (a + b) * h / 2 + (0.10 + 1.0) * 1 / 2, + (1.0 + 1.0) * 1 / 2, + ], + dtype=torch.float64, + ), + ), + ( + (1e-3, 1e-1), + torch.tensor( + [ + torch.nan, + # average of two trapezium areas / 2 (normalizing factor) + (((1e-3 + 1e-2) * 1 / 2) + ((1e-2 + 1e-1) * 1 / 2)) / 2, + (((1e-2 + 1e-1) * 1 / 2) + ((1e-1 + 1.0) * 1 / 2)) / 2, + ], + dtype=torch.float64, + ), + ), + ( + (1e-5, 1e-4), + torch.tensor( + [ + torch.nan, + (1e-5 + 1e-4) * 1 / 2, + (1e-4 + 1e-3) * 1 / 2, + ], + dtype=torch.float64, + ), + ), + ] + metafunc.parametrize( + argnames=( + "fpr_bounds", + "expected_aupimos", # trapezoid surfaces + ), + argvalues=argvalues_tensors, + ) + + if metafunc.function is test_aupimo_edge: + metafunc.parametrize( + argnames=( + "anomaly_maps", + "masks", + ), + argvalues=[ + ( + anomaly_maps, + masks, + ), + ( + 10 * anomaly_maps, + masks, + ), + ], + ) + metafunc.parametrize( + argnames=("fpr_bounds",), + argvalues=[ + ((1e-1, 1.0),), + ((1e-3, 1e-2),), + ((1e-5, 1e-4),), + (None,), + ], + ) + + +def _do_test_pimo_outputs( + thresholds: Tensor, + shared_fpr: Tensor, + per_image_tprs: Tensor, + image_classes: Tensor, + expected_thresholds: Tensor, + expected_shared_fpr: Tensor, + expected_per_image_tprs: Tensor, + expected_image_classes: Tensor, +) -> None: + """Test if the outputs of any of the PIMO interfaces are correct.""" + assert isinstance(shared_fpr, Tensor) + assert isinstance(per_image_tprs, Tensor) + assert isinstance(image_classes, Tensor) + assert isinstance(expected_thresholds, Tensor) + assert isinstance(expected_shared_fpr, Tensor) + assert isinstance(expected_per_image_tprs, Tensor) + assert isinstance(expected_image_classes, Tensor) + allclose = torch.allclose + + assert thresholds.ndim == 1 + assert shared_fpr.ndim == 1 + assert per_image_tprs.ndim == 2 + assert tuple(image_classes.shape) == (3,) + + assert allclose(thresholds, expected_thresholds) + assert allclose(shared_fpr, expected_shared_fpr) + assert allclose(per_image_tprs, expected_per_image_tprs, equal_nan=True) + assert (image_classes == expected_image_classes).all() + + +def test_pimo( + anomaly_maps: Tensor, + masks: Tensor, + expected_thresholds: Tensor, + expected_shared_fpr: Tensor, + expected_per_image_tprs: Tensor, + expected_image_classes: Tensor, +) -> None: + """Test if `pimo()` returns the expected values.""" + + def do_assertions(pimo_result: PIMOResult) -> None: + thresholds = pimo_result.thresholds + shared_fpr = pimo_result.shared_fpr + per_image_tprs = pimo_result.per_image_tprs + image_classes = pimo_result.image_classes + _do_test_pimo_outputs( + thresholds, + shared_fpr, + per_image_tprs, + image_classes, + expected_thresholds, + expected_shared_fpr, + expected_per_image_tprs, + expected_image_classes, + ) + + # metric interface + metric = pimo.PIMO( + num_thresholds=7, + ) + metric.update(anomaly_maps, masks) + pimo_result = metric.compute() + do_assertions(pimo_result) + + +def _do_test_aupimo_outputs( + thresholds: Tensor, + shared_fpr: Tensor, + per_image_tprs: Tensor, + image_classes: Tensor, + aupimos: Tensor, + expected_thresholds: Tensor, + expected_shared_fpr: Tensor, + expected_per_image_tprs: Tensor, + expected_image_classes: Tensor, + expected_aupimos: Tensor, +) -> None: + _do_test_pimo_outputs( + thresholds, + shared_fpr, + per_image_tprs, + image_classes, + expected_thresholds, + expected_shared_fpr, + expected_per_image_tprs, + expected_image_classes, + ) + assert isinstance(aupimos, Tensor) + assert isinstance(expected_aupimos, Tensor) + allclose = torch.allclose + assert tuple(aupimos.shape) == (3,) + assert allclose(aupimos, expected_aupimos, equal_nan=True) + + +def test_aupimo_values( + anomaly_maps: torch.Tensor, + masks: torch.Tensor, + fpr_bounds: tuple[float, float], + expected_thresholds: torch.Tensor, + expected_shared_fpr: torch.Tensor, + expected_per_image_tprs: torch.Tensor, + expected_image_classes: torch.Tensor, + expected_aupimos: torch.Tensor, +) -> None: + """Test if `aupimo()` returns the expected values.""" + + def do_assertions(pimo_result: PIMOResult, aupimo_result: AUPIMOResult) -> None: + # test metadata + assert aupimo_result.fpr_bounds == fpr_bounds + # recall: this one is not the same as the number of thresholds in the curve + # this is the number of thresholds used to compute the integral in `aupimo()` + # always less because of the integration bounds + assert aupimo_result.num_thresholds < 7 + + # test data + # from pimo result + thresholds = pimo_result.thresholds + shared_fpr = pimo_result.shared_fpr + per_image_tprs = pimo_result.per_image_tprs + image_classes = pimo_result.image_classes + # from aupimo result + aupimos = aupimo_result.aupimos + _do_test_aupimo_outputs( + thresholds, + shared_fpr, + per_image_tprs, + image_classes, + aupimos, + expected_thresholds, + expected_shared_fpr, + expected_per_image_tprs, + expected_image_classes, + expected_aupimos, + ) + thresh_lower_bound = aupimo_result.thresh_lower_bound + thresh_upper_bound = aupimo_result.thresh_upper_bound + assert anomaly_maps.min() <= thresh_lower_bound < thresh_upper_bound <= anomaly_maps.max() + + # metric interface + metric = pimo.AUPIMO( + num_thresholds=7, + fpr_bounds=fpr_bounds, + return_average=False, + force=True, + ) + metric.update(anomaly_maps, masks) + pimo_result_from_metric, aupimo_result_from_metric = metric.compute() + do_assertions(pimo_result_from_metric, aupimo_result_from_metric) + + # metric interface + metric = pimo.AUPIMO( + num_thresholds=7, + fpr_bounds=fpr_bounds, + return_average=True, # only return the average AUPIMO + force=True, + ) + metric.update(anomaly_maps, masks) + metric.compute() + + +def test_aupimo_edge( + anomaly_maps: torch.Tensor, + masks: torch.Tensor, + fpr_bounds: tuple[float, float], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test some edge cases.""" + # None is the case of testing the default bounds + fpr_bounds = {"fpr_bounds": fpr_bounds} if fpr_bounds is not None else {} + + # not enough points on the curve + # 10 thresholds / 6 decades = 1.6 thresholds per decade < 3 + with pytest.raises(RuntimeError): # force=False --> raise error + functional.aupimo_scores( + anomaly_maps, + masks, + num_thresholds=10, + force=False, + **fpr_bounds, + ) + + with caplog.at_level(logging.WARNING): # force=True --> warn + functional.aupimo_scores( + anomaly_maps, + masks, + num_thresholds=10, + force=True, + **fpr_bounds, + ) + assert "Computation was forced!" in caplog.text + + # default number of points on the curve (300k thresholds) should be enough + torch.manual_seed(42) + functional.aupimo_scores( + anomaly_maps * torch.FloatTensor(anomaly_maps.shape).uniform_(1.0, 1.1), + masks, + force=False, + **fpr_bounds, + ) diff --git a/third-party-programs.txt b/third-party-programs.txt index 3155b2a930..5eeaca8ea9 100644 --- a/third-party-programs.txt +++ b/third-party-programs.txt @@ -42,3 +42,7 @@ terms are listed below. 7. CLIP neural network used for deep feature extraction in AI-VAD model Copyright (c) 2022 @openai, https://github.com/openai/CLIP. SPDX-License-Identifier: MIT + +8. AUPIMO metric implementation is based on the original code + Copyright (c) 2023 @jpcbertoldo, https://github.com/jpcbertoldo/aupimo + SPDX-License-Identifier: MIT From 404e896c769aa069f77deb79081e19024e8596a8 Mon Sep 17 00:00:00 2001 From: Marcus Pertlwieser <116986601+Marcus1506@users.noreply.github.com> Date: Thu, 3 Oct 2024 16:20:38 +0200 Subject: [PATCH 06/32] Makes batch size dynamic (#2339) Made batch dimension of ONNX export dynamic when specifying input shape. --- src/anomalib/models/components/base/export_mixin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/anomalib/models/components/base/export_mixin.py b/src/anomalib/models/components/base/export_mixin.py index e0627b462c..5e7e5e9481 100644 --- a/src/anomalib/models/components/base/export_mixin.py +++ b/src/anomalib/models/components/base/export_mixin.py @@ -142,7 +142,9 @@ def to_onnx( export_root = _create_export_root(export_root, ExportType.ONNX) input_shape = torch.zeros((1, 3, *input_size)) if input_size else torch.zeros((1, 3, 1, 1)) dynamic_axes = ( - None if input_size else {"input": {0: "batch_size", 2: "height", 3: "weight"}, "output": {0: "batch_size"}} + {"input": {0: "batch_size"}, "output": {0: "batch_size"}} + if input_size + else {"input": {0: "batch_size", 2: "height", 3: "weight"}, "output": {0: "batch_size"}} ) _write_metadata_to_json(self._get_metadata(task), export_root) onnx_path = export_root / "model.onnx" From 2ba5ede95daa2abfdb2026ec08f29f4e8a818f67 Mon Sep 17 00:00:00 2001 From: Joao P C Bertoldo <24547377+jpcbertoldo@users.noreply.github.com> Date: Sun, 6 Oct 2024 08:43:49 +0200 Subject: [PATCH 07/32] Add pimo tutorial advanced i (fixed) (#2336) * uset all padim features to make it deterministic Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add aupimo notebook advanced i Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * update readme Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * modify changelog Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct readme Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct again Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * minor corrections Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --------- Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --- CHANGELOG.md | 2 + notebooks/700_metrics/701a_aupimo.ipynb | 68 +- .../700_metrics/701b_aupimo_advanced_i.ipynb | 1433 +++++++++++++++++ notebooks/700_metrics/pimo_viz.svg | 619 +++++++ notebooks/README.md | 7 + 5 files changed, 2092 insertions(+), 37 deletions(-) create mode 100644 notebooks/700_metrics/701b_aupimo_advanced_i.ipynb create mode 100644 notebooks/700_metrics/pimo_viz.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index fc80fa3e7e..e0e0cc955e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Add `AUPIMO` tutorials notebooks in https://github.com/openvinotoolkit/anomalib/pull/2330 and https://github.com/openvinotoolkit/anomalib/pull/2336 +- Add `AUPIMO` metric by [jpcbertoldo](https://github.com/jpcbertoldo) in https://github.com/openvinotoolkit/anomalib/pull/1726 and refactored by [ashwinvaidya17](https://github.com/ashwinvaidya17) in https://github.com/openvinotoolkit/anomalib/pull/2329 - Add requirements into `pyproject.toml` & Refactor anomalib install `get_requirements` by @harimkang in https://github.com/openvinotoolkit/anomalib/pull/1808 ### Changed diff --git a/notebooks/700_metrics/701a_aupimo.ipynb b/notebooks/700_metrics/701a_aupimo.ipynb index e6333df6df..c6831fd1f7 100644 --- a/notebooks/700_metrics/701a_aupimo.ipynb +++ b/notebooks/700_metrics/701a_aupimo.ipynb @@ -71,7 +71,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -177,7 +177,7 @@ "model = Padim(\n", " # only use one layer to speed it up\n", " layers=[\"layer1\"],\n", - " n_features=32,\n", + " n_features=64,\n", " backbone=\"resnet18\",\n", " pre_trained=True,\n", ")" @@ -225,7 +225,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "58335955473a43dab43e586caf66aa11", + "model_id": "880e325e4e4842b2b679340ca8007849", "version_major": 2, "version_minor": 0 }, @@ -242,9 +242,9 @@ "
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n",
        "┃        Test metric        ┃       DataLoader 0        ┃\n",
        "┑━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n",
-       "β”‚        image_AUROC        β”‚    0.9735053777694702     β”‚\n",
-       "β”‚       image_F1Score       β”‚    0.9518716335296631     β”‚\n",
-       "β”‚       pixel_AUPIMO        β”‚    0.6273086756193275     β”‚\n",
+       "β”‚        image_AUROC        β”‚    0.9887908697128296     β”‚\n",
+       "β”‚       image_F1Score       β”‚    0.9726775884628296     β”‚\n",
+       "β”‚       pixel_AUPIMO        β”‚    0.7428419829089654     β”‚\n",
        "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n",
        "
\n" ], @@ -252,9 +252,9 @@ "┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n", "┃\u001b[1m \u001b[0m\u001b[1m Test metric \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1m DataLoader 0 \u001b[0m\u001b[1m \u001b[0m┃\n", "┑━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━┩\n", - "β”‚\u001b[36m \u001b[0m\u001b[36m image_AUROC \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.9735053777694702 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", - "β”‚\u001b[36m \u001b[0m\u001b[36m image_F1Score \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.9518716335296631 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", - "β”‚\u001b[36m \u001b[0m\u001b[36m pixel_AUPIMO \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.6273086756193275 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", + "β”‚\u001b[36m \u001b[0m\u001b[36m image_AUROC \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.9887908697128296 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", + "β”‚\u001b[36m \u001b[0m\u001b[36m image_F1Score \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.9726775884628296 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", + "β”‚\u001b[36m \u001b[0m\u001b[36m pixel_AUPIMO \u001b[0m\u001b[36m \u001b[0mβ”‚\u001b[35m \u001b[0m\u001b[35m 0.7428419829089654 \u001b[0m\u001b[35m \u001b[0mβ”‚\n", "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n" ] }, @@ -264,9 +264,9 @@ { "data": { "text/plain": [ - "[{'pixel_AUPIMO': 0.6273086756193275,\n", - " 'image_AUROC': 0.9735053777694702,\n", - " 'image_F1Score': 0.9518716335296631}]" + "[{'pixel_AUPIMO': 0.7428419829089654,\n", + " 'image_AUROC': 0.9887908697128296,\n", + " 'image_F1Score': 0.9726775884628296}]" ] }, "execution_count": 8, @@ -314,7 +314,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "678cb90805ee4b7bb1dd0c30944edab9", + "model_id": "e8116b80da39406e966c2099ecb2fdb1", "version_major": 2, "version_minor": 0 }, @@ -390,27 +390,21 @@ "name": "stdout", "output_type": "stream", "text": [ - "tensor([8.7932e-01, 8.4367e-01, 7.9861e-02, 9.2154e-02, 1.5300e-04, 5.8312e-01,\n", - " 8.5351e-01, 3.8730e-01, 1.9997e-02, 1.7658e-01, 8.0739e-01, 7.1827e-01,\n", - " 5.2631e-01, 4.3051e-01, 5.0168e-01, 3.5604e-01, 8.9605e-01, 8.8349e-02,\n", - " 6.0475e-01, 9.6092e-01, 5.8595e-01, 5.7159e-01, 9.8821e-01, 8.8012e-01,\n", - " 5.8205e-01, 9.9295e-01, 1.0000e+00, 9.9967e-01, 5.5366e-01, 8.7399e-01,\n", - " 7.0559e-01, 9.4203e-01, 7.3299e-01, 6.6430e-01, 8.0979e-01, 9.4388e-01,\n", - " 9.9854e-01, 5.8814e-01, 8.8821e-01, 6.3341e-01, 4.2244e-01, 7.3422e-01,\n", - " 4.4623e-01, 5.9982e-01, 1.1232e-01, 2.5705e-01, 3.2403e-01, 5.6662e-02,\n", - " 5.3151e-02, 3.1629e-01, 2.6974e-01, 2.8646e-01, 5.3762e-01, 4.5617e-01,\n", - " 4.4067e-01, 9.8349e-01, 1.2953e-02, 7.9532e-01, 1.7765e-01, 1.1363e-01,\n", - " 9.7337e-01, 4.9871e-01, 2.7917e-01, 4.9118e-01, 2.5533e-02, 0.0000e+00,\n", - " 9.0295e-04, 0.0000e+00, 9.3272e-01, 1.0000e+00, 1.0000e+00, 3.0749e-02,\n", - " 8.0794e-01, 9.4464e-01, nan, nan, nan, nan,\n", - " nan, nan, nan, nan, nan, nan,\n", - " nan, nan, nan, nan, nan, nan,\n", - " nan, nan, nan, nan, nan, nan,\n", - " nan, nan, nan, nan, nan, nan,\n", - " nan, nan, nan, nan, 9.8743e-01, 8.4611e-01,\n", - " 9.7309e-01, 9.8823e-01, 1.0000e+00, 1.0000e+00, 9.6653e-01, 9.6560e-01,\n", - " 1.0000e+00, 1.0000e+00, 9.5783e-01, 1.0000e+00, 9.1427e-01, 9.9806e-01,\n", - " 1.0000e+00, 1.0000e+00, 9.9345e-01, 1.0000e+00], dtype=torch.float64)\n" + "tensor([1.0000, 0.9144, 0.4944, 0.2837, 0.2784, 0.8687, 1.0000, 0.7463, 0.2899,\n", + " 0.8998, 1.0000, 0.9147, 0.6389, 0.9422, 0.9582, 0.9396, 0.9890, 0.5130,\n", + " 0.9698, 0.9237, 0.5732, 0.4620, 0.9995, 0.9078, 0.5873, 1.0000, 1.0000,\n", + " 1.0000, 0.3785, 0.6764, 0.4217, 0.9299, 0.7756, 0.4339, 0.8334, 0.9297,\n", + " 0.9992, 0.5584, 0.9937, 0.7811, 0.4986, 0.7630, 0.5361, 0.7157, 0.1689,\n", + " 0.3086, 0.3604, 0.2423, 0.2880, 0.6404, 0.5570, 0.3274, 0.7749, 0.6740,\n", + " 0.5516, 1.0000, 0.2399, 0.9721, 0.5346, 0.4709, 1.0000, 0.9732, 0.8470,\n", + " 0.8863, 0.0596, 0.0000, 0.5244, 0.0000, 1.0000, 1.0000, 1.0000, 0.0088,\n", + " 0.9706, 1.0000, nan, nan, nan, nan, nan, nan, nan,\n", + " nan, nan, nan, nan, nan, nan, nan, nan, nan,\n", + " nan, nan, nan, nan, nan, nan, nan, nan, nan,\n", + " nan, nan, nan, nan, nan, nan, nan, 0.9895, 0.8531,\n", + " 0.9985, 0.9470, 1.0000, 1.0000, 0.9918, 0.9792, 1.0000, 1.0000, 0.8824,\n", + " 1.0000, 0.9996, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000],\n", + " dtype=torch.float64)\n" ] } ], @@ -439,9 +433,9 @@ "output_type": "stream", "text": [ "MEAN\n", - "aupimo_result.aupimos[~isnan].mean().item()=0.6273086756193275\n", + "aupimo_result.aupimos[~isnan].mean().item()=0.7428419829089654\n", "OTHER STATISTICS\n", - "DescribeResult(nobs=92, minmax=(0.0, 1.0), mean=0.6273086756193275, variance=0.12220088826183258, skewness=-0.506530110649306, kurtosis=-1.1586400848600655)\n" + "DescribeResult(nobs=92, minmax=(0.0, 1.0), mean=0.7428419829089654, variance=0.08757789538421837, skewness=-0.9285672286850366, kurtosis=-0.3299234749959594)\n" ] } ], @@ -469,7 +463,7 @@ "outputs": [ { "data": { - "image/png": "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b5UoUUIlSpTIUyi6lczMTH3zzTd68cUX1bZtW23fvl1hYWEaNWqUOnfufMP3paWl2aYQkGR7flt6errS09OdXidyL+v7px/cj74oWOiPgqMo9EVmZqb8/Pzk62mRt4dxdzl5lpnnhHJ7LMYYh761a9euaezYsfroo4906dIlSVLx4sU1bNgwjRkzxu4WfocKsVi0cOFCW+g5efKkKlasKH9/f7355ptq0aKFli9frldeeUVr1qy54Qzc0dHRGjt2bLb22NhY+fv756k2AADgWikpKerZs6eSkpIUEBDgsuM6nMeGDRumBQsWaMKECWrSpIkkaePGjYqOjta5c+c0efJkpxSWmZkpSXrkkUf03HPPSZLuvfde/fTTT5oyZcoNg9GoUaP0/PPP25aTk5MVHBysFi1aqEyZMk6pDXmTnp6ulStXqnXr1nkO0HAO+qJgoT8KjqLQFzt37lRERITK93xb3uUL793imQnH3HJch4NRbGys5syZYzc2qG7dugoODlaPHj2cFozKli0rT09P1apVy669Zs2aN330iI+Pj3x8fLK1e3l5Fdof8qKGvig46IuChf4oOApzXxQrVkypqam6cs3IZFjcXU6emWvuOa7Dt+v7+PgoNDQ0W3tYWJi8vb2dUZOk62OYGjVqpLi4OLv23377TVWqVHHacQAAALI4fMZo6NCheuONNzRjxgzbmZm0tDSNGzdOQ4cOdWhfly5dspss8vDhw9qxY4dKly6tkJAQjRw5Ut27d1dERIRtjNGSJUu0du1aR8sGAAC4pVwFoz/PMfT999+rcuXKqlevnqTr1zOvXr2qli1bOnTwLVu2qEWLFrblrLFBvXv31syZM/Xoo49qypQpGj9+vIYPH64aNWroq6++UtOmTR06DgAAQG7kKhgFBgbaLXfp0sVuOTg4OE8Hb968uW51U1zfvn3Vt2/fPO0fAADAEbkKRjNmzMjvOgAAANzO4cHXAAAARZXDg6/PnTun119/XWvWrNHp06dt8w1lOX/+vNOKAwAAcCWHg9ETTzyhgwcPql+/fipfvrwslsI7RwIAAMD/cjgYrV+/Xhs2bLDdkQYAAFBUODzG6O6771Zqamp+1AIAAOBWDgejTz/9VKNHj9a6det07tw5JScn270AAAAKK4cvpZUsWVLJycl66KGH7NqNMbJYLMrIyHBacQAAAK7kcDDq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mPz8/BQeHq433njD7rlb9EX++eGHH9SpUydVqlRJFotFixYtslt/q+9euv73Uq9evRQQEKCSJUuqX79+unTpkm39kSNHFBERoTvuuEMRERE6cuSI3fs7duyor776yvHiXfoAknwwZ84c4+3tbT7//HOzZ88e079/f1OyZElz6tQps3jxYlO+fHnzyy+/mNjYWOPr62vOnDljjDEmMTHRVK9e3Rw9etTNn6Bwatu2rZkxY4bZvXu32bFjh+nQoYMJCQkxly5dsm0zaNAgExwcbFatWmW2bNli7r//fvPAAw/Y1j///POmWbNmJi4uzjz77LOmQYMGtnUbN240DRo0MNeuXXPp5yrsNm/ebEJDQ03dunXNiBEjbO30hWucP3/eVKlSxfTp08ds2rTJHDp0yKxYscIcPHjQts3bb79tAgMDzaJFi8zOnTvNww8/bMLCwkxqaqoxxpiPPvrI1KxZ0+zevdu8++67JigoyGRmZhpjjDl06JCpXr26SUpKcsvnK0zGjRtnypQpY5YuXWoOHz5s5s2bZ4oXL24+/PBD2zb0Rf759ttvzejRo82CBQuMJLNw4UK79bf67o0xpl27dqZevXrm559/NuvXrzfVqlUzPXr0sK1/7LHHzOOPP25+++03061bN9OlSxfbujlz5phOnTrlqfZCH4waN25shgwZYlvOyMgwlSpVMuPHjzfvvPOO6d69u21duXLlzObNm40xxgwYMMBMnDjR5fUWVadPnzaSzLp164wx14Onl5eXmTdvnm2bffv2GUlm48aNxhhj2rdvbyZPnmyMMWbv3r3G39/fGGPM1atXTb169cwvv/zi4k9RuF28eNFUr17drFy50jRr1swWjOgL13nppZdM06ZNb7g+MzPTVKhQwbz77ru2tsTEROPj42Nmz55tjDFm8ODB5qWXXjLGGJOSkmIkmdOnTxtjrv+HZMGCBfn4CYqOyMhI07dvX7u2xx57zPTq1csYQ1+40p+DUW6++7179xpJdn/3LFu2zFgsFnP8+HFjjDE1a9Y0y5YtM8ZcD2K1atUyxhhz4cIFU61aNRMfH5+negv1pbSrV69q69atatWqla2tWLFiatWqlTZu3Kh69eppy5YtunDhgrZu3arU1FRVq1ZNGzZs0LZt2zR8+HA3Vl+0JCUlSZLtYX9bt25Venq6Xd/cfffdCgkJ0caNGyVJ9erV0+rVq3Xt2jWtWLFCdevWlSRNmDBBzZs3V8OGDV38KQq3IUOGKDIy0u47l+gLV1q8eLEaNmyorl27qly5cqpfv74+++wz2/rDhw/r5MmTdn0RGBio++67z64vNmzYoNTUVK1YsUIVK1ZU2bJlFRMTI19fXz366KMu/1yF0QMPPKBVq1bpt99+kyTt3LlTGzZsUPv27SXRF+6Um+9+48aNKlmypN3fPa1atVKxYsW0adMmSdf75/vvv1dmZqa+++47299bI0eO1JAhQxQcHJy3AvMUpwqI48ePG0nmp59+smsfOXKkady4sTHGmDFjxpjw8HBzzz33mAULFpi0tDRzzz33mC1btphJkyaZu+66yzzwwANm9+7d7vgIRUJGRoaJjIw0Dz74oK0tJibGeHt7Z9u2UaNG5sUXXzTGXP8fQo8ePUxISIiJiIgwe/bsMb/99pupXr26OXv2rBk4cKAJCwszXbt2NYmJiS77PIXR7NmzzT333GM7Df2/Z4zoC9fx8fExPj4+ZtSoUWbbtm1m6tSpxtfX18ycOdMYY8yPP/5oJJkTJ07Yva9r166mW7duxpjrZ+meeeYZExoaaho2bGjWr19vzp07Z6pWrWri4+PN6NGjTXh4uGnTpo35448/XP4ZC4uMjAzz0ksvGYvFYjw9PY3FYjFvvfWWbT194Tr60xmj3Hz348aNM3fddVe2fQUFBZlPP/3UGGPMH3/8YSIjI01wcLCJjIw0f/zxh1m3bp1p2LChOXfunOnatasJCwszAwcONGlpabmut1A8EuR2REdHKzo62rY8duxYtWrVSl5eXnrzzTf166+/aunSpXryySe1detW9xVaiA0ZMkS7d+/Whg0bHHpfYGCgYmNj7doeeughvfvuu4qJidGhQ4cUFxen/v3765///CeDf2/g2LFjGjFihFauXClfX9887YO+cI7MzEw1bNhQb731liSpfv362r17t6ZMmaLevXvnah9eXl765JNP7NqeeuopDR8+XNu3b9eiRYu0c+dOTZgwQcOHD8/b4NK/gC+//FIxMTGKjY1V7dq1tWPHDj377LOqVKkSfVFE3HnnnVq6dKltOS0tTW3bttWsWbP05ptvqkSJEoqLi1O7du00derUXA+UL9SX0sqWLSsPDw+7u2sk6dSpU6pQoUK27ffv368vvvhCb7zxhtauXauIiAgF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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAkYAAAHHCAYAAABa2ZeMAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAABHeUlEQVR4nO3deVyVZeL+8eugrCKgiKAJgktuqZVLWo0raoqW6aiZlFtuqVk21tdpUcuyZbLFXBvDmkDLMkctNXMrjVwwTVNJc8FccAVUEBHu3x8ez29OuHAQzgH8vF8vXuO5n+c8z3W4G7t6tmMxxhgBAABAbq4OAAAAUFRQjAAAAKwoRgAAAFYUIwAAACuKEQAAgBXFCAAAwIpiBAAAYEUxAgAAsKIYAQAAWFGMABQba9askcVi0Zo1a2xj/fr1U3h4uFP2Hx4ern79+tlez5kzRxaLRZs3b3bK/lu1aqVWrVo5ZV/ArYpiBBQB06ZNk8Vi0T333HPV5QcOHJDFYtG//vWvqy7/17/+JYvFogMHDtjGWrVqJYvFYvspX768mjRpoo8//lg5OTm29fr16ydfX1+77V15b82aNa+6vxUrVti2++WXX+Za/ttvvyk6Olq33XabPD09VblyZfXp00e//fbbjX4VTrFz506NHz/e7vdVVBTlbMCtoLSrAwCQYmNjFR4ero0bN2rv3r2qUaNGgWy3SpUqmjRpkiTpxIkT+vTTTzVw4ED9/vvveuONN677Xi8vL+3du1cbN25U06ZNc+X18vLShQsXcr1vwYIF6t27t8qXL6+BAwcqIiJCBw4c0OzZs/Xll19q3rx5evjhhwvk80nSRx99ZFf08mLnzp2aMGGCWrVq5dDRpsTERLm5Fe5/T14v23fffVeo+wbAESPA5fbv36+ffvpJkydPVlBQkGJjYwts2/7+/oqOjlZ0dLSeeeYZrV+/XlWqVNGHH36orKys6763evXqqlWrlubOnWs3fuHCBX399deKiorK9Z4//vhDjz32mKpVq6Zff/1VEydO1MCBA/Xqq6/q119/VbVq1fTYY49p3759BfYZ3d3d5enpWWDb+ytjjDIyMiRJnp6ecnd3L7R93YiHh4c8PDxctn/gVkAxAlwsNjZW5cqVU1RUlP7+978XaDH6Kx8fHzVr1kznz5/XiRMnbrh+79699fnnn9sdkVm8eLHS09PVs2fPXOu//fbbSk9P16xZsxQUFGS3rEKFCpo5c6bOnz+vt95664b7/vPPP9W1a1eVKVNGFStW1DPPPKPMzMxc613tGqN58+apUaNGKlu2rPz8/FS/fn29//77ki5fF9SjRw9JUuvWrW2nBK9ctxQeHq7OnTtr+fLlaty4sby9vTVz5kzbsv+9xuiK9PR0DRkyRIGBgfLz89Pjjz+uM2fO2K1jsVg0fvz4XO/9323eKNvVrjE6fvy4Bg4cqODgYHl5ealhw4b65JNP7Nb531Oxs2bNUvXq1eXp6akmTZpo06ZNuTIBtzJOpQEuFhsbq27dusnDw0O9e/fW9OnTtWnTJjVp0qRQ9rdv3z6VKlVKAQEBN1z30Ucf1fjx47VmzRq1adNGkhQXF6e2bduqYsWKudZfvHixwsPD9be//e2q22vRooXCw8P1zTffXHe/GRkZatu2rZKSkvTUU0+pcuXK+s9//qNVq1bdMPOKFSvUu3dvtW3bVm+++aYkadeuXVq/fr1GjRqlFi1a6KmnntIHH3ygf/7zn6pTp44k2f5XunzKrHfv3hoyZIgGDRqkWrVqXXefI0aMUEBAgMaPH6/ExERNnz5dBw8etF0snld5yfa/MjIy1KpVK+3du1cjRoxQRESE5s+fr379+iklJUWjRo2yWz8uLk5nz57VkCFDZLFY9NZbb6lbt27at2+fS4+EAUUJxQhwoYSEBO3evVtTpkyRJN1///2qUqWKYmNjC6QYZWdn6+TJk5KkkydPavr06dqyZYu6dOkiHx+fG76/Zs2aaty4seLi4tSmTRulpKTo22+/1UcffZRr3dTUVB05ckQPPfTQdbfZoEEDLVq0SGfPnlXZsmWvus6sWbP0+++/64svvrAdQRk0aJAaNmx4w8zffPON/Pz8tHz5cpUqVSrX8mrVqulvf/ubPvjgA7Vr1+6qd3nt3btXy5YtU4cOHW64P+nyKa6VK1faykXVqlX13HPPafHixXrwwQfztI28Zvtfs2bN0q5du/TZZ5+pT58+kqShQ4eqZcuWevHFFzVgwAC733FSUpL27NmjcuXKSZJq1aqlhx56SMuXL1fnzp3znBMoyTiVBrhQbGysgoOD1bp1a0mXT7f06tVL8+bNU3Z29k1vf/fu3QoKClJQUJDq1KmjKVOmKCoqSh9//HGet/Hoo49qwYIFunjxor788kuVKlXqqhdPnz17VpKuWXauuLI8LS3tmut8++23qlSpkv7+97/bxnx8fDR48OAb5g0ICND58+e1YsWKG657LREREXkuRZI0ePBguyMuw4YNU+nSpfXtt9/mO0NefPvttwoJCVHv3r1tY+7u7nrqqad07tw5rV271m79Xr162UqRJNuRvYK85gso7ihGgItkZ2dr3rx5at26tfbv36+9e/dq7969uueee5ScnKyVK1c6vM2/nrYJDw/XihUr9P3332vdunU6duyYlixZogoVKuR5m4888ohSU1O1dOlSxcbGqnPnzlctP1fGrhSka8lLgTp48KBq1KiR6/Pc6JSWJD355JO6/fbb1bFjR1WpUkUDBgzQsmXLbvi+/xUREeHQ+n99rIGvr68qVapU6LfcHzx4UDVr1sx1p9yVU28HDx60Gw8LC7N7faUk/fV6KOBWxqk0wEVWrVqlo0ePat68eZo3b16u5bGxsWrfvr2ky7fOS7LdHfVX6enpdutdUaZMGUVGRt5UzkqVKqlVq1Z65513tH79en311VdXXc/f31+VKlXSr7/+et3t/frrr7rtttvk5+d3U7mupWLFitq6dauWL1+upUuXaunSpYqJidHjjz+e66Lka/H29i6UbFdTEEcG8+pqpxaly3feAbiMI0aAi8TGxqpixYqaP39+rp/evXvr66+/thWhoKAg+fj4KDEx8arbSkxMlI+Pj0NHghzx6KOP6scff5Sfn586dep0zfU6d+6s/fv3a926dVdd/uOPP+rAgQM3vJ6latWq+uOPP3L9C/tan/+vPDw81KVLF02bNk1//PGHhgwZok8//VR79+6VlPvI2s3as2eP3etz587p6NGjdnfLlStXTikpKXbrXbx4UUePHrUbcyRb1apVtWfPnlzPcdq9e7dtOQDHUIwAF8jIyNCCBQvUuXNn/f3vf8/1M2LECJ09e1aLFi2SdPm/9Nu3b6/FixcrKSnJbltJSUlavHix2rdvf80jAjfr73//u8aNG6dp06Zd9zk6Y8aMkbe3t4YMGaJTp07ZLTt9+rSGDh0qHx8fjRkz5rr769Spk44cOWL3VO0rjwG4kb/u183NTQ0aNJAk2+3+ZcqUkaRcRSW/Zs2aZfdcqOnTp+vSpUvq2LGjbax69er64Ycfcr3vr0eMHMnWqVMnHTt2TJ9//rlt7NKlS5oyZYp8fX3VsmXL/Hwc4JbGqTTABa7clXWtO5aaNWtme9hjr169JEmvv/66mjVrprvvvluDBw9WeHi4Dhw4oFmzZslisej1118vtLz+/v5XfQbPX9WsWVOffPKJ+vTpo/r16+d68vXJkyc1d+5cVa9e/brbGTRokD788EM9/vjjSkhIUKVKlfSf//wnT3fSPfHEEzp9+rTatGmjKlWq6ODBg5oyZYruvPNO27U3d955p0qVKqU333xTqamp8vT0VJs2ba76CIK8uHjxotq2bauePXsqMTFR06ZN0/333283v0888YSGDh2q7t27q127dtq2bZuWL1+e6yifI9kGDx6smTNnql+/fkpISFB4eLi+/PJLrV+/Xu+9994NL4QHcBUGgNN16dLFeHl5mfPnz19znX79+hl3d3dz8uRJ29iuXbtMr169TMWKFU3p0qVNxYoVzSOPPGJ27dqV6/0tW7Y09erVu2GWvn37mjJlyjj83tWrVxtJZv78+bmW/frrr6Z3796mUqVKxt3d3YSEhJjevXub7du33zDPFQcPHjQPPvig8fHxMRUqVDCjRo0yy5YtM5LM6tWr7fJXrVrV9vrLL7807du3NxUrVjQeHh4mLCzMDBkyxBw9etRu+x999JGpVq2aKVWqlN02q1ataqKioq6aqWrVqqZv37621zExMUaSWbt2rRk8eLApV66c8fX1NX369DGnTp2ye292drZ5/vnnTYUKFYyPj4/p0KGD2bt3b65tXi9by5YtTcuWLe3WTU5ONv379zcVKlQwHh4epn79+iYmJsZunf379xtJ5u233871mSSZcePGXfXzArciizFcdQcAACBxjREAAIANxQgAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKxK/AMec3JydOTIEZUtW7bAvwYAAAAUDmOMzp49q8qVK+f6ouTCVOKL0ZEjRxQaGurqGAAAIB8OHTqkKlWqOG1/Jb4YXXkk/v79+1W+fHkXp7m1ZWVl6bvvvlP79u3l7u7u6ji3NOaiaGE+ig7moug4ffq0IiIinP7VNiW+GF05fVa2bFn5+fm5OM2tLSsrSz4+PvLz8+MvHBdjLooW5qPoYC6KjitfzOzsy2C4+BoAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKwoRgAAAFYUIwAAACuKEQAAgBXFCAAAwIpiBAAAYEUxAgAAsKIY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CoeLkcVi0QsvvKAxY8Zo7969OnfunOrWrStfX9/CyAcAAOA0DhejKzw8PFS2bFmVLVuWUgQAAEoEh68xunTpkl566SX5+/srPDxc4eHh8vf314svvqisrKzCyAgAAOAUDh8xGjlypBYsWKC33npLzZs3lyTFx8dr/PjxOnXqlKZPn17gIQEAAJzB4WIUFxenefPmqWPHjraxBg0aKDQ0VL1796YYAQCAYsvhU2menp4KDw/PNR4RESEPD4+CyAQAAOASDhejESNG6NVXX7V9R5okZWZm6rXXXtOIESMKNBwAAIAz5elUWrdu3exef//996pSpYoaNmwoSdq2bZsuXryotm3bFnxCAAAAJ8lTMfL397d73b17d7vXoaGhBZcIAADARfJUjGJiYgo7BwAAgMs5fI0RAABASeXw7fqnTp3Syy+/rNWrV+v48ePKycmxW3769OkCCwcAAOBMDhejxx57THv37tXAgQMVHBwsi8VSGLkAAACczuFi9OOPP2rdunW2O9IAAABKCoevMapdu7YyMjIKIwsAAIBLOVyMpk2bphdeeEFr167VqVOnlJaWZvcDAABQXDl8Ki0gIEBpaWlq06aN3bgxRhaLRdnZ2QUWDgAAwJkcLkZ9+vSRu7u74uLiuPgaAACUKA4Xox07duiXX35RrVq1CiMPAACAyzh8jVHjxo116NChwsgCAADgUg4fMRo5cqRGjRqlMWPGqH79+nJ3d7db3qBBgwILBwAA4EwOF6NevXpJkgYMGGAbs1gsXHwNAACKPYeL0f79+wsjBwAAgMs5fI1R1apVr/vjiEmTJqlJkyYqW7asKlasqK5duyoxMdFunQsXLmj48OEKDAyUr6+vunfvruTkZEdjAwAA3JDDR4w+/fTT6y5//PHH87yttWvXavjw4WrSpIkuXbqkf/7zn2rfvr127typMmXKSJKeeeYZffPNN5o/f778/f01YsQIdevWTevXr3c0OgAAwHU5XIxGjRpl9zorK0vp6eny8PCQj4+PQ8Vo2bJldq/nzJmjihUrKiEhQS1atFBqaqpmz56tuLg42wMlY2JiVKdOHf38889q1qyZo/EBAACuyeFidObMmVxje/bs0bBhwzRmzJibCpOamipJKl++vCQpISFBWVlZioyMtK1Tu3ZthYWFKT4+/qrFKDMzU5mZmbbXV76mJCsrS1lZWTeV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NHXqVIWHh8vLy0v33HOPNm7caFs2evRolS9fXqGhoYqNjbV73/z589WlSxdnxy0RJk2apCZNmqhs2bKqWLGiunbtqsTERLt1Lly4oOHDhyswMFC+vr7q3r273VPMT58+rS5dusjX11d33XWXfvnlF7v3Dx8+XO+8845TPk9J8sYbb8hisejpp5+2jTEXznP48GFFR0crMDBQ3t7eql+/vjZv3mxbbozRyy+/rEqVKsnb21uRkZHas2ePbXlmZqYee+wx+fn56fbbb9f3339vt/23335bI0eOdNrnKa6ys7P10ksvKSIiQt7e3qpevbpeffVVu+/dYi4Kzw8//KAuXbqocuXKslgsWrhwod3yG/3upct/L/Xp00d+fn4KCAjQwIEDde7cOdvyAwcOqEWLFipTpoxatGihAwcO2L2/c+fO+uqrrxwP79QvICkE8+bNMx4eHubjjz82v/32mxk0aJAJCAgwycnJZtGiRSY4ONhs2rTJxMXFGS8vL3PixAljjDEpKSmmZs2a5uDBgy7+BMVThw4dTExMjNmxY4fZunWr6dSpkwkLCzPnzp2zrTN06FATGhpqVq5caTZv3myaNWtm7r33Xtvy0aNHm5YtW5rExETz9NNPm0aNGtmWxcfHm0aNGplLly459XMVdxs3bjTh4eGmQYMGZtSoUbZx5sI5Tp8+bapWrWr69etnNmzYYPbt22eWL19u9u7da1vnjTfeMP7+/mbhwoVm27Zt5sEHHzQREREmIyPDGGPMBx98YOrUqWN27Nhh3n77bRMUFGRycnKMMcbs27fP1KxZ06Smprrk8xUnr732mgkMDDRLliwx+/fvN/Pnzze+vr7m/ffft63DXBSeb7/91rzwwgtmwYIFRpL5+uuv7Zbf6HdvjDEPPPCAadiwofn555/Njz/+aGrUqGF69+5tW96tWzfzyCOPmN9//9307NnTdO/e3bZs3rx5pkuXLvnKXuyLUdOmTc3w4cNtr7Ozs03lypXNpEmTzJtvvml69eplW1axYkWzceNGY4wxgwcPNpMnT3Z63pLq+PHjRpJZu3atMeZy8XR3dzfz58+3rbNr1y4jycTHxxtjjOnYsaOZPn26McaYnTt3Gh8fH2OMMRcvXjQNGzY0mzZtcvKnKN7Onj1ratasaVasWGFatmxpK0bMhfM8//zz5v7777/m8pycHBMSEmLefvtt21hKSorx9PQ0c+fONcYYM2zYMPP8888bY4xJT083kszx48eNMZf/g2TBggWF+AlKjqioKDNgwAC7sW7dupk+ffoYY5gLZ/prMcrL737nzp1Gkt3fPUuXLjUWi8UcPnzYGGNMnTp1zNKlS40xl4tY3bp1jTHGnDlzxtSoUcMkJSXlK2+xPpV28eJFJSQkKDIy0jbm5uamyMhIxcfHq2HDhtq8ebPOnDmjhIQEZWRkqEaNGlq3bp22bNmip556yoXpS5bU1FRJsn3ZX0JCgrKysuzmpnbt2goLC1N8fLwkqWHDhlq1apUuXbqk5cuXq0GDBpKkt956S61atVLjxo2d/CmKt+HDhysqKsrudy4xF860aNEiNW7cWD169FDFihV111136aOPPrIt379/v44dO2Y3F/7+/rrnnnvs5mLdunXKyMjQ8uXLValSJVWoUEGxsbHy8vLSww8/7PTPVRzde++9WrlypX7//XdJ0rZt27Ru3Tp17NhREnPhSnn53cfHxysgIMDu757IyEi5ublpw4YNki7Pz/fff6+cnBx99913tr+3xowZo+HDhys0NDR/AfNVp4qIw4cPG0nmp59+shsfM2aMadq0qTHGmHHjxpnq1aubO+64wyxYsMBkZmaaO+64w2zevNlMmTLF3H777ebee+81O3bscMVHKBGys7NNVFSUue+++2xjsbGxxsPDI9e6TZo0Mc8995wx5vJ/IfTu3duEhYWZFi1amN9++838/vvvpmbNmubkyZNmyJAhJiIiwvTo0cOkpKQ47fMUR3PnzjV33HGH7TD0/x4xYi6cx9PT03h6epqxY8eaLVu2mJkzZxovLy8zZ84cY4wx69evN5LMkSNH7N7Xo0cP07NnT2PM5aN0Tz75pAkPDzeNGzc2P/74ozl16pSpVq2aSUpKMi+88IKpXr26ad++vfnzzz+d/hmLi+zsbPP8888bi8ViSpcubSwWi3n99ddty5kL59Ffjhjl5Xf/2muvmdtvvz3XtoKCgsy0adOMMcb8+eefJioqyoSGhpqoqCjz559/mrVr15rGjRubU6dOmR49epiIiAgzZMgQk5mZmee8xeIrQW7G+PHjNX78eNvrCRMmKDIyUu7u7po4caK2b9+uJUuW6PHHH1dCQoLrghZjw4cP144dO7Ru3TqH3ufv76+4uDi7sTZt2ujtt99WbGys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PClJSk1N1QsvvKCpU6fm2k758uUVFBSU598JgKKDYgTgus6dO6fPP/9cw4YNU1RUlObMmXNT2wsICFBISIjuuOMOTZ8+XRkZGVqxYsU117/77rsVGhqqBQsW2MYWLFigsLAw3XXXXXbrDh8+XB4eHvruu+/UsmVLhYWFqWPHjvr+++91+PBhvfDCCw5l3bVrl1q1aqXGjRurd+/e8vPz0/79+yVJzz33nIYNG5arnAEo3ihGAK7riy++UO3atVWrVi1FR0fr448/VkF9xaK3t7ck6eLFi9ddb8CAAYqJibG9/vjjj9W/f3+7dU6fPq3ly5frySeftG33ipCQEPXp00eff/65Q9kbNmyozZs368yZM0pISFBGRoZq1KihdevWacuWLXrqqafyvC0AxQPFCMB1zZ49W9HR0ZKkBx54QKmpqVq7du1Nbzc9PV0vvviiSpUqpZYtW1533ejoaK1bt04HDx7UwYMHtX79elumK/bs2SNjjOrUqXPVbdSpU0dnzpzRiRMn8pyxQ4cOio6OVpMmTdSvXz998sknKlOmjIYNG6YZM2Zo+vTpqlWrlu677z799ttved4ugKKrtKsDACi6EhMTtXHjRn399deSLl+D06tXL82ePVutWrXK1zZ79+6tUqVKKSMjQ0FBQZo9e7YaNGhw3fcEBQXZTuMZYxQVFaUKFSpcdd2COpp1xfjx4zV+/Hjb6wkTJigyMlLu7u6aOHGitm/friVLlujxxx9XQkJCge4bgPNRjABc0+zZs3Xp0iVVrlzZNmaMkaenpz788EP5+/vLz89P0uWLkQMCAuzen5KSIn9/f7uxd999V5GRkfL393foAuUBAwZoxIgRknTVC55r1Kghi8WiXbt26eGHH861fNeuXSpXrtxNXRS9e/duffbZZ/rll1/08ccfq0WLFgoKClLPnj01YMAAnT17VmXLls339gG4HqfSAFzVpUuX9Omnn+qdd97R1q1bbT/btm1T5cqVNXfuXElSzZo15ebmlutoyb59+5Samqrbb7/dbjwkJEQ1atRwuKA88MADunjxorKystShQ4dcywMDA9WuXTtNmzbN7g42STp27JhiY2PVq1cvWSwWh/Z7hTFGQ4YM0eTJk+Xr66vs7GxlZWVJku1/s7Oz87VtAEUHR4wAXNWSJUt05swZDRw4MNdRn+7du2v27NkaOnSoypYtqyeeeELPPvusSpcurfr16+vQoUN6/vnn1axZM917770FkqdUqVLatWuX7c9X8+GHH+ree+9Vhw4dNHHiREVEROi3337TmDFjdNttt+m1117L9/7//e9/KygoyPbcovvuu0/jx4/Xzz//rKVLl6pu3bq5jpgBKH44YgTgqmbPnm075fVX3bt31+bNm/Xrr79Kkt5//3317dtXzz//vOrVq6d+/fqpQYMGWrx4cb6P0FyNn5+f7dTd1dSsWVObN29WtWrV1LNnT1WvXl2DBw9W69atFR8fr/Lly+drv8nJyXrttdf0wQcf2MaaNm2qZ599VlFRUfriiy/s7poDUHxZTEFfqQgAxdD48eO1cOHCAvsKjwMHDigiIkK//PKL7rzzzgLZJoDCxxEjALDavn27fH19NW3atJvaTseOHVWvXr0CSgXAmThiBAC6/IDIK18ZEhQUdNVTiHl1+PBh2wXgYWFh8vDwKJCMAAofxQgAAMCKU2kAAABWFCMAAAArihEAAIAVxQgAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKwoRgAAAFb/D60PVy7Nlq5qAAAAAElFTkSuQmCC", "text/plain": [ "
" ] diff --git a/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb b/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb new file mode 100644 index 0000000000..37876e5bf6 --- /dev/null +++ b/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb @@ -0,0 +1,1433 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# AUPIMO\n", + "\n", + "Advance use cases of the metric AUPIMO (pronounced \"a-u-pee-mo\").\n", + "\n", + "> For basic usage, please check the notebook [701a_aupimo.ipynb](./701a_aupimo.ipynb).\n", + "\n", + "Includes:\n", + "- selection of test representative samples for qualitative analysis\n", + "- visualization of the AUPIMO metric with heatmaps" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "# What is AUPIMO?\n", + "\n", + "The `Area Under the Per-Image Overlap [curve]` (AUPIMO) is a metric of recall (higher is better) designed for visual anomaly detection.\n", + "\n", + "Inspired by the [ROC](https://en.wikipedia.org/wiki/Receiver_operating_characteristic) and [PRO](https://link.springer.com/article/10.1007/s11263-020-01400-4) curves, \n", + "\n", + "> AUPIMO is the area under a curve of True Positive Rate (TPR or _recall_) as a function of False Positive Rate (FPR) restricted to a fixed range. \n", + "\n", + "But:\n", + "- the TPR (Y-axis) is *per-image* (1 image = 1 curve/score);\n", + "- the FPR (X-axis) considers the (average of) **normal** images only; \n", + "- the FPR (X-axis) is in log scale and its range is [1e-5, 1e-4]\\* (harder detection task!).\n", + "\n", + "\\* The score (the area under the curve) is normalized to be in [0, 1].\n", + "\n", + "AUPIMO can be interpreted as\n", + "\n", + "> average segmentation recall in an image given that the model (nearly) does not yield false positives in normal images.\n", + "\n", + "References in the last cell.\n", + "\n", + "![AUROC vs. AUPRO vs. AUPIMO](./roc_pro_pimo.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Setup" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install `anomalib` using `pip`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO(jpcbertoldo): replace by `pip install anomalib` when AUPIMO is released # noqa: TD003\n", + "%pip install ../.." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Change the directory to have access to the datasets." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "# NOTE: Provide the path to the dataset root directory.\n", + "# If the datasets is not downloaded, it will be downloaded\n", + "# to this directory.\n", + "dataset_root = Path.cwd().parent.parent / \"datasets\" / \"MVTec\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import cv2\n", + "import matplotlib as mpl\n", + "import numpy as np\n", + "import pandas as pd\n", + "import torch\n", + "from matplotlib import pyplot as plt\n", + "from matplotlib.ticker import PercentFormatter\n", + "from scipy import stats\n", + "\n", + "from anomalib import TaskType\n", + "from anomalib.data import MVTec\n", + "from anomalib.data.utils import read_image\n", + "from anomalib.engine import Engine\n", + "from anomalib.metrics import AUPIMO\n", + "from anomalib.models import Padim" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "pd.set_option(\"display.float_format\", \"{:.2f}\".format)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Basics\n", + "\n", + "This part was covered in the notebook [701a_aupimo.ipynb](701a_aupimo.ipynb), so we'll not discuss it here.\n", + "\n", + "It will train a model and evaluate it using AUPIMO.\n", + "We will use dataset Leather from MVTec AD with `PaDiM` (performance is not the best, but it is fast to train).\n", + "\n", + "> See the notebooks below for more details on:\n", + "> - datamodules: [100_datamodules]((https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules));\n", + "> - models: [200_models](https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/200_models)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# train the model\n", + "task = TaskType.SEGMENTATION\n", + "datamodule = MVTec(\n", + " root=dataset_root,\n", + " category=\"leather\",\n", + " image_size=256,\n", + " train_batch_size=32,\n", + " eval_batch_size=32,\n", + " num_workers=8,\n", + " task=task,\n", + ")\n", + "model = Padim(\n", + " # only use one layer to speed it up\n", + " layers=[\"layer1\"],\n", + " n_features=64,\n", + " backbone=\"resnet18\",\n", + " pre_trained=True,\n", + ")\n", + "engine = Engine(\n", + " pixel_metrics=\"AUPIMO\", # others can be added\n", + " accelerator=\"auto\", # \\<\"cpu\", \"gpu\", \"tpu\", \"ipu\", \"hpu\", \"auto\">,\n", + " devices=1,\n", + " logger=False,\n", + ")\n", + "engine.fit(datamodule=datamodule, model=model)\n", + "# infer\n", + "predictions = engine.predict(dataloaders=datamodule.test_dataloader(), model=model, return_predictions=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compute AUPIMO" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Metric `AUPIMO` will save all targets and predictions in buffer. For large datasets this may lead to large memory footprint.\n" + ] + } + ], + "source": [ + "aupimo = AUPIMO(\n", + " # with `False` all the values are returned in a dataclass\n", + " return_average=False,\n", + ")\n", + "\n", + "anomaly_maps = []\n", + "masks = []\n", + "labels = []\n", + "image_paths = []\n", + "for batch in predictions:\n", + " anomaly_maps.append(batch_anomaly_maps := batch[\"anomaly_maps\"].squeeze(dim=1))\n", + " masks.append(batch_masks := batch[\"mask\"])\n", + " labels.append(batch[\"label\"])\n", + " image_paths.append(batch[\"image_path\"])\n", + " aupimo.update(anomaly_maps=batch_anomaly_maps, masks=batch_masks)\n", + "\n", + "# list[list[str]] -> list[str]\n", + "image_paths = [item for sublist in image_paths for item in sublist]\n", + "anomaly_maps = torch.cat(anomaly_maps, dim=0)\n", + "masks = torch.cat(masks, dim=0)\n", + "labels = torch.cat(labels, dim=0)\n", + "\n", + "# `pimo_result` has the PIMO curves of each image\n", + "# `aupimo_result` has the AUPIMO values\n", + "# i.e. their Area Under the Curve (AUC)\n", + "pimo_result, aupimo_result = aupimo.compute()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Statistics and score distribution." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MEAN\n", + "aupimo_result.aupimos[labels == 1].mean().item()=0.742841961578308\n", + "OTHER STATISTICS\n", + "DescribeResult(nobs=92, minmax=(0.0, 1.0), mean=0.742841961578308, variance=0.08757792704451817, skewness=-0.9285678601866055, kurtosis=-0.3299211772047075)\n" + ] + }, + { + "data": { + "image/png": "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KlXqqhdPnz17VpKuWXauuLI8LS3tmut8++23qlSpkv7+97/bxnx8fDR48OAb5g0ICND58+e1YsWKG657LREREXkuRZI0ePBguyMuw4YNU+nSpfXtt9/mO0NefPvttwoJCVHv3r1tY+7u7nrqqad07tw5rV271m79Xr162UqRJNuRvYK85gso7ihGgItkZ2dr3rx5at26tfbv36+9e/dq7969uueee5ScnKyVK1c6vM2/nrYJDw/XihUr9P3332vdunU6duyYlixZogoVKuR5m4888ohSU1O1dOlSxcbGqnPnzlctP1fGrhSka8lLgTp48KBq1KiR6/Pc6JSWJD355JO6/fbb1bFjR1WpUkUDBgzQsmXLbvi+/xUREeHQ+n99rIGvr68qVapU6LfcHzx4UDVr1sx1p9yVU28HDx60Gw8LC7N7faUk/fV6KOBWxqk0wEVWrVqlo0ePat68eZo3b16u5bGxsWrfvr2ky7fOS7LdHfVX6enpdutdUaZMGUVGRt5UzkqVKqlVq1Z65513tH79en311VdXXc/f31+VKlXSr7/+et3t/frrr7rtttvk5+d3U7mupWLFitq6dauWL1+upUuXaunSpYqJidHjjz+e66Lka/H29i6UbFdTEEcG8+pqpxaly3feAbiMI0aAi8TGxqpixYqaP39+rp/evXvr66+/thWhoKAg+fj4KDEx8arbSkxMlI+Pj0NHghzx6KOP6scff5Sfn586dep0zfU6d+6s/fv3a926dVdd/uOPP+rAgQM3vJ6latWq+uOPP3L9C/tan/+vPDw81KVLF02bNk1//PGHhgwZok8//VR79+6VlPvI2s3as2eP3etz587p6NGjdnfLlStXTikpKXbrXbx4UUePHrUbcyRb1apVtWfPnlzPcdq9e7dtOQDHUIwAF8jIyNCCBQvUuXNn/f3vf8/1M2LECJ09e1aLFi2SdPm/9Nu3b6/FixcrKSnJbltJSUlavHix2rdvf80jAjfr73//u8aNG6dp06Zd9zk6Y8aMkbe3t4YMGaJTp07ZLTt9+rSGDh0qHx8fjRkz5rr769Spk44cOWL3VO0rjwG4kb/u183NTQ0aNJAk2+3+ZcqUkaRcRSW/Zs2aZfdcqOnTp+vSpUvq2LGjbax69er64Ycfcr3vr0eMHMnWqVMnHTt2TJ9//rlt7NKlS5oyZYp8fX3VsmXL/Hwc4JbGqTTABa7clXWtO5aaNWtme9hjr169JEmvv/66mjVrprvvvluDBw9WeHi4Dhw4oFmzZslisej1118vtLz+/v5XfQbPX9WsWVOffPKJ+vTpo/r16+d68vXJkyc1d+5cVa9e/brbGTRokD788EM9/vjjSkhIUKVKlfSf//wnT3fSPfHEEzp9+rTatGmjKlWq6ODBg5oyZYruvPNO27U3d955p0qVKqU333xTqamp8vT0VJs2ba76CIK8uHjxotq2bauePXsqMTFR06ZN0/333283v0888YSGDh2q7t27q127dtq2bZuWL1+e6yifI9kGDx6smTNnql+/fkpISFB4eLi+/PJLrV+/Xu+9994NL4QHcBUGgNN16dLFeHl5mfPnz19znX79+hl3d3dz8uRJ29iuXbtMr169TMWKFU3p0qVNxYoVzSOPPGJ27dqV6/0tW7Y09erVu2GWvn37mjJlyjj83tWrVxtJZv78+bmW/frrr6Z3796mUqVKxt3d3YSEhJjevXub7du33zDPFQcPHjQPPvig8fHxMRUqVDCjRo0yy5YtM5LM6tWr7fJXrVrV9vrLL7807du3NxUrVjQeHh4mLCzMDBkyxBw9etRu+x999JGpVq2aKVWqlN02q1ataqKioq6aqWrVqqZv37621zExMUaSWbt2rRk8eLApV66c8fX1NX369DGnTp2ye292drZ5/vnnTYUKFYyPj4/p0KGD2bt3b65tXi9by5YtTcuWLe3WTU5ONv379zcVKlQwHh4epn79+iYmJsZunf379xtJ5u233871mSSZcePGXfXzArciizFcdQcAACBxjREAAIANxQgAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKxK/AMec3JydOTIEZUtW7bAvwYAAAAUDmOMzp49q8qVK+f6ouTCVOKL0ZEjRxQaGurqGAAAIB8OHTqkKlWqOG1/Jb4YXXkk/v79+1W+fHkXp7m1ZWVl6bvvvlP79u3l7u7u6ji3NOaiaGE+ig7moug4ffq0IiIinP7VNiW+GF05fVa2bFn5+fm5OM2tLSsrSz4+PvLz8+MvHBdjLooW5qPoYC6KjitfzOzsy2C4+BoAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKwoRgAAAFYUIwAAACuKEQAAgBXFCAAAwIpiBAAAYEUxAgAAsKIY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CoeLkcVi0QsvvKAxY8Zo7969OnfunOrWrStfX9/CyAcAAOA0DhejKzw8PFS2bFmVLVuWUgQAAEoEh68xunTpkl566SX5+/srPDxc4eHh8vf314svvqisrKzCyAgAAOAUDh8xGjlypBYsWKC33npLzZs3lyTFx8dr/PjxOnXqlKZPn17gIQEAAJzB4WIUFxenefPmqWPHjraxBg0aKDQ0VL1796YYAQCAYsvhU2menp4KDw/PNR4RESEPD4+CyAQAAOASDhejESNG6NVXX7V9R5okZWZm6rXXXtOIESMKNBwAAIAz5elUWrdu3exef//996pSpYoaNmwoSdq2bZsuXryotm3bFnxCAAAAJ8lTMfL397d73b17d7vXoaGhBZcIAADARfJUjGJiYgo7BwAAgMs5fI0RAABASeXw7fqnTp3Syy+/rNWrV+v48ePKycmxW3769OkCCwcAAOBMDhejxx57THv37tXAgQMVHBwsi8VSGLkAAACczuFi9OOPP2rdunW2O9IAAABKCoevMapdu7YyMjIKIwsAAIBLOVyMpk2bphdeeEFr167VqVOnlJaWZvcDAABQXDl8Ki0gIEBpaWlq06aN3bgxRhaLRdnZ2QUWDgAAwJkcLkZ9+vSRu7u74uLiuPgaAACUKA4Xox07duiXX35RrVq1CiMPAACAyzh8jVHjxo116NChwsgCAADgUg4fMRo5cqRGjRqlMWPGqH79+nJ3d7db3qBBgwILBwAA4EwOF6NevXpJkgYMGGAbs1gsXHwNAACKPYeL0f79+wsjBwAAgMs5fI1R1apVr/vjiEmTJqlJkyYqW7asKlasqK5duyoxMdFunQsXLmj48OEKDAyUr6+vunfvruTkZEdjAwAA3JDDR4w+/fTT6y5//PHH87yttWvXavjw4WrSpIkuXbqkf/7zn2rfvr127typMmXKSJKeeeYZffPNN5o/f778/f01YsQIdevWTevXr3c0OgAAwHU5XIxGjRpl9zorK0vp6eny8PCQj4+PQ8Vo2bJldq/nzJmjihUrKiEhQS1atFBqaqpmz56tuLg42wMlY2JiVKdOHf38889q1qyZo/EBAACuyeFidObMmVxje/bs0bBhwzRmzJibCpOamipJKl++vCQpISFBWVlZioyMtK1Tu3ZthYWFKT4+/qrFKDMzU5mZmbbXV76mJCsrS1lZWTeV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NHXqVIWHh8vLy0v33HOPNm7caFs2evRolS9fXqGhoYqNjbV73/z589WlSxdnxy0RJk2apCZNmqhs2bKqWLGiunbtqsTERLt1Lly4oOHDhyswMFC+vr7q3r273VPMT58+rS5dusjX11d33XWXfvnlF7v3Dx8+XO+8845TPk9J8sYbb8hisejpp5+2jTEXznP48GFFR0crMDBQ3t7eql+/vjZv3mxbbozRyy+/rEqVKsnb21uRkZHas2ePbXlmZqYee+wx+fn56fbbb9f3339vt/23335bI0eOdNrnKa6ys7P10ksvKSIiQt7e3qpevbpeffVVu+/dYi4Kzw8//KAuXbqocuXKslgsWrhwod3yG/3upct/L/Xp00d+fn4KCAjQwIEDde7cOdvyAwcOqEWLFipTpoxatGihAwcO2L2/c+fO+uqrrxwP79QvICkE8+bNMx4eHubjjz82v/32mxk0aJAJCAgwycnJZtGiRSY4ONhs2rTJxMXFGS8vL3PixAljjDEpKSmmZs2a5uDBgy7+BMVThw4dTExMjNmxY4fZunWr6dSpkwkLCzPnzp2zrTN06FATGhpqVq5caTZv3myaNWtm7r33Xtvy0aNHm5YtW5rExETz9NNPm0aNGtmWxcfHm0aNGplLly459XMVdxs3bjTh4eGmQYMGZtSoUbZx5sI5Tp8+bapWrWr69etnNmzYYPbt22eWL19u9u7da1vnjTfeMP7+/mbhwoVm27Zt5sEHHzQREREmIyPDGGPMBx98YOrUqWN27Nhh3n77bRMUFGRycnKMMcbs27fP1KxZ06Smprrk8xUnr732mgkMDDRLliwx+/fvN/Pnzze+vr7m/ffft63DXBSeb7/91rzwwgtmwYIFRpL5+uuv7Zbf6HdvjDEPPPCAadiwofn555/Njz/+aGrUqGF69+5tW96tWzfzyCOPmN9//9307NnTdO/e3bZs3rx5pkuXLvnKXuyLUdOmTc3w4cNtr7Ozs03lypXNpEmTzJtvvml69eplW1axYkWzceNGY4wxgwcPNpMnT3Z63pLq+PHjRpJZu3atMeZy8XR3dzfz58+3rbNr1y4jycTHxxtjjOnYsaOZPn26McaYnTt3Gh8fH2OMMRcvXjQNGzY0mzZtcvKnKN7Onj1ratasaVasWGFatmxpK0bMhfM8//zz5v7777/m8pycHBMSEmLefvtt21hKSorx9PQ0c+fONcYYM2zYMPP8888bY4xJT083kszx48eNMZf/g2TBggWF+AlKjqioKDNgwAC7sW7dupk+ffoYY5gLZ/prMcrL737nzp1Gkt3fPUuXLjUWi8UcPnzYGGNMnTp1zNKlS40xl4tY3bp1jTHGnDlzxtSoUcMkJSXlK2+xPpV28eJFJSQkKDIy0jbm5uamyMhIxcfHq2HDhtq8ebPOnDmjhIQEZWRkqEaNGlq3bp22bNmip556yoXpS5bU1FRJsn3ZX0JCgrKysuzmpnbt2goLC1N8fLwkqWHDhlq1apUuXbqk5cuXq0GDBpKkt956S61atVLjxo2d/CmKt+HDhysqKsrudy4xF860aNEiNW7cWD169FDFihV111136aOPPrIt379/v44dO2Y3F/7+/rrnnnvs5mLdunXKyMjQ8uXLValSJVWoUEGxsbHy8vLSww8/7PTPVRzde++9WrlypX7//XdJ0rZt27Ru3Tp17NhREnPhSnn53cfHxysgIMDu757IyEi5ublpw4YNki7Pz/fff6+cnBx99913tr+3xowZo+HDhys0NDR/AfNVp4qIw4cPG0nmp59+shsfM2aMadq0qTHGmHHjxpnq1aubO+64wyxYsMBkZmaaO+64w2zevNlMmTLF3H777ebee+81O3bscMVHKBGys7NNVFSUue+++2xjsbGxxsPDI9e6TZo0Mc8995wx5vJ/IfTu3duEhYWZFi1amN9++838/vvvpmbNmubkyZNmyJAhJiIiwvTo0cOkpKQ47fMUR3PnzjV33HGH7TD0/x4xYi6cx9PT03h6epqxY8eaLVu2mJkzZxovLy8zZ84cY4wx69evN5LMkSNH7N7Xo0cP07NnT2PM5aN0Tz75pAkPDzeNGzc2P/74ozl16pSpVq2aSUpKMi+88IKpXr26ad++vfnzzz+d/hmLi+zsbPP8888bi8ViSpcubSwWi3n99ddty5kL59Ffjhjl5Xf/2muvmdtvvz3XtoKCgsy0adOMMcb8+eefJioqyoSGhpqoqCjz559/mrVr15rGjRubU6dOmR49epiIiAgzZMgQk5mZmee8xeIrQW7G+PHjNX78eNvrCRMmKDIyUu7u7po4caK2b9+uJUuW6PHHH1dCQoLrghZjw4cP144dO7Ru3TqH3ufv76+4uDi7sTZt2ujtt99WbGys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PClJSk1N1QsvvKCpU6fm2k758uUVFBSU598JgKKDYgTgus6dO6fPP/9cw4YNU1RUlObMmXNT2wsICFBISIjuuOMOTZ8+XRkZGVqxYsU117/77rsVGhqqBQsW2MYWLFigsLAw3XXXXXbrDh8+XB4eHvruu+/UsmVLhYWFqWPHjvr+++91+PBhvfDCCw5l3bVrl1q1aqXGjRurd+/e8vPz0/79+yVJzz33nIYNG5arnAEo3ihGAK7riy++UO3atVWrVi1FR0fr448/VkF9xaK3t7ck6eLFi9ddb8CAAYqJibG9/vjjj9W/f3+7dU6fPq3ly5frySeftG33ipCQEPXp00eff/65Q9kbNmyozZs368yZM0pISFBGRoZq1KihdevWacuWLXrqqafyvC0AxQPFCMB1zZ49W9HR0ZKkBx54QKmpqVq7du1Nbzc9PV0vvviiSpUqpZYtW1533ejoaK1bt04HDx7UwYMHtX79elumK/bs2SNjjOrUqXPVbdSpU0dnzpzRiRMn8pyxQ4cOio6OVpMmTdSvXz998sknKlOmjIYNG6YZM2Zo+vTpqlWrlu677z799ttved4ugKKrtKsDACi6EhMTtXHjRn399deSLl+D06tXL82ePVutWrXK1zZ79+6tUqVKKSMjQ0FBQZo9e7YaNGhw3fcEBQXZTuMZYxQVFaUKFSpcdd2COpp1xfjx4zV+/Hjb6wkTJigyMlLu7u6aOHGitm/friVLlujxxx9XQkJCge4bgPNRjABc0+zZs3Xp0iVVrlzZNmaMkaenpz788EP5+/vLz89P0uWLkQMCAuzen5KSIn9/f7uxd999V5GRkfL393foAuUBAwZoxIgRknTVC55r1Kghi8WiXbt26eGHH861fNeuXSpXrtxNXRS9e/duffbZZ/rll1/08ccfq0WLFgoKClLPnj01YMAAnT17VmXLls339gG4HqfSAFzVpUuX9Omnn+qdd97R1q1bbT/btm1T5cqVNXfuXElSzZo15ebmlutoyb59+5Samqrbb7/dbjwkJEQ1atRwuKA88MADunjxorKystShQ4dcywMDA9WuXTtNmzbN7g42STp27JhiY2PVq1cvWSwWh/Z7hTFGQ4YM0eTJk+Xr66vs7GxlZWVJku1/s7Oz87VtAEUHR4wAXNWSJUt05swZDRw4MNdRn+7du2v27NkaOnSoypYtqyeeeELPPvusSpcurfr16+vQoUN6/vnn1axZM917770FkqdUqVLatWuX7c9X8+GHH+ree+9Vhw4dNHHiREVEROi3337TmDFjdNttt+m1117L9/7//e9/KygoyPbcovvuu0/jx4/Xzz//rKVLl6pu3bq5jpgBKH44YgTgqmbPnm075fVX3bt31+bNm/Xrr79Kkt5//3317dtXzz//vOrVq6d+/fqpQYMGWrx4cb6P0FyNn5+f7dTd1dSsWVObN29WtWrV1LNnT1WvXl2DBw9W69atFR8fr/Lly+drv8nJyXrttdf0wQcf2MaaNm2qZ599VlFRUfriiy/s7poDUHxZTEFfqQgAxdD48eO1cOHCAvsKjwMHDigiIkK//PKL7rzzzgLZJoDCxxEjALDavn27fH19NW3atJvaTseOHVWvXr0CSgXAmThiBAC6/IDIK18ZEhQUdNVTiHl1+PBh2wXgYWFh8vDwKJCMAAofxQgAAMCKU2kAAABWFCMAAAArihEAAIAVxQgAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKwoRgAAAFb/D60PVy7Nlq5qAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# the normal images have `nan` values because\n", + "# recall is not defined for them so we ignore them\n", + "print(f\"MEAN\\n{aupimo_result.aupimos[labels == 1].mean().item()=}\")\n", + "print(f\"OTHER STATISTICS\\n{stats.describe(aupimo_result.aupimos[labels == 1])}\")\n", + "\n", + "fig, ax = plt.subplots()\n", + "ax.hist(aupimo_result.aupimos[labels == 1].numpy(), bins=np.linspace(0, 1, 11), edgecolor=\"black\")\n", + "ax.set_ylabel(\"Count (number of images)\")\n", + "ax.set_xlim(0, 1)\n", + "ax.set_xlabel(\"AUPIMO [%]\")\n", + "ax.xaxis.set_major_formatter(PercentFormatter(1))\n", + "ax.grid()\n", + "ax.set_title(\"AUPIMO distribution\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Until here we just reproduded the notebook with the basic usage of AUPIMO." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Selecting Representative Samples for Qualitative Analysis\n", + "\n", + "Instead of cherry picking or inspecting the 92 samples from above, we'll try to choose them smartly.\n", + "\n", + "Our goal here is to select a handful of samples in a meaningful way.\n", + "\n", + "> Notice that a random selection from the distribution above would probably miss the worst cases.\n", + "\n", + "We will summarize this distribution with a boxplot, then select the samples corresponding to the statistics in it." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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ERGR6jHwEBERkeox8BAREZHqMfAQERGR6jHwEBERkeox8BAREZHqMfAQERGR6jHwEBERkeox8BAREZHqMfAQERGR6jHwEBERkeox8BAREZHqMfAQERGR6jHwEBERkeox8BAREZHqMfAQERGR6jHwEBERkeox8BAREZHqMfAQERGR6jHwEBERkeox8BAREZHq6ZX1BIiInicxMRGZmZllPQ0iKgHm5uZwdXUt9XEZeIjotZaYmAg3N7eyngZRqbEz02CQlwG+j3mE6/ekrKfzSpw9e7bUQw8DDxG91vKv7CxfvhweHh5lPBuiV8847Sw8dg1Cj69+RraVusJ+XFwcgoKCyuSKLQMPEb0RPDw80KBBg7KeBtGrd00H2AV4uLsDDvXKejaqwU3LREREpHoMPERERKR6DDxERESkegw8REREpHoMPERERKR6DDxERESkegw8REREpHoMPERERKR6DDxERESkegw8REREpHoMPERERKR6JR54srKycPToUWRlZZV010RERKQCcXFxpZ4TSjzwxMfHw8vLC/Hx8SXdNREREalAUFBQqecE3tIiIiIi1WPgISIiItVj4CEiIiLVY+AhIiIi1WPgISIiItVj4CEiIiLVY+AhIiIi1WPgISIiItVj4CEiIiLVY+AhIiIi1WPgISIiItVj4CEiIiLVY+AhIiIi1WPgISIiItXTK+kOs7OzAQBxcXEl3TURvYXy/5bk/20hojfX33+PS/t3usQDT3JyMgAgKCiopLsmordYcnIymjdvXtbTIKKXkJ8R8n8uzd/pEg88zs7OAIDly5fDw8OjpLsnordMXFwcgoKClL8tRPTm+vvvcWn/Tpd44DE2NgYAeHh4oEGDBiXdPRG9pfL/thDRm+vvv8el/TvNTctERESkegw8REREpHoMPERERKR6DDxERESkegw8REREpHoMPERERKR6DDxERESkegw8REREpHoMPERERKR6DDxERESkegw8REREpHoMPERERKR6DDxERESkeiUeeNzd3RETEwN3d/eS7pqIiIhUYPny5aWeE/RKukMTExM0aNCgpLslIiIilfDw8ICJiUmpjslbWkRERKR6DDxERESkegw8REREpHoMPERERKR6DDxERESkegw8REREpHoMPERERKR6DDxERESkegw8REREpHoMPERERKR6DDxERESkegw8RERE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", + "text/plain": [ + "
" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(7, 2))\n", + "boxplot_data = ax.boxplot(\n", + " aupimo_result.aupimos[labels == 1].numpy(),\n", + " vert=False,\n", + " widths=0.4,\n", + ")\n", + "_ = ax.set_yticks([])\n", + "ax.set_xlim(0 - (eps := 2e-2), 1 + eps)\n", + "ax.xaxis.set_major_formatter(PercentFormatter(1))\n", + "ax.set_xlabel(\"AUPIMO [%]\")\n", + "ax.set_title(\"AUPIMO Scores Boxplot\")\n", + "num_images = (labels == 1).sum().item()\n", + "ax.annotate(\n", + " text=f\"Number of images: {num_images}\",\n", + " xy=(0.03, 0.95),\n", + " xycoords=\"axes fraction\",\n", + " xytext=(0, 0),\n", + " textcoords=\"offset points\",\n", + " annotation_clip=False,\n", + " verticalalignment=\"top\",\n", + ")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To get the values in the boxplot (e.g., whiskers, quartiles, etc.), we're going to use `matplotlib`'s internal function `mpl.cbook.boxplot_stats()`." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['mean', 'iqr', 'cilo', 'cihi', 'whishi', 'whislo', 'fliers', 'q1', 'med', 'q3'])\n" + ] + } + ], + "source": [ + "boxplot_data = mpl.cbook.boxplot_stats(aupimo_result.aupimos[labels == 1].numpy())[0]\n", + "print(boxplot_data.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We'll select 5 of those and find images in the dataset that match them." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " statistic value image_index\n", + "0 whislo 0.00 65\n", + "1 q1 0.53 58\n", + "2 med 0.89 63\n", + "3 q3 1.00 22\n", + "4 whishi 1.00 0\n" + ] + } + ], + "source": [ + "image_selection = []\n", + "\n", + "for key in [\"whislo\", \"q1\", \"med\", \"q3\", \"whishi\"]:\n", + " value = boxplot_data[key]\n", + " # find the image that is closest to the value of the statistic\n", + " # `[labels == 1]` is not used here so that the image's\n", + " # indexes are the same as the ones in the dataset\n", + " # we use `sort()` -- instead of `argmin()` -- so that\n", + " # the `nan`s are not considered (they are at the end)\n", + " closest_image_index = (aupimo_result.aupimos - value).abs().argsort()[0]\n", + " image_selection.append({\"statistic\": key, \"value\": value, \"image_index\": closest_image_index.item()})\n", + "\n", + "image_selection = pd.DataFrame(image_selection)\n", + "print(image_selection)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice that they are sorted from the worst to the best AUPIMO score." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Visualizing the Representative Samples" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's visualize what the heatmaps of these samples." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "# will be used to normalize the anomaly maps to fit a colormap\n", + "global_vmin, global_vmax = torch.quantile(anomaly_maps, torch.tensor([0.02, 0.98]))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(2, 5, figsize=(16, 7), layout=\"constrained\")\n", + "\n", + "for ax_column, (_, row) in zip(axes.T, image_selection.iterrows(), strict=False):\n", + " ax_above, ax_below = ax_column\n", + " image = cv2.resize(read_image(image_paths[row.image_index]), (256, 256))\n", + " anomaly_map = anomaly_maps[row.image_index].numpy()\n", + " mask = masks[row.image_index].squeeze().numpy()\n", + " ax_above.imshow(image)\n", + " ax_above.contour(mask, levels=[0.5], colors=\"magenta\", linewidths=1)\n", + " ax_below.imshow(image)\n", + " ax_below.imshow(anomaly_map, cmap=\"jet\", vmin=global_vmin, vmax=global_vmax, alpha=0.30)\n", + " ax_below.contour(mask, levels=[0.5], colors=\"magenta\", linewidths=1)\n", + " ax_above.set_title(f\"{row.statistic}: {row.value:.0%} AUPIMO image {row.image_index}\")\n", + "\n", + "for ax in axes.flatten():\n", + " ax.set_xticks([])\n", + " ax.set_yticks([])\n", + "\n", + "axes[0, 0].set_ylabel(\"Image + GT Mask\")\n", + "axes[1, 0].set_ylabel(\"Image + GT Mask + Anomaly Map\")\n", + "fig.text(\n", + " 0.03,\n", + " -0.01,\n", + " \"Magenta: contours of the ground truth (GT) mask. \"\n", + " \"Anomaly maps colored in JET colormap with global (across all images) min-max normalization.\",\n", + " ha=\"left\",\n", + " va=\"top\",\n", + " fontsize=\"small\",\n", + " color=\"dimgray\",\n", + ")\n", + "\n", + "fig.suptitle(\"Anomalous samples from AUPIMO boxplot's statistics\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The heatmaps give the impression that all samples are properly detected, right?\n", + "\n", + "Notice that the lowest AUPIMO (left) is 0, but the heatmap is (contradictorily) showing a good detection.\n", + "\n", + "Why is that?\n", + "\n", + "These heatmaps are colored with a gradient from the minimum to the maximum value in all the heatmaps from the test set.\n", + "\n", + "This is not taking into account the contraints (FPR restriction) in AUPIMO.\n", + "\n", + "Let's compare with the heatmaps from some normal images." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(2, 5, figsize=(16, 7), layout=\"constrained\")\n", + "\n", + "# random selection of normal images\n", + "rng = np.random.default_rng(42)\n", + "normal_images_selection = rng.choice(np.where(labels == 0)[0], size=5, replace=False)\n", + "\n", + "for ax_column, index in zip(axes.T, normal_images_selection, strict=False):\n", + " ax_above, ax_below = ax_column\n", + " image = cv2.resize(read_image(image_paths[index]), (256, 256))\n", + " anomaly_map = anomaly_maps[index].numpy()\n", + " ax_above.imshow(image)\n", + " ax_below.imshow(image)\n", + " ax_below.imshow(anomaly_map, cmap=\"jet\", vmin=global_vmin, vmax=global_vmax, alpha=0.30)\n", + " ax_above.set_title(f\"image {index}\")\n", + "\n", + "for ax in axes.flatten():\n", + " ax.set_xticks([])\n", + " ax.set_yticks([])\n", + "\n", + "axes[0, 0].set_ylabel(\"Image\")\n", + "axes[1, 0].set_ylabel(\"Image + Anomaly Map\")\n", + "fig.text(\n", + " 0.03,\n", + " -0.01,\n", + " \"Anomaly maps colored in JET colormap with global (across all images) min-max normalization.\",\n", + " ha=\"left\",\n", + " va=\"top\",\n", + " fontsize=\"small\",\n", + " color=\"dimgray\",\n", + ")\n", + "\n", + "fig.suptitle(\"Normal samples (test set)\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice how the normal images also have high anomaly scores (\"hot\" colors) although there is no anomaly.\n", + "\n", + "As a matter of fact, the heatmaps can barely differentiate between some normal and anomalous images.\n", + "\n", + "See the two heatmaps below for instance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(7, 4), layout=\"constrained\")\n", + "\n", + "for ax, index in zip(axes.flatten(), [87, 65], strict=False):\n", + " image = cv2.resize(read_image(image_paths[index]), (256, 256))\n", + " anomaly_map = anomaly_maps[index].numpy()\n", + " mask = masks[index].squeeze().numpy()\n", + " ax.imshow(image)\n", + " ax.contour(mask, levels=[0.5], colors=\"magenta\", linewidths=1)\n", + " ax.imshow(anomaly_map, cmap=\"jet\", vmin=global_vmin, vmax=global_vmax, alpha=0.30)\n", + " ax.set_title(f\"image {index}\")\n", + "\n", + "for ax in axes.flatten():\n", + " ax.set_xticks([])\n", + " ax.set_yticks([])\n", + "\n", + "axes[0].set_title(f\"{axes[0].get_title()} (normal)\")\n", + "axes[1].set_title(f\"{axes[1].get_title()} (anomalous)\")\n", + "\n", + "fig.text(\n", + " 0.03,\n", + " -0.01,\n", + " \"Magenta: contours of the ground truth (GT) mask.\\n\"\n", + " \"Anomaly maps colored in JET colormap with global (across all images) min-max normalization.\",\n", + " ha=\"left\",\n", + " va=\"top\",\n", + " fontsize=\"small\",\n", + " color=\"dimgray\",\n", + ")\n", + "\n", + "fig.suptitle(\"Normal vs. Anomalous Samples\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "One would expect image 65 (anomalous) to a 'hotter' heatmap than image 87 (normal), but it is the opposite.\n", + "\n", + "This shows that the model is not doing a great job." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Visualizing the AUPIMO on the Heatmaps\n", + "\n", + "We will create another visualization to link the heatmaps to AUPIMO.\n", + "\n", + "Recall that AUPIMO computes this integral (simplified):\n", + "\n", + "$$\n", + " \\int_{\\log(L)}^{\\log(U)} \n", + " \\operatorname{TPR}^{i}\\left( \\operatorname{FRP^{-1}}( z ) \\right)\n", + " \\, \n", + " \\mathrm{d}\\log(z) \n", + "$$\n", + "\n", + "The integration bounds -- $L$[ower] and $U$[pper] -- are FPR values.\n", + "\n", + "> More details about their meaning in the next notebook.\n", + "\n", + "We will leverage these two bounds to create a heatmap that shows them in a gradient like this:\n", + "\n", + "![Visualization of AUPIMO on the heatmaps](./pimo_viz.svg)\n", + "\n", + "If the anomaly score is\n", + "1. too low (below the lowest threshold of AUPIMO) $\\rightarrow$ not shown; \n", + "2. between the bounds $\\rightarrow$ shown in a JET gradient;\n", + "3. too high (above the highest threshold of AUPIMO) $\\rightarrow$ shown in a single color.\n", + "\n", + "> Technical detail: lower/upper bound of FPR correspond to the upper/lower bound of threshold.\n", + "\n", + "> **Why low values are not shown?**\n", + ">\n", + "> Because the values below the lower (threshold) bound would _never_ be seen as \"anomalous\" by the metric.\n", + ">\n", + "> Analogously, high values are shown in red because they are _always_ seen as \"anomalous\" by the metric." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "FPR bounds\n", + "Lower bound: 0.00001\n", + "Upper bound: 0.00010\n", + "Thresholds corresponding to the FPR bounds\n", + "Lower threshold: 0.504\n", + "Upper threshold: 0.553\n" + ] + } + ], + "source": [ + "# the fpr bounds are fixed in advance in the metric object\n", + "print(f\"\"\"FPR bounds\n", + "Lower bound: {aupimo.fpr_bounds[0]:.5f}\n", + "Upper bound: {aupimo.fpr_bounds[1]:.5f}\"\"\")\n", + "\n", + "# their corresponding thresholds depend on the model's behavior\n", + "# so they only show in the result object\n", + "print(f\"\"\"Thresholds corresponding to the FPR bounds\n", + "Lower threshold: {aupimo_result.thresh_lower_bound:.3g}\n", + "Upper threshold: {aupimo_result.thresh_upper_bound:.3g}\"\"\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# we re-sample other normal images\n", + "# the FPR bounds are so strict that the heatmaps in the normal images\n", + "# become almost invisible with this colormap\n", + "max_anom_score_per_image = anomaly_maps.max(dim=2).values.max(dim=1).values # noqa: PD011\n", + "normal_images_with_highest_max_score = sorted(\n", + " zip(max_anom_score_per_image[labels == 0], torch.where(labels == 0)[0], strict=False),\n", + " reverse=True,\n", + " key=lambda x: x[0],\n", + ")\n", + "normal_images_with_highest_max_score = [idx.item() for _, idx in normal_images_with_highest_max_score[:5]]\n", + "\n", + "fig, axes = plt.subplots(2, 5, figsize=(16, 7), layout=\"constrained\")\n", + "\n", + "for ax, (_, row) in zip(axes[0], image_selection.iterrows(), strict=False):\n", + " image = cv2.resize(read_image(image_paths[row.image_index]), (256, 256))\n", + " anomaly_map = anomaly_maps[row.image_index].numpy()\n", + " mask = masks[row.image_index].squeeze().numpy()\n", + " ax.imshow(image)\n", + " #\n", + " # where the magic happens!\n", + " #\n", + " ax.imshow(\n", + " # anything below the lower threshold is set to `nan` so it's not shown\n", + " # because such values would never be detected as anomalies with AUPIMO's contraints\n", + " np.where(anomaly_map < aupimo_result.thresh_lower_bound, np.nan, anomaly_map),\n", + " cmap=\"jet\",\n", + " alpha=0.50,\n", + " # notice that vmin/vmax changed here to use the thresholds from the result object\n", + " vmin=aupimo_result.thresh_lower_bound,\n", + " vmax=aupimo_result.thresh_upper_bound,\n", + " )\n", + " ax.contour(anomaly_map, levels=[aupimo_result.thresh_lower_bound], colors=[\"blue\"], linewidths=1)\n", + " ax.contour(mask, levels=[0.5], colors=\"magenta\", linewidths=1)\n", + " ax.set_title(f\"{row.statistic}: {row.value:.0%}AUPIMO image {row.image_index}\")\n", + "\n", + "for ax, index in zip(axes[1], normal_images_with_highest_max_score, strict=False):\n", + " image = cv2.resize(read_image(image_paths[index]), (256, 256))\n", + " anomaly_map = anomaly_maps[index].numpy()\n", + " mask = masks[index].squeeze().numpy()\n", + " ax.imshow(image)\n", + " ax.imshow(\n", + " np.where(anomaly_map < aupimo_result.thresh_lower_bound, np.nan, anomaly_map),\n", + " cmap=\"jet\",\n", + " alpha=0.30,\n", + " vmin=aupimo_result.thresh_lower_bound,\n", + " vmax=aupimo_result.thresh_upper_bound,\n", + " )\n", + " ax.contour(anomaly_map, levels=[aupimo_result.thresh_lower_bound], colors=[\"blue\"], linewidths=1)\n", + " ax.set_title(f\"image {index}\")\n", + "\n", + "for ax in axes.flatten():\n", + " ax.set_xticks([])\n", + " ax.set_yticks([])\n", + "\n", + "axes[0, 0].set_ylabel(\"Anomalous\")\n", + "axes[1, 0].set_ylabel(\"Normal\")\n", + "fig.text(\n", + " 0.03,\n", + " -0.01,\n", + " \"Magenta: contours of the ground truth (GT) mask. \"\n", + " \"Anomaly maps colored in JET colormap between the thresholds in AUPIMO's integral. \"\n", + " \"Lower values are transparent, higher values are red.\",\n", + " ha=\"left\",\n", + " va=\"top\",\n", + " fontsize=\"small\",\n", + " color=\"dimgray\",\n", + ")\n", + "\n", + "fig.suptitle(\"Visualization linked to AUPIMO's bounds\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now the AUPIMO scores make sense with what you see in the heatmaps.\n", + "\n", + "The samples on the left and right are special cases: \n", + "- left (0% AUPIMO): nothing is seen because the model completely misses the anomaly\\*;\n", + "- right (100% AUPIMO): is practically red only because the detected the anomaly very well. \n", + "\n", + "\\* Because the scores in image 65 are as low as those in normal images." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cite Us\n", + "\n", + "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "\n", + "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "\n", + "```bibtex\n", + "@misc{bertoldo2024aupimo,\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", + " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " year={2024},\n", + " eprint={2401.01984},\n", + " archivePrefix={arXiv},\n", + " primaryClass={cs.CV},\n", + " url={https://arxiv.org/abs/2401.01984}, \n", + "}\n", + "```\n", + "\n", + "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", + "\n", + "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Utils\n", + "\n", + "Here we provide some utility functions to reproduce the techniques shown in this notebook.\n", + "\n", + "They are `numpy` compatible and cover edge cases not discussed here (check the examples)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Representative samples from the boxplot's statistics\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from numpy import ndarray\n", + "from torch import Tensor\n", + "\n", + "\n", + "def _validate_tensor_or_ndarray(x: Tensor | ndarray) -> ndarray:\n", + " if not isinstance(x, Tensor | ndarray):\n", + " msg = f\"Expected argument to be a tensor or ndarray, but got {type(x)}.\"\n", + " raise TypeError(msg)\n", + "\n", + " if isinstance(x, Tensor):\n", + " x = x.cpu().numpy()\n", + "\n", + " return x\n", + "\n", + "\n", + "def _validate_values(values: ndarray) -> None:\n", + " if values.ndim != 1:\n", + " msg = f\"Expected argument `values` to be a 1D, but got {values.ndim}D.\"\n", + " raise ValueError(msg)\n", + "\n", + "\n", + "def _validate_labels(labels: ndarray) -> ndarray:\n", + " if labels.ndim != 1:\n", + " msg = f\"Expected argument `labels` to be a 1D, but got {labels.ndim}D.\"\n", + " raise ValueError(msg)\n", + "\n", + " # if torch.is_floating_point(labels):\n", + " if np.issubdtype(labels.dtype, np.floating):\n", + " msg = f\"Expected argument `labels` to be of int or binary types, but got float: {labels.dtype}.\"\n", + " raise TypeError(msg)\n", + "\n", + " # check if it is binary and convert to int\n", + " if np.issubdtype(labels.dtype, np.bool_):\n", + " labels = labels.astype(int)\n", + "\n", + " unique_values = np.unique(labels)\n", + " nor_0_nor_1 = (unique_values != 0) & (unique_values != 1)\n", + " if nor_0_nor_1.any():\n", + " msg = f\"Expected argument `labels` to have 0s and 1s as ground truth labels, but got values {unique_values}.\"\n", + " raise ValueError(msg)\n", + "\n", + " return labels\n", + "\n", + "\n", + "def boxplot_stats(\n", + " values: Tensor | ndarray,\n", + " labels: Tensor | ndarray,\n", + " only_label: int | None = 1,\n", + " flier_policy: str | None = None,\n", + " repeated_policy: str | None = \"avoid\",\n", + ") -> list[dict[str, str | int | float | None]]:\n", + " \"\"\"Compute boxplot statistics of `values` and find the samples that are closest to them.\n", + "\n", + " This function uses `matplotlib.cbook.boxplot_stats`, which is the same function used by `matplotlib.pyplot.boxplot`.\n", + "\n", + " Args:\n", + " values (Tensor | ndarray): Values to compute boxplot statistics from.\n", + " labels (Tensor | ndarray): Labels of the samples (0=normal, 1=anomalous). Must have the same shape as `values`.\n", + " only_label (int | None): If 0 or 1, only use samples of that class. If None, use both. Defaults to 1.\n", + " flier_policy (str | None): What happens with the fliers ('outliers')?\n", + " - None: Do not include fliers.\n", + " - 'high': Include only high fliers.\n", + " - 'low': Include only low fliers.\n", + " - 'both': Include both high and low fliers.\n", + " Defaults to None.\n", + " repeated_policy (str | None): What happens if a sample has already selected [for another statistic]?\n", + " - None: Don't care, repeat the sample.\n", + " - 'avoid': Avoid selecting the same one, go to the next closest.\n", + " Defaults to 'avoid'.\n", + "\n", + " Returns:\n", + " list[dict[str, str | int | float | None]]: List of boxplot statistics.\n", + " Keys:\n", + " - 'statistic' (str): Name of the statistic.\n", + " - 'value' (float): Value of the statistic (same units as `values`).\n", + " - 'nearest' (float): Value of the sample in `values` that is closest to the statistic.\n", + " Some statistics (e.g. 'mean') are not guaranteed to be a value in `values`.\n", + " This value is the actual one when they that is the case.\n", + " - 'index': Index in `values` that has the `nearest` value to the statistic.\n", + " \"\"\"\n", + " # operate on numpy arrays only for simplicity\n", + " values = _validate_tensor_or_ndarray(values) # (N,)\n", + " labels = _validate_tensor_or_ndarray(labels) # (N,)\n", + "\n", + " # validate the arguments\n", + " _validate_values(values)\n", + " labels = _validate_labels(labels)\n", + " if values.shape != labels.shape:\n", + " msg = (\n", + " \"Expected arguments `values` and `labels` to have the same shape, \"\n", + " f\"but got {values.shape=} and {labels.shape=}.\"\n", + " )\n", + " raise ValueError(msg)\n", + " assert only_label in {None, 0, 1}, f\"Invalid argument `only_label`: {only_label}\"\n", + " assert flier_policy in {None, \"high\", \"low\", \"both\"}, f\"Invalid argument `flier_policy`: {flier_policy}\"\n", + " assert repeated_policy in {None, \"avoid\"}, f\"Invalid argument `repeated_policy`: {repeated_policy}\"\n", + "\n", + " if only_label is not None and only_label not in labels:\n", + " msg = f\"Argument {only_label=} but `labels` does not contain this class.\"\n", + " raise ValueError(msg)\n", + "\n", + " # only consider samples of the given label\n", + " # `values` and `labels` now have shape (n,) instead of (N,), where n <= N\n", + " label_filter_mask = (labels == only_label) if only_label is not None else np.ones_like(labels, dtype=bool)\n", + " values = values[label_filter_mask] # (n,)\n", + " labels = labels[label_filter_mask] # (n,)\n", + " indexes = np.nonzero(label_filter_mask)[0] # (n,) values are indices in {0, 1, ..., N-1}\n", + "\n", + " indexes_selected = set() # values in {0, 1, ..., N-1}\n", + "\n", + " def append(records_: dict, statistic_: str, value_: float) -> None:\n", + " indices_sorted_by_distance = np.abs(values - value_).argsort() # (n,)\n", + " candidate = indices_sorted_by_distance[0] # idx that refers to {0, 1, ..., n-1}\n", + "\n", + " nearest = values[candidate]\n", + " index = indexes[candidate] # index has value in {0, 1, ..., N-1}\n", + " label = labels[candidate]\n", + "\n", + " if index in indexes_selected and repeated_policy == \"avoid\":\n", + " for candidate in indices_sorted_by_distance:\n", + " index_of_candidate = indexes[candidate]\n", + " if index_of_candidate in indexes_selected:\n", + " continue\n", + " # if the code reaches here, it means that `index_of_candidate` is not repeated\n", + " # if this is never reached, the first choice will be kept\n", + " nearest = values[candidate]\n", + " label = labels[candidate]\n", + " index = index_of_candidate\n", + " break\n", + "\n", + " indexes_selected.add(index)\n", + "\n", + " records_.append(\n", + " {\n", + " \"statistic\": statistic_,\n", + " \"value\": float(value_),\n", + " \"nearest\": float(nearest),\n", + " \"index\": int(index),\n", + " \"label\": int(label),\n", + " },\n", + " )\n", + "\n", + " # function used in `matplotlib.boxplot`\n", + " boxplot_stats = mpl.cbook.boxplot_stats(values)[0] # [0] is for the only boxplot\n", + "\n", + " records = []\n", + " for stat, val in boxplot_stats.items():\n", + " if stat in {\"iqr\", \"cilo\", \"cihi\"}:\n", + " continue\n", + "\n", + " if stat != \"fliers\":\n", + " append(records, stat, val)\n", + " continue\n", + "\n", + " if flier_policy is None:\n", + " continue\n", + "\n", + " for val_ in val:\n", + " stat_ = \"flierhi\" if val_ > boxplot_stats[\"med\"] else \"flierlo\"\n", + " if flier_policy == \"high\" and stat_ == \"flierlo\":\n", + " continue\n", + " if flier_policy == \"low\" and stat_ == \"flierhi\":\n", + " continue\n", + " # else means that they match or `fliers == \"both\"`\n", + " append(records, stat_, val_)\n", + "\n", + " return sorted(records, key=lambda r: r[\"value\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Basic Usage" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " statistic value nearest index label\n", + "0 whislo 0.00 0.00 65 1\n", + "1 q1 0.53 0.53 58 1\n", + "2 mean 0.74 0.75 7 1\n", + "3 med 0.89 0.89 63 1\n", + "4 q3 1.00 1.00 22 1\n", + "5 whishi 1.00 1.00 0 1\n" + ] + } + ], + "source": [ + "# basic usage\n", + "boxplot_statistics = boxplot_stats(aupimo_result.aupimos, labels)\n", + "boxplot_statistics = pd.DataFrame.from_records(boxplot_statistics)\n", + "print(boxplot_statistics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Repeated Statistics" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " statistic value nearest index label\n", + "0 whislo 0.00 0.00 67 1\n", + "1 q1 0.59 0.59 58 1\n", + "2 mean 0.78 0.79 43 1\n", + "3 med 0.98 0.99 9 1\n", + "4 whishi 1.00 1.00 0 1\n", + "5 q3 1.00 1.00 36 1\n" + ] + } + ], + "source": [ + "# repeated values\n", + "# if the distribution is very skewed to one side,\n", + "# some statistics may have the same value\n", + "# e.g. the Q3 and the high whisker\n", + "#\n", + "# let's simulate this situation\n", + "\n", + "# increase all values by 10% and clip to [0, 1]\n", + "mock = torch.clip(aupimo_result.aupimos.clone() * 1.10, 0, 1)\n", + "\n", + "# 'avoid' is the default policy\n", + "# notice how Q3 and the high whisker have the same value, but different indexes\n", + "print(pd.DataFrame.from_records(boxplot_stats(mock, labels, repeated_policy=\"avoid\")))" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " statistic value nearest index label\n", + "0 whislo 0.00 0.00 67 1\n", + "1 q1 0.59 0.59 58 1\n", + "2 mean 0.78 0.79 43 1\n", + "3 med 0.98 0.99 9 1\n", + "4 whishi 1.00 1.00 0 1\n", + "5 q3 1.00 1.00 0 1\n" + ] + } + ], + "source": [ + "# this behavior can be changed to allow repeated values\n", + "print(pd.DataFrame.from_records(boxplot_stats(mock, labels, repeated_policy=None)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fliers" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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qsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKrHwENERESqx8BDREREqsfAQ0RERKqnX9iGIgIASE9Pf2WDISIiIgL+L2/k5Y+XVejAk5GRAQBwdHQslh0TERERPU9GRgasrKxeuh+NFDI65ebm4sqVK7CwsIBGo3lqu/T0dDg6OuLixYuwtLR86QFS8eL8lH2co7KN81O2cX7KvsLOkYggIyMDDg4O0NN7+RU4hT7Do6enhypVqhS6Y0tLS/6wlWGcn7KPc1S2cX7KNs5P2VeYOSqOMzt5uGiZiIiIVI+Bh4iIiFSv2AOPkZERxo0bByMjo+LumooB56fs4xyVbZyfso3zU/aV1hwVetEyERER0euKl7SIiIhI9Rh4iIiISPUYeIiIiEj1GHiIiIhI9V4o8MybNw/Ozs4wNjZGo0aNsH//fqVu5MiRsLGxgaOjIyIiInS2W716NQICAl5uxKSYMmUK3nrrLVhYWKBixYoIDAxEQkKCTpsHDx4gLCwM5cuXh7m5Obp06YLU1FSl/tatWwgICIC5uTnq16+PuLg4ne3DwsIwc+bMEjketZs6dSo0Gg1GjBihlHF+St/ly5cREhKC8uXLw8TEBJ6enjh48KBSLyL4+uuvYW9vDxMTE/j6+iIxMVGpf/jwIXr16gVLS0u4ublhy5YtOv3PmDEDH3/8cYkdj5rk5OTgq6++gouLC0xMTFC9enVMmDBB57uVOD8lZ+fOnQgICICDgwM0Gg1+//13nfrnzQXw+Hdaz549YWlpCWtra/Tv3x93795V6pOTk9GyZUuYmZmhZcuWSE5O1tm+Y8eOWLNmzYsdgBTRypUrxdDQUBYvXiwnTpyQjz76SKytrSU1NVXWrVsnlSpVkgMHDsjy5cvF2NhYrl+/LiIid+7cEVdXVzl//nxR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", + "text/plain": [ + "
" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# fliers\n", + "# if the distribution is very skewed to one side,\n", + "# it is possible that some extreme values are considered\n", + "# are considered as outliers, showing as fliers in the boxplot\n", + "#\n", + "# there are two types of fliers: high and low\n", + "# they are defined as:\n", + "# - high: values > high whisker = Q3 + 1.5 * IQR\n", + "# - low: values < low whisker = Q1 - 1.5 * IQR\n", + "# where IQR = Q3 - Q1\n", + "\n", + "# let's artificially simulate this situation\n", + "# we will create a distortion in the values so that\n", + "# high values (close to 1) become even higher\n", + "# and low values (close to 0) become even lower\n", + "\n", + "\n", + "def distortion(vals: Tensor) -> Tensor:\n", + " \"\"\"Artificial distortion to simulate a skewed distribution.\n", + "\n", + " To visualize it:\n", + " ```\n", + " fig, ax = plt.subplots()\n", + " t = np.linspace(0, 1, 100)\n", + " ax.plot(t, np.clip(distortion(t), 0, 1), label=\"distortion\")\n", + " ax.plot(t, t, label=\"identity\", linestyle=\"--\")\n", + " fig\n", + " ```\n", + " \"\"\"\n", + " return vals + 0.12 * (vals * (1 - vals) * 4)\n", + "\n", + "\n", + "mock = torch.clip(distortion(aupimo_result.aupimos.clone()), 0, 1)\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 2))\n", + "ax.boxplot(\n", + " mock[labels == 1].numpy(),\n", + " vert=False,\n", + " widths=0.4,\n", + ")\n", + "_ = ax.set_yticks([])\n", + "ax.set_xlim(0 - (eps := 2e-2), 1 + eps)\n", + "ax.xaxis.set_major_formatter(PercentFormatter(1))\n", + "ax.set_xlabel(\"AUPIMO [%]\")\n", + "ax.set_title(\"AUPIMO Scores Boxplot\")\n", + "num_images = (labels == 1).sum().item()\n", + "ax.annotate(\n", + " text=f\"Number of images: {num_images}\",\n", + " xy=(0.03, 0.95),\n", + " xycoords=\"axes fraction\",\n", + " xytext=(0, 0),\n", + " textcoords=\"offset points\",\n", + " annotation_clip=False,\n", + " verticalalignment=\"top\",\n", + ")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " statistic value nearest index label\n", + "0 whislo 0.24 0.24 44 1\n", + "1 q1 0.65 0.65 58 1\n", + "2 mean 0.79 0.78 29 1\n", + "3 med 0.94 0.93 63 1\n", + "4 q3 1.00 1.00 22 1\n", + "5 whishi 1.00 1.00 0 1\n" + ] + } + ], + "source": [ + "# `None` is the default policy, so the fliers are not returned\n", + "print(pd.DataFrame.from_records(boxplot_stats(mock, labels, flier_policy=None)))" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "with option 'low'\n", + " statistic value nearest index label\n", + "0 flierlo 0.00 0.00 65 1\n", + "1 flierlo 0.00 0.00 67 1\n", + "2 flierlo 0.01 0.01 71 1\n", + "3 flierlo 0.09 0.09 64 1\n", + "4 whislo 0.24 0.24 44 1\n", + "5 q1 0.65 0.65 58 1\n", + "6 mean 0.79 0.78 29 1\n", + "7 med 0.94 0.93 63 1\n", + "8 q3 1.00 1.00 22 1\n", + "9 whishi 1.00 1.00 0 1\n", + "with option 'both'\n", + " statistic value nearest index label\n", + "0 flierlo 0.00 0.00 65 1\n", + "1 flierlo 0.00 0.00 67 1\n", + "2 flierlo 0.01 0.01 71 1\n", + "3 flierlo 0.09 0.09 64 1\n", + "4 whislo 0.24 0.24 44 1\n", + "5 q1 0.65 0.65 58 1\n", + "6 mean 0.79 0.78 29 1\n", + "7 med 0.94 0.93 63 1\n", + "8 q3 1.00 1.00 22 1\n", + "9 whishi 1.00 1.00 0 1\n" + ] + } + ], + "source": [ + "# one can choose to include only high or low fliers, or both\n", + "# since there are only low fliers...\n", + "\n", + "# 'low' and 'both' will return the same result\n", + "print(\"with option 'low'\")\n", + "print(pd.DataFrame.from_records(boxplot_stats(mock, labels, flier_policy=\"low\")))\n", + "\n", + "print(\"with option 'both'\")\n", + "print(pd.DataFrame.from_records(boxplot_stats(mock, labels, flier_policy=\"both\")))" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "with option 'high'\n", + " statistic value nearest index label\n", + "0 whislo 0.24 0.24 44 1\n", + "1 q1 0.65 0.65 58 1\n", + "2 mean 0.79 0.78 29 1\n", + "3 med 0.94 0.93 63 1\n", + "4 q3 1.00 1.00 22 1\n", + "5 whishi 1.00 1.00 0 1\n" + ] + } + ], + "source": [ + "# and 'high' will return no fliers (same as `flier_policy=None` in this case)\n", + "print(\"with option 'high'\")\n", + "print(pd.DataFrame.from_records(boxplot_stats(mock, labels, flier_policy=\"high\")))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Other applications and `only_label` argument" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "stats for the maximum anomaly score in the anomaly maps\n", + " statistic value nearest index label\n", + "0 whislo 0.46 0.46 65 1\n", + "1 q1 0.63 0.63 48 1\n", + "2 med 0.70 0.71 10 1\n", + "3 mean 0.73 0.73 118 1\n", + "4 q3 0.81 0.81 115 1\n", + "5 whishi 1.00 1.00 22 1\n" + ] + } + ], + "source": [ + "# other applications\n", + "# since the function is agnostic to the meaning of the values\n", + "# we can also use it to find representative samples\n", + "# with another metric or signal\n", + "#\n", + "# in the last plot cell we used the maximum anomaly score per image\n", + "# to select normal images, so let's reuse that criterion here\n", + "\n", + "# recompute it for didactic purposes\n", + "max_anom_score_per_image = anomaly_maps.max(dim=2).values.max(dim=1).values # noqa: PD011\n", + "print(\"stats for the maximum anomaly score in the anomaly maps\")\n", + "print(pd.DataFrame.from_records(boxplot_stats(max_anom_score_per_image, labels)))\n", + "# notice that the indices are not the same as before" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " statistic value nearest index label\n", + "0 whislo 0.42 0.42 90 0\n", + "1 q1 0.43 0.43 80 0\n", + "2 med 0.45 0.45 105 0\n", + "3 mean 0.46 0.46 89 0\n", + "4 q3 0.48 0.48 75 0\n", + "5 whishi 0.52 0.52 95 0\n" + ] + } + ], + "source": [ + "# we can also use the `only_label` argument to select only the\n", + "# samples from the normal class\n", + "print(pd.DataFrame.from_records(boxplot_stats(max_anom_score_per_image, labels, only_label=0)))\n", + "# notice the labels are all 0 now" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " statistic value nearest index label\n", + "0 whislo 0.42 0.42 90 0\n", + "1 q1 0.52 0.52 95 0\n", + "2 med 0.65 0.65 17 1\n", + "3 mean 0.66 0.66 45 1\n", + "4 q3 0.77 0.77 108 1\n", + "5 whishi 1.00 1.00 22 1\n" + ] + } + ], + "source": [ + "# or we can consider data from both classes (`None` option)\n", + "print(pd.DataFrame.from_records(boxplot_stats(max_anom_score_per_image, labels, only_label=None)))\n", + "# notice that the labels are mixed" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cite Us\n", + "\n", + "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "\n", + "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "\n", + "```bibtex\n", + "@misc{bertoldo2024aupimo,\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", + " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " year={2024},\n", + " eprint={2401.01984},\n", + " archivePrefix={arXiv},\n", + " primaryClass={cs.CV},\n", + " url={https://arxiv.org/abs/2401.01984}, \n", + "}\n", + "```\n", + "\n", + "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", + "\n", + "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "anomalib-dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/700_metrics/pimo_viz.svg b/notebooks/700_metrics/pimo_viz.svg new file mode 100644 index 0000000000..962c95f463 --- /dev/null +++ b/notebooks/700_metrics/pimo_viz.svg @@ -0,0 +1,619 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +PIMO + + + + + +i + + + + + +AUPIMO + + + + + +i + + +Recall(t) + + +Upper bound + + +Lower bound + + +FPR(t) + + +β‡’ + +Recall(t) + +Upper bound + +Lower bound + +t [anomaly score threholds] + +Transparent(never detected as anomalous) + +RED(always detectedas anomalous) + +JET(AUPIMO range) + + diff --git a/notebooks/README.md b/notebooks/README.md index 36976a6855..2f93aa5c8c 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -51,3 +51,10 @@ To install Python, Git and other required tools, [OpenVINO Notebooks](https://gi | ---------------------- | ------------------------------------------------------------------------------------------------------------- | ----- | | Dobot Dataset Creation | [501a_training](/notebooks/500_use_cases/501_dobot/501a_training_a_model_with_cubes_from_a_robotic_arm.ipynb) | | | Training | [501b_training](/notebooks/500_use_cases/501_dobot/501b_inference_with_a_robotic_arm.ipynb) | | + +## 7. Metrics + +| Notebook | GitHub | Colab | +| ----------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| AUPIMO basics | [701a_aupimo](/notebooks/700_metrics/701a_aupimo.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701a_aupimo.ipynb) | +| AUPIMO representative samples and visualization | [701b_aupimo_advanced_i](/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | From f1c734f6c67085fcefe4efd3f9b940399b270416 Mon Sep 17 00:00:00 2001 From: Joao P C Bertoldo <24547377+jpcbertoldo@users.noreply.github.com> Date: Wed, 9 Oct 2024 13:23:54 +0200 Subject: [PATCH 08/32] Pimo tutorials/02 advanced ii (#2347) * uset all padim features to make it deterministic Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add aupimo notebook advanced i Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * update readme Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * modify changelog Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct readme Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct again Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * minor corrections Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add aupimo notebook advanced ii (pimo curve and integration bounds) Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * fix links Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * correct change log Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --------- Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --- CHANGELOG.md | 5 +- notebooks/700_metrics/701a_aupimo.ipynb | 2 +- .../700_metrics/701b_aupimo_advanced_i.ipynb | 4 +- .../700_metrics/701c_aupimo_advanced_ii.ipynb | 936 ++++++++++++++++++ notebooks/README.md | 9 +- 5 files changed, 947 insertions(+), 9 deletions(-) create mode 100644 notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e0cc955e..340641fb7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Add `AUPIMO` tutorials notebooks in https://github.com/openvinotoolkit/anomalib/pull/2330 and https://github.com/openvinotoolkit/anomalib/pull/2336 +- Add `AUPIMO` metric by [jpcbertoldo](https://github.com/jpcbertoldo) in https://github.com/openvinotoolkit/anomalib/pull/1726 and refactored by [ashwinvaidya17](https://github.com/ashwinvaidya17) in https://github.com/openvinotoolkit/anomalib/pull/2329 + ### Changed ### Deprecated @@ -120,8 +123,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- Add `AUPIMO` tutorials notebooks in https://github.com/openvinotoolkit/anomalib/pull/2330 and https://github.com/openvinotoolkit/anomalib/pull/2336 -- Add `AUPIMO` metric by [jpcbertoldo](https://github.com/jpcbertoldo) in https://github.com/openvinotoolkit/anomalib/pull/1726 and refactored by [ashwinvaidya17](https://github.com/ashwinvaidya17) in https://github.com/openvinotoolkit/anomalib/pull/2329 - Add requirements into `pyproject.toml` & Refactor anomalib install `get_requirements` by @harimkang in https://github.com/openvinotoolkit/anomalib/pull/1808 ### Changed diff --git a/notebooks/700_metrics/701a_aupimo.ipynb b/notebooks/700_metrics/701a_aupimo.ipynb index c6831fd1f7..5c5497b3b8 100644 --- a/notebooks/700_metrics/701a_aupimo.ipynb +++ b/notebooks/700_metrics/701a_aupimo.ipynb @@ -127,7 +127,7 @@ "We will use dataset Leather from MVTec AD. \n", "\n", "> See the notebooks below for more details on datamodules. \n", - "> [github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules]((https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules))" + "> [github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules](https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules)" ] }, { diff --git a/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb b/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb index 37876e5bf6..a785075060 100644 --- a/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb +++ b/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb @@ -143,13 +143,13 @@ "source": [ "# Basics\n", "\n", - "This part was covered in the notebook [701a_aupimo.ipynb](701a_aupimo.ipynb), so we'll not discuss it here.\n", + "This part was covered in the notebook [701a_aupimo.ipynb](./701a_aupimo.ipynb), so we'll not discuss it here.\n", "\n", "It will train a model and evaluate it using AUPIMO.\n", "We will use dataset Leather from MVTec AD with `PaDiM` (performance is not the best, but it is fast to train).\n", "\n", "> See the notebooks below for more details on:\n", - "> - datamodules: [100_datamodules]((https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules));\n", + "> - datamodules: [100_datamodules](https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules);\n", "> - models: [200_models](https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/200_models)." ] }, diff --git a/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb b/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb new file mode 100644 index 0000000000..ed647ef666 --- /dev/null +++ b/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb @@ -0,0 +1,936 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# AUPIMO\n", + "\n", + "Advance use cases of the metric AUPIMO (pronounced \"a-u-pee-mo\").\n", + "\n", + "> For basic usage, please check the notebook [701a_aupimo.ipynb](./701a_aupimo.ipynb).\n", + "\n", + "Includes:\n", + "- visualization of the PIMO curve\n", + "- theoretical AUPIMO of a random classifier (\"baseline\")\n", + "- understanding the x-axis (FPR) bounds\n", + "- customizing the x-axis (FPR) bounds" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "# What is AUPIMO?\n", + "\n", + "The `Area Under the Per-Image Overlap [curve]` (AUPIMO) is a metric of recall (higher is better) designed for visual anomaly detection.\n", + "\n", + "Inspired by the [ROC](https://en.wikipedia.org/wiki/Receiver_operating_characteristic) and [PRO](https://link.springer.com/article/10.1007/s11263-020-01400-4) curves, \n", + "\n", + "> AUPIMO is the area under a curve of True Positive Rate (TPR or _recall_) as a function of False Positive Rate (FPR) restricted to a fixed range. \n", + "\n", + "But:\n", + "- the TPR (Y-axis) is *per-image* (1 image = 1 curve/score);\n", + "- the FPR (X-axis) considers the (average of) **normal** images only; \n", + "- the FPR (X-axis) is in log scale and its range is [1e-5, 1e-4]\\* (harder detection task!).\n", + "\n", + "\\* The score (the area under the curve) is normalized to be in [0, 1].\n", + "\n", + "AUPIMO can be interpreted as\n", + "\n", + "> average segmentation recall in an image given that the model (nearly) does not yield false positives in normal images.\n", + "\n", + "References in the last cell.\n", + "\n", + "![AUROC vs. AUPRO vs. AUPIMO](./roc_pro_pimo.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Setup" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install `anomalib` using `pip`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO(jpcbertoldo): replace by `pip install anomalib` when AUPIMO is released # noqa: TD003\n", + "%pip install ../.." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Change the directory to have access to the datasets." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "# NOTE: Provide the path to the dataset root directory.\n", + "# If the datasets is not downloaded, it will be downloaded\n", + "# to this directory.\n", + "dataset_root = Path.cwd().parent.parent / \"datasets\" / \"MVTec\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import cv2\n", + "import numpy as np\n", + "import torch\n", + "from matplotlib import pyplot as plt\n", + "from matplotlib.axes import Axes\n", + "from matplotlib.ticker import FixedLocator, PercentFormatter\n", + "from numpy import ndarray\n", + "from scipy import stats\n", + "from torch import Tensor\n", + "\n", + "from anomalib import TaskType\n", + "from anomalib.data import MVTec\n", + "from anomalib.data.utils import read_image\n", + "from anomalib.engine import Engine\n", + "from anomalib.metrics import AUPIMO\n", + "from anomalib.models import Padim" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Basics\n", + "\n", + "This part was covered in the notebook [701a_aupimo.ipynb](./701a_aupimo.ipynb), so we'll not discuss it here.\n", + "\n", + "It will train a model and evaluate it using AUPIMO.\n", + "We will use dataset Leather from MVTec AD with `PaDiM` (performance is not the best, but it is fast to train).\n", + "\n", + "> See the notebooks below for more details on:\n", + "> - datamodules: [100_datamodules](https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/100_datamodules);\n", + "> - models: [200_models](https://github.com/openvinotoolkit/anomalib/tree/main/notebooks/200_models)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# train the model\n", + "task = TaskType.SEGMENTATION\n", + "datamodule = MVTec(\n", + " root=dataset_root,\n", + " category=\"leather\",\n", + " image_size=256,\n", + " train_batch_size=32,\n", + " eval_batch_size=32,\n", + " num_workers=8,\n", + " task=task,\n", + ")\n", + "model = Padim(\n", + " # only use one layer to speed it up\n", + " layers=[\"layer1\"],\n", + " n_features=64,\n", + " backbone=\"resnet18\",\n", + " pre_trained=True,\n", + ")\n", + "engine = Engine(\n", + " pixel_metrics=\"AUPIMO\", # others can be added\n", + " accelerator=\"auto\", # \\<\"cpu\", \"gpu\", \"tpu\", \"ipu\", \"hpu\", \"auto\">,\n", + " devices=1,\n", + " logger=False,\n", + ")\n", + "engine.fit(datamodule=datamodule, model=model)\n", + "# infer\n", + "predictions = engine.predict(dataloaders=datamodule.test_dataloader(), model=model, return_predictions=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compute AUPIMO" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Metric `AUPIMO` will save all targets and predictions in buffer. For large datasets this may lead to large memory footprint.\n" + ] + } + ], + "source": [ + "aupimo = AUPIMO(\n", + " # with `False` all the values are returned in a dataclass\n", + " return_average=False,\n", + ")\n", + "\n", + "anomaly_maps = []\n", + "masks = []\n", + "labels = []\n", + "image_paths = []\n", + "for batch in predictions:\n", + " anomaly_maps.append(batch_anomaly_maps := batch[\"anomaly_maps\"].squeeze(dim=1))\n", + " masks.append(batch_masks := batch[\"mask\"])\n", + " labels.append(batch[\"label\"])\n", + " image_paths.append(batch[\"image_path\"])\n", + " aupimo.update(anomaly_maps=batch_anomaly_maps, masks=batch_masks)\n", + "\n", + "# list[list[str]] -> list[str]\n", + "image_paths = [item for sublist in image_paths for item in sublist]\n", + "anomaly_maps = torch.cat(anomaly_maps, dim=0)\n", + "masks = torch.cat(masks, dim=0)\n", + "labels = torch.cat(labels, dim=0)\n", + "\n", + "# `pimo_result` has the PIMO curves of each image\n", + "# `aupimo_result` has the AUPIMO values\n", + "# i.e. their Area Under the Curve (AUC)\n", + "pimo_result, aupimo_result = aupimo.compute()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Statistics and score distribution." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MEAN\n", + "aupimo_result.aupimos[labels == 1].mean().item()=0.742841961578308\n", + "OTHER STATISTICS\n", + "DescribeResult(nobs=92, minmax=(0.0, 1.0), mean=0.742841961578308, variance=0.08757792704451818, skewness=-0.9285678601866053, kurtosis=-0.3299211772047079)\n" + ] + }, + { + "data": { + "image/png": "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KlXqqhdPnz17VpKuWXauuLI8LS3tmut8++23qlSpkv7+97/bxnx8fDR48OAb5g0ICND58+e1YsWKG657LREREXkuRZI0ePBguyMuw4YNU+nSpfXtt9/mO0NefPvttwoJCVHv3r1tY+7u7nrqqad07tw5rV271m79Xr162UqRJNuRvYK85gso7ihGgItkZ2dr3rx5at26tfbv36+9e/dq7969uueee5ScnKyVK1c6vM2/nrYJDw/XihUr9P3332vdunU6duyYlixZogoVKuR5m4888ohSU1O1dOlSxcbGqnPnzlctP1fGrhSka8lLgTp48KBq1KiR6/Pc6JSWJD355JO6/fbb1bFjR1WpUkUDBgzQsmXLbvi+/xUREeHQ+n99rIGvr68qVapU6LfcHzx4UDVr1sx1p9yVU28HDx60Gw8LC7N7faUk/fV6KOBWxqk0wEVWrVqlo0ePat68eZo3b16u5bGxsWrfvr2ky7fOS7LdHfVX6enpdutdUaZMGUVGRt5UzkqVKqlVq1Z65513tH79en311VdXXc/f31+VKlXSr7/+et3t/frrr7rtttvk5+d3U7mupWLFitq6dauWL1+upUuXaunSpYqJidHjjz+e66Lka/H29i6UbFdTEEcG8+pqpxaly3feAbiMI0aAi8TGxqpixYqaP39+rp/evXvr66+/thWhoKAg+fj4KDEx8arbSkxMlI+Pj0NHghzx6KOP6scff5Sfn586dep0zfU6d+6s/fv3a926dVdd/uOPP+rAgQM3vJ6latWq+uOPP3L9C/tan/+vPDw81KVLF02bNk1//PGHhgwZok8//VR79+6VlPvI2s3as2eP3etz587p6NGjdnfLlStXTikpKXbrXbx4UUePHrUbcyRb1apVtWfPnlzPcdq9e7dtOQDHUIwAF8jIyNCCBQvUuXNn/f3vf8/1M2LECJ09e1aLFi2SdPm/9Nu3b6/FixcrKSnJbltJSUlavHix2rdvf80jAjfr73//u8aNG6dp06Zd9zk6Y8aMkbe3t4YMGaJTp07ZLTt9+rSGDh0qHx8fjRkz5rr769Spk44cOWL3VO0rjwG4kb/u183NTQ0aNJAk2+3+ZcqUkaRcRSW/Zs2aZfdcqOnTp+vSpUvq2LGjbax69er64Ycfcr3vr0eMHMnWqVMnHTt2TJ9//rlt7NKlS5oyZYp8fX3VsmXL/Hwc4JbGqTTABa7clXWtO5aaNWtme9hjr169JEmvv/66mjVrprvvvluDBw9WeHi4Dhw4oFmzZslisej1118vtLz+/v5XfQbPX9WsWVOffPKJ+vTpo/r16+d68vXJkyc1d+5cVa9e/brbGTRokD788EM9/vjjSkhIUKVKlfSf//wnT3fSPfHEEzp9+rTatGmjKlWq6ODBg5oyZYruvPNO27U3d955p0qVKqU333xTqamp8vT0VJs2ba76CIK8uHjxotq2bauePXsqMTFR06ZN0/333283v0888YSGDh2q7t27q127dtq2bZuWL1+e6yifI9kGDx6smTNnql+/fkpISFB4eLi+/PJLrV+/Xu+9994NL4QHcBUGgNN16dLFeHl5mfPnz19znX79+hl3d3dz8uRJ29iuXbtMr169TMWKFU3p0qVNxYoVzSOPPGJ27dqV6/0tW7Y09erVu2GWvn37mjJlyjj83tWrVxtJZv78+bmW/frrr6Z3796mUqVKxt3d3YSEhJjevXub7du33zDPFQcPHjQPPvig8fHxMRUqVDCjRo0yy5YtM5LM6tWr7fJXrVrV9vrLL7807du3NxUrVjQeHh4mLCzMDBkyxBw9etRu+x999JGpVq2aKVWqlN02q1ataqKioq6aqWrVqqZv37621zExMUaSWbt2rRk8eLApV66c8fX1NX369DGnTp2ye292drZ5/vnnTYUKFYyPj4/p0KGD2bt3b65tXi9by5YtTcuWLe3WTU5ONv379zcVKlQwHh4epn79+iYmJsZunf379xtJ5u233871mSSZcePGXfXzArciizFcdQcAACBxjREAAIANxQgAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKxK/AMec3JydOTIEZUtW7bAvwYAAAAUDmOMzp49q8qVK+f6ouTCVOKL0ZEjRxQaGurqGAAAIB8OHTqkKlWqOG1/Jb4YXXkk/v79+1W+fHkXp7m1ZWVl6bvvvlP79u3l7u7u6ji3NOaiaGE+ig7moug4ffq0IiIinP7VNiW+GF05fVa2bFn5+fm5OM2tLSsrSz4+PvLz8+MvHBdjLooW5qPoYC6KjitfzOzsy2C4+BoAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKwoRgAAAFYUIwAAACuKEQAAgBXFCAAAwIpiBAAAYEUxAgAAsKIY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CoeLkcVi0QsvvKAxY8Zo7969OnfunOrWrStfX9/CyAcAAOA0DhejKzw8PFS2bFmVLVuWUgQAAEoEh68xunTpkl566SX5+/srPDxc4eHh8vf314svvqisrKzCyAgAAOAUDh8xGjlypBYsWKC33npLzZs3lyTFx8dr/PjxOnXqlKZPn17gIQEAAJzB4WIUFxenefPmqWPHjraxBg0aKDQ0VL1796YYAQCAYsvhU2menp4KDw/PNR4RESEPD4+CyAQAAOASDhejESNG6NVXX7V9R5okZWZm6rXXXtOIESMKNBwAAIAz5elUWrdu3exef//996pSpYoaNmwoSdq2bZsuXryotm3bFnxCAAAAJ8lTMfL397d73b17d7vXoaGhBZcIAADARfJUjGJiYgo7BwAAgMs5fI0RAABASeXw7fqnTp3Syy+/rNWrV+v48ePKycmxW3769OkCCwcAAOBMDhejxx57THv37tXAgQMVHBwsi8VSGLkAAACczuFi9OOPP2rdunW2O9IAAABKCoevMapdu7YyMjIKIwsAAIBLOVyMpk2bphdeeEFr167VqVOnlJaWZvcDAABQXDl8Ki0gIEBpaWlq06aN3bgxRhaLRdnZ2QUWDgAAwJkcLkZ9+vSRu7u74uLiuPgaAACUKA4Xox07duiXX35RrVq1CiMPAACAyzh8jVHjxo116NChwsgCAADgUg4fMRo5cqRGjRqlMWPGqH79+nJ3d7db3qBBgwILBwAA4EwOF6NevXpJkgYMGGAbs1gsXHwNAACKPYeL0f79+wsjBwAAgMs5fI1R1apVr/vjiEmTJqlJkyYqW7asKlasqK5duyoxMdFunQsXLmj48OEKDAyUr6+vunfvruTkZEdjAwAA3JDDR4w+/fTT6y5//PHH87yttWvXavjw4WrSpIkuXbqkf/7zn2rfvr127typMmXKSJKeeeYZffPNN5o/f778/f01YsQIdevWTevXr3c0OgAAwHU5XIxGjRpl9zorK0vp6eny8PCQj4+PQ8Vo2bJldq/nzJmjihUrKiEhQS1atFBqaqpmz56tuLg42wMlY2JiVKdOHf38889q1qyZo/EBAACuyeFidObMmVxje/bs0bBhwzRmzJibCpOamipJKl++vCQpISFBWVlZioyMtK1Tu3ZthYWFKT4+/qrFKDMzU5mZmbbXV76mJCsrS1lZWTeV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NHXqVIWHh8vLy0v33HOPNm7caFs2evRolS9fXqGhoYqNjbV73/z589WlSxdnxy0RJk2apCZNmqhs2bKqWLGiunbtqsTERLt1Lly4oOHDhyswMFC+vr7q3r273VPMT58+rS5dusjX11d33XWXfvnlF7v3Dx8+XO+8845TPk9J8sYbb8hisejpp5+2jTEXznP48GFFR0crMDBQ3t7eql+/vjZv3mxbbozRyy+/rEqVKsnb21uRkZHas2ePbXlmZqYee+wx+fn56fbbb9f3339vt/23335bI0eOdNrnKa6ys7P10ksvKSIiQt7e3qpevbpeffVVu+/dYi4Kzw8//KAuXbqocuXKslgsWrhwod3yG/3upct/L/Xp00d+fn4KCAjQwIEDde7cOdvyAwcOqEWLFipTpoxatGihAwcO2L2/c+fO+uqrrxwP79QvICkE8+bNMx4eHubjjz82v/32mxk0aJAJCAgwycnJZtGiRSY4ONhs2rTJxMXFGS8vL3PixAljjDEpKSmmZs2a5uDBgy7+BMVThw4dTExMjNmxY4fZunWr6dSpkwkLCzPnzp2zrTN06FATGhpqVq5caTZv3myaNWtm7r33Xtvy0aNHm5YtW5rExETz9NNPm0aNGtmWxcfHm0aNGplLly459XMVdxs3bjTh4eGmQYMGZtSoUbZx5sI5Tp8+bapWrWr69etnNmzYYPbt22eWL19u9u7da1vnjTfeMP7+/mbhwoVm27Zt5sEHHzQREREmIyPDGGPMBx98YOrUqWN27Nhh3n77bRMUFGRycnKMMcbs27fP1KxZ06Smprrk8xUnr732mgkMDDRLliwx+/fvN/Pnzze+vr7m/ffft63DXBSeb7/91rzwwgtmwYIFRpL5+uuv7Zbf6HdvjDEPPPCAadiwofn555/Njz/+aGrUqGF69+5tW96tWzfzyCOPmN9//9307NnTdO/e3bZs3rx5pkuXLvnKXuyLUdOmTc3w4cNtr7Ozs03lypXNpEmTzJtvvml69eplW1axYkWzceNGY4wxgwcPNpMnT3Z63pLq+PHjRpJZu3atMeZy8XR3dzfz58+3rbNr1y4jycTHxxtjjOnYsaOZPn26McaYnTt3Gh8fH2OMMRcvXjQNGzY0mzZtcvKnKN7Onj1ratasaVasWGFatmxpK0bMhfM8//zz5v7777/m8pycHBMSEmLefvtt21hKSorx9PQ0c+fONcYYM2zYMPP8888bY4xJT083kszx48eNMZf/g2TBggWF+AlKjqioKDNgwAC7sW7dupk+ffoYY5gLZ/prMcrL737nzp1Gkt3fPUuXLjUWi8UcPnzYGGNMnTp1zNKlS40xl4tY3bp1jTHGnDlzxtSoUcMkJSXlK2+xPpV28eJFJSQkKDIy0jbm5uamyMhIxcfHq2HDhtq8ebPOnDmjhIQEZWRkqEaNGlq3bp22bNmip556yoXpS5bU1FRJsn3ZX0JCgrKysuzmpnbt2goLC1N8fLwkqWHDhlq1apUuXbqk5cuXq0GDBpKkt956S61atVLjxo2d/CmKt+HDhysqKsrudy4xF860aNEiNW7cWD169FDFihV111136aOPPrIt379/v44dO2Y3F/7+/rrnnnvs5mLdunXKyMjQ8uXLValSJVWoUEGxsbHy8vLSww8/7PTPVRzde++9WrlypX7//XdJ0rZt27Ru3Tp17NhREnPhSnn53cfHxysgIMDu757IyEi5ublpw4YNki7Pz/fff6+cnBx99913tr+3xowZo+HDhys0NDR/AfNVp4qIw4cPG0nmp59+shsfM2aMadq0qTHGmHHjxpnq1aubO+64wyxYsMBkZmaaO+64w2zevNlMmTLF3H777ebee+81O3bscMVHKBGys7NNVFSUue+++2xjsbGxxsPDI9e6TZo0Mc8995wx5vJ/IfTu3duEhYWZFi1amN9++838/vvvpmbNmubkyZNmyJAhJiIiwvTo0cOkpKQ47fMUR3PnzjV33HGH7TD0/x4xYi6cx9PT03h6epqxY8eaLVu2mJkzZxovLy8zZ84cY4wx69evN5LMkSNH7N7Xo0cP07NnT2PM5aN0Tz75pAkPDzeNGzc2P/74ozl16pSpVq2aSUpKMi+88IKpXr26ad++vfnzzz+d/hmLi+zsbPP8888bi8ViSpcubSwWi3n99ddty5kL59Ffjhjl5Xf/2muvmdtvvz3XtoKCgsy0adOMMcb8+eefJioqyoSGhpqoqCjz559/mrVr15rGjRubU6dOmR49epiIiAgzZMgQk5mZmee8xeIrQW7G+PHjNX78eNvrCRMmKDIyUu7u7po4caK2b9+uJUuW6PHHH1dCQoLrghZjw4cP144dO7Ru3TqH3ufv76+4uDi7sTZt2ujtt99WbGys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PClJSk1N1QsvvKCpU6fm2k758uUVFBSU598JgKKDYgTgus6dO6fPP/9cw4YNU1RUlObMmXNT2wsICFBISIjuuOMOTZ8+XRkZGVqxYsU117/77rsVGhqqBQsW2MYWLFigsLAw3XXXXXbrDh8+XB4eHvruu+/UsmVLhYWFqWPHjvr+++91+PBhvfDCCw5l3bVrl1q1aqXGjRurd+/e8vPz0/79+yVJzz33nIYNG5arnAEo3ihGAK7riy++UO3atVWrVi1FR0fr448/VkF9xaK3t7ck6eLFi9ddb8CAAYqJibG9/vjjj9W/f3+7dU6fPq3ly5frySeftG33ipCQEPXp00eff/65Q9kbNmyozZs368yZM0pISFBGRoZq1KihdevWacuWLXrqqafyvC0AxQPFCMB1zZ49W9HR0ZKkBx54QKmpqVq7du1Nbzc9PV0vvviiSpUqpZYtW1533ejoaK1bt04HDx7UwYMHtX79elumK/bs2SNjjOrUqXPVbdSpU0dnzpzRiRMn8pyxQ4cOio6OVpMmTdSvXz998sknKlOmjIYNG6YZM2Zo+vTpqlWrlu677z799ttved4ugKKrtKsDACi6EhMTtXHjRn399deSLl+D06tXL82ePVutWrXK1zZ79+6tUqVKKSMjQ0FBQZo9e7YaNGhw3fcEBQXZTuMZYxQVFaUKFSpcdd2COpp1xfjx4zV+/Hjb6wkTJigyMlLu7u6aOHGitm/friVLlujxxx9XQkJCge4bgPNRjABc0+zZs3Xp0iVVrlzZNmaMkaenpz788EP5+/vLz89P0uWLkQMCAuzen5KSIn9/f7uxd999V5GRkfL393foAuUBAwZoxIgRknTVC55r1Kghi8WiXbt26eGHH861fNeuXSpXrtxNXRS9e/duffbZZ/rll1/08ccfq0WLFgoKClLPnj01YMAAnT17VmXLls339gG4HqfSAFzVpUuX9Omnn+qdd97R1q1bbT/btm1T5cqVNXfuXElSzZo15ebmlutoyb59+5Samqrbb7/dbjwkJEQ1atRwuKA88MADunjxorKystShQ4dcywMDA9WuXTtNmzbN7g42STp27JhiY2PVq1cvWSwWh/Z7hTFGQ4YM0eTJk+Xr66vs7GxlZWVJku1/s7Oz87VtAEUHR4wAXNWSJUt05swZDRw4MNdRn+7du2v27NkaOnSoypYtqyeeeELPPvusSpcurfr16+vQoUN6/vnn1axZM917770FkqdUqVLatWuX7c9X8+GHH+ree+9Vhw4dNHHiREVEROi3337TmDFjdNttt+m1117L9/7//e9/KygoyPbcovvuu0/jx4/Xzz//rKVLl6pu3bq5jpgBKH44YgTgqmbPnm075fVX3bt31+bNm/Xrr79Kkt5//3317dtXzz//vOrVq6d+/fqpQYMGWrx4cb6P0FyNn5+f7dTd1dSsWVObN29WtWrV1LNnT1WvXl2DBw9W69atFR8fr/Lly+drv8nJyXrttdf0wQcf2MaaNm2qZ599VlFRUfriiy/s7poDUHxZTEFfqQgAxdD48eO1cOHCAvsKjwMHDigiIkK//PKL7rzzzgLZJoDCxxEjALDavn27fH19NW3atJvaTseOHVWvXr0CSgXAmThiBAC6/IDIK18ZEhQUdNVTiHl1+PBh2wXgYWFh8vDwKJCMAAofxQgAAMCKU2kAAABWFCMAAAArihEAAIAVxQgAAMCKYgQAAGBFMQIAALCiGAEAAFhRjAAAAKwoRgAAAFb/D60PVy7Nlq5qAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# the normal images have `nan` values because\n", + "# recall is not defined for them so we ignore them\n", + "print(f\"MEAN\\n{aupimo_result.aupimos[labels == 1].mean().item()=}\")\n", + "print(f\"OTHER STATISTICS\\n{stats.describe(aupimo_result.aupimos[labels == 1])}\")\n", + "\n", + "fig, ax = plt.subplots()\n", + "ax.hist(aupimo_result.aupimos[labels == 1].numpy(), bins=np.linspace(0, 1, 11), edgecolor=\"black\")\n", + "ax.set_ylabel(\"Count (number of images)\")\n", + "ax.set_xlim(0, 1)\n", + "ax.set_xlabel(\"AUPIMO [%]\")\n", + "ax.xaxis.set_major_formatter(PercentFormatter(1))\n", + "ax.grid()\n", + "ax.set_title(\"AUPIMO distribution\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Until here we just reproduded the notebook with the basic usage of AUPIMO." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# The PIMO curve \n", + "\n", + "We'll select a bunch of images to visualize the PIMO curves.\n", + "\n", + "To make sure we have best and worst detection examples, we'll use the representative samples selected in the previous notebook ([701b_aupimo_advanced_i.ipynb](./701b_aupimo_advanced_i.ipynb)).\n", + "\n", + "> Note the FPR (X-axis) is the average (in-image) FPR of the normal images in the test set. We'll note it as `FPRn`." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "# representative samples (in terms of the AUPIMO value)\n", + "# from lowest to highest AUPIMO score\n", + "samples = [65, 7, 58, 63, 22]" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "def fmt_pow10(value: float) -> str:\n", + " \"\"\"Format the power of 10.\"\"\"\n", + " return \"1\" if value == 1 else f\"$10^{{{int(np.log10(value))}}}$\"\n", + "\n", + "\n", + "def plot_pimo_with_auc_zone(\n", + " ax: Axes,\n", + " tpr: ndarray,\n", + " fpr: ndarray,\n", + " lower_bound: float,\n", + " upper_bound: float,\n", + " fpr_in_auc: ndarray,\n", + " tpr_in_auc: ndarray,\n", + ") -> None:\n", + " \"\"\"Helper function to plot the PIMO curve with the AUC zone.\"\"\"\n", + " # plot\n", + " ax.plot(fpr, tpr, linewidth=3.5)\n", + " ax.axvspan(lower_bound, upper_bound, color=\"magenta\", alpha=0.3, zorder=-1)\n", + " ax.fill_between(fpr_in_auc, tpr_in_auc, alpha=1, color=\"tab:purple\", zorder=1)\n", + "\n", + " # config plots\n", + " ax.set_ylabel(\"TPR [%]\")\n", + " ax.yaxis.set_major_locator(FixedLocator(np.linspace(0, 1, 6)))\n", + " ax.yaxis.set_major_formatter(PercentFormatter(1, 0, symbol=\"\"))\n", + " ax.set_ylim(0, 1 + 3e-2)\n", + " ax.set_xlabel(\"FPRn\")\n", + " ax.set_xscale(\"log\")\n", + " ax.xaxis.set_major_locator(FixedLocator(np.logspace(-6, 0, 7)))\n", + " ax.xaxis.set_major_formatter(lambda x, _: fmt_pow10(x))\n", + " ax.set_xlim(1e-6 / (eps := (1 + 3e-1)), 1 * eps)\n", + " ax.grid()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA8YAAAHuCAYAAABd8RWzAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAD2IElEQVR4nOzdd1hT1/8H8HfYeypLEREnLqy77klx1o20ddXRfrXuWbVuraMttnW0ttW2grVqtVpHxd2qqHVbt6LiABUEZIfk/P6w5EdMAmGFC3m/nodHc865N5/kkg/35J57jkwIIUBERERERERkpExKOgAiIiIiIiKiksSOMRERERERERk1doyJiIiIiIjIqLFjTEREREREREaNHWMiIiIiIiIyauwYExERERERkVFjx5iIiIiIiIiMGjvGREREREREZNTYMSYiIiIiIiKjxo4xERERERERGTV2jImIyGhs2LABMplM9WNlZYXq1atjzJgxiI2NVbU7cuQIZDIZtm7dqnXbv//+W2PfQgh4e3tDJpOhW7duGvUpKSlYsGAB6tWrBxsbGzg6OqJVq1b46aefIIQonhdMREREejEr6QCIiIgMbf78+fD19UV6ejr+/vtvrFmzBnv27MGVK1dgY2OT67ZWVlYIDw9Hy5Yt1cqPHj2Khw8fwtLSUmOb2NhYdOjQAdeuXUNwcDDGjBmD9PR0bNu2DYMHD8aePXsQFhYGU1PTIn2dREREpB92jImIyOgEBQWhUaNGAIDhw4fD1dUVn3/+OX7//XcMHDgw1227dOmCLVu24Msvv4SZ2f//GQ0PD0fDhg3x/PlzjW0GDx6Ma9euYfv27ejRo4eqfOzYsZgyZQpWrFiBBg0aYNq0aUX0CgsmPT0dFhYWMDHhgDIiIjIu/MtHRERGr3379gCAqKioPNsOHDgQcXFxiIiIUJVlZmZi69atCAkJ0WgfGRmJP//8E0OGDFHrFGdbsmQJqlWrhqVLlyItLS3P59+7dy/atGkDe3t7ODg4oHHjxggPD1fVV65cGUOGDNHYrm3btmjbtq3qcfZw8V9++QWzZs1ChQoVYGNjg3PnzkEmk+HHH3/U2Meff/4JmUyGP/74Q1X26NEjDBs2DO7u7rC0tETt2rXxww8/aGz71VdfoXbt2rCxsYGzszMaNWqkFjcREVFJYseYiIiM3p07dwAArq6uebatXLkymjdvjk2bNqnK9u7di8TERAQHB2u037VrFwBg0KBBWvdnZmaGkJAQvHjxAsePH8/1uTds2ICuXbsiPj4eM2bMwKeffoqAgADs27cvz7h1WbBgAXbv3o3Jkydj8eLF8Pf3R5UqVfDrr79qtN28eTOcnZ0RGBgI4NUQ8WbNmuHAgQMYM2YMVq5ciapVq+L9999HaGioart169Zh7Nix8Pf3R2hoKObNm4eAgACcOnWqwHETEREVJQ6lJiIio5OYmIjnz58jPT0dx48fx/z582Ftba110ixtQkJCMGPGDKSlpcHa2hphYWFo06YNvLy8NNpevXoVAFC/fn2d+8uuu3btGjp27Kgz5rFjx6JJkyY4cuQIrKysVHWFmbwrPT0d//zzD6ytrVVlAwYMwIoVK/DixQs4OzsDeHVVfPv27ejduzfMzc0BADNnzoRCocDly5dVXyp88MEHGDhwIObOnYtRo0bB2toau3fvRu3atbFly5YCx0lERFSceMWYiIiMTseOHVG+fHl4e3sjODgYdnZ22L59OypUqKDX9v3790daWhr++OMPvHz5En/88YfWYdQA8PLlSwCAvb29zv1l1yUlJelsExERgZcvX2L69OlqnWIAkMlkesWtzeDBg9U6xcCrjrFcLsdvv/2mKtu/fz8SEhIwYMAAAK8649u2bUP37t0hhMDz589VP4GBgUhMTMS5c+cAAE5OTnj48CHOnDlT4DiJiIiKE68YExGR0Vm1ahWqV68OMzMzuLu7o0aNGvmacKp8+fLo2LEjwsPDkZqaCoVCgb59+2ptm93pffnyJZycnLS20afznD3cu06dOnrHqQ9fX1+Nsvr166NmzZrYvHkz3n//fQCvhlGXK1dOdT/2s2fPkJCQgG+//Rbffvut1n0/ffoUADBt2jQcOHAATZo0QdWqVdG5c2eEhISgRYsWRfpaiIiICoodYyIiMjpNmjRRzUpdUCEhIRgxYgRiYmIQFBSks9Nbq1Yt7NixA5cuXULr1q21trl06RIAwN/fv1AxAbqvHisUCq3LQb1+tTjbgAEDsGjRIjx//hz29vbYuXMnBg4cqJqJW6lUAgDeffddDB48WOs+6tWrB+DVe3Djxg388ccf2LdvH7Zt24bVq1fjk08+wbx58/L9GomIiIoah1ITEREVQK9evWBiYoLIyEidw6gBqO5b/umnn7TWKxQKhIeHw9nZOdcrqH5+fgCAK1eu5BqXs7MzEhISNMrv37+f63avGzBgALKysrBt2zbs3bsXSUlJapOLlS9fHvb29lAoFOjYsaPWHzc3N1V7W1tbDBgwAOvXr8eDBw/QtWtXLFq0COnp6fmKi4iIqDiwY0xERFQAdnZ2WLNmDebOnYvu3bvrbPfmm2+iY8eOWL9+vdoyR9lmzpyJmzdvYurUqTqv3gJA586dYW9vjyVLlmh0JnNOvuXn54fIyEhkZmaqyv744w9ER0fn5+WhVq1aqFu3LjZv3ozNmzfD09NT7Yq3qakp+vTpg23btmntrD979kz1/7i4OLU6CwsL+Pv7QwgBuVyer7iIiIiKA4dSExERFZCuIcSv++mnn9ChQwf07NkTISEhaNWqFTIyMvDbb7/hyJEjGDBgAKZMmZLrPhwcHPDFF19g+PDhaNy4MUJCQuDs7IyLFy8iNTVVte7w8OHDsXXrVrz11lvo378/7ty5g40bN6quOOfHgAED8Mknn8DKygrvv/++xn3Yn376KQ4fPoymTZtixIgR8Pf3R3x8PM6dO4cDBw4gPj4ewKtOvYeHB1q0aAF3d3dcu3YNX3/9Nbp27ZrrfdVERESGwivGRERExczT0xOnT5/GJ598gvPnz2P8+PGYOXMm5HI5NmzYgE2bNmm9//d177//Pnbu3AkHBwcsWLAA06ZNw7lz5xAUFKRqExgYiM8++ww3b97E+PHjcfLkSfzxxx+oWLFivuMeMGAAlEolUlNTVbNR5+Tu7o7Tp09j6NCh+O2331RrGcfHx2Pp0qWqdqNGjUJycjI+//xzjB49Gjt27MDYsWOxcePGfMdERERUHGSiMIsfEhEREREREZVyvGJMRERERERERo0dYyIiIiIiIjJq7BgTERERERGRUWPHmIiIiIiIiIwaO8ZERERERERk1NgxJiIiIiIiIqPGjjEREREREREZNXaMiYiIiIiIyKixY0xERERERERGjR1jIiIiIiIiMmrsGBMREREREZFRY8eYiIiIiIiIjBo7xkRERERERGTU2DEmIiIiIiIio8aOMRERERERERk1doyJiIiIiIjIqLFjTEREREREREaNHWMiIiIiIiIyauwYExERERERkVFjx5iIiIiIiIiMGjvGREREREREZNTYMSYiIiIiIiKjxo4xERERERERGTV2jKlMSU5OhpubG8LCwko6FMrh6tWrMDMzw5UrV0o6FKJSZ9myZahZsyaUSmVJh0IFwPxHVLR4rlf6TZ8+HU2bNi3pMDSwY1yCNmzYAJlMhn/++aekQyl2sbGxGDVqFCpUqAArKytUrlwZ77//vlqbuXPnQiaTafxYWVnp/TwrV66Evb09goOD1coTEhIwcuRIlC9fHra2tmjXrh3OnTun1kYIgXnz5qFChQpwc3PD+PHjkZmZqdYmOTkZFSpUQHh4eD7fAeDatWuq15OQkKC1TeXKldGtWzetdf/88w9kMhk2bNigKnv9PbOxsYG/vz9mzZqFpKQkVTttv2vZ25qYmCA6Olrj+ZKSkmBtbQ2ZTIYxY8Zo1MfFxWHKlCmoUaMGrKys4OLigsDAQPzxxx8abf39/dG1a1d88sknut4eMjLGkP+yX6OuH31O6pKSkrB06VJMmzYNJiav/mQfOXIk1/0uWrRIrxhiYmJU7Zj/1DH/UVExhlwHQGee+fTTTzXaHjhwAO3atUO5cuXg5OSEJk2a4Oeff9b7ubSd6+mb6wBgwoQJeOONN+Di4gIbGxvUqlULc+fORXJyslq7R48eoWvXrnBwcIC/vz927dqlEctvv/0GNzc3JCYm6h1/tqlTp0Imk2HAgAFa67Nz/datW7XWjxkzBjKZTK2scuXKaq/dzc0NrVq1wvbt29XatW3bFnXq1NG6bceOHbU+37p161T71fb7fPz4cfTq1Qvu7u6wtLRE5cqVMWrUKDx48ECj7fjx43Hx4kXs3LlT63OVFLOSDoDKvujoaLRo0QIA8MEHH6BChQp4/PgxTp8+rbX9mjVrYGdnp3psamqq1/PI5XKsXLkSEyZMUNtGqVSia9euuHjxIqZMmYJy5cph9erVaNu2Lc6ePYtq1aoBAMLCwrB48WJMmzYNtra2WLRoEdzd3TFjxgzVvhYtWoTKlSsjJCQk3+/Dxo0b4eHhgRcvXmDr1q0YPnx4vvehS/Z7lpycjP3792PRokU4dOgQjh8/rpE0X2dpaYlNmzZh6tSpauW//fabzm1u3LiBDh064NmzZxg6dCgaNWqEhIQEhIWFoXv37pg8eTKWL1+uts0HH3yALl264M6dO/Dz8yv4iyUqJVq3bq31ZO+LL77AxYsX0aFDhzz38cMPPyArKwsDBw5UldWqVUvrfn/++Wfs378fnTt31qibP38+fH191cqcnJxU/2f+Y/4jKqxOnTph0KBBamUNGjRQe7xz5068/fbbaN68ueoLql9//RWDBg3C8+fPMWHChFyfQ9e5Xra8ch0AnDlzBq1atcLQoUNhZWWF8+fP49NPP8WBAwdw7Ngx1ZeQgwcPxqNHj7B06VIcP34c/fr1w/Xr11G5cmUAQHp6OiZPnoyFCxfC0dFRn7dIRQiBTZs2oXLlyti1axdevnwJe3v7fO1Dl4CAAEyaNAkA8PjxY3zzzTfo3bs31qxZgw8++CDXba2srHD48GHExMTAw8NDrS4sLAxWVlZIT0/X2O6rr77CuHHjUKVKFXz00Ufw9PTEtWvX8N1332Hz5s3Ys2cP3nzzTVV7Dw8P9OzZEytWrECPHj2K4FUXEUElZv369QKAOHPmTEmHUqyCgoKEr6+veP78ea7t5syZIwCIZ8+eFeh5fvvtNwFA3L59W6188+bNAoDYsmWLquzp06fCyclJDBw4UFU2YMAAMXToULV4mjVrpnp8+/ZtYW1tXaDjpVQqReXKlcXEiRNFr169RNu2bbW28/HxEV27dtVad+bMGQFArF+/Xi1Gbe9Z7969BQBx4sQJIYT237XsbXv37i0CAgI0nq9Tp06iT58+AoAYPXq0qjwzM1PUqVNH2NjYiMjISLVtsrKyxIABAwQA8csvv6jVZWZmCmdnZzF79mytr4+Mi7Hkv9elpqYKe3t70alTJ73a16tXT7z77rt6ta1ataqoVq2aWpm+7zPznzrmPyoqxpLrXv+s6NKpUyfh5eUl0tPTVWVyuVz4+fmJevXq5bm9rnO9wr7PK1asEADEyZMnhRCvcrVMJhNHjx4VQrzKY76+vmLt2rWqbRYsWCACAgKEQqHI9/MdOnRIABCHDh0S5ubmYsOGDRptDh8+rHH+mtPo0aPF6105bXn0yZMnwtbWVlSvXl1V1qZNG1G7dm2NbTt06CAcHBxEaGioWl10dLQwMTFR5cWc7/Pff/8tTExMRKtWrURKSoradrdv3xbu7u7C09NTxMfHq9Vt3bpVyGQycefOHa2vryRwKLXEDBkyBHZ2dnjw4AG6desGOzs7VKhQAatWrQIAXL58Ge3bt4etrS18fHw0hrTFx8dj8uTJqFu3Luzs7ODg4ICgoCBcvHhR47nu37+PHj16wNbWFm5ubpgwYQL+/PNPyGQyHDlyRK3tqVOn8NZbb8HR0RE2NjZo06YNjh8/nufruX79Ovbu3YspU6bA1dUV6enpkMvluW4jhEBSUhKEEHnuP6cdO3agcuXKGt/Gb926Fe7u7ujdu7eqrHz58ujfvz9+//13ZGRkAADS0tLg7OysauPi4oLU1FTV40mTJiE4OBiNGjXKV1zAq+El9+7dQ3BwMIKDg3Hs2DE8fPgw3/vRV/v27QEAUVFRebYNCQnBhQsXcP36dVVZTEwMDh06pPXK0LZt23DlyhWt94eYmprim2++gZOTE+bOnatWZ25ujrZt2+L3338vwCsiY1DW8p822VcG3nnnnTzbRkVF4dKlSzqHteV0+vRp3L59O9f9vnz5EgqFQmsd8x/zHxlOWc51aWlpWq8oZktKSoKzszMsLS1VZWZmZihXrhysra3z3L+uc72ccst1umRfBc6+1SM9PR1CCFVelMlkcHJyUuXFR48e4dNPP8XKlStVV5jzIywsDP7+/mjXrh06duxYrPdLe3h4oFatWnrlRCsrK/Tu3Vvjd27Tpk1wdnZGYGCgxjYLFiyATCbDjz/+CBsbG7U6Pz8/LFu2DE+ePME333yjVpf9t01KeZEdYwlSKBQICgqCt7c3li1bhsqVK2PMmDHYsGED3nrrLTRq1AhLly6Fvb09Bg0apPaLfvfuXezYsQPdunXD559/jilTpuDy5cto06YNHj9+rGqXkpKC9u3b48CBAxg7dixmzpyJEydOYNq0aRrxHDp0CK1bt0ZSUhLmzJmDxYsXIyEhAe3bt9c5HDrbgQMHAADu7u7o0KEDrK2tYW1tjaCgINy7d0/rNlWqVIGjoyPs7e3x7rvvIjY2Vq/37cSJE3jjjTc0ys+fP4833nhDI3E1adIEqampuHnzJgCgcePG2LRpEyIjI3H58mV88803aNKkCQAgIiIChw4dwuLFi/WK5XVhYWHw8/ND48aN0b17d9jY2GDTpk0F2pc+7ty5AwBwdXXNs23r1q1RsWJFtSS4efNm2NnZoWvXrhrts++xeX24VDZHR0f07NkT169fx+3bt9XqGjZsiCtXrqjd/0eUU1nKf9qEhYXB2tpa7Ys6XU6cOAEAWvOatv0C0NkxbteuHRwcHGBjY4MePXrg1q1bavXMf8x/ZFhlMddt2LABtra2sLa2hr+/v9b5CNq2bYt///0Xs2fPxu3bt3Hnzh0sWLAA//zzj8YtDdroOtfLlleuy5aVlYXnz5/j8ePH2L9/P2bNmgV7e3tV3nN2doafnx8WL16MqKgohIWF4cKFC6r6qVOnIigoCK1bt9bnrVGTkZGBbdu2qW6RGThwIA4dOqRxL3RRkcvliI6O1isnAq++MDx9+rQqlwJAeHg4+vbtC3Nzc7W2qampOHjwIFq1aqUxhD3bgAEDYGlpqTEHg6OjI/z8/Ar8RXOxKOEr1kZN27CPwYMHCwBi8eLFqrIXL14Ia2trIZPJ1IZnXb9+XQAQc+bMUZWlp6drDOmIiooSlpaWYv78+aqyzz77TAAQO3bsUJWlpaWJmjVrCgDi8OHDQohXQ0eqVasmAgMDhVKpVLVNTU0Vvr6+eQ4HHDt2rAAgXF1dxVtvvSU2b94sli9fLuzs7ISfn5/akIvQ0FAxZswYERYWJrZu3SrGjRsnzMzMRLVq1URiYmKuzyOXy4VMJhOTJk3SqLO1tRXDhg3TKN+9e7cAIPbt2yeEECIpKUm0bNlSABAARO3atcXDhw+FXC4X/v7+4tNPP801Bl0yMzOFq6urmDlzpqosJCRE1K9fX6NtQYcS3rhxQzx79kxERUWJb775RlhaWgp3d3fV+5vbUMJnz56JyZMni6pVq6rqGjdurBpWideGRwUEBAhHR8dcX/Pnn38uAIidO3eqlYeHhwsA4tSpU7luT2WfMeS/18XFxQkLCwvRv39/vdrPmjVLABAvX77MtV1WVpZwd3cXTZo00ajbvHmzGDJkiPjxxx/F9u3bxaxZs4SNjY0oV66cePDggaod8x/zHxUPY8l1b775pggNDRW///67WLNmjahTp44AIFavXq3WLjk5WfTv31/IZDJVvrGxsVGLUZfczvX0zXXZTp48qXp+AKJGjRqq9yPbwYMHhbOzs6rN+PHjhRBCHD9+XFhbW4t79+7lGbM2W7duFQDErVu3hBCv8q+VlZX44osv1NoVdCh1586dxbNnz8SzZ8/ExYsXRXBwsAAgPvroI1U7XUOpu3btKrKysoSHh4dYsGCBEEKIq1evCgDi6NGjGr/PFy5cEADEuHHjcn3N9erVEy4uLhrlnTt3FrVq1cp1W0PiFWOJyjkxiZOTE2rUqAFbW1v0799fVV6jRg04OTnh7t27qjJLS0vVlVGFQoG4uDjY2dmhRo0aarMw79u3DxUqVFC74d3KygojRoxQi+PChQu4desWQkJCEBcXh+fPn+P58+dISUlBhw4dcOzYsVyXEMme4c/DwwO7d+9G//79MXnyZKxbtw537txR+zZx3Lhx+OqrrxASEoI+ffogNDQUP/74I27duoXVq1fn+n7Fx8erDXnJKS0tTW3ITs7Xm10PAPb29jh69Cj+/fdfXLhwARcuXECFChWwevVqZGRkYMKECbh69SratWuHChUq4N1339Xrm/+9e/ciLi5ObfKcgQMH4uLFi/j333/z3F4fNWrUQPny5eHr64tRo0ahatWq2L17t8aQFl1CQkJw+/ZtnDlzRvWvrgl29JkgIrv+9fcn+/g8f/5cr7jIOJWV/Pe6rVu3IjMzU69h1MCrmY/NzMzUJiPU5uDBg4iNjdW63/79+2P9+vUYNGgQ3n77bSxYsAB//vkn4uLi1GavZv5j/iPDK0u57vjx4xg3bhx69OiBDz74AGfPnkWdOnXw8ccfq86zsmOvXr06+vbti02bNmHjxo1o1KgR3n33XURGRub6HLmd6+mb67L5+/sjIiICO3bswNSpU2Fra6sxK3X79u3x4MEDREZG4sGDB/jiiy+gVCoxduxYTJo0CT4+PlizZg1q1qyJGjVqYO3atbnGny0sLAyNGjVC1apVAbzKGV27di2y4dT79+9H+fLlUb58edSvXx9btmzBe++9h6VLl+q1vampKfr3768a2RMWFgZvb2+0atVKo+3Lly9VryE39vb2Wv9mODs7SyonclZqCbKyskL58uXVyhwdHVGxYkWNGTYdHR3x4sUL1WOlUomVK1di9erViIqKUrvHIucQivv378PPz09jf9kf0mzZQ1AGDx6sM97ExEStSQqA6n6R/v37qw1l7tevH9577z2cOHEi19lJQ0JCMGnSJBw4cADTp0/X2S6b0HJfsrW1teo+4pyy74HJeU+LiYkJ/P39VY+fP3+OuXPn4ocffoBMJkO3bt3QrVs3LF++HBMnTsRHH32EH3/8MdeYNm7cCF9fX1haWqqG1vn5+cHGxkY1E2x+aJtlddu2bXBwcIC5uTkqVqyY71lPGzRogJo1ayI8PBxOTk7w8PBQ3af3Ont7+zyTmK5EmX188poploxXWcp/rwsLC4OLiwuCgoL0aq+vsLAwmJqa6lzy43UtW7ZE06ZNVbe6ZGP+Y/4jwynLuQ4ALCwsMGbMGFUnuWXLlgBeLTEUGRmJc+fOqc4L+/fvj9q1a2PcuHE4depUnvvWdq6nja5cBwAODg6qe1x79uyJ8PBw9OzZE+fOnUP9+vVV7ezs7NTmE1i/fj1iYmIwffp0HDhwAFOmTMHGjRshk8kQEhKCGjVqoF27djpjSkhIwJ49ezBmzBi12y1atGiBbdu24ebNm6hevbper0+Xpk2bYuHChapl7GrVqqUxM3deQkJC8OWXX+LixYsIDw9HcHCw1tyVneey854uur5UFEJIKieyYyxBupYn0lWeM0EsXrwYs2fPxrBhw7BgwQK4uLjAxMQE48ePz9eVjWzZ2yxfvhwBAQFa2+R2NcPLywvAq3uMczI1NYWrq6taotfF29sb8fHxubZxcXGBTCbTuj9PT088efJEozy7LDtGbWbPno033ngDb7/9Nv766y88efIEy5Ytg5WVFebNm4e33noL69ev1znxQlJSEnbt2oX09HTVslA5hYeHY9GiRaqkYGVlpfbNak7ZEz5oW9e5devWKFeunM7XoY+QkBCsWbMG9vb2GDBggM7XVKtWLVy4cAEPHjxApUqVtLa5dOkSAKidZANQHZ/CxkplV1nKfzk9ePAAf/31F0aOHKlxj5Yurq6uyMrKyvUqZVpaGrZv346OHTtq5NnceHt748aNG7m2Yf7TxPxHRaWs5rqcvL29AUB1DpeZmYnvv/8eU6dOVfuMmZubIygoCF9//TUyMzNhYWGhdX+5nevlFkNeuQ4Aevfujffeew+//PKLWsc4p6SkJMycORMrVqyAra0tNm3ahL59++Ltt98GAPTt2xdhYWG5doy3bNmCjIwMfPbZZ/jss8806sPCwjBv3jwAmiMbX5eamqo1J5YrV06vSRtz07RpU/j5+WH8+PGIiorSOYqmatWqMDMzU+U9bTIyMnDjxg2tkze+ePFCUjmRHeMyZuvWrWjXrh2+//57tfKEhAS1XzwfHx9cvXpV45ua1ycLyf7mPec3a/nRsGFDAK9m78spMzMTz58/1/i29HVCCNy7d09jHbzXmZmZwc/PT+uMewEBAfjrr7+gVCrVEvGpU6dgY2Oj85u5ixcv4ocffsDZs2cBvFoLztnZWZWEvLy8kJmZiWfPnuk8If3tt9+Qnp6ONWvWaHzwb9y4gVmzZuH48eOqb1Kzj4s22Yndx8cnt7eiwEJCQvDJJ5/gyZMnWtdIzdatWzds2rQJP/30E2bNmqVRn5SUhN9//x01a9bU+FY6KioKJiYmhf42lEgbqeW/nDZt2gQhhN7DqAGgZs2aAF59burVq6e1zc6dO/We5Tqnu3fv5pp/mf+0Y/4jKZByrsspe/h3dq6Ji4tDVlaW1hmj5XI5lEplrrNJ53aul1sMeZ1rAq86b0qlEomJiTrbZK+RnJ1vHz9+rHZ+6uXlhQsXLuT6PGFhYahTpw7mzJmjUffNN98gPDxc1THOzne6OvY3btwotpwIvLrtZeHChahVq5bOL0xsbW3Rrl07HDp0CPfv39caz6+//oqMjAx069ZNoy4qKkrnFxElgfcYlzGmpqYaQ0y2bNmi0TENDAzEo0ePsHPnTlVZeno61q1bp9auYcOG8PPzw4oVKzTuvQCAZ8+e5RpP27Zt4ebmhrCwMLXp+zds2ACFQoFOnTrluq81a9bg2bNneOutt3J9HgBo3rw5/vnnH43yvn37IjY2Fr/99puq7Pnz59iyZQu6d++u9f5j4NU9z8OHD0edOnUAvLrq/ezZM9U3n9euXVMtMaDLxo0bUaVKFXzwwQfo27ev2s/kyZNhZ2endk9Jly5d8PDhQ+zYsUNtPxkZGfjuu+/g5uam1wy1BeHn54fQ0FAsWbJENeuiNn379oW/vz8+/fRTjfdbqVTiww8/xIsXL7Qm/bNnz6J27dpwdHQs8viJpJb/cgoPD0elSpVUnUB9NG/eHAC05rWc+7WxsUGvXr201muLcc+ePTh79myueZX5TzvmP5ICqeU6bfUvX75EaGgoypUrp7pI4ubmBicnJ2zfvh2ZmZmqtsnJydi1axdq1qyZ55JNus719M11CQkJWpcN/e677wBA55J0N2/exNdff42VK1eqvmRwd3dXW+rt2rVr8PDw0Bl7dHQ0jh07hv79+2vkxL59+2Lo0KG4ffu2aji5p6cnAgICsHHjRtUyUtnOnj2LyMjIIr81J6fhw4djzpw5Wq9s5zRr1iwIITBkyBCNq9tRUVGYOnUqPD09MWrUKLW6xMRE3LlzB2+++WaRx15QvGJcxnTr1g3z58/H0KFD8eabb+Ly5csICwtDlSpV1NqNGjUKX3/9NQYOHIhx48bB09MTYWFhqqsB2R96ExMTfPfddwgKCkLt2rUxdOhQVKhQAY8ePcLhw4fh4OCgWr5CG0tLSyxfvhyDBw9G69at8d577+HBgwdYuXIlWrVqpbZkiY+PDwYMGIC6devCysoKf//9N3755RcEBARofJi06dmzJ37++WeN+zP69u2LZs2aYejQobh69SrKlSuH1atXQ6FQqL6Ve92WLVtw6dIlbNu2TVXWvHlzuLu7o1+/fujduzdWrFiB3r176xz29PjxYxw+fBhjx47V+d4EBgZiy5Yt+PLLL2Fubo6RI0fihx9+QL9+/TBs2DA0aNAAcXFx2Lx5M65cuYKffvpJ5xCjojBu3Lg821hYWGDr1q3o0KEDWrZsiaFDh6JRo0ZISEhAeHg4zp07p1rzNCe5XI6jR4/if//7X3GFT0ZOavkv25UrV3Dp0iVMnz49X/dSValSBXXq1MGBAwcwbNgwjfr4+Hjs3bsXffr00TnM8c0330SDBg3QqFEjODo64ty5c/jhhx/g7e2Njz/+WOs2zH+6Mf+RFEgt161atQo7duxA9+7dUalSJTx58gQ//PADHjx4gJ9//ln1uTU1NcXkyZMxa9YsNGvWDIMGDYJCocD333+Phw8fYuPGjXm+dl3nevrmuiNHjmDs2LHo27cvqlWrhszMTPz111/47bffVJOAaTNhwgQMGDBA7Yuzvn37omfPnqr979q1S2NJopzCw8MhhFCbDC2nLl26wMzMDGFhYar7mj///HMEBgYiICAAQ4YMgZeXF65du4Zvv/0Wnp6emDFjRp7vWUH5+PhorMmuTevWrbFixQpMnDgR9erVw5AhQ+Dp6Ynr169j3bp1UCqV2LNnj8Y96gcOHIAQAj179iymV1AABp0Dm9TomsLf1tZWo622adWF0FzeIj09XUyaNEl4enoKa2tr0aJFC3Hy5EnRpk0b0aZNG7Vt7969K7p27Sqsra1F+fLlxaRJk8S2bdsEABEZGanW9vz586J3797C1dVVWFpaCh8fH9G/f39x8OBBvV7rpk2bRP369VXLaIwZM0YkJSWptRk+fLjw9/cX9vb2wtzcXFStWlVMmzZNo50uGRkZoly5cqrp5XOKj48X77//vnB1dRU2NjaiTZs2au97TqmpqcLHx0d8+eWXGnVnzpwRb7zxhrC3txfdu3cXT58+1RlP9jIJub1HGzZsEADE77//rip78eKFmDBhgvD19RXm5ubCwcFBtGvXTuzdu1dj+5xLjuQmr+VKcoPXlivJ9vTpUzFx4kRRtWpVYWlpKZycnETHjh01lijJtnfvXrXlCci4GVP+mz59ugAgLl26pFf7nD7//HNhZ2cnUlNTNerWrl2rdVmgnGbOnKlaYsjc3FxUqlRJfPjhhyImJkZre+Y/dcx/VFjGkOv2798vOnXqJDw8PIS5ublwcnISnTt31rldWFiYaNKkiXBychLW1taiadOmYuvWrbk+RzZd53r65rrbt2+LQYMGiSpVqghra2thZWUlateuLebMmSOSk5O1Pufu3buFnZ2dePz4sUbdkiVLhJeXl/D09BRLly7NNfa6deuKSpUq5dqmbdu2ws3NTcjlclVZZGSk6Natm3B2dhZmZmaiQoUKYvjw4eLhw4ca2+e27F1OuS3XlBttv8/Zjh07Jnr27CnKlSunOgYjRozQuazVgAEDRMuWLfOM1ZBkQug5tRsZhdDQUEyYMAEPHz5EhQoVSjqcfFuwYAHWr1+PW7du6bySQSXj7bffhkwmw/bt20s6FCKtpJj/EhMTUaVKFSxbtgzvv/9+SYdDBcT8R1IixVyXHzzXK/1iYmLg6+uLX375RVJXjNkxNmJpaWlq93Kkp6ejQYMGUCgUuHnzZglGVnDJycmoUqUKvvjii3xPRkPF59q1a6hbty4uXLigumeRqCSVpvy3dOlSrF+/HlevXtU5WzJJF/MflaTSlOv0xXO90m/69Ok4dOgQTp8+XdKhqGHH2IgFBQWhUqVKCAgIQGJiIjZu3Ih///0XYWFhOqdlJyIqC5j/iMgYMNcR6Y+TbxmxwMBAfPfddwgLC4NCoYC/vz9++eUXDBgwoKRDIyIqVsx/RGQMmOuI9McrxkRERERERGTUeLMSERERERERGTUOpQagVCrx+PFj2Nvb52uNSSIqHYQQePnyJby8vDh50WuY/4jKPuZA7Zj/iMq+/OQ/dowBPH78GN7e3iUdBhEVs+joaFSsWLGkw5AU5j8i48EcqI75j8h46JP/SrRjfOzYMSxfvhxnz57FkydPsH37drz99tuqeiEE5syZg3Xr1iEhIQEtWrTAmjVrUK1aNVWb+Ph4fPTRR9i1axdMTEzQp08frFy5EnZ2dnrHYW9vD+DVG+bg4AC5XI79+/ejc+fOMDc3L7LXW1Tk8XLsP7kfnRM6w9xSgvFlyLHfaT86N+8McxcJxif14yvx+ADpx/h6fElJSfD29lZ91qVCCjnw9fwHlILjyxxYKJI/voyvULTFJ8UcyPxXMMx/hSP548v4CqWw+a9EO8YpKSmoX78+hg0bht69e2vUL1u2DF9++SV+/PFH+Pr6Yvbs2QgMDMTVq1dhZWUFAHjnnXfw5MkTREREQC6XY+jQoRg5ciTCw8P1jiN7+IyDg4OqY2xjYwMHBwfJHnQbGxs4yBxgbivB+FLksLG2gYO9A8wdJBhfaTm+RRyfEAK3nybjzrMUrfVKIbBs33Xci0vVc48OmHExssjiK3qv4rv3aVdVidSGykkhB76e/4BS9BkxghyYpVAi+kUaUjKyMHXrJVx9klREUZaOz690lY74lvSui4FNKqlKpZQDmf8KxpjyX3EoquMrhMBv5x5hx4VHUAqBJwnpuPtc+/lV/pWO/CJdr+KrV9ERO8e0VJXqk/9KtGMcFBSEoKAgrXVCCISGhmLWrFno2bMnAOCnn36Cu7s7duzYgeDgYFy7dg379u3DmTNn0KhRIwDAV199hS5dumDFihXw8vLSuu+MjAxkZGSoHiclvTrRkMvlqp/sx1Ikz/ovPsgBCc4pLsd/8WXJAQm+hZI/vsUQn0IpUHf+AcgVEvyFKWY5P9NSUxI5MK/8l/3/nP9KjbHkwB0XHmPKtitFFBUZI4VCIdkcyPxXMMaS/4pLfo+vXKHEzotP8O/jJNyITYazjTn+vPq0OEOkIiKEyHf+k+w9xlFRUYiJiUHHjh1VZY6OjmjatClOnjyJ4OBgnDx5Ek5OTqqECAAdO3aEiYkJTp06hV69emnd95IlSzBv3jyN8v3798PGxkb1OCIioghfUdGLsI6QZFKE9at/IiIl/v5J/fgWQXyZCmDjbRNcjDfeyVb27NmD1FR9r4JLR3HlQH3zH1AKPiNlJAfGZwDXE2RIV/x/WUyqDKeeGe/nlorG5cuXYf/0UqnLgcx/eSsr+a+k5HZ8H6UAX1w2hVxIZ4QF5V9CQmK+zwEl2zGOiYkBALi7u6uVu7u7q+piYmLg5uamVm9mZgYXFxdVG21mzJiBiRMnqh5njz3v3Lmzaih1REQEOnXqJM1hNPFyRERGoFNaJ5jbSDC+VDkirCPQqVknyd5fIunjW8D47j5Lwe8XnyAuJVNVtvmfh8URYqnSpUsX1VWB0qS4cmBe+Q8oBZ+RUpwDbz1Nxh+XYhCX8uqq1cWHSbge87IkwiQjULduXXRpVLHU5UDmP91Kc/6TgpzH19TUDDdik7H3SgzWHIsq6dCoiDk5OaJLl2b5yn+S7RgXJ0tLS1haWmqUm5ubqyXB1x9Lxn9HzRzmMJdJML7/mJtJ9P37j2SP73/yii86PhXB30YiPiUTaXKFznaFUcnFRmu5EAKpqamwsbGR1D1r2XLGJ/XjbGj65j9dZZJQCnPg6ah4zNn5L64V2T3Cuj+feSlNn1/Gl38543OwsZTu57gEMP8ZjlTPAW/FJmPcSTPg5GGDPF9+83Rpyi9Sj8/D0Trfn2PJdow9PDwAALGxsfD09FSVx8bGIiAgQNXm6VP1cf5ZWVmIj49XbU9UFt2IeYnA0GOF2sf7LX1haqKZ1ExkMtSp4IAudTxhoqUeePWN6549e9ClSytJ/uHLGV9pxRxY+sWlZmLGzvM4fONZkezPxdYCb1RyRhNfZwxt4Qtz04INtS5Nn1/Gl39Sj08fzH9UVGKT0vHD8Sh8c/RusT7P7G7+qOVpDy9Ha/i4FrzTKPXPb1mPT7IdY19fX3h4eODgwYOqJJiUlIRTp07hww8/BAA0b94cCQkJOHv2LBo2bAgAOHToEJRKJZo2bVpSoRMVu8J0ioMbe2Nxr7o6O70kDcyBpdvhxzKMO3mkyPbXr2FFLO9Xv8j2RyRlzH9UGDGJ6Wi25GCx7HvIm5XhV94WjjYW6FDTDbaWku1KUQGU6NFMTk7G7du3VY+joqJw4cIFuLi4oFKlShg/fjwWLlyIatWqqabq9/LyUq1zV6tWLbz11lsYMWIE1q5dC7lcjjFjxiA4OFjnjNREpd25By8KvO3RKW3h42pbhNFQYTAHli1305JxOTkRGx5G4XyqaYH20byKK7rU+/8rZKYyGep7O6K2l2NRhUkkCcx/VJRik9IxbMMZ/Pu4aO+nn9W1Ft5v6SvJYcNU9Eq0Y/zPP/+gXbt2qsfZEyIMHjwYGzZswNSpU5GSkoKRI0ciISEBLVu2xL59+1Tr1wFAWFgYxowZgw4dOqgWd//yyy8N/lqIitOvZ6Lxw/EoZCqUuKtjHWIAsLUwRUqmAjO71EItT4f/L7c0hb+XAyzNCnayTsWDObBsOJv0AkOvnkaSIitf2wU39kb3+q9O4E1NZKjt5QB7K+kNTSMqDsx/VFiXHyai+9d/F8m+KrvaoImvC8a0q4ZKrgWbv4FKvxLtGLdt2xZC6J5rXiaTYf78+Zg/f77ONi4uLnov5E5UGq348wa+Pnw7z3b3Pu1qgGioKDEHll5CCGx9+hBTbl/K13bl7CwxNbAG+jasyNsZyKgx/1FBPX2ZjiaLCj9UOmx4U7SoWq4IIqKyggPjiSTsekySXp3iX0c1N0A0RBSdnorQ6FvY9jR/S6GNal0F096qyc4wEVEBZGQp0HLpYTx7mVHgfVR2tcEHrX1h8ugienXvIsnJo6hksWNMJEGZCuB/4RcQce1pnm0tTE3QyMfZAFERGTeFEAi5EonojLR8bfdJN38Ma+lbTFEREZVtlafvLvC2s7v54/0c+Vcul2NPzMWiCIvKIHaMiSTorxgZIh7k3SnuUNMN3w5qxKtQRAZwPSVJ705xw3JKdKhdA23re8DfyyHvDYiISM28Xf9i/fF7+d5uyJuVMb5jNTjZWBR9UFSmsWNMJEE7H+ieJOuzfvXRvb4XzExk7BATGdBWPYZPt0kzQ4DCFFWap6BLE1+Yu3KoHhFRfmQplKg6c2++ttn24ZtoyNFzVEjsGBNJTGKaXGddVTc79GlY0YDREFG2sJgHWsstBNArxQLeWSaQQQaZme4JhYiISLdtZx9i0hb9hzqPbF0FkzvXgIWZSTFGRcaCHWMiiZm4Rfsstx93qYkRraoYOBoiAoD9cTHIFEqN8spyE/RLsSyBiIiIypbwUw/w8fbLebYb2qIyZnf156g5KnLsGBNJyInbz3HsVpzWupGt/QwcDREBwO/PHmHczQta6xpl8M8oEVFh7Ln8BP8LO6dX26glXSCTsUNMxYN/0Ykk4vjt53jnu1Na68Z2qGbgaIgIAG6nJuvsFAOAu4LD94iI8ismMR3Nlui/FvG1+W/B2kL3/CtERYEdY6ISJlcosen0A3zy+78624xnx5jI4BKz5Oh4/qjOeieFDDaCVy6IiPQlhIDvjD16t2/i64LNI5vxKjEZBDvGRCVICIFqecy8+G6zSryPhqgETLut/X5/APCWm6B/CpcCISLS16OENLT49JDe7Ue388OUwJrFGBGROnaMiUpIRpYCNWbty7VNdXc7LOhZx0AREVG26PRU7IuL0VpXIcsEwZxwi4hIbyduP0eIjtvFtNk/oTWqu9sXY0REmtgxJioBSqXAt0fv5trG1sIUf45vzeFDRCWg1dnDWstlAghO5pViIiJ9/XomGlO36R6Bk1P4iKZ4069cMUdEpB07xkQGdiPmJQJDj+XaxsJEIHRAPXaKiUpAclaWzrrxiVYwAT+XRET6iEvOyLNTPCOoJka14cobVPLYMSYyoCeJaXl2ireMbIJ7F06gbfXyBoqKiHL6Meae1vKG6aYwY6eYiEhvDRceyLWeyy+RlHCdCSIDeZGSieZLcp90Yt/4VgjwdgJXJCAqGamKLCy/f0NrXft0DqEmItJX5em7ddbV9LDHvU+7slNMksKOMZGBrDl6J9d6F1sL1PRwMFA0RJSTUgj8GRcD/8g/tdbXzOS3VURE+pq+94rOuvdb+mLf+NYGjIZIP5LuGCsUCsyePRu+vr6wtraGn58fFixYACGEqo0QAp988gk8PT1hbW2Njh074tatWyUYNZF23x7TPdnWvB61ETmjgwGjodKAOdBw5tz9F6Oun9VZ3yqddx4RGRLzX+mVrgC2XXmss352N38DRkOkP0l3jJcuXYo1a9bg66+/xrVr17B06VIsW7YMX331larNsmXL8OWXX2Lt2rU4deoUbG1tERgYiPT09BKMnEhdzj/kOVVzs8O9T7ti8JuVYWEm6Y8jlQDmQMM4//IFfo65r7PeRgk4Kfn5JDIk5r/Sa9pp3V8kRi3pYsBIiPJH0l+BnzhxAj179kTXrl0BAJUrV8amTZtw+vRpAK86G6GhoZg1axZ69uwJAPjpp5/g7u6OHTt2IDg4uMRiJ8rpyM1nWssjJrYxcCRUmjAHFr90hQK9Lp3Itc2IJCsDRUNE2Zj/SqeLKYk6664veIv3FJOkSbpj/Oabb+Lbb7/FzZs3Ub16dVy8eBF///03Pv/8cwBAVFQUYmJi0LFjR9U2jo6OaNq0KU6ePKkzKWZkZCAjI0P1OCkpCQAgl8tVP9mPpUie9V98kAPaL0SWKDn+iy9LDkjwLTT08X2UkIah68/kGou2Mqn+/gHSj/H1+KQaZ16KIwfmlf+y/5/zX6kpyhz4z8v4XOu7pJvD0gzIzxPJTF+1ZQ4sGMZXONrik2qsuWH+007q54B9b53SWt6hZnmYQgm5XGngiNRJ/vgyvkIpbP6TdMd4+vTpSEpKQs2aNWFqagqFQoFFixbhnXfeAQDExMQAANzd3dW2c3d3V9Vps2TJEsybN0+jfP/+/bCxsVE9joiIKIqXUWwirCMkmRRh/eqfiEiJv38GOL4nY2X45a72SXsalVNiz549OreV+u8fIP0Ys+NLTU0t4UgKpjhyoL75DygFx7cIcuD2eBNou6vI21ZgeA0FnCx1r2mcF+bAwmF8hZMzvtKYA5n/cifFc8Dw29rzKQD0cH6CPXueGDagXEj++DK+Qilo/pN0x/jXX39FWFgYwsPDUbt2bVy4cAHjx4+Hl5cXBg8eXOD9zpgxAxMnTlQ9TkpKgre3Nzp37gwHBwfI5XJERESgU6dOMDc3L4qXUqTk8XJEREagU1onmNtIML5UOSKsI9CpWSeYu0gwPgMd3++P38MvJ2/qrJ/auxkaeDuVWHyFIfUYX48v+6pAaVMcOTCv/AeUguNbRDnwbNILHHmifTRHv1grpMTKkFKA/cpMBbzapxh9Diwoxlc42uIrjTmQ+U87qZ4DnkiIw6ln2icwPDihJSq52GitMzTJH1/GVyiFzX+S7hhPmTIF06dPVw2HqVu3Lu7fv48lS5Zg8ODB8PDwAADExsbC09NTtV1sbCwCAgJ07tfS0hKWlpYa5ebm5moH+fXHkvHfUTOHOcxlEozvP+ZmEn3//lOcx/fyw0R8uk93pxgAmlQpn2u9ZH//cpB6jNnxSTnG3BRHDtQ3/+kqk4QiyIFKIRB8RXunuGm6GUyzTAp9McaYc2BRYHyFkzM+KcepC/OfDhI8B0xXKjD4qu5Z/f3cHQ0YjX4ke3z/w/gKp6D5T9LTbKampsLERD1EU1NTKJWv7k/w9fWFh4cHDh48qKpPSkrCqVOn0Lx5c4PGSpRTv29yn8zn33mBBoqESjPmwOLzZbTuJV3qcs1iohLH/Fd61Dy5T2fdhU86GTASosKR9BXj7t27Y9GiRahUqRJq166N8+fP4/PPP8ewYcMAADKZDOPHj8fChQtRrVo1+Pr6Yvbs2fDy8sLbb79dssGTUUvXMbnEsj710L+xt4GjodKKObD4hOroGNfJMIUzl2YiKnHMf6VD5eO7ddb9ProFnGwsDBgNUeFIumP81VdfYfbs2fjf//6Hp0+fwsvLC6NGjcInn3yiajN16lSkpKRg5MiRSEhIQMuWLbFv3z5YWXF5DSoZm04/0Fr+zXsNEVjbw8DRUGnGHFg8zia90FkXlMaTOCIpYP6Tvn1xuifTcrU0Q30t86gQSZmkO8b29vYIDQ1FaGiozjYymQzz58/H/PnzDRcYkQ5CCMz47bLWOnaKKb+YA4vHuZfaO8bvvdS895CISgbzn/R9cP2czrqN7WobMBKiosHxYkRFaMne61rLm/q6GDgSItLliwfaJ8bzUPBPIhGRPq6l6J7p9/NmBV/mjqgk8SyAqAh9e+yu1vLZ3fwNHAkRaaMQAqlKhUZ5h1Tpzq5JRCQlX0XfQtCFv7TW/S/FEqYyAwdEVEQkPZSaqDSJT8nUWl7NzQ51KkhvqQIiY5Si0H4lw03BMzkiorzMuXsFPz65r7PeTjCXUunFjjFREXljQYTW8l0ftTRwJESUX54cRk1ElKtpty5h89NonfXdUzjyhko3ngkQFYHnyRk666zMuSYqkVQkZsk1ysorZDAFr3IQEeUmt05xtUwT1JTzehuVbvwNJiokpVKg0cIDWuu+H9zIwNEQUW76XT6pUWYiSiAQIqJSJDIxTmddUIo56rBTTGUArxgTFUJKRhaqfLxHZ32HWu4GjIaI8hKTma5RlsmLxUREuQq+Eqm1vE6GKTvFVGbo9Zv8xhtv5GunMpkMO3fuRIUKFQoUFFFpUWfunzrrFveqa8BIqDgxB5YdJgCUr5U1yuBJHZEuzH+UoWUm/2xBaRYGjISoeOl1NnDhwgVMmjQJdnZ2ebYVQuDTTz9FRobuey6JygKFUkDkMgSzT0OeFJQVzIFlhwwyAOof3IBMdoyJdGH+o1XRt7WW909mp5jKFr3PBqZMmQI3Nze92n722WcFDoiotFjwx1WddX9NbQdLM066VZYwB5Z+cqUSitc6xfZKjqMmygvzn3H78qH2jrFPFs9zqGzRq2McFRWF8uXL673Tq1evwsvLq8BBEZUGG07c01p+c2EQLMx4+35ZwhxY+sXJMxBy5ZRGObvFRLlj/jNuSh1D42xevyeFqAzQq2Ps4+OTr516e3sXKBii0q55FVd2issg5sDSLUso0ffSSUSlp5R0KESlDvOfcbuWkqS1fPBLKwNHQlT8CnxjVVZWFr755hscOXIECoUCLVq0wOjRo2FlxQ8KlX1PEtO0lv8wpLGBI6GSwhxYenS/eFxnp9iWVz2I8o35z3h0vfi31nI7wfE2VPYUuGM8duxY3Lx5E71794ZcLsdPP/2Ef/75B5s2bSrK+IgkJ12uQPMlh7TWWVvwfhtjwRxYOlxJTtR5xQMAGnBGaqJ8Y/4zDmeS4ks6BCKD0vuMYPv27ejVq5fq8f79+3Hjxg2Ymr7qCAQGBqJZs2ZFHyGRxNScva+kQ6ASwBxYOnXTcbUDAHonW8CPk8cQ5Yn5z/gohEC/yye11r2RwbxJZZPeN0P+8MMPePvtt/H48WMAr9a1++CDD7Bv3z7s2rULU6dORePGHEZKZVtcsu4lKGZ1rWXASMjQmANLl+eZGdgX90Rnfbs0c3aKifTE/Gd82p87orsuzdxwgRAZkN4d4127dmHgwIFo27YtvvrqK3z77bdwcHDAzJkzMXv2bHh7eyM8PLw4YyUqcX/ffq6zrm/DigaMhAyNObD02PA4Co3OHMAH189prR+QbIFGHEJNpDfmP+PyPDMD99NTtdaFvLT4bz14orInX9PnDhgwAKdPn8bly5cRGBiId999F2fPnsWFCxewatWqfE3nr69Hjx7h3XffhaurK6ytrVG3bl38888/qnohBD755BN4enrC2toaHTt2xK1bt4o8DiK5Qolxv1zQWnd+dic42XCh+7KOOVD6DsTHYm6U7jXGAaASrxQT5Rvzn/FodOaAzroKCuZPKrvyva6Mk5MTvv32WyxfvhyDBg3ClClTkJ6eXhyx4cWLF2jRogXMzc2xd+9eXL16FZ999hmcnZ1VbZYtW4Yvv/wSa9euxalTp2Bra4vAwMBii4mM19K917WWj2pTBc627BQbC+ZAaVv/OKqkQyAqs5j/jNtHiZx1nMo2vceSPXjwAJMnT8a1a9dQr149rFixAmfPnsWiRYtQv359hIaGIigoqEiDW7p0Kby9vbF+/XpVma+vr+r/QgiEhoZi1qxZ6NmzJwDgp59+gru7O3bs2IHg4GCt+83IyEBGxv/fK5qU9GrGUrlcrvrJfixF8qz/4oMc0L7ueomS47/4suSABN/Cgh7f7/7WfsLdoUa5Iv1dkfrvHyD9GF+PryjiLCs5MK/8l/3/nP9KTW458H4eaxWPTLGAzKx4E6fM9NX+y1oONBTGVzja4itsrMx/0lHc54D/JL3QWt4+wwzWpkBeT5qd/xRCIcn3UPLHl/EVSmHzn0wIodfHqm3btvDw8MCQIUPw559/4s6dO9i5cycA4Nq1axg1ahQ8PDzw66+/5if+XPn7+yMwMBAPHz7E0aNHUaFCBfzvf//DiBEjAAB3796Fn58fzp8/j4CAANV2bdq0QUBAAFauXKl1v3PnzsW8efM0ysPDw2FjY1Nk8VPZkZgJfHJW+/dIK5tnGTgayq/U1FSEhIQgMTERDg4OBdpHWcmBZTX/pWYBP940wfVEzYFQHtYCrTyUqOsi4MjBHWSECpsDmf+Mx7iTPNehsiU/+U/vjrGdnR0uXrwIPz8/CCHg6+uLe/fuqbX59ttvMXLkyAIH/rrsheInTpyIfv364cyZMxg3bhzWrl2LwYMH48SJE2jRogUeP34MT09P1Xb9+/eHTCbD5s2bte5X2zeG3t7eeP78ORwcHCCXyxEREYFOnTrB3Fx6M+/J4+WIiIxAp7ROMLeRYHypckRYR6BTs04wd5FgfAU4vquO3EXowdsa5dfmdoSZab7vSCjy+AxN6jG+Hl9SUhLKlStXqI5xWcmBeeU/oBQc39dy4O/PnmDyrcs6209OtoSJASeLkZkKeLVPKVM50JAYX+Foi6+wOZD5TzqK8xxQKQRqnIzQWjc1Wb9h1Nn5z88hADVaehVleEVC8seX8RVKYfOf3kOpGzZsiE8++QSDBw/GgQMHULduXY02RZkQAUCpVKJRo0ZYvHgxAKBBgwa4cuWKKikWlKWlJSwtLTXKzc3N1Q7y648l47+jZg5zmMskGN9/zM0k+v79Jz/HNz1L+/dH1laav0dFRbK/fzlIPcbs+IoixrKSA/XNf7rKJCFHDnyZJXLtFJsLAFkyiBKYRbUs5cCSwPgKJ2d8hY2T+U9CivEc8HFmmtbyvskWEFn5y6GmMlNpvn//kezx/Q/jK5yC5j+9L3X99NNPyMjIwIQJE/Do0SN88803+Y8ynzw9PeHv769WVqtWLTx48AAA4OHhAQCIjY1VaxMbG6uqIyoKa4/e0Sh7v6WvlpZUVjEHSk+GUpHrWpsAUCPTlEuLEBUS81/ZJ4TAm/8c0lpXOatoR8YRSZXeV4x9fHywdevW4oxFQ4sWLXDjxg21sps3b8LHxwfAq0kYPDw8cPDgQdX9JUlJSTh16hQ+/PBDg8ZKxqdFVdeSDoEMiDlQeg4lPUNClu5JNd7IMEWbNOl+o01UWjD/lX27nj/RWccvF8lY6PUVUPasffp6+fJlgYJ53YQJExAZGYnFixfj9u3bCA8Px7fffovRo0cDAGQyGcaPH4+FCxdi586duHz5MgYNGgQvLy+8/fbbRRIDUXS89kXuA7ydtZZT2cMcKD0KJTD23iWd9R8kWqFDmgXMeEJHVCjMf2WfEAJjb57XWtcrmTMWkvHQq2Ps7OyMp0+f6r3TChUq4O7duwUOKlvjxo2xfft2bNq0CXXq1MGCBQsQGhqKd955R9Vm6tSp+OijjzBy5Eg0btwYycnJ2Ldvn2rSBqLCarXssNZyF65dbDSYA6UjXa7AwkPXMfGU7gFPIxMtYS/YISYqCsx/ZZ/viT0666pmmRowEqKSpddQaiEEvvvuO9jZ2em106Jc26pbt27o1q2bznqZTIb58+dj/vz5RfacRNluxRbNN99UujEHSsP5By/Qa/WJXNv0T7aAo+D9cERFhfmvbGtzVvuX/wAwPKn4JhglkiK9OsaVKlXCunXr9N6ph4eHpGcqI9JXpy+OaS3/OqSBgSOhksQcWLKeJKbhy4O3sen0gzzb+vDqBlGRYv4ruxKz5Lifrv12MQBwVvJLRjIuenWMX1+rjsgY5LbEd7d60lubj4oPc2DJOHv/BVYevIVjN5/p1b5jKk/GiYoa81/ZVf/Ufp11PVJ4uxgZH71npSYyNpcfJWot3zWmpYEjITI+C/64iu//jtKrrZNChpbpZqgl5580IiJ9xMszddYNT7Lk1WIySjyLINJhx/nHWsvrVnQ0cCRExuXw9ad6d4qHJlmiHE/giIjy5Y3TEdrLM0zZKSajxY4xkQ4/HNc8MW/i61ICkRAZl79vP8+zTcNySnS4ZwOh5OzTRET5EZWWorOuQxqHUJPx4ldCRFooldrvL+5a19PAkRAZn4wshc66tjXK4/SYthhUTWnAiIiIyo7+l09qLW+dxutlZNz4CSDSYuHua1rLe79RwcCREBmPjCwFfjpxHxsjNWef7lrPEysHBMDM1ATyuKJbDoaIyNg8k2doLW+YwW4BGbciu2L822+/oV69ekW1O6ISpW0YNQDYW3HWW9KOObBw/rr1DDVm7cOiPdq/lBrQyBtmphzkRCRFzH+lR1jMfa3lQSnmMANvTSHjlq+zjG+++QZ9+/ZFSEgITp06BQA4dOgQGjRogPfeew8tWrQoliCJDOngtVit5d3rc4kmY8ccWDzuPEvGe9+fzrWNiYwnbEQlifmvbJh554rW8ppyrgFPpHfH+NNPP8VHH32Ee/fuYefOnWjfvj0WL16Md955BwMGDMDDhw+xZs2a4oyVqNg9e5mB93/8R2vdZ/3qGzgakhLmwOLzlx7rFNfytDdAJESkDfNf2dD1wl9ay2UCvFpMhHzcY7x+/XqsW7cOgwcPxl9//YU2bdrgxIkTuH37NmxtbYszRiKDabH0kM46CzMO4zRmzIEF9yAuFcfvPEdqpvZJtdYcuaNzWwszE8zqWguudpbFFR4R5YH5r3QTQuDtSyfwb0qS1vrRSVYGjohImvTuGD948ADt27cHALRq1Qrm5uaYN28eEyKVKZlZ2me6jZjQ2sCRkNQwBxbMidvPMWTDGZ2frdxs/9+bqOnhAGsLDvEjKknMf6XXpZcJ6HHpeK5trAWvFhMB+egYZ2RkwMrq/79RsrCwgIsL13SlsiM1M0trebMqLqjmzmGcxo45sGDWHrtboE7xp73rokEl52KIiIjyi/mvdJIrlXl2iicl8GoxUbZ8zcs+e/Zs2NjYAAAyMzOxcOFCODo6qrX5/PPPiy46IgN69CJNa/kvI5sbOBKSKubA/FEqBW7FvizQtpVcbYo4GiIqDOa/0kUpBKqd3Jtrm37JFjDhvcVEKnp3jFu3bo0bN26oHr/55pu4e/euWhsZZw2lUiwyKr6kQyAJYw7Uz8MXqUhIlePLg7ew/6r2Gd7z8qafKxr58GoUkVQw/5UeV5ITMfnWRVxPzf1LyT7JFqicxdtUiHLSu2N85MiRYgyDqOR9qmX9VDd7TvhDrzAH5i4xTY5hG87g7P0Xebad1bUWzEy0n0RXLmeL5n6unOyOSEKY/0qHxxlpGHglEi8V2m8Ny/beS0t4KJhjiV6Xr09FUlISIiIisHv3bjx7lvfyGkXt008/hUwmw/jx41Vl6enpGD16NFxdXWFnZ4c+ffogNrZgVynIeGVkKZCiZcbcXg0qlEA0JFXMgeqeJqUj/NQDrDxwC/Xn7derU2xlboJhLXwxRMdP2xpusDTjVQwiqWH+k75fYqPz7BRPSLBip5hIB70/GRcuXEDNmjURGBiI7t27o2rVqvjzzz+LMzY1Z86cwTfffIN69eqplU+YMAG7du3Cli1bcPToUTx+/Bi9e/c2WFxUNszfdVVreZsa5Q0cCUkVc6C6Rwlp6P713/h4+2V8ceCm3tt1qOUOEx1Xi4lImpj/Socvo2/lWj8uwYrrFRPlQu+h1NOmTYOvry+2bdsGKysrLFiwAGPGjMGtW7l/CItCcnIy3nnnHaxbtw4LFy5UlScmJuL7779HeHi4ahmB9evXo1atWoiMjESzZs207i8jIwMZGRmqx0lJr9Z1k8vlqp/sx1Ikz/ovPsgBUcLBaCHHf/FlyQEJvoXajm/YqQda2wZUsDf474HUf/8A6cf4enxFEWdZyYF55b/s/+f8V5vtZ6MRm5Shs/511dxs8aafKyZ2rFro45GdA2WmEkyA+P+4SlMOlBLGVzja4itsrMx/0qHrHPBQvO6r+J4KGd5Js4CJGVDcJ47Z+U8hFJJ8DyV/fBlfoRQ2/8mEEHp9QsqVK4f9+/fjjTfeAAAkJCTAxcUFCQkJcHBwyE/M+TZ48GC4uLjgiy++QNu2bREQEIDQ0FAcOnQIHTp0wIsXL+Dk5KRq7+Pjg/Hjx2PChAla9zd37lzMmzdPozw8PFw14yIZl3EnNb8jqu+ixLAa+V9mhqQnNTUVISEhSExMLHC+Kis5UFf+85u0GaaW+ue/DEXeVx1qOSnhbQu09FDC0ULvXRNREStsDizr+a80n/9FJwMrLuu+zjW/YRbzLxm1/OQ/va8Yx8fHo2LFiqrHTk5OsLW1RVxcXLEmxV9++QXnzp3DmTNnNOpiYmJgYWGhlhABwN3dHTExMTr3OWPGDEycOFH1OCkpCd7e3ujcuTMcHBwgl8sRERGBTp06wdzcvMheS1GRx8sRERmBTmmdYG4jwfhS5YiwjkCnZp1g7iLB+LQc35nnDiE5Q/2+nI1jOsHK3PD3Okr99w+Qfoyvx5d9VaAwykoO1JX/MhUymOjR2dXXhA5V8b+2VYpsfzll58DHh2whijDmoiIzFfBqn1KqcqCUML7C0RZfYXNgWc9/2ed/QCk4vjnOAXcmP8WK2//m2j75qB2SDRQb8P/5z88hADVaehnwmfUj+ePL+AqlsPkvX+sYX716VS3ZCCFw7do1vHz5/1PCv37/R2FER0dj3LhxiIiIUFtYvrAsLS1haak527C5ubnaQX79sWT8d9TMYQ5zmQTj+4+5mUTfv//kPL6vd4oBwN6mZBe9l+zvXw5SjzE7vqKKsSzkQF35rygMaOSNZn4uqFvBEVXd7IvlOQCocqBQyCCypNcxzlaacqAUMb7CyRlfUcRZlvOftmMp2eOb4xxweh6d4gHJFiWWI01lptJ8//4j2eP7H8ZXOAXNf/nqGHfo0AGvj7zu1q0bZDIZhBCQyWRQKDRn9i2os2fP4unTp6qhOwCgUChw7NgxfP311/jzzz+RmZmJhIQEtW8MY2Nj4eHhUWRxUNmWlK5574GHQ8l2ikmamAN1s7cyw9wetWFtwRmlicoi5j9pWfnkdq71jdLNUInrFBPli94d46ioqOKMQ6sOHTrg8uXLamVDhw5FzZo1MW3aNHh7e8Pc3BwHDx5Enz59AAA3btzAgwcP0Lx5c4PHS6XT3stPNMpiktJLIBKSsrKeA11szGFq9epGNAGBzIxMWFhaQKbHDKY1POwxJbAGO8VEZVRZz3+lTVoW8HXsXZ31LdPM0DxDulfziKRK747xjz/+iMmTJxt0cgJ7e3vUqVNHrczW1haurq6q8vfffx8TJ06Ei4sLHBwc8NFHH6F58+Y6Z6Qmet2aI3dKOgQqBcp6Djw2rb3aPXZ79uxBly7tJD1UiogMo6znv9JCCIHzjxOw+ILuLyGnJFgbMCKiskXvjvG8efPwwQcfSG7Wvi+++AImJibo06cPMjIyEBgYiNWrV5d0WFSK3ItL1Shrx/WL6TXMgURkrJj/Sk66XIFJWy5i96Wco9u0j+QZlVg880cQGQu9O8Z6rupU7I4cOaL22MrKCqtWrcKqVatKJiAq1TKztC/H1NjXxcCRkNQxBxKRsWL+M6wshRJbzj7EjN8u5934Pz5yEzgIk2KMiqjsy9fkWzKZdGf/JCqIhgsjtJa/39LXwJFQacAcSETGivnPcKZuu4Tfzj3K1zb1M/N1Sk9EWuTrU1S9evU8E2N8fHyhAiIylMwsJV6may7TBACWZpxEiDQxBxKRsWL+M4znyRn57hRXzzRBdTmvFhMVVr46xvPmzYOjo2NxxUJkUD8cv6e1fMiblQ0aB5UezIFEZKyY/wzj7P0XerVzVspQM9MUzdLNYKbH6gFElLd8dYyDg4Ph5uZWXLEQGdRnB7SvATinu7+BI6HSgjmQiIwV81/xeZkux4o/b+DHk/dzbTeilS8mNPZDxMl9eBRhB5HFDjFRUdK7Y8x7S6gsuZOkvXxwcx/+rpNW/L0gImPF/Fc8FEqBq4+T0P3rv3Nt16paOfz8flMAgDxObojQiIxSqZuVmqgobL+n/R7i8R2rGzgSKi2YA4nIWDH/Fb0/Lj3GmPDzerVtUpkrZRAZgt4dY6VS+7I2RKVRdIr2b7+dbS0MHAmVFsyBRGSsmP8KLjUzC3/deo6HL9JUZSkZWfg84qZe2ztYmWFQ88rFFB0R5cS53Yn+s/WD5iUdAhEREZURqZlZGPhtJC4+TCzQ9gt61sa7zXiLF5GhsGNMRkfXkLD63k6GDYSIiIjKrGM3nxWoU/zHRy1RpwJnACcyNHaMyeicfZCgtdzMhN/IEhERUf7JFUqcvBOHm7Ev8SI1E6sO38n3PmZ1rYVeDSrA1c6yGCIkorywY0xGZ/4f17WWc6gSERER5ZcQAmPCz+HPf2MLtH0FJ2ts/bA5PB2tizgyIsoPdozJ6FyLealRxj4xERER6et5cgZWHb6Nfx8n4fqTJCSlZ+m1XQ13ewxs4q16XM7eEq2qloejjXlxhUpEemLHmIxKbFK61vIfhjQ2cCRERERUGgkhMGT9aVx5lJTvbVcODEBND4diiIqICosdYzIql3RMgtGyajkDR0JERESlgRACt54m49/HiRACePgiLd+dYhdbC4zrUI2dYiIJY8eYjMqkXy9oLTc3NTFsIERERCQJqZlZMMt8NRRaLs9ChuJVmbl4dZ/VFxE3se6vqHzts4mvC94OqIAmvi4ob28Je0szmHCSTyJJY8eYjIq2e4ACa7uXQCREREQkBU0WHYSJpU2OEjNMPX2oQPuyMDPBwMbemNO9NjvCRKWMpC+TLVmyBI0bN4a9vT3c3Nzw9ttv48aNG2pt0tPTMXr0aLi6usLOzg59+vRBbGzBZgWksk2p1L5+ca8GFQ0cCZF+mAOJyFiVtvzXta4nTn3cAZfmdMa8nnXYKSYqhSTdMT569ChGjx6NyMhIREREQC6Xo3PnzkhJSVG1mTBhAnbt2oUtW7bg6NGjePz4MXr37l2CUZNUnX3wQmt5Z39eMSZpYg4kImNVmvKfg5UZxnaoBncHK1iZmxr8+YmoaEh6KPW+ffvUHm/YsAFubm44e/YsWrdujcTERHz//fcIDw9H+/btAQDr169HrVq1EBkZiWbNmpVE2CRRF6MTtJbzW12SKuZAIjJWUs5/NT3sEVjbAwDgbGOODrXc4e1ik8dWRCR1ku4Yvy4x8dWMwi4uLgCAs2fPQi6Xo2PHjqo2NWvWRKVKlXDy5EmdSTEjIwMZGRmqx0lJr2YWlMvlqp/sx1Ikz/ovPsgB7aODS5Qc/8WXJQck9Bb++W+MRlmgv5vkjrPUf/8A6cf4enxSjTO/iiIH5pX/sv+f81+pyc6BMlMJJkD8f1xSy4HZJH98GV+haItPqrHmR3Hmv+putjCzsgUACAi8fJkMe3s7yKD+xbmTjTneDvBCnzcqaOzbUO9xacl/CqGQ5O9dafz8SklpjC8/scqEENL8ZL1GqVSiR48eSEhIwN9//w0ACA8Px9ChQ9WSHAA0adIE7dq1w9KlS7Xua+7cuZg3b55GeXh4OGxs+I1fWTXupOb3QO29lOjpoyyBaMiQUlNTERISgsTERDg4lM6lMooqBzL/ERmf0p4Dmf+IqKDyk/9KzRXj0aNH48qVK6qEWBgzZszAxIkTVY+TkpLg7e2Nzp07w8HBAXK5HBEREejUqRPMzc0L/XxFTR4vR0RkBDqldYK5jQTjS5UjwjoCnZp1grmLdOIbd3K/Rtm7HRuhVTVprWEs9d8/QPoxvh5f9lWB0qyocmBe+Q8oBcf3vxz4+JAthEJ6t0LITAW82qdILgdmk/zxZXyFoi2+0p4Dmf/+X2nJf34OAajR0qukw9Eg+ePL+AqlsPmvVHSMx4wZgz/++APHjh1DxYr/P4Owh4cHMjMzkZCQACcnJ1V5bGwsPDw8dO7P0tISlpaWGuXm5uZqB/n1x5Lx31EzhznMZRKM7z/mZtJ5/6Kep2gtr1fJRTIxvk6yv385SD3G7PikHKM+ijIH6pv/dJVJwn85UChkEFnSOzHMJqUcqI1kj+9/GF/h5IxPynHmhfnvNaUk/5nKTKX5/v1Hssf3P4yvcAqa/yQ9K7UQAmPGjMH27dtx6NAh+Pr6qtU3bNgQ5ubmOHjwoKrsxo0bePDgAZo3b27ocEnC2q04orXc1dbCsIEQ5QNzIBEZK+Y/IjI0SV8xHj16NMLDw/H777/D3t4eMTGvJk9ydHSEtbU1HB0d8f7772PixIlwcXGBg4MDPvroIzRv3pyzsZJeZDLpfttKxBxIRMaK+Y+IDE3SHeM1a9YAANq2batWvn79egwZMgQA8MUXX8DExAR9+vRBRkYGAgMDsXr1agNHSlKmVGqfX27Hh/zDSdLGHEhExor5j4gMTdIdY30mzLayssKqVauwatUqA0REpdF5HesX+3vaGzYQonxiDiQiY8X8R0SGJul7jImKwuwdV7SWcxg1EREREREB7BiTEbj6pHQvU0FERERERMWLHWMySuNqZ5V0CEREREREJBHsGFOZpmvirSoOBg6EiIiIiIgkix1jKtM4jJqIiIiIiPLCjjGVWUIIvL3qeEmHQUREREREEseOMZVZ9+NSkaVlKLWvq00JRENERERERFLFjjGVWb9feKy1fHTbKgaOhIiIiIiIpIwdYyqzzke/0FoeVMfDwJEQEREREZGUsWNMZdbZ+5od47Y1ysPCjL/2RERERET0/9hDoDLrZbrmWsVv1ebVYiIiIiIiUseOMZVJutYvblbF1cCREBERERGR1LFjTGXS3ecpWssrl7M1cCRERERERCR17BhTmdRl5V8lHQIREREREZUS7BhTmZSpUJZ0CEREREREVEqwY0xlTmaW9k7x1yENDBwJERERERGVBuwYU5mjbZkmAOha19PAkRARERERUWlgVtIBFJVVq1Zh+fLliImJQf369fHVV1+hSZMm+drHwWuxsLVLhUKhwOV4GSyuPYWpqWkxRVxwipf/xZf5FKbpEowvQ4HLaTJY3HoK0xjDxzfy57Nay2UymYEjITKMosh/RESlFXMgERWFMtEx3rx5MyZOnIi1a9eiadOmCA0NRWBgIG7cuAE3Nze99zPulwswsbT575EpvrtxoVjiLRqm+A4XSjqIXEjr/etQU//fA6LSpKjyHxFRacQcSERFpUx0jD///HOMGDECQ4cOBQCsXbsWu3fvxg8//IDp06drtM/IyEBGRobqcWJiIgBAmZFqmIDJ4OxNMhAXFwcAkMvlSE1NRVxcHMzNzUs4Mk1Sjw+Qfoyvx/fy5UsAgBDa17cuzYoq/8XHx0MulwMoBcf3xav40rMAoZTeSBBZlnj1/r2Igzkk+P5J/fgyvkLRFh9z4CvMf8UvO/8lmiYgLs66pMPRIPnjy/gKpdD5T5RyGRkZwtTUVGzfvl2tfNCgQaJHjx5at5kzZ44AwB/+8MfIfqKjow2QlQyH+Y8//OFPfn6MPQcy//GHP8b7o0/+K/VXjJ8/fw6FQgF3d3e1cnd3d1y/fl3rNjNmzMDEiRNVj5VKJeLj4+Hq6gqZTIakpCR4e3sjOjoaDg4OxRp/QTC+wmF8hSf1GF+PTwiBly9fwsvLq6RDK1LFkf+A0nd8pYbxFQ7jKxxt8TEHvsL8V/wYX+EwvsIpbP4r9R3jgrC0tISlpaVamZOTk0Y7BwcHSR70bIyvcBhf4Uk9xpzxOTo6lnA00qBv/gNK1/GVIsZXOIyvcF6PjzmQ+c+QGF/hML7CKWj+K/XLNZUrVw6mpqaIjY1VK4+NjYWHh0cJRUVEVPyY/4jImDEHElFRKvUdYwsLCzRs2BAHDx5UlSmVShw8eBDNmzcvwciIiIoX8x8RGTPmQCIqSmViKPXEiRMxePBgNGrUCE2aNEFoaChSUlJUMxTml6WlJebMmaMx3EYqGF/hML7Ck3qMUo+vKBV1/gOk//4xvsJhfIXD+KSF54DSwvgKh/EVTmHjkwlRNubu//rrr1WLuwcEBODLL79E06ZNSzosIqJix/xHRMaMOZCIikKZ6RgTERERERERFUSpv8eYiIiIiIiIqDDYMSYiIiIiIiKjxo4xERERERERGTV2jImIiIiIiMiosWNcBKKiotCuXTv4+/ujbt26SElJKemQ1FSuXBn16tVDQEAA2rVrV9LhaJWamgofHx9Mnjy5pENRk5CQgEaNGiEgIAB16tTBunXrSjokNdHR0Wjbti38/f1Rr149bNmypaRD0tCrVy84Ozujb9++xf5cx44dQ/fu3eHl5QWZTIYdO3YU+3MaO+a/wmP+KxjmP3XMf4bH/Fd4Us1/AHNgUTBUDiyq/Fcm1jEuaUOGDMHChQvRqlUrxMfHS3JtrxMnTsDOzq6kw9Bp0aJFaNasWUmHocHe3h7Hjh2DjY0NUlJSUKdOHfTu3Ruurq4lHRoAwMzMDKGhoQgICEBMTAwaNmyILl26wNbWtqRDUxk3bhyGDRuGH3/8sdifKyUlBfXr18ewYcPQu3fvYn8+Yv4rCsx/BcP8p475z/CY/wpPqvkPYA4sCobKgUWV/9gxLqR///0X5ubmaNWqFQDAxcWlhCMqfW7duoXr16+je/fuuHLlSkmHo8bU1BQ2NjYAgIyMDAghIKUVzjw9PeHp6QkA8PDwQLly5RAfHy+ppNi2bVscOXLEIM8VFBSEoKAggzwXMf8VBea/gmP+U8f8Z1jMf4Un5fwHMAcWBUPlwKLKf2V+KLU+l9ZXrVqFypUrw8rKCk2bNsXp06f13v+tW7dgZ2eH7t2744033sDixYslFR8AyGQytGnTBo0bN0ZYWJjk4ps8eTKWLFmSr20MGV9CQgLq16+PihUrYsqUKShXrpyk4st29uxZKBQKeHt7SzI+MjzmP+Y/5j9pxEeGx/xXtvOfoWJkDjSuHFjmO8bZl9ZXrVqltX7z5s2YOHEi5syZg3PnzqF+/foIDAzE06dPVW2y7y14/efx48fIysrCX3/9hdWrV+PkyZOIiIhARESEZOIDgL///htnz57Fzp07sXjxYly6dEky8f3++++oXr06qlevrndMhowPAJycnHDx4kVERUUhPDwcsbGxkooPAOLj4zFo0CB8++23esdmyPioZDD/Mf8x/5V8fFQymP/Kdv4zRIwAc6DR5UBhRACI7du3q5U1adJEjB49WvVYoVAILy8vsWTJEr32eeLECdG5c2fV42XLlolly5ZJJr7XTZ48Waxfv14y8U2fPl1UrFhR+Pj4CFdXV+Hg4CDmzZsnmfhe9+GHH4otW7ZIKr709HTRqlUr8dNPPxUoruKOTwghDh8+LPr06VOo+PJL2+sxZsx/zH/Mf4aPTwjmPylg/ivb+a+4Ynwdc2DpyIGFyX9l/opxbjIzM3H27Fl07NhRVWZiYoKOHTvi5MmTeu2jcePGePr0KV68eAGlUoljx46hVq1akokvJSUFL1++BAAkJyfj0KFDqF27tmTiW7JkCaKjo3Hv3j2sWLECI0aMwCeffCKZ+GJjY1XvX2JiIo4dO4YaNWpIJj4hBIYMGYL27dvjvffeK5K4ijI+ki7mv5KPj/mP+Y9KBvNfycdXnPmvqGJkDjS+HGjUk289f/4cCoUC7u7uauXu7u64fv26XvswMzPD4sWL0bp1awgh0LlzZ3Tr1k0y8cXGxqJXr14AAIVCgREjRqBx48aSia84FUV89+/fx8iRI1UTLnz00UeoW7euZOI7fvw4Nm/ejHr16qnuDfn555+LJMaiOr4dO3bExYsXkZKSgooVK2LLli1o3rx5oeOjwmH+K/n4ihPzX8nHBzD/SRXzX8nHV9yYA0s+PqD05UCj7hgXFSnPBFmlShVcvHixpMPQy5AhQ0o6BA1NmjTBhQsXSjoMnVq2bAmlUlnSYeTqwIEDBnuu5ORk3L59W/U4KioKFy5cgIuLCypVqmSwOIwJ81/RYP7LP+Y/dcx/hsf8VzSkmP8A5sCiYKgcWFT5z6g7xuXKlYOpqanGjfSxsbHw8PAooaj+H+MrHMZXOFKPT5t//vkH7dq1Uz2eOHEiAGDw4MHYsGFDCUUlTVI/voyvcBhf4Ug9Pm2Y//Qn9ePL+ApP6jEyvqJVVPnPqO8xtrCwQMOGDXHw4EFVmVKpxMGDByVxmZ/xFQ7jKxypx6dN27ZtVUOecv7wpFCT1I8v4yscxlc4Uo9PG+Y//Un9+DK+wpN6jIyvaBVV/ivzV4zzurQ+ceJEDB48GI0aNUKTJk0QGhqKlJQUDB06lPExPsZXwvFR4Uj9+DI+xsf4qLhI/fgyvrIfI+MrhQo0l3UpcvjwYQFA42fw4MGqNl999ZWoVKmSsLCwEE2aNBGRkZGMj/ExPgnER4Uj9ePL+Bgf46PiIvXjy/jKfoyMr/SRCSGEvp1oIiIiIiIiorLGqO8xJiIiIiIiImLHmIiIiIiIiIwaO8ZERERERERk1NgxJiIiIiIiIqPGjjEREREREREZNXaMiYiIiIiIyKixY0xERERERERGjR1jIiIiIiIiMmrsGBMREREREZFRY8eYiIiIiIiIjBo7xlQqDRkyBDKZTOPn9u3banUWFhaoWrUq5s+fj6ysLADAkSNH1LYpX748unTpgsuXL5fwqyIi0g9zIBEZK+Y/Ki7sGFOp9dZbb+HJkydqP76+vmp1t27dwqRJkzB37lwsX75cbfsbN27gyZMn+PPPP5GRkYGuXbsiMzOzJF4KEVG+MQcSkbFi/qPiwI4xlVqWlpbw8PBQ+zE1NVWr8/HxwYcffoiOHTti586datu7ubnBw8MDb7zxBsaPH4/o6Ghcv35dVd+2bVuMHTsWU6dOhYuLCzw8PDB37lxDvkQiIp2YA4nIWDH/UXFgx5iMgrW1tc5vAhMTE/HLL78AACwsLNTqfvzxR9ja2uLUqVNYtmwZ5s+fj4iIiGKPl4ioKDEHEpGxYv4jfbFjTKXWH3/8ATs7O9VPv379NNoIIXDgwAH8+eefaN++vVpdxYoVYWdnBycnJ4SHh6NHjx6oWbOmWpt69ephzpw5qFatGgYNGoRGjRrh4MGDxfq6iIj0wRxIRMaK+Y+Kg1lJB0BUUO3atcOaNWtUj21tbVX/z06YcrkcSqUSISEhGkNg/vrrL9jY2CAyMhKLFy/G2rVrNZ6jXr16ao89PT3x9OnTon0hREQFwBxIRMaK+Y+KAzvGVGrZ2tqiatWqWuuyE6aFhQW8vLxgZqb5q+7r6wsnJyfUqFEDT58+xYABA3Ds2DG1Nubm5mqPZTIZlEpl0b0IIqICYg4kImPF/EfFgUOpqUzKTpiVKlXSmhBfN3r0aFy5cgXbt283QHRERMWLOZCIjBXzHxUUO8ZEAGxsbDBixAjMmTMHQoiSDoeIyKCYA4nIWDH/UTZ2jIn+M2bMGFy7dg1btmwp6VCIiAyOOZCIjBXzHwGATPCrESIiIiIiIjJivGJMRERERERERo0dYyIiIiIiIjJq7BgTERERERGRUWPHmIiIiIiIiIwaO8ZERERERERk1NgxJiIiIiIiIqPGjjEREREREREZNXaMiYiIiIiIyKixY0xERERERERGjR1jIiIiIiIiMmrsGBMREREREZFRY8eYiIiIiIiIjBo7xkRERERERGTU2DEmIiIiIiIio8aOMRERERERERk1doyJiIiIiIjIqLFjTEREREREREaNHWMqlZRKJerUqYNFixaVdChUQNOnT0fTpk1LOgwiyevSpQtGjBhR0mGQATRr1gxTp04t6TCIiIwSO8YGsGHDBshkMvzzzz8lHUqxi42NxahRo1ChQgVYWVmhcuXKeP/999XabN++HYGBgfDy8oKlpSUqVqyIvn374sqVK3o/z6ZNmxAdHY0xY8aold+6dQvBwcGoWLEibGxsULNmTcyfPx+pqalq7eRyOebNm4cqVarA0tISVapUwcKFC5GVlaXW7tGjR+jatSscHBzg7++PXbt2acTy22+/wc3NDYmJiXrHn23q1KmQyWQYMGCA1vojR45AJpNh69atWuvHjBkDmUymVla5cmXIZDLVj5ubG1q1aoXt27ertWvbti3q1KmjdduOHTtqfb5169ap9qvt9/n48ePo1asX3N3dYWlpicqVK2PUqFF48OCBRtvx48fj4sWL2Llzp9bnotLHGHJddHQ05s2bhyZNmsDZ2RnlypVD27ZtceDAAY22Bw8exLBhw1C9enXY2NigSpUqGD58OJ48eaL38x0/fhz79+/HtGnT1MoXLVqEHj16wN3dHTKZDHPnztW5j0ePHqF///5wcnKCg4MDevbsibt372pt+/3336NWrVqwsrJCtWrV8NVXX2mN6Y033oC9vT3atm2L69eva7QZO3YsAgMD9X6dOTVp0gQymQxr1qzRWj937lzIZDI8f/5ca32dOnXQtm1b1eN79+6p5URTU1NUqlQJvXr1woULF9S2lclkan9Xcm67cOFCrc/3zjvvQCaTwc7OTqNOCIGff/4ZrVu3hpOTE2xsbFC3bl3Mnz8fKSkpGu2nTZuGVatWISYmRutzERFR8WHHmIpMdHQ0GjdujL179+KDDz7A6tWrMXz4cDx79kyt3eXLl+Hs7Ixx48Zh9erV+PDDD3H+/Hk0adIEFy9e1Ou5li9fjuDgYDg6Oqo9f5MmTRAZGYkx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", + "text/plain": [ + "
" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fig, axes = plt.subplots(2, 3, figsize=(10, 5), layout=\"tight\")\n", + "\n", + "for ax, index in zip(axes.flatten(), samples, strict=False):\n", + " score = aupimo_result.aupimos[index].item()\n", + " tpr = pimo_result.per_image_tprs[index]\n", + " fpr = pimo_result.shared_fpr\n", + " lower_bound, upper_bound = aupimo.fpr_bounds\n", + " threshs_auc_mask = (pimo_result.thresholds > aupimo_result.thresh_lower_bound) & (\n", + " pimo_result.thresholds < aupimo_result.thresh_upper_bound\n", + " )\n", + " fpr_in_auc = fpr[threshs_auc_mask]\n", + " tpr_in_auc = tpr[threshs_auc_mask]\n", + "\n", + " plot_pimo_with_auc_zone(ax, tpr, fpr, lower_bound, upper_bound, fpr_in_auc, tpr_in_auc)\n", + " ax.set_title(f\"Image {index} ({score:.0%} AUPIMO)\")\n", + "\n", + "axes[-1, -1].axis(\"off\")\n", + "axes[-1, -1].text(\n", + " -0.08,\n", + " 0,\n", + " \"\"\"\n", + "FPRn: Avg. [in-image] False Positive Rate (FPR)\n", + " on normal images only ('n').\n", + "\n", + "TPR: [in-image] True Positive Rate (TPR),\n", + " or Recall.\n", + "\n", + "Integration zone in light pink, and area\n", + "under the curve (AUC) in purple.\n", + "\n", + "This area is normalized by the range size\n", + "so that AUPIMO is in [0, 1].\n", + "\"\"\",\n", + " ha=\"left\",\n", + " va=\"bottom\",\n", + " fontsize=\"x-small\",\n", + " color=\"dimgray\",\n", + " font=\"monospace\",\n", + ")\n", + "\n", + "fig.suptitle(\"PIMO curves\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Meaning of the FPRn bounds\n", + "\n", + "AUPIMOo only uses _normal images_ in the X-axis -- i.e. the $\\operatorname{FPRn}$.\n", + "\n", + "**Why?** \n", + "\n", + "Because the integration range is a validation\\* of \"usable operating thresholds\", so using $\\operatorname{FPRn}$ makes it unbiased (to the anomalies).\n", + "\n", + "> Recall that, in practice, a threshold is set to decide if a pixel/image is anomalous.\n", + "> \n", + "> This strategy was inspired on [AUPRO](https://link.springer.com/article/10.1007/s11263-020-01400-4).\n", + "\n", + "---\n", + "\n", + "**Definition 1**: Average FPR on Normal Images ($\\operatorname{FPRn}$):\n", + "\n", + "$$\n", + " \\operatorname{FPRn} : t \\mapsto \\frac{1}{N} \\sum_{i=1}^{N} \\; \\times \\; \\operatorname{FPR}^{i}(t)\n", + "$$\n", + "\n", + "where $i$ and $N$ are, respectively, the index and the number of normal images in the test set. Note that $\\operatorname{FPRn}$ is the empirical average of $\\operatorname{FPR}^{i}$, so \n", + "\n", + "$$\n", + " \\operatorname{FPRn} \\approx \\mathbb{E} \\left[ \\operatorname{FPR}^{i} \\right]\n", + "$$\n", + "\n", + "**Defintion 2**: FPR of the $i$-th normal image ($\\operatorname{FPR}^{i}$): \n", + "\n", + "$$\n", + " \\operatorname{FPR}^{i} : t \\mapsto \\frac{\\text{Area of } \\mathbb{a}^{i} \\text{ above } t}{\\text{Area of } \\mathbb{a}^{i}}\n", + "$$\n", + "\n", + "where $\\mathbb{a}^{i}$ is the anomaly score map of the $i$-th image.\n", + "\n", + "---\n", + "\n", + "No further ado, let's visualize this $\\operatorname{FPRn}$!\n", + "\n", + "> For more details on this topic, check our paper in the last cell." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visualizing the FPR of normal images ($\\operatorname{FPR}^{i}$)\n", + "\n", + "$\\operatorname{FPRn}$ is the average of $\\operatorname{FPR}^{i}$, so let's first visualize the latter." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# visalization of $FPR^i$\n", + "# since normal images do not have anomalous pixels\n", + "# their FPR actually correspond to the ratio of pixels\n", + "# (wrongly) classified as anomalous\n", + "\n", + "# we'll visualize 3 levels of FPR^(i) on some normal images\n", + "FRP_levels = [1e-2, 1e-3, 1e-4]\n", + "# technical detail: decreasing order of FPR --> increasing order of threshold\n", + "\n", + "\n", + "def threshold_from_fpr(anomaly_map: Tensor, fpr_level: float | Tensor) -> float:\n", + " \"\"\"Find the threshold that corresponds to the given FPR level.\n", + "\n", + " Args:\n", + " anomaly_map (torch.Tensor): Anomaly map, assumed to be from a normal image.\n", + " fpr_level (float): Desired FPR level.\n", + "\n", + " Returns:\n", + " float: Threshold such that `(anomaly_map > threshold).mean() == fpr_level`.\n", + " \"\"\"\n", + " # make a dicothomic search\n", + " lower, upper = anomaly_map.min(), anomaly_map.max() # initial bounds\n", + " middle = (lower + upper) / 2\n", + " fpr_level = torch.tensor(fpr_level)\n", + "\n", + " def fpr(threshold: Tensor) -> Tensor:\n", + " return (anomaly_map > threshold).float().mean()\n", + "\n", + " while not torch.isclose(fpr(middle), fpr_level, rtol=1e-2):\n", + " if torch.isclose(lower, upper, rtol=1e-3):\n", + " break\n", + " if fpr(middle) < fpr_level:\n", + " upper = middle\n", + " else:\n", + " lower = middle\n", + " middle = (lower + upper) / 2\n", + " return middle.item()\n", + "\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(13, 5), layout=\"constrained\")\n", + "\n", + "# select normal images with low and high mean anomaly scores\n", + "avg_anom_score_per_image = anomaly_maps.mean(dim=(1, 2))\n", + "# get the indices of the normal images sorted by their mean anomaly score\n", + "argsort = avg_anom_score_per_image.sort().indices\n", + "argsort = argsort[torch.isin(argsort, torch.where(labels == 0)[0])]\n", + "# select first, median and last\n", + "normal_images_selection = argsort[[0, len(argsort) // 2, -1]]\n", + "\n", + "# heatmaps will be normalized across *normal* images\n", + "# so the range of thresholds have an exact mapping to the range of [0, 1] in FPRn\n", + "# PS: it is not exactly true because we don't get a min-max, but a quantile-based normalization\n", + "global_normal_vmin, global_normal_vmax = torch.quantile(anomaly_maps[labels == 0], torch.tensor([0.02, 0.98]))\n", + "\n", + "for ax, index in zip(axes, normal_images_selection, strict=False):\n", + " image = cv2.resize(read_image(image_paths[index]), (256, 256))\n", + " anomaly_map = anomaly_maps[index]\n", + " thresholds = [threshold_from_fpr(anomaly_map, fpr_level) for fpr_level in FRP_levels]\n", + " anomaly_map = anomaly_map.numpy()\n", + "\n", + " ax.imshow(image)\n", + " ax.imshow(anomaly_map, cmap=\"jet\", alpha=0.10, vmin=global_normal_vmin, vmax=global_normal_vmax)\n", + " c = ax.contour(anomaly_map, levels=thresholds, linewidths=1, colors=[\"blue\", \"yellow\", \"red\"])\n", + " ax.set_title(f\"image {index}\")\n", + "\n", + "for ax in axes.flatten():\n", + " ax.set_xticks([])\n", + " ax.set_yticks([])\n", + "\n", + "fig.text(\n", + " 0.03,\n", + " -0.01,\n", + " \"Anomaly maps colored in JET colormap with min-max normalization across all normal images. \"\n", + " \" $\\\\operatorname{FPR}^{i}$ levels: \"\n", + " f\"Blue = {fmt_pow10(FRP_levels[0])} Yellow = {fmt_pow10(FRP_levels[1])} Red = {fmt_pow10(FRP_levels[2])}\",\n", + " ha=\"left\",\n", + " va=\"top\",\n", + " color=\"dimgray\",\n", + ")\n", + "\n", + "fig.suptitle(\"Contours of $\\\\operatorname{FPR}^{i}$ levels on normal samples from the test set\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A few notes about the different FPR levels:\n", + "- $10^{-2}$ (blue): images have many and/or quite visible false positive regions;\n", + "- $10^{-3}$ (yellow): most regions disappear, but a few are still visible; \n", + "- $10^{-4}$ (red): usually one or two regions, barely visible." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visualizing the Average FPR on Normal Images ($\\operatorname{FPRn}$)\n", + "\n", + "Let's now visualize the $\\operatorname{FPRn}$ and the variance of $\\operatorname{FPR}^{i}$ across the normal images." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# visalization of $FPRn$\n", + "# this one is an average behavior of the previous\n", + "# so one should expect a similar behavior but with\n", + "# some variations at each FPR level\n", + "\n", + "# we'll visualize the same FPR levels\n", + "FRP_levels = [1e-2, 1e-3, 1e-4]\n", + "# technical detail: decreasing order of FPR --> increasing order of threshold\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(14, 5.2), layout=\"constrained\")\n", + "\n", + "# function `threshold_from_fpr()` is replaced by an equivalent function\n", + "# for FPRn is already implemented in `pimo_result.thresh_at`\n", + "thresholds = [pimo_result.thresh_at(fpr_level)[1] for fpr_level in FRP_levels]\n", + "# note that all images used the same (ie 'shared') thresholds now\n", + "\n", + "# `normal_images_selection` is the same from the previous cell\n", + "for ax, index in zip(axes, normal_images_selection, strict=False):\n", + " image = cv2.resize(read_image(image_paths[index]), (256, 256))\n", + " anomaly_map = anomaly_maps[index]\n", + " fprs = [(anomaly_map > threshold).float().mean() for threshold in thresholds]\n", + " anomaly_map = anomaly_map.numpy()\n", + "\n", + " ax.imshow(image)\n", + " # `global_normal_vmin` and `global_normal_vmax` are the same from the previous cell\n", + " ax.imshow(anomaly_map, cmap=\"jet\", alpha=0.10, vmin=global_normal_vmin, vmax=global_normal_vmax)\n", + " c = ax.contour(anomaly_map, levels=thresholds, linewidths=1, colors=[\"blue\", \"yellow\", \"red\"])\n", + " ax.set_title(f\"image {index}\")\n", + "\n", + " ax.annotate(\n", + " \"$\\\\operatorname{FPR}^{i}$ levels: \"\n", + " f\"Blue = {fprs[0] * 100:.1g}% Yellow = {fprs[1] * 100:.1g}% Red = {fprs[2] * 100:.1g}%\",\n", + " xy=(0.01, 0.01),\n", + " xycoords=\"axes fraction\",\n", + " ha=\"left\",\n", + " va=\"bottom\",\n", + " color=\"white\",\n", + " )\n", + "\n", + "for ax in axes.flatten():\n", + " ax.set_xticks([])\n", + " ax.set_yticks([])\n", + "\n", + "fig.text(\n", + " 0.03,\n", + " -0.01,\n", + " \"Anomaly maps colored in JET colormap with min-max normalization across all normal images. \"\n", + " \" $\\\\operatorname{FPRn}$ levels: \"\n", + " f\"Blue = {fmt_pow10(FRP_levels[0])} Yellow = {fmt_pow10(FRP_levels[1])} Red = {fmt_pow10(FRP_levels[2])}\",\n", + " ha=\"left\",\n", + " va=\"top\",\n", + " color=\"dimgray\",\n", + ")\n", + "\n", + "fig.suptitle(\"Contours of $\\\\operatorname{FPRn}$ levels on normal samples from the test set\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Discussion\n", + "\n", + "#### Variance\n", + "\n", + "Note that each $\\operatorname{FPR}^{i}$ has a wide variance\\* of visual results across images.\n", + " \n", + "For instance, the blue level ranges from 0.2% to 3%, which visually is a huge difference, and the red level doesn't even show in most images.\n", + "\n", + "This variance is specific to each model-dataset, we observed many state-of-the-art models on the datasets from MVTec-AD and VisA, and we noticed that low levels tend to have a negligible visual variance.\n", + "\n", + "#### Default bounds (L and U)\n", + "\n", + "So how were the default bounds chosen?\n", + "\n", + "> Recall: \n", + "> \n", + "> $$\n", + "> \\text{AUPIMO} \n", + "> \\; = \\; \n", + "> \\frac{1}{\\log(U/L)}\n", + "> \\int_{\\log(L)}^{\\log(U)} \n", + "> \\operatorname{TPR}^{i}\\left( \\operatorname{FRPn^{-1}}( z ) \\right)\n", + "> \\, \n", + "> \\mathrm{d}\\log(z) \n", + "> $$\n", + "\n", + "##### Upper bound U = 10^{-4}\n", + "\n", + "The upper bound $U$ sets the requirement level of the detection task.\n", + "\n", + "The lower the $U$, the harder the task, and ideally we'd like it be zero (i.e. anomalies are detected with no false positives).\n", + "\n", + "Compared to the images' content, the regions at $\\operatorname{FPRn} = 10^{-4}$ are _visually negligible_\\*.\n", + " \n", + "##### Lower bound L = 10^{-5}\n", + "\n", + "The lower bound $L$ has two numerical motivations.\n", + "\n", + "First, AUPIMO's integral is in log scale, so necessarily $L > 0$ and more weight is given to lower FPR levels.\n", + "\n", + "Second, images/masks/anomaly maps have finite resolution ($\\approx 10^{6}$ pixels/image\\*) -- so $\\operatorname{FPR}^{i}$ and $\\operatorname{FPRn}$ have discrete ranges.\n", + "\n", + "At $\\operatorname{FPRn} = 10^{-5}$, the discretization effects are still reasonable.\n", + "\n", + "##### Be careful!\n", + "\n", + "\\* These observations are based on the datasets we analyzed (from MVTec-AD and VisA).\n", + "\n", + "For other datasets, the default bounds may not be the best choice.\n", + "\n", + "Fortunately, AUPIMO allows customizing the bounds!\n", + "\n", + "> More details on these topics in our paper (see the last cell)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Custom FPRn bounds\n", + "\n", + "It's very easy to customize the $\\operatorname{FPRn}$ bounds $L$ and $U$ in AUPIMO.\n", + "\n", + "You can guess from the signature:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[0;31mInit signature:\u001b[0m\n", + "\u001b[0mAUPIMO\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\u001b[0m\n", + "\u001b[0;34m\u001b[0m \u001b[0mnum_thresholds\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mint\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m300000\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n", + "\u001b[0;34m\u001b[0m \u001b[0mfpr_bounds\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mtuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mfloat\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfloat\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0;36m1e-05\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m0.0001\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n", + "\u001b[0;34m\u001b[0m \u001b[0mreturn_average\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mbool\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mTrue\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n", + "\u001b[0;34m\u001b[0m \u001b[0mforce\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mbool\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mFalse\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n", + "\u001b[0;34m\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mDocstring:\u001b[0m \n", + "Area Under the Per-Image Overlap (PIMO) curve.\n", + "\n", + "This torchmetrics interface is a wrapper around the functional interface, which is a wrapper around the numpy code.\n", + "The tensors are converted to numpy arrays and then passed and validated in the numpy code.\n", + "The results are converted back to tensors and wrapped in an dataclass object.\n", + "\n", + "Scores are computed from the integration of the PIMO curves within the given FPR bounds, then normalized to [0, 1].\n", + "It can be thought of as the average TPR of the PIMO curves within the given FPR bounds.\n", + "\n", + "Details: `anomalib.metrics.per_image.pimo`.\n", + "\n", + "Notation:\n", + " N: number of images\n", + " H: image height\n", + " W: image width\n", + " K: number of thresholds\n", + "\n", + "Attributes:\n", + " anomaly_maps: floating point anomaly score maps of shape (N, H, W)\n", + " masks: binary (bool or int) ground truth masks of shape (N, H, W)\n", + "\n", + "Args:\n", + " num_thresholds: number of thresholds to compute (K)\n", + " fpr_bounds: lower and upper bounds of the FPR integration range\n", + " force: whether to force the computation despite bad conditions\n", + "\n", + "Returns:\n", + " tuple[PIMOResult, AUPIMOResult]: PIMO and AUPIMO results dataclass objects. See `PIMOResult` and `AUPIMOResult`.\n", + "\u001b[0;31mInit docstring:\u001b[0m\n", + "Area Under the Per-Image Overlap (PIMO) curve.\n", + "\n", + "Args:\n", + " num_thresholds: [passed to parent `PIMO`] number of thresholds used to compute the PIMO curve\n", + " fpr_bounds: lower and upper bounds of the FPR integration range\n", + " return_average: if True, return the average AUPIMO score; if False, return all the individual AUPIMO scores\n", + " force: if True, force the computation of the AUPIMO scores even in bad conditions (e.g. few points)\n", + "\u001b[0;31mFile:\u001b[0m ~/miniconda3/envs/anomalib-dev/lib/python3.10/site-packages/anomalib/metrics/pimo/pimo.py\n", + "\u001b[0;31mType:\u001b[0m ABCMeta\n", + "\u001b[0;31mSubclasses:\u001b[0m " + ] + } + ], + "source": [ + "AUPIMO?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's recompute the scores with the following situation: \n", + "- $U = 10^{-2}$ to make the detection task easier;\n", + "- $L = 10^{-4}$ assuming that \"small\" anomalies are not important for the application." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Metric `AUPIMO` will save all targets and predictions in buffer. For large datasets this may lead to large memory footprint.\n" + ] + } + ], + "source": [ + "aupimo_custom = AUPIMO(\n", + " # with `False` all the values are returned in a dataclass\n", + " return_average=False,\n", + " # customized!\n", + " fpr_bounds=(1e-4, 1e-2),\n", + ")\n", + "\n", + "# we already have all of them in concatenated tensors\n", + "# so we don't need to loop over the batches like before\n", + "aupimo_custom.update(anomaly_maps=anomaly_maps, masks=masks)\n", + "pimo_result_custom, aupimo_result_custom = aupimo_custom.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA8YAAAHuCAYAAABd8RWzAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzdd1hT1xsH8G/YeyogoIJ7770VcY/WLbaOutqfe1U7rFXrHlVbd50V1Kp114Grtu49Km7ciiAiG0I4vz9oUkISCPtCvp/n4dGcc+7NG5K83HPvuefIhBACRERERERERAbKKL8DICIiIiIiIspP7BgTERERERGRQWPHmIiIiIiIiAwaO8ZERERERERk0NgxJiIiIiIiIoPGjjEREREREREZNHaMiYiIiIiIyKCxY0xEREREREQGjR1jIiIiIiIiMmjsGBMREREREZFBY8eYiIgMxsaNGyGTyVQ/FhYWKFeuHEaOHImQkBBVu1OnTkEmk2Hnzp1at/3777819i2EQPHixSGTydCpUyeN+piYGMycORPVqlWDlZUV7O3t0bRpU2zevBlCiNx5wURERKQXk/wOgIiIKK/NmDED3t7eiI+Px99//42VK1fijz/+wO3bt2FlZZXuthYWFggICECTJk3Uyv/880+8ePEC5ubmGtuEhITAx8cHQUFB6NOnD0aOHIn4+Hjs2rULAwYMwB9//AF/f38YGxvn6OskIiIi/bBjTEREBqd9+/aoU6cOAGDIkCFwdnbG4sWLsXfvXvTt2zfdbTt06IAdO3Zg2bJlMDH5789oQEAAateujbCwMI1tBgwYgKCgIOzevRtdunRRlY8ePRqTJk3CwoULUbNmTUyePDmHXmHWxMfHw8zMDEZGHFBGRESGhX/5iIjI4LVq1QoAEBwcnGHbvn374t27dwgMDFSVJSYmYufOnfDz89Nof/78eRw5cgQDBw5U6xQrzZkzB2XLlsW8efMQFxeX4fMfOnQIzZs3h62tLezs7FC3bl0EBASo6r28vDBw4ECN7Vq0aIEWLVqoHiuHi2/btg3ffvstPDw8YGVlhatXr0Imk2HTpk0a+zhy5AhkMhkOHDigKnv58iU+++wzuLq6wtzcHJUrV8b69es1tv3pp59QuXJlWFlZwdHREXXq1FGLm4iIKD+xY0xERAbv0aNHAABnZ+cM23p5eaFhw4bYunWrquzQoUP48OED+vTpo9F+//79AID+/ftr3Z+JiQn8/Pzw/v17nDlzJt3n3rhxIzp27Ijw8HB89dVXmDt3LmrUqIHDhw9nGLcuM2fOxMGDBzFx4kTMnj0blSpVQqlSpfDbb79ptN2+fTscHR3Rtm1bAClDxBs0aIBjx45h5MiRWLp0KcqUKYPBgwdjyZIlqu3Wrl2L0aNHo1KlSliyZAmmT5+OGjVq4MKFC1mOm4iIKCdxKDURERmcDx8+ICwsDPHx8Thz5gxmzJgBS0tLrZNmaePn54evvvoKcXFxsLS0hL+/P5o3bw53d3eNtnfu3AEAVK9eXef+lHVBQUFo3bq1zphHjx6NevXq4dSpU7CwsFDVZWfyrvj4eFy+fBmWlpaqst69e2PhwoV4//49HB0dAaRcFd+9eze6desGU1NTAMA333wDhUKBW7duqU4qfP755+jbty++//57DB8+HJaWljh48CAqV66MHTt2ZDlOIiKi3MQrxkREZHBat26NokWLonjx4ujTpw9sbGywe/dueHh46LV9r169EBcXhwMHDiAqKgoHDhzQOowaAKKiogAAtra2OvenrIuMjNTZJjAwEFFRUZgyZYpapxgAZDKZXnFrM2DAALVOMZDSMZbL5fj9999VZUePHkVERAR69+4NIKUzvmvXLnTu3BlCCISFhal+2rZtiw8fPuDq1asAAAcHB7x48QKXLl3KcpxERES5iVeMiYjI4CxfvhzlypWDiYkJXF1dUb58+UxNOFW0aFG0bt0aAQEBiI2NhUKhQI8ePbS2VXZ6o6Ki4ODgoLWNPp1n5XDvKlWq6B2nPry9vTXKqlevjgoVKmD79u0YPHgwgJRh1EWKFFHdjx0aGoqIiAisWbMGa9as0brvt2/fAgAmT56MY8eOoV69eihTpgzatGkDPz8/NG7cOEdfCxERUVaxY0xERAanXr16qlmps8rPzw9Dhw7Fmzdv0L59e52d3ooVK2LPnj24efMmmjVrprXNzZs3AQCVKlXKVkyA7qvHCoVC63JQaa8WK/Xu3RuzZs1CWFgYbG1tsW/fPvTt21c1E3dycjIA4JNPPsGAAQO07qNatWoAUn4H9+7dw4EDB3D48GHs2rULK1aswHfffYfp06dn+jUSERHlNA6lJiIiyoKPP/4YRkZGOH/+vM5h1ABU9y1v3rxZa71CoUBAQAAcHR3TvYJaunRpAMDt27fTjcvR0REREREa5U+fPk13u7R69+6NpKQk7Nq1C4cOHUJkZKTa5GJFixaFra0tFAoFWrdurfXHxcVF1d7a2hq9e/fGhg0b8OzZM3Ts2BGzZs1CfHx8puIiIiLKDewYExERZYGNjQ1WrlyJ77//Hp07d9bZrlGjRmjdujU2bNigtsyR0jfffIP79+/jyy+/1Hn1FgDatGkDW1tbzJkzR6MzmXryrdKlS+P8+fNITExUlR04cADPnz/PzMtDxYoVUbVqVWzfvh3bt29HsWLF1K54Gxsbo3v37ti1a5fWznpoaKjq/+/evVOrMzMzQ6VKlSCEgFwuz1RcREREuYFDqYmIiLJI1xDitDZv3gwfHx907doVfn5+aNq0KRISEvD777/j1KlT6N27NyZNmpTuPuzs7PDjjz9iyJAhqFu3Lvz8/ODo6IgbN24gNjZWte7wkCFDsHPnTrRr1w69evXCo0ePsGXLFtUV58zo3bs3vvvuO1hYWGDw4MEa92HPnTsXJ0+eRP369TF06FBUqlQJ4eHhuHr1Ko4dO4bw8HAAKZ16Nzc3NG7cGK6urggKCsLPP/+Mjh07pntfNRERUV7hFWMiIqJcVqxYMVy8eBHfffcdrl27hrFjx+Kbb76BXC7Hxo0bsXXrVq33/6Y1ePBg7Nu3D3Z2dpg5cyYmT56Mq1evon379qo2bdu2xaJFi3D//n2MHTsW586dw4EDB+Dp6ZnpuHv37o3k5GTExsaqZqNOzdXVFRcvXsSgQYPw+++/q9YyDg8Px7x581Tthg8fjujoaCxevBgjRozAnj17MHr0aGzZsiXTMREREeUGmcjO4odEREREREREBRyvGBMREREREZFBY8eYiIiIiIiIDBo7xkRERERERGTQ2DEmIiIiIiIig8aOMRERERERERk0doyJiIiIiIjIoLFjTERERERERAaNHWMiIiIiIiIyaOwYExERERERkUFjx5iIiIiIiIgMGjvGREREREREZNDYMSYiIiIiIiKDxo4xERERERERGTR2jImIiIiIiMigsWNMREREREREBo0dYyIiIiIiIjJo7BgTERERERGRQWPHmIiIiIiIiAwaO8ZERERERERk0NgxJiIiIiIiIoPGjjEREREREREZNHaMiYiIiIiIyKCxY0xEREREREQGjR1jKpCio6Ph4uICf3///A6FsmjVqlUoUaIEEhIS8jsUIkmbP38+KlSogOTk5PwOhbLgzp07MDExwe3bt/M7FKJCgceABV+fPn3Qq1ev/A5DAzvGeWDjxo2QyWS4fPlyfoeS60JCQjB8+HB4eHjAwsICXl5eGDx4sFqb77//HjKZTOPHwsJC7+dZunQpbG1t0adPH1XZ6dOn0aVLFxQvXhwWFhZwc3NDu3btcObMGbVtY2NjsXz5crRp0wbFihWDra0tatasiZUrV0KhUKi1jYiIQL9+/eDo6IhSpUph3bp1GrFcvnwZVlZWCA4O1jt+pRUrVkAmk6F+/fpa6588eQKZTIaFCxdqrV+4cCFkMhmePHmiKmvRooXa79XJyQl169bF+vXr1Q6sBw4cCBsbG7X9KbctW7as1ucLDAxU7Xfnzp0a9f/88w8++eQTeHh4wNzcHO7u7ujXrx/++ecfjbYDBw5EYmIiVq9erfW5qHAzhLyofI26fvQ5qIuMjMS8efMwefJkGBn99yc7OjoaY8eOhaenJ8zNzVGxYkWsXLlS6z4CAwPRpEkTWFlZwdHRET169FDLGQAghMD06dPh4eEBFxcXjB07FomJiWptoqOj4eHhgYCAgEz/LoKCglR5PiIiQmsbLy8vdOrUSWvd5cuXIZPJsHHjRlVZ2r8lVlZWqFSpEr799ltERkaq2mn7rCm3NTIywvPnzzWeLzIyEpaWlpDJZBg5cqRG/bt37zBp0iSUL18eFhYWcHJyQtu2bXHgwAGNtpUqVULHjh3x3Xff6fr1UCFmCLkOgM48N3fuXI22x44dQ8uWLVGkSBE4ODigXr16+PXXX/V+Lm3HgIB+uS6tR48ewcLCQut7dOfOHTRt2hS2traoU6cOzp07p7H94sWLUblyZSQlJekdv1KvXr0gk8kwefJkrfUZfXY6deoELy8vtbLUv3sjIyO4u7ujTZs2OHXqlFo7bflWud2QIUO0Pt8333yjahMWFqZRf+DAAbRr1w7Ozs6wsLBAuXLlMHHiRLx7906j7eTJk7Fr1y7cuHFD63PlF5P8DoAKj+fPn6Nx48YAgM8//xweHh549eoVLl68qLX9ypUr1TpmxsbGej2PXC7H0qVLMW7cOLVt7t+/DyMjI3z++edwc3PD+/fvsWXLFjRr1gwHDx5Eu3btAACPHz/GqFGj4OPjg/Hjx8POzg5HjhzB//73P5w/fx6bNm1S7XPixIk4deoUpk+fjocPH2Lo0KGoWLEiGjVqBCDlYHL06NEYO3YsvL29M/cLA+Dv7w8vLy9cvHgRDx8+RJkyZTK9D208PT0xZ84cAEBoaCg2b96MwYMH4/79+1r/SKVmYWGBhw8f4uLFi6hXr55GvBYWFoiPj9fY7vfff0ffvn3h5OSEwYMHw9vbG0+ePMG6deuwc+dObNu2DR9//LHa8wwYMACLFy/GqFGjIJPJcuCVE0lHs2bNtB7s/fjjj7hx4wZ8fHwy3Mf69euRlJSEvn37qsoUCgXatm2Ly5cvY8SIEShbtqwqh71//x5ff/21qu2BAwfQtWtX1KpVC3PnzkVkZCSWLl2KJk2a4Nq1ayhatCiAlO/27NmzMXnyZFhbW2PWrFlwdXXFV199pdrXrFmz4OXlBT8/v0z/LrZs2aLKyzt37tR54JUVyr8l0dHROHr0KGbNmoUTJ07gzJkzGeYVc3NzbN26FV9++aVa+e+//65zm3v37sHHxwehoaEYNGgQ6tSpg4iICPj7+6Nz586YOHEiFixYoLbN559/jg4dOuDRo0coXbp01l8skYT5+vqif//+amU1a9ZUe7xv3z589NFHaNiwoeoE1W+//Yb+/fsjLCwM48aNS/c5dB0D6pvr0ho3bhxMTEw0Rq8pFAp069YNTk5OWLBgAfbt24euXbvi4cOHsLOzAwC8ffsWM2bMwG+//QYTk8x1qSIjI7F//354eXlh69atmDt3bo4dBynfByEEgoODsWLFCrRq1QoHDx5E+/bt093WwsICu3btwooVK2BmZqZWt3XrVp3HgBMnTsSiRYtQvXp1TJ48GU5OTrh69Sp+/vlnbNu2DcePH0f58uVV7WvWrIk6depg0aJF2Lx5c4687hwhKNdt2LBBABCXLl3K71ByVfv27YW3t7cICwtLt920adMEABEaGpql5/n9998FAPHw4cMM28bExAhXV1fRtm1bVVloaKi4ffu2RttBgwYJAOLBgweqMldXV7Fp0ybV4+bNm4spU6aoHv/666/C3d1dREVFZfp1PH78WAAQv//+uyhatKj4/vvvNdoEBwcLAGLBggVa97FgwQIBQAQHB6vFWLlyZbV2MTExwtPTU1hbW4vExEQhhBADBgwQ1tbWau2U25YvX16MHTtWrS4uLk7Y2dmJ7t27CwBix44dqrqHDx8KKysrUaFCBfH27Vu17UJDQ0WFChWEtbW1ePTokVrd5cuXBQBx/PhxHb8lKqwMJS+mFRsbK2xtbYWvr69e7atVqyY++eQTtbLffvtNABDr1q1TK+/evbuwsLAQISEhqrJKlSqJMmXKiISEBFXZ9evXhZGRkRg/fryqrHfv3mLQoEGqx9OmTRMNGjRQPX748KGwtLTM0vuVnJwsvLy8xPjx48XHH38sWrRoobVdyZIlRceOHbXWXbp0SQAQGzZsUItR29+Sbt26CQDi7NmzQgjtnzXltt26dRM1atTQeD5fX19VrhsxYoSqPDExUVSpUkVYWVmJ8+fPq22TlJQkevfuLQCIbdu2qdUlJiYKR0dHMXXqVK2vjwovQ8l1ab8ruvj6+gp3d3cRHx+vKpPL5aJ06dKiWrVqGW6v6xhQ31yX2uHDh4WZmZn49ttvNd6joKAgAUA8ffpUCJFyHGVpaSkOHz6sajN48GDRuXPnDGPWZv369cLU1FScOHFCABCnTp3SaJPRZ6djx46iZMmSamXa3oebN28KAKJNmzaqMm35FoD46KOPhJGRkdizZ49a3ZkzZwQAVV5MnXcDAgIEANG7d2+RlJSktt2FCxeElZWVqFq1qpDL5Wp1CxcuFNbW1lk6hs4tHEqdT5TDWJ89e4ZOnTrBxsYGHh4eWL58OQDg1q1baNWqFaytrVGyZEmNoWvh4eGYOHEiqlatChsbG9jZ2aF9+/ZahyQ8ffoUXbp0gbW1NVxcXDBu3DgcOXIEMplMY2jFhQsX0K5dO9jb28PKygrNmzfXGIqszd27d3Ho0CFMmjQJzs7OiI+Ph1wuT3cbIQQiIyMhhMhw/6nt2bMHXl5eep11t7KyQtGiRdWG7hUpUgSVK1fWaKu8mhkUFKQqi4uLg6Ojo+qxk5MTYmNjAQAxMTGYMmUK5syZozEkWR/+/v5wdHREx44d0aNHj1y9V8bKygoNGjRATEwMQkNDM2zft29fbN++XW3o9f79+xEbG6v1npAFCxYgNjYWa9as0TgrW6RIEaxevRoxMTGYP3++Wl3t2rXh5OSEvXv3ZvGVUWFS2PKiNvv370dUVBT69euXYdvg4GDcvHkTrVu3Viv/66+/AEBjGGGfPn0QHx+v+j6Fh4fjzp07+Pjjj9XO/FevXh0VK1bEtm3bVGXp5ToAmDBhAvr06YM6depk4tWmOHPmDJ48eYI+ffqgT58+OH36NF68eJHp/eirVatWAKDX7S1+fn64fv067t69qyp78+YNTpw4ofXK+K5du3D79m1MmTJF4xYYY2NjrF69Gg4ODvj+++/V6kxNTdGiRQvmOgJQuHNdXFyc1iuKSpGRkXB0dIS5ubmqzMTEBEWKFIGlpWWG+9d2DJiZXKckl8sxZswYjBkzRuvxZFxcHACo8qKVlRUsLS1VefHq1avw9/fH4sWLM4xZG39/f/j6+qJly5aoWLFirh4DVq1aFUWKFNErJ3p4eKBZs2Yanzl/f39UrVoVVapU0dhm+vTpcHR0xJo1azRGf9arVw+TJ0/GrVu3NG7B8/X1RUxMDAIDA7PwqnIHO8b5SKFQoH379ihevDjmz58PLy8vjBw5Ehs3bkS7du1Qp04dzJs3D7a2tujfv7/aB/rx48fYs2cPOnXqhMWLF2PSpEm4desWmjdvjlevXqnaxcTEoFWrVjh27BhGjx6Nb775BmfPntV6P8OJEyfQrFkzREZGYtq0aZg9ezYiIiLQqlUrncOhlY4dOwYAcHV1hY+PDywtLWFpaYn27dvrvL+jVKlSsLe3h62tLT755BOEhITo9Xs7e/YsatWqpbM+MjISYWFhuHv3Lr7++mvcvn1bryGLb968AZDSkVOqW7cuFi9ejAcPHuDIkSM4fPiwanjx7Nmz4eHhgU8//VSvuNPy9/dHt27dYGZmhr59++LBgwe4dOlSlvalj8ePH8PY2BgODg4ZtvXz88Pr16/V/mgGBATAx8cHLi4uGu2Vw4GaNm2qdX/NmjWDl5cXDh48qFFXq1atLHcyqPApTHlRG39/f1haWqJbt24Ztj179iwAaOS7hIQEGBsbawxzs7KyAgBcuXJF1Q6A1oNNKysrvHr1SpX36tati61bt+L8+fO4desWVq9ercp1gYGBOHHiBGbPnp2Zl6ri7++P0qVLo27duujcuTOsrKywdevWLO1LH48ePQIAODs7Z9i2WbNm8PT0VDsI3L59O2xsbNCxY0eN9vv37wcAjeGiSvb29ujatSvu3r2Lhw8fqtXVrl0bt2/fVrv/mQxXYcx1GzduhLW1NSwtLVGpUiWt8xG0aNEC//zzD6ZOnYqHDx/i0aNHmDlzJi5fvqxxS4M22o4BM5PrlJYsWYL379/j22+/1fo85cqVg729Pb7//ns8ffoUCxYsQGRkpOq5R48ejZEjR2bpFrhXr17h5MmTqltk+vbti507d2rM65BT3r9/j/fv3+uVE4GUY8D9+/cjOjoaAJCUlIQdO3ZoPVn44MED3Lt3D127dlUNMU9LmS/TzsFQqVIlWFpaSusYML8vWRsCbUMhBgwYIACI2bNnq8rev38vLC0thUwmUxuGdffuXQFATJs2TVUWHx8vFAqF2vMEBwcLc3NzMWPGDFXZokWLBAC1IRFxcXGiQoUKAoA4efKkECJlqFvZsmVF27ZtRXJysqptbGys8Pb2znDY3+jRowUA4ezsLNq1aye2b98uFixYIGxsbETp0qVFTEyMqu2SJUvEyJEjhb+/v9i5c6cYM2aMMDExEWXLlhUfPnxI93nkcrmQyWRiwoQJOtu0bdtWABAAhJmZmRg+fLiIi4tLd78JCQmiUqVKwtvbW22ox82bN4Wnp6dqf927dxcKhUI8fvxYWFpainPnzqW7X12UQ4gDAwOFECm/f09PTzFmzBi1dlkdSl2hQgURGhoqQkNDRVBQkOr9ST3kJ72h1EIIUadOHTF48GAhRMpn08zMTGzatEmcPHlSbSh1RESEACC6du2a7mvu0qWLACAiIyPVyocNGyYsLS3T3ZYKH0PIi2m9e/dOmJmZiV69eunVXjm8L+0wM2X8f/31l1r5lClTBADRqVMnIYQQCoVCODg4CB8fH7V2YWFhwtraWgAQly9fFkIIERkZKZo0aaLKdZUrVxYvXrwQcrlcVKpUScydOzdTr1UpMTFRODs7i2+++UZV5ufnJ6pXr67RNqtDqe/duydCQ0NFcHCwWL16tTA3Nxeurq6qvzvpDaUODQ0VEydOFGXKlFHV1a1bVzWsHGmGJdaoUUPY29un+5oXL14sAIh9+/aplSuHG164cCHd7alwMZRc16hRI7FkyRKxd+9esXLlSlGlShUBQKxYsUKtXXR0tOjVq5eQyWSqfGNlZaUxdFcbXceAmcl1Qgjx+vVrYWtrK1avXi2E0D1kOSAgQFhaWgoAwtjYWCxcuFAIIYS/v79wdXXN8JhVl4ULFwpLS0vV8dD9+/cFALF79261dlkdSj148GARGhoq3r59Ky5cuCB8fHwEALFo0SJVO11DqUeMGCHCw8OFmZmZ+PXXX4UQQhw8eFDIZDLx5MkTjVtY9uzZIwCIH3/8Md3XbGdnJ2rVqqVRXq5cOdG+fft0t81LvGKcz1JPQOLg4IDy5cvD2tpabbhq+fLl4eDggMePH6vKzM3NVTOUKhQKvHv3DjY2NihfvjyuXr2qanf48GF4eHigS5cuqjILCwsMHTpULY7r16/jwYMH8PPzw7t37xAWFoawsDDExMTAx8cHp0+fTnepEOVZJTc3Nxw8eBC9evXCxIkTsXbtWjx69EjtrOGYMWPw008/wc/PD927d8eSJUuwadMmPHjwACtWrEj39xUeHg4hhNqQv7Tmzp2Lo0ePYt26dWjQoAESExMznC1w5MiRuHPnDn7++We1CRSqVq2qupL74MED7Ny5E0ZGRpgwYQK6d++OBg0a4Pfff0f16tXh7e2NGTNm6DU03N/fH66urmjZsiWAlJkAe/fujW3btmnMjJ0Vd+/eRdGiRVG0aFFUrFgRP/30Ezp27Ij169frvQ8/Pz/8/vvvSExMxM6dO2FsbKw2eZZSVFQUAMDW1jbd/Snr014tcXR0RFxcnNqwTTJshSUvpqW8IqDPMGogZeZjExMTjVs1/Pz8YG9vj88++wyBgYF48uQJ1qxZo8qfyiGARkZGGD58OI4fP46vvvoKDx48wJUrV9CrVy/VlQllW1tbW/z555/4559/cP36dVy/fh0eHh5YsWIFEhISMG7cONy5cwctW7aEh4cHPvnkE72ufB46dAjv3r1Tmzysb9++uHHjhtbZ6rOifPnyKFq0KLy9vTF8+HCUKVMGBw8eVF1Bz4ifnx8ePnyIS5cuqf7VNcFYVFRUtnIdAK2zuZJhKky57syZMxgzZgy6dOmCzz//HFeuXEGVKlXw9ddfq/KMMvZy5cqhR48e2Lp1K7Zs2YI6dergk08+wfnz59N9Dl3HgJnJdUDKjMilSpXKcBLAvn374uXLlzh37hxevnyJCRMmIDY2FpMnT8asWbNgY2OD6dOno1SpUqhWrRp2796d7v6U/P390bFjR1WuKFu2LGrXrp1jw6nXrVuHokWLwsXFBfXr18eZM2cwfvx4jB07Vq/tHR0d0a5dO9XInoCAADRq1AglS5bUaJuZY0BtfzMcHR0llRM5K3U+srCw0Lgf097eHp6enhoz09nb2+P9+/eqx8nJyVi6dClWrFiB4OBgtc5U6qEST58+RenSpTX2l3box4MHDwAAAwYM0Bnvhw8fdHZIlcNXevXqpbakSM+ePfHpp5/i7Nmz6SYgPz8/TJgwAceOHcOUKVN0tlNKr/NZo0YN1f8/+eQT1KpVCwMHDtS6vBCQcn/s2rVrMXPmTHTo0EGj3sLCQu2+uhMnTuDo0aO4d+8e7t27hz59+mD16tXw8vJC3759Ubx4cQwaNEhnfAqFAtu2bUPLli3VhkbVr18fixYtwvHjx9GmTZv0Xr6GtO+vl5cX1q5dq1oepWzZslqHQKenT58+mDhxIg4dOgR/f3906tRJa+JTlimToy66kqfyveSs1AQUrryYlr+/P5ycnDKcFTQjbm5u2LdvHz799FNVrrCzs8NPP/2EAQMGqHWkZ8yYgbCwMMyfP181I32bNm0wePBgrFq1Sq2tkZERKlWqpHocFhaG77//HuvXr4dMJkOnTp3QqVMnLFiwAOPHj8eoUaPUZvHXZsuWLfD29oa5ublqaHHp0qVhZWWlmgk7M7TliV27dsHOzg6mpqbw9PTM9KzPNWvWRIUKFRAQEAAHBwe4ubmp7lNOy9bWNsODOOY60kdhznUAYGZmhpEjR6o6yU2aNAGQciHi/PnzuHr1qup4sVevXqhcuTLGjBmDCxcuZLhvbceA+ua68+fP49dff8Xx48fVjld1cXR0RIMGDVSP58yZAxcXFwwaNAjr16/HqlWr4O/vjydPnqB37964c+dOusOrg4KCcO3aNfTv31/tdosWLVpg+fLliIyM1DkkWRtt+aRr164YOXIkZDIZbG1tUblyZVhbW+u9TyDluPzTTz/Fs2fPsGfPHo05YpQycwyo7ThUCCGpnMiOcT7StTyRrvLUiWD27NmYOnUqPvvsM8ycORNOTk4wMjLC2LFjM3UFQ0m5zYIFC9Q6lqmlN8GUu7s7gJR7jFMzNjaGs7OzWkLXpXjx4ggPD0+3jZOTE2QymV77A1ISc5cuXTB37lzExcVp3H+yceNGTJ48GZ9//rnO+0xSUygUGDNmDKZMmQIPDw/MnDkTjRo1UnWEhw8fDn9//3Q7xidOnMDr16+xbds2rRNC+Pv7qw52lWs7pz7TmZryKmvaNaCtra01JuzJrGLFiqFFixZYtGgRzpw5g127dmltZ29vj2LFiuHmzZvp7u/mzZvw8PDQSPjv379XTWpBVJjyYmrPnj3DX3/9hWHDhsHU1FSvbZydnZGUlKT1KmWzZs3w+PFj3Lp1CzExMahevbrq3sJy5cqp2pmZmeGXX37BrFmzcP/+fbi6uqJcuXLw8/ODkZFRugdwU6dORa1atfDRRx/hr7/+wuvXrzF//nxYWFhg+vTpaNeuHTZs2KDz4FK5HEl8fLzWtdEDAgIwa9Ys1UGRhYVFpnOd8neRem6IrPDz88PKlStha2uL3r1763xNFStWxPXr1/Hs2TOUKFFCaxtlLkx9kgGA6u9WdmOlwqGw5rrUihcvDgCqY7vExESsW7cOX375pdp3zNTUFO3bt8fPP/+MxMREjfkTlNI7BtQ313355Zdo2rSpaklJ4L9RHK9fv073u/3kyRMsWrQIR48ehZGREbZu3Yrhw4erTqRt2rQJ27ZtS/d4csuWLQBSlonStjTVrl27VMeQ+hwDasuJnp6e2T4G7NKlC8zNzTFgwAAkJCRonXgVSMmJANI9Bnz69CkiIyM1ciKQkhe1/X3IL+wYF1A7d+5Ey5YtsW7dOrXyiIgItT+6JUuWxJ07dzTOyKSdFER5ht3Ozi5LX6batWsDAF6+fKlWnpiYiLCwMJ3rxykJIfDkyRON9e7SMjExQenSpfWaWU8pLi4OQghERUWpdb727t2LIUOGoFu3bqqZIDOycuVKREVFYeLEiQBSJlBQnhQAUk4QpP0dpOXv7w8XFxetz/n7779j9+7dWLVqFSwtLVG0aFFYWVnh3r17Wvd17949WFlZ5dqBlp+fH4YMGQIHBwetV9OVOnXqhLVr1+Lvv/9WnRVO7a+//sKTJ08wfPhwjbrg4GBVYiXKDqnlxdS2bt0KIYTew6gBoEKFCgBSviPVqlXTqDc2NlY7iFVOgqgtVldXV9WJS4VCgVOnTqF+/fo6D3Zv3LiB9evXqybyevXqFRwdHVUHYe7u7khMTERoaKjGCVGl33//HfHx8Vi5cqVGjrp37x6+/fZbnDlzRpUzlO+LNsocqG0oX07w8/PDd999h9evX2tde1qpU6dO2Lp1KzZv3qz14DcyMhJ79+5FhQoVNE46BAcHw8jISO3EBVFWSDnXpaYc/q08Bnz37h2SkpK03jIml8uRnJyc7u1k+hwDZpTrnj17hqdPn8Lb21tj2y5dusDe3l5tJZPUJk6ciC5duqhyVmaPAYUQCAgIQMuWLfG///1Po37mzJlqF1eU+e7evXtaJze9f/++1lmic4KlpSU++ugjbNmyBe3bt9d5nFmuXDmUK1cOe/bswdKlS7WOLFSuU9ypUye18qSkJDx//lxtqH9+4z3GBZSxsbHGUJIdO3ZofCHbtm2Lly9fYt++faqy+Ph4rF27Vq1d7dq1Ubp0aSxcuFB1v3BqGS3x06JFC7i4uMDf319tmv6NGzdCoVDA19c33X2tXLkSoaGhaNeuXbrPAwANGzbE5cuXNcrfvn2rURYREYFdu3ahePHiakM4Tp8+jT59+qBZs2bw9/fXazhNeHg4pk2bhgULFqgODl1dXdWW+QgKCoKbm5vOfcTFxeH3339Hp06d0KNHD42fkSNHIioqSvV+GRsbo02bNti/fz+ePXumtq9nz55h//79aNOmjc4zzNnVo0cPTJs2TetC76lNmjQJlpaWGD58ON69e6dWFx4ejs8//xxWVlaYNGmSxrZXr15Fo0aNcjx2MjxSy4upBQQEoESJElpPHOnSsGFDANCa77TFMm/ePFSrVi3DA9uFCxfi9evXmDBhgs42Y8aMwZAhQ1QHXa6urggNDVVd+QkKClItsaLLli1bUKpUKXz++ecauW7ixImwsbFRu6euQ4cOePHiBfbs2aO2n4SEBPzyyy9wcXFJd0WC7ChdujSWLFmCOXPmqGbj1qZHjx6oVKkS5s6dq/G+JCcn44svvsD79+8xbdo0jW2vXLmCypUrw97ePsfjJ8MitVynrT4qKgpLlixBkSJFVBdPXFxc4ODggN27d6vNwBwdHY39+/ejQoUKGY4e03UMqI22XLdmzRrs3r1b7WfUqFGq9rru8z158iT++OMPtSHFmT0GVC5dN2jQIK3HgL1798bJkydVo39q164NFxcX/PLLL6qZt5X27NmDly9fZvvWnPRMnDgR06ZNw9SpU9Nt99133+H9+/f4/PPPNU5sXLlyBfPmzUOVKlXQvXt3tbo7d+4gPj5eUseAvGJcQHXq1AkzZszAoEGD0KhRI9y6dQv+/v4oVaqUWrvhw4fj559/Rt++fTFmzBgUK1YM/v7+qo6d8gyikZERfvnlF7Rv3x6VK1fGoEGD4OHhgZcvX+LkyZOws7NTLVOhjbm5ORYsWIABAwagWbNmqvsSli5diqZNm6otTVKyZEn07t0bVatWhYWFBf7++29s27YNNWrU0HpFMa2uXbvi119/xf3799XOvLdv3x6enp6oX78+XFxc8OzZM2zYsAGvXr3C9u3bVe2Ua/rJZDL06NEDO3bsUNt/tWrVtF6dmTp1KqpWrYqePXuqyrp3744ZM2bgiy++QMmSJbF69ep017Tbt28foqKidJ4da9CgAYoWLQp/f3/07t0bQMqQqQYNGqBWrVoYNmwYvLy8VJPtyGSyLC+hog/lUgUZKVu2LDZt2oR+/fqhatWqGDx4sGqY0rp16xAWFoatW7dq3Pt35coVhIeHo2vXrrn0CsiQSC0vKt2+fRs3b97ElClTMnUvValSpVClShUcO3YMn332mVpd8+bN0bBhQ5QpUwZv3rzBmjVrEB0djQMHDqid6NuyZQt27dqFZs2awcbGBseOHcNvv/2GIUOGaBykKO3YsQM3b95Uu32iYcOGcHV1Rc+ePdGtWzcsXLgQ3bp103lSTrkcyejRo7XWm5ubo23bttixYweWLVsGU1NTDBs2DOvXr0fPnj3x2WefoWbNmnj37h22b9+O27dvY/PmzemeoMuuMWPGZNjGzMwMO3fuhI+PD5o0aYJBgwahTp06iIiIQEBAAK5evapa8zk1uVyOP//8U+tVIqLMklquW758Ofbs2YPOnTujRIkSeP36NdavX49nz57h119/VX1vjY2NMXHiRHz77bdo0KAB+vfvD4VCgXXr1uHFixeqYcbp0XUMqG+u0zaHi/IKcfPmzbWu1a5QKDB27FhMmjRJbZh1jx498OWXX6Jo0aJ4+vSp6n3Qxd/fH8bGxlqXggNSrlh/88032LZtG8aPHw8zMzMsXLgQAwYMQN26ddG7d284Ozvj2rVrWL9+PapVq4Zhw4Zl+DvLqurVq6N69eoZtuvXrx8uXbqEpUuX4s6dO+jXrx8cHR1x9epVrF+/Hs7Ozti5c6fGbUSBgYGwsrJSu3iW7/J0DmwDpWuq/rRL5QihvlxOammnVY+PjxcTJkwQxYoVE5aWlqJx48bi3Llzonnz5qJ58+Zq2z5+/Fh07NhRWFpaiqJFi4oJEyaIXbt2CQDi/Pnzam2vXbsmunXrJpydnYW5ubkoWbKk6NWrlzh+/Lher3Xr1q2ievXqquUyRo4cqbE8z5AhQ0SlSpWEra2tMDU1FWXKlBGTJ0/WaKdLQkKCKFKkiJg5c6Za+c8//yyaNGkiihQpIkxMTETRokVF586dxenTp9XaKZcb0vWTekkEpZs3bwozMzNx7do1jbqNGzcKLy8v4ezsLMaPHy+SkpJ0xt65c2dhYWGhtnxVWgMHDhSmpqYiLCxMVRYUFCR69+4tXFxchImJiXBxcRF9+vQRQUFBGtvr+gylldFyTbqkXa4ptZs3b4q+ffuKYsWKCVNTU+Hm5ib69u0rbt26pXVfkydPFiVKlFBbHoIMgyHlReUySjdv3tSrfWqLFy8WNjY2IjY2Vq183LhxolSpUsLc3FwULVpU+Pn5iUePHmlsf+HCBdGsWTPh6OgoLCwsRPXq1cWqVat0fudiY2NFyZIlxbJlyzTqLl26JGrVqiVsbW1F586dxdu3b3XGrVwmJr3f0caNGwUAsXfvXlXZ+/fvxbhx44S3t7cwNTUVdnZ2omXLluLQoUMa26ddNkSXjJZrSg/SLNek9PbtWzF+/HhRpkwZYW5uLhwcHETr1q01lmhSOnTokAAgHjx4kO7zUeFjCLnu6NGjwtfXV7i5uQlTU1Ph4OAg2rRpo3M7f39/Ua9ePeHg4CAsLS1F/fr1xc6dO9N9DiVdx4CZzXWpZbQs0vLly4Wnp6fGsZtcLhfjx48XRYoUESVLlhSbNm3S+RzKpeuaNm2abize3t6iZs2aamWHDh0SLVu2FHZ2dsLU1FR4e3uL8ePHi/fv32tsrytnpZXeck3pSS937tmzR/j6+gpHR0dhbm4uypQpIyZMmKAzz9avX1988sknGcaal2RC6LG2DBU6S5Yswbhx4/DixQt4eHjkdziZNnPmTGzYsAEPHjzItWHElLsSEhLg5eWFKVOm6HWlhii3STEvfvjwAaVKlcL8+fMxePDg/A6Hsuijjz6CTCbTezkXotwkxVyXGTwGLPiuX7+OWrVq4erVqzonfMsP7BgbgLSzMcfHx6NmzZpQKBS4f/9+PkaWddHR0ShVqhR+/PHHTE1mQ9KxatUqzJ49Gw8ePIC5uXl+h0MGpiDlxXnz5mHDhg24c+eOXvMhkLQEBQWhatWquH79eq5NlEOkS0HKdfriMWDB16dPHyQnJ+O3337L71DUsGNsANq3b48SJUqgRo0a+PDhA7Zs2YJ//vkH/v7+8PPzy+/wiIjyHPMiERkC5joi/XHyLQPQtm1b/PLLL/D394dCoUClSpWwbds21eRORESGhnmRiAwBcx2R/njFmIiIiIiIiAwab1YiIiIiIiIig8ah1ACSk5Px6tUr2NraZmqNSSIqGIQQiIqKgru7OycvSoP5j6jwYw7UjvmPqPDLTP5jxxjAq1evULx48fwOg4hy2fPnz+Hp6ZnfYUgK8x+R4WAOVMf8R2Q49Ml/+doxPn36NBYsWIArV67g9evX2L17Nz766CNVvRAC06ZNw9q1axEREYHGjRtj5cqVKFu2rKpNeHg4Ro0ahf3798PIyAjdu3fH0qVLYWNjo3cctra2AFJ+YXZ2dpDL5Th69CjatGkDU1PTHHu9OUXy8YXLcfTcUbSJaANTcwnGBzmOWh5Fm7g2MIUE40uQ46jDUbRp2AamTtKLDygAn8E08UVGRqJ48eKq77pUSCEHps1/QMF7f6WGOTB7pJ4DJf/50xKfFHMg81/WSD4+5r9sYf7Lnuzmv3ztGMfExKB69er47LPP0K1bN436+fPnY9myZdi0aRO8vb0xdepUtG3bFnfu3IGFhQUAoF+/fnj9+jUCAwMhl8sxaNAgDBs2DAEBAXrHoRw+Y2dnp+oYW1lZwc7OTrJveoGIT2YHU2sJxifksBJWsLO2g6lMgvHFyGFlaQU7WzuY2uVcfEIIPHwbjUehMVrrk4XA/MN38eRdrJ57tMNXN87nWHw5LyW+J3M7qkqkNlROCjkwbf4DClCOkXp8OZADk0QynsfHIUaRhC8f3sSdmMgcitIOX0HC39+nBSO/SFdKfHO6VUXfeiVUpVLKgcx/WVNg4ivkx4BCCPwe+hJ7Ql8iWQi8TojH43jtx1eZxvyXTSnxVfO0x76RTVSl+uS/fO0Yt2/fHu3bt9daJ4TAkiVL8O2336Jr164AgM2bN8PV1RV79uxBnz59EBQUhMOHD+PSpUuoU6cOAOCnn35Chw4dsHDhQri7u2vdd0JCAhISElSPIyNTDjTkcrnqR/lYiiQfX9K/8UEOSHDOc7mQq/0rNXL8G1+SHMihEBXJAlVnHINcIcE3JJel/k5LTX7kwIzyn/L/qf+VGsnHl0M5cM/bV5j08HYORUWGSKFQSDYHMv9ljeTjK2THgPLkZOwLe41/oiNxLzYajiamOBL+NjdDpBwihMh0/pPsPcbBwcF48+YNWrdurSqzt7dH/fr1ce7cOfTp0wfnzp2Dg4ODKiECQOvWrWFkZIQLFy7g448/1rrvOXPmYPr06RrlR48ehZWVlepxYGBgDr6inCf5+CwDJZkUlQIh0fgsU/4JPJ/99zdRAWx5aIQb4YY72coff/yB2Fh9r4JLR27lQH3zH1AAcozU49MzB4YnAHcjZIhX/Ff2JlaGC6GG+72lnHHr1i3Yvr1Z4HIg81/GJB9fAT4GfBkD/HjLGHIhnREWlHkRER8yfQwo2Y7xmzdvAACurq5q5a6urqq6N2/ewMXFRa3exMQETk5OqjbafPXVVxg/frzqsXLseZs2bVRDqQMDA+Hr6yvZYSqSji9cjsDzgfCN84WplQTjE3IEIhC+8JXmUOpYOQItA+HbwDdT95c8Do3B3huv8S4mUVW2/fKL3AixQOnQoYPqqkBBkls5MKP8BxSAHCP1+NLJgQ9io3Eg7A3eyVOuWt2IisTd2Kj8CJMMQNWqVdGhjmeBy4HMf7pJPr4CdAxoDBPci43GobA3WPkyOL9Doxzm4GCPDh0aZCr/SbZjnJvMzc1hbm6uUW5qaqqWZNI+lhrJxvfvp8oUppLseAIABGAqk3B8AExN0n9/n4fHos+a8wiPSUScXKGzXXaUcLLSWi6EQGxsLKysrCR1z5pS6vgk+z3JJ/rmP11lUiLZ+LTkwIsfwjEt+B8E5dg9woC9ImvfPZkMMLZMhiLOCEKCV3SU8VlZWEFmLO38IvX8Z2dlLt3vST5g/ssDEj8GfBAbjTHXTQCczJPny2yeVuY/E5kZzCV4YqEg5T83e8tMf08k2zF2c3MDAISEhKBYsWKq8pCQENSoUUPV5u1b9XH+SUlJCA8PV21PVBjdexOFtktOZ2sfg5t4w9hIM6kZyWSo4mGHDlWKwUhLPZByxvqPP/5Ahw5NJfmHOXV8BRVzYMH3LikRX925hpPvQ3Nkf5bJgIfCCB5JRqidYAJjZLFjbCLg0TgaLwMtIZKkd2CjjM+3XiNYFbPI73A0FKT8J8X49MH8RzklJCEe618HY/XLx7n6PC3jTOGikME2WQaHZBlk2czPJYzKoWZnr5wNMgdIPb9kNz7Jdoy9vb3h5uaG48ePq5JgZGQkLly4gC+++AIA0LBhQ0RERODKlSuoXbs2AODEiRNITk5G/fr18yt0olyXnU5xn7rFMfvjqjo7vSQNzIEF28lXMox5eirH9lclwRjt48xybH9EUsb8R9nxJiEeDS4fz5V910owhpPCCBYCKC03hlkWO8AkTfnaMY6OjsbDhw9Vj4ODg3H9+nU4OTmhRIkSGDt2LH744QeULVtWNVW/u7u7ap27ihUrol27dhg6dChWrVoFuVyOkSNHok+fPjpnpCYq6K4+e5/lbf+c1AIlna1zMBrKDubAwuVxaDRuvfyAjaeDce2VcZb2UUJuhHLy/7Y1AuCmMIKrghNxUeHC/Ec5KSQhHp8FXcI/OXi7CpByJbh2gnGWrwBTwZKvHePLly+jZcuWqsfKCREGDBiAjRs34ssvv0RMTAyGDRuGiIgINGnSBIcPH1atXwcA/v7+GDlyJHx8fFSLuy9btizPXwtRbvrt0nOsPxOMREUyHutYhxgArM2MEZOowDcdKqJiMbv/ys2NUcndDuYmWTtYp9zBHFg4XHn6HoM2XERkfFKmtquWYIwK/3aCjQTgojCCOQ++yEAw/1F23Yr+gM43/s6RfTkoZCieZIQGCSZwSOaJSEOVrx3jFi1aQKQz84dMJsOMGTMwY8YMnW2cnJz0XsidqCBaeOQefj75MMN2T+Z2zINoKCcxBxZcQgjsvPICk3bezNR2VslAs3hTVEnkFQgybMx/lFVvE+NR71L2h0r3ijZDySReMKD/SPYeYyIC7r6J1KtT/NvwhnkQDRE9D4/FkmMPsOtq5pZCqxtvgubxJuwMExFlQUKyAk0un0Tov0vdZYWDQoaGcmO0bBaPsBM2kpx8kPIXO8ZEEpSoAP63+zoCH77NsK2ZsRHqlHTMg6iIDJsiWcDvl/N4Hh6Xqe1axZqidiL/3BIRZYXXmYNZ3rZlnCnqJPyXf2UmAua8SEw68C81kQT99UaGwGcZd4p9KrhgTf86nGGaKA/cfROpd6e4dpFk2Lw0g1eCMVx4vxoRUaZNf/wPNrx+kuntaiUYo1G8KSwFj40oc9gxJpKgfc90n85c1LM6Old3h4mRjB1iojy080rGw6e/7lABtRJM8DL2Ol4+MYFI5neUiCgzkkQyypw9lKlt/KLM4KHgpWDKHnaMiSTmQ5JcZ10ZFxt0r+2Zh9EQkZL/hWday23MTbCmf2008HaGkZEMd05m7v5jIiJKsevtC0x4cEPv9nXjTdA03gTGnL+BcgA7xkQSM/6p9lluv+5QAUOblsrjaIgIAI7+8waJScka5U3LFsGvg+vnQ0RERIVLwJtn+PrRrQzb1U4wRss4U05mSDmOHWMiCTkbEYbTUe+01g1rVjqPoyEiANh7/SXGbLuutY4nq4iIsuePsNf4372rerWdGGHBDjHlGnaMiSTiTEQY+v1zQWvdaJ+yeRwNEQHAw7fROjvFAFDFwz7vgiEiKiTeJMSjwWX91yIeG2EBU3aIKZexY0yUz+TJydga8gzfPf5HZ5ux7BgT5bkPsXK0XvynznovZys4WZvlYURERAWbEALeZ//Qu71nkhH6RJvxKjHlCXaMifKREAJlz6U/8+InDUpw9mmifDB5l/b7/QGgQSknbOG9xUREenuZEIfGl0/o3b5BvAmaxpvmYkRE6tgxJsonCckKlD93ON025VxtMLNrlTyKiIiUnofH4vA/b7TW1SnpiG3DGuZxREREBdfZiDD46bhdTJtBkeYowjXgKY+xY0yUD5KFwJqXj9NtYy6T4cjYZpDJeLWYKK81nX9Sa7mRDNg2rEEeR0NEVHD9FvIcXz7UPQIntd7RZiiRxPWIKX+wY0yUx+7FRKHt9dPptjEzEhhatCg7xUT5IDohSWdd0Mx2MDHmVQwiIn28kydk2CluHmeCegkcMk35jx1jojz0OiEuw07xJ7FmqN4iFnahlnkUFRGltunsE63lnzX2hrkJr2QQEemr9sVj6dZz+SWSEnaMifLIe3kiGmYw6cTASHO4GMlgxmNvonwRm5iEBUfuaa37rnOlPI6GiKjg8jpzUGddUYUMA6Ms8jAaooyxY0yUR1a+eJRuvWUyUDTZCDASeRQRESklJwsEBoVg+K9XtNZ3qlYsjyMiIiq4pjy7rbOudrwxWsVzqTuSHknfKKVQKDB16lR4e3vD0tISpUuXxsyZMyHEfx0HIQS+++47FCtWDJaWlmjdujUePHiQj1ETabfmle7JtnxiTfFFJM+ckjrmwLwzbd8/OjvFADC5XYU8jIaImP8KrngFsCv8lc56dopJqiTdMZ43bx5WrlyJn3/+GUFBQZg3bx7mz5+Pn376SdVm/vz5WLZsGVatWoULFy7A2toabdu2RXx8fD5GTqQu9R/y1JwVMkyKsEStRBMY8x4bSoM5MG9ce/Yev55/qrPe2doMxZ2s8jAiImL+K7gmX9Q9IHViBC8CkHRJeij12bNn0bVrV3Ts2BEA4OXlha1bt+LixYsAUjobS5YswbfffouuXbsCADZv3gxXV1fs2bMHffr0ybfYiVI7FRGqtfwz3l9D6WAOzH3xcgU+XnE23Tanv2yZR9EQkRLzX8F04/UHnXXjONEWSZykO8aNGjXCmjVrcP/+fZQrVw43btzA33//jcWLFwMAgoOD8ebNG7Ru3Vq1jb29PerXr49z587pTIoJCQlISEhQPY6MjAQAyOVy1Y/ysRRJPr6kf+ODHJDg7bJyIVf7N7e9jI/DoDuXtNbJTDR/QTLjlDIhS5bueyz1z2Ca+KQaZ0ZyIwdmlP+U/0/9r9TkZHyXg8PTrZ/XrTLMjESmnkshFAD++y5LjTIuqceXpEiS5GewIH4/pBprepj/tJN6fD22XNBaXibJCKYmQH4fGBaU/JcsFJJ8j6X++ctu/pN0x3jKlCmIjIxEhQoVYGxsDIVCgVmzZqFfv34AgDdv3gAAXF1d1bZzdXVV1WkzZ84cTJ8+XaP86NGjsLL6b7hcYGBgTryMXCP5+CwD8zv/pSsQuR/fuRAZtj3WPsV0nSLJ8GgYrXPbBNcn+OOPJ7kUWc6Q/Gfw3/hiY2PzOZKsyY0cqG/+AwrO+5sdu58YQdtdRcWtBYaUV8Di9Q388fpGlvbt3iomm9HlLqnHd/LK8fwOIV0F6ftREHMg81/6pBhfwEPt+RQARjVNBJCYp/GkR+r574W4jRd/6J7ALL9J8fOXWlbzn6Q7xr/99hv8/f0REBCAypUr4/r16xg7dizc3d0xYMCALO/3q6++wvjx41WPIyMjUbx4cbRp0wZ2dnaQy+UIDAyEr68vTE2lt+C45OMLlyPwfCB843xhaiXB+IQcgQiEL3xhKsu9+Na9fIJtT+/rrK/wzAIvn2j+AZEZC7i3ioF5iBd8BklzeRjJfwbTxKe8KlDQ5EYOzCj/AQXv/c2qK0/f49Q57aM5Dk30hblJ1qbhuPvXKzyOuo5XJ6whFNIbNqjMMVKPr2VtH1i6mud3OBoK4vejIOZA5j/tpBrf2UfvcOGc9gkMh8WY4WWgNG4dKyj5z1NWBdXal8jvcDRI9fOnlN38J+mO8aRJkzBlyhTVcJiqVavi6dOnmDNnDgYMGAA3NzcAQEhICIoV+28pjZCQENSoUUPnfs3NzWFurvnH1tTUVO1NTvtYaiQb37+fKlOY5mrHM1sEYCrLvfhuRX/A3HQ6xQBQLNE43QvWMmEkzfc3Fcl+Bv+ljE/KMaYnN3KgvvlPV5mUZCe+5GSBPr9o7xR/0aI0bCyz3iEzlqWMEhEKGUSS9A68lKQen4mxSaH9/OWF1PFJOU5dmP/SJ6X44uUKDNioe1Z/e3n6xzv5Qer5z0hmLJn3Vxspff60yWr+k/Ss1LGxsTAyUg/R2NgYycnJAABvb2+4ubnh+PH/hltFRkbiwoULaNiwYZ7GSpRaz1vpT+YzhrMykh6YA3PPshO6l3TpVad4HkZCRNow/xUcFaYe1lk38gOPd6jgkPQV486dO2PWrFkoUaIEKleujGvXrmHx4sX47LPPAAAymQxjx47FDz/8gLJly8Lb2xtTp06Fu7s7Pvroo/wNngxa/L9/uNNqF2uKqomS/tqRhDAH5p4lx7R3jHvU9oR3Ees8joaI0mL+Kxi8phzUWfdJlDkshXSvyhKlJekj9J9++glTp07F//73P7x9+xbu7u4YPnw4vvvuO1WbL7/8EjExMRg2bBgiIiLQpEkTHD58GBYWPENF+WPrm2dayz+KMUNZufaJuIi0YQ7MHVeevtdZt7Bn9TyMhIh0Yf6TvsO3X+uss0kGiikkPTCVSIOkO8a2trZYsmQJlixZorONTCbDjBkzMGPGjLwLjEgHIQS+enRLax07xZRZzIG546qOjvG+kY3zOBIi0oX5T/o+33JVZ93/Yi0kd18xUUZ4KocoB815cldrefEkftWIpOLHY9onxqvm6ZC3gRARFVBBr3XP9Lu4QVIeRkKUc3i0TpSD1rx6rLW8ZZx0Z+4jMiSKZIHYRIVG+fQulfMhGiKiguen4w/QfulfWuvOftEcxrytmAooSQ+lJipIwuXaF653VsjgyvtsiCQhJlH7lYyKxezyOBIiooJn2t7b2HTuqc76ojbSW3ucSF/sGBPlkFoXA7WWfxrFPxJEUlezhEN+h0BEJGmTd97E9svPddb/1LdmHkZDlPN4GYsoB4QlJuisMwXHFBFJxYdYuUZZBTdbmBrzzyERUXrS6xS3reyKztXd8zAaopzHK8ZE2ZQsBOpcOqa1rlu0WR5HQ0Tp6bnqnEaZCW+IIyJK1/nH73TWLepZHd1re+ZhNES5g6fIibIhRpGEUmf/0FlfOolLNBFJyZvIeI2ymATNybiIiOg/fdac11res7YnO8VUaOh1xbhWrVqZ2qlMJsO+ffvg4eGRpaCICooq54/orGsTy5moCwvmwMLDSAYkp1lcc0hT7/wJhqgAYP6jhCTdJw8X9Kyeh5EQ5S69OsbXr1/HhAkTYGNjk2FbIQTmzp2LhATd91wSFQYKIdJdvL5yIq8WFxbMgYWHTCYDhPo3t1/9kvkUDZH0Mf/R8hMPtZYHDKmfx5EQ5S697zGeNGkSXFxc9Gq7aNGiLAdEVFDMDL6js25opDlMOOlWocIcWPDJFclQpLlcXMzeIp+iISo4mP8M2zIdHeNGZYrkcSREuUuvjnFwcDCKFi2q907v3LkDd3fOTEeF28bXT7SWj4+wgDE7xYUKc2DB9y46AX5rL2iUG8n4XSVKD/OfYUtOe+/Jv5ytObkoFT56dYxLlszcMLPixYtnKRiigq6E3Iid4kKIObBgS1Iko8eqcwgOi8nvUIgKHOY/wxb0JlJr+R9jmuZxJES5L8vLNSUlJWH16tU4deoUFAoFGjdujBEjRsDCgsPSqPB7nRCntbxbDM+gGgrmwIKj889ndHaKXezM8zgaooKP+c9wdFz2t9ZyVzu+11T4ZLljPHr0aNy/fx/dunWDXC7H5s2bcfnyZWzdujUn4yOSnHiFAg0vn9BaZ8qrxQaDObBguP3yA4Jea7/iAQD9G3LiLaLMYv4zDJeehOd3CER5Su+O8e7du/Hxxx+rHh89ehT37t2DsXHKzLtt27ZFgwYNcj5CIompcP5wfodA+YA5sGDq9JP2qx0AsG5AHfhUdM3DaIgKJuY/w6NIFui56pzWuoGNvPI2GKI8YqRvw/Xr1+Ojjz7Cq1evAKSsa/f555/j8OHD2L9/P7788kvUrVs31wIlkoJ3ct1LULSM47rFhRlzYMESFp2Aw7df66yf2qkSO8VEemL+MzytFp3SWfddp0p5FwhRHtK7Y7x//3707dsXLVq0wE8//YQ1a9bAzs4O33zzDaZOnYrixYsjICAgN2Mlynd/R4TprOO6xYUbc2DBsfFMMOr8cAyfb7mqtX7r0AYY3MQ7j6MiKriY/wxLWHQCnr6L1Vq364tGMDLibWNUOOndMQaA3r174+LFi7h16xbatm2LTz75BFeuXMH169exfPnyTE3nr6+XL1/ik08+gbOzMywtLVG1alVcvnxZVS+EwHfffYdixYrB0tISrVu3xoMHD3I8DiJ5cjLG3L+utW7kBwtYCv6hKOyYA6Xv2J0QfL9f9xrjANCwtHMeRUNUeDD/GY46PxzTWVe7pGMeRkKUtzLVMQYABwcHrFmzBgsWLED//v0xadIkxMfH50ZseP/+PRo3bgxTU1McOnQId+7cwaJFi+Do+N+Xcv78+Vi2bBlWrVqFCxcuwNraGm3bts21mMhwzXt6V2t5vXgTdooNCHOgtG04G5zfIRAVWsx/hu3GtDb5HQJRrtJ78q1nz55h4sSJCAoKQrVq1bBw4UJcuXIFs2bNQvXq1bFkyRK0b98+R4ObN28eihcvjg0bNqjKvL3/G/4mhMCSJUvw7bffomvXrgCAzZs3w9XVFXv27EGfPn207jchIQEJCf/dKxoZmTJjqVwuV/0oH0uR5ONL+jc+yAHt68LnK7mQq/2rr19eaT/gLpdsBJlJzr1QmXHKvoQsWbrvsdQ/g2niy4k4C0sOzCj/Kf+f+l+pSS++pxmsVXx8XJNcf10KoQDw33dZapRxST2+JEWSJD+DBfH7kd1Ymf+kI7fju/z0vdbybzqUh5VJxs+rPAaUen6RenzJQiHJz2BB/H5kJlaZEEKvT0aLFi3g5uaGgQMH4siRI3j06BH27dsHAAgKCsLw4cPh5uaG3377LTPxp6tSpUpo27YtXrx4gT///BMeHh743//+h6FDhwIAHj9+jNKlS+PatWuoUaOGarvmzZujRo0aWLp0qdb9fv/995g+fbpGeUBAAKysrHIsfio8PiQC313Rfh5pacOkPI6GMis2NhZ+fn748OED7OzssrSPwpIDC2v+i00CNt03wt0PmgOh3CwFmrolo6qTgD2XGicDlN0cyPxnOMac47EOFS6ZyX96d4xtbGxw48YNlC5dGkIIeHt748mTJ2pt1qxZg2HDhmU58LSUC8WPHz8ePXv2xKVLlzBmzBisWrUKAwYMwNmzZ9G4cWO8evUKxYoVU23Xq1cvyGQybN++Xet+tZ0xLF68OMLCwmBnZwe5XI7AwED4+vrC1FR6Mw1LPr5wOQLPB8I3zhemVhKMT8gRiED4whemMv3iW/78MZY8f6hRPjHaHEY5vHaxzFjAvVUMzEO84DNImjM/Sv4zmCa+yMhIFClSJFsd48KSAzPKf0DBe3/33niNiTtv6Wwf9H1rmBhn+s6hLLv71ys8jrqOVyesIRTSu81CmWOkHl/L2j6wdDXP73A0FLTvB4Bs50DmP+nIzfiSkwXKTwvUWvdgpn7DqJXHgFLPL1KPz1NWBdXal8jvcDQUxO9HZvKf3kOpa9euje+++w4DBgzAsWPHULVqVY02OZkQASA5ORl16tTB7NmzAQA1a9bE7du3VUkxq8zNzWFurvnH1tTUVO1NTvtYaiQb37+fKlOY6t3xzHMCMJXpH198svbzR7Iko1wbLS4TRtJ8f1OR7GfwX8r4ciLGwpID9c1/usqkxNTUFFGJIt1OsbWZMczNzPJ0FlVjWcoM9UIhg0iS3oGXktTjMzE2kfznr6DEl904mf+kJzfiexURp7V882f19H+uf48BpZ5fpB6fkczY4D5/OSmr+U/vU+ibN29GQkICxo0bh5cvX2L16tWZjzKTihUrhkqV1K+YVaxYEc+ePQMAuLm5AQBCQkLU2oSEhKjqiHLCqpePNMpqx3N5JkPCHCg9CXJFumttAkCHqsW4tAhRNjH/FX5CCDSae0JrXZMyRfI4GqL8ofcV45IlS2Lnzp25GYuGxo0b4969e2pl9+/fR8mSJQGkTMLg5uaG48ePq+4viYyMxIULF/DFF1/kaaxkeEomsWNsSJgDpefEvVBExOqeVGNgIy981aFCHkZEVDgx/xV++2++1lnHk4tkKPTqGEdGRmbqnpSoqCjY2tpmOSilcePGoVGjRpg9ezZ69eqFixcvYs2aNVizZg0AQCaTYezYsfjhhx9QtmxZeHt7Y+rUqXB3d8dHH32U7ecnAoDn8doXuS+myLt7Fil/MQdKjyIZGL/9ps76c1+1QjF7yzyMiKhwYv4r/IQQGL31mta6X/rXyeNoiPKPXkf2jo6OePv2rd479fDwwOPHj7MclFLdunWxe/dubN26FVWqVMHMmTOxZMkS9OvXT9Xmyy+/xKhRozBs2DDUrVsX0dHROHz4sGrSBqLsanrlpNZyK65dbDCYA6UjXq7AD3/cxfgLus/r/j25JTvFRDmE+a/w8/7qD511rSu55mEkRPlLryvGQgj88ssvsLGx0WunObm2VadOndCpUyed9TKZDDNmzMCMGTNy7DmJlB7ERuV3CCQBzIHScO3Ze3y84my6bQKG1IenI5ddIcopzH+FW/MF2k/+A8CpiS3yLhAiCdCrY1yiRAmsXbtW7526ublJeqYyIn35XjuttbxzDD/fhoQ5MH+9/hCHZccfYuvFZxm2bcRJYohyFPNf4fUhVo6n77TfLgYAXkWs8zAaovynV8c47Vp1RIYgvSW+K8j1nreOCgHmwPxx5el7LD3+AKfvh+rVfuZHVXI5IiLDw/xXeFWfcVRn3XK/WnkYCZE08OieSIdb0R+0ln8apbkGIhHlrJkH7mDd38F6tS3pbIXxvuXQtYZHLkdFRFQ4hMck6qz7c1ILlHTm1WIyPOwYE+mwJ/SV1nI3zkZNlKtO3n2rd6c4cFwzlHXN/gy4RESGpNbMQK3lAxt5sVNMBosdYyId1r/WPDD3TGKnmCi3/f0wLMM2tYskY9uYdryXkYgok4LDYnTWfd+lch5GQiQtPMon0iJZx/3F5RON8zgSIsOTkKTQWdeifFFc/KoF+pdNzsOIiIgKj16rz2ktn9yuQh5HQiQtvGJMpMUPwUFayyuzY0yUaxKSFNh89im2nNecfbpjtWJY2rsGTIyNcnQ5GCIiQxMalaC1fFBjr7wNhEhicuyK8e+//45q1arl1O6I8pW2YdQAYA5ZHkdCBQVzYPb89SAU5b89jFl/aD8p1btOcZgYc5ATkRQx/xUc/heeai1f1LM6LEx58p8MW6aOMlavXo0ePXrAz88PFy5cAACcOHECNWvWxKefforGjRvnSpBEeel4eIjW8gq8WmzwmANzx6PQaHy67mK6bYxkPClFlJ+Y/wqHb3bf1lreqXqxPI6ESHr07hjPnTsXo0aNwpMnT7Bv3z60atUKs2fPRr9+/dC7d2+8ePECK1euzM1YiXJdaGICBgdd1lrXIZaT/Bgy5sDc85ce6xRXLMaZp4nyC/Nf4dBx2V9ay41kgLkJT/4T6X2P8YYNG7B27VoMGDAAf/31F5o3b46zZ8/i4cOHsLbmtO5UODS+fEJnnTGHURs05sCse/YuFmcehSE2UfukWitPPdK5rZmJEb7tWBHONlw/nCi/MP8VbEIIfLTiLP55Fam1/upU3zyOiEia9O4YP3v2DK1atQIANG3aFKamppg+fToTIhUqiUL7TLeDInlQbuiYA7Pm7MMwDNx4CYlJmZ9Fevf/GqGCmx0szXglgyg/Mf8VXDdfRKDLz2fSbeNgZZZH0RBJm94d44SEBFhYWKgem5mZwcnJKVeCIsoPsYokreXF5UYoksxJfwwdc2DWrDr9OEud4rndqqJmCcdciIiIMov5r2CSK5Iz7BQ/nNU+j6Ihkr5MLdc0depUWFlZAQASExPxww8/wN7eXq3N4sWLcy46ojz0MiFOa3mfGF4tphTMgZmTnCzwICQqS9uWcLbK4WiIKDuY/wqW5GSBst8cSrfNlsH1Ods/USp6d4ybNWuGe/fuqR43atQIjx8/Vmsj46yhVICd/xCe3yGQhDEH6ufF+1hExMqx7PgDHL2jfYb3jDQq7Yw6JXk1ikgqmP8KjtsvP2Dijhu4+yb9k5IbBtZFk7JF8igqooJB747xqVOncjEMovw394nm+qnWmR8BSoUUc2D6PsTJ8dnGS7jy9H2Gbb/tWBEmRtoPor2KWKNhaWeYmfAqBpFUMP8VDK8i4tB3zXlEJWi/NUxp/8gmqOppn24bIkOUqSOPyMhIBAYG4uDBgwgNzXh5jZw2d+5cyGQyjB07VlUWHx+PESNGwNnZGTY2NujevTtCQrJ2lYIMV0KyAjHJmjPmVkrM1N0GVMgxB6p7GxmPgAvPsPTYA1SfflSvTrGFqRE+a+yNgTp+WpR34bIhRBLE/Cd92y49z7BTfO+HduwUE+mgd8f4+vXrqFChAtq2bYvOnTujTJkyOHLkSG7GpubSpUtYvXo1qlWrplY+btw47N+/Hzt27MCff/6JV69eoVu3bnkWFxUOM4LvaC0vlcSrVpSCOVDdy4g4dP75b3y9+xZ+PHZf7+18KrrCSMfVYiKSJua/gmHZ8Qfp1t+Z0ZYnHonSofflsMmTJ8Pb2xu7du2ChYUFZs6ciZEjR+LBg/S/hDkhOjoa/fr1w9q1a/HDDz+oyj98+IB169YhICBAtYzAhg0bULFiRZw/fx4NGjTQur+EhAQkJCSoHkdGpqzrJpfLVT/Kx1Ik+fiS/o0PckDkczBayIVc7V8A8H/zTGtbT8ggM8nbFyEzTnk+IUuW7nss9c9gmvhyIs7CkgMzyn/K/6f+V5vdV54jJDJBZ31aZV2s0ai0M8a3LpPt90Pqnz+FSBl9ovwuS40yLqnHl6RIkuR7LPXPn7b4shsr85906IrvxD3dV/Gre9pj+9B6MJaJXH9dymNAqecXqceXLBSS/AwWxO9HZmKVCSH0+mQUKVIER48eRa1atQAAERERcHJyQkREBOzs7DITc6YNGDAATk5O+PHHH9GiRQvUqFEDS5YswYkTJ+Dj44P379/DwcFB1b5kyZIYO3Ysxo0bp3V/33//PaZPn65RHhAQoJpxkQzLmHOa54iqOyXjs/K8ybgwiI2NhZ+fHz58+JDlfFVYcqCu/Fd6wnYYm+uf/xIUGV/1reiQjOLWQBO3ZNhzmUyifJPdHFjY819BPv57Hg0svKX7OteM2knMv2TQMpP/9L5iHB4eDk9PT9VjBwcHWFtb4927d7maFLdt24arV6/i0qVLGnVv3ryBmZmZWkIEAFdXV7x580bnPr/66iuMHz9e9TgyMhLFixdHmzZtYGdnB7lcjsDAQPj6+sLU1DTHXktOkXx84XIEng+Eb5wvTK0kGJ+QIxCB8IUvTGUp8X1jfALRadYxbvXMEi+f5f2QT5mxgHurGJiHeMFnUKU8f359SP4zmCY+5VWB7CgsOVBX/ktUyGCkR2dXX+N8yuB/LUrl2P5Sk/rn7+5fr/A46jpenbCGyMHfaU5R5hipx9eytg8sXaW3XJ7UP3/a4stuDizs+U95/AcUrPd33623WLj7n3Tb9/2oQx5FlkJ5DCj1/CL1+DxlVVCtfYn8DkdDQfp+ZCX/ZWpmoTt37qglGyEEgoKCEBX135Twae//yI7nz59jzJgxCAwMVFtYPrvMzc1hbq75x9bU1FTtTU77WGokG9+/nypTmKo6npIjAFPZf/Gl7RQDgEmSUb6OBJcJI2m+v6lI9jP4L2V8ORVjYciBuvJfTuhdpzgalHZCVQ97lHGxzZXnSE2qnz9jWco9fEIhg0iS3oGXktTjMzE2keT7qyTVz59S6vhyIs7CnP+0vZcF4f2dkkGneOvQBnn/Gv49BpR6fpF6fEYyY8l//gpKfJmJM1MdYx8fH6Qded2pUyfIZDIIISCTyaBQaM7sm1VXrlzB27dvVUN3AEChUOD06dP4+eefceTIESQmJiIiIkLtjGFISAjc3NxyLA4q3CKTNO89sOEIatKCOVA3WwsTfN+lMizNOLELUWHE/CctS48/TLd+aFNvNCztnEfREBUOeneMg4ODczMOrXx8fHDr1i21skGDBqFChQqYPHkyihcvDlNTUxw/fhzdu3cHANy7dw/Pnj1Dw4YN8zxeKpgOvXutURbNyagpjcKeA52sTGFskXIjmoBAYkIizMzNIEPGZ9TLu9liUtvy7BQTFVKFPf8VNHFJwM/nHuusn9imHEa2KpuHEREVDnp3jDdt2oSJEyfm6eQEtra2qFKlilqZtbU1nJ2dVeWDBw/G+PHj4eTkBDs7O4waNQoNGzbUOSM1UVorXzzK7xCoACjsOfD05FZq99j98ccf6NChpaSHShFR3ijs+a+gEELg2vMIzL6u+yTkk7kd8zAiosJF747x9OnT8fnnn0tu1r4ff/wRRkZG6N69OxISEtC2bVusWLEiv8OiAuRJfKxGWSk5LxmTOuZAIjJUzH/5J16uwIQdN3DwZurRbdpH8pyd0ipvgiIqpPTuGOu5qlOuO3XqlNpjCwsLLF++HMuXL8+fgKhAS0zWfjOxZxI7xqSOOZCIDBXzX95KUiRjx5UX+Or3Wxk3/leTMkXg7mCZi1ERFX6ZmnxLJpPu7G1EWVH7YqD28oRMfTXIQDAHEpGhYv7LO1/uuonfr77M1Db96ktvaR+igiZTR//lypXLMDGGh4dnKyCivJKYnIwoLcs0AYCJHhMOkeFhDiQiQ8X8lzfCohMy3SluX8UNbSsX/pm4iXJbpjrG06dPh729fW7FQpSn1r96orW8VgJn1iXtmAOJyFAx/+WNK0/f69XO29kKnaq7Y0TLMrAw5XELUU7IVMe4T58+cHFxya1YiPLUomfa1wBsFcdZeEk75kAiMlTMf7knKl6OhUfuYdO5p+m2G9rUG+N8SiPwyGF06NCEqwYQ5TC9O8a8t4QKk0eR2strJhjrtW4rGR7mQCIyVMx/uUORLHDnVSQ6//x3uu2ali2CXwfXB5CynB4R5Y4CNys1UU7Y/UT7sKPG8Tz7StoxBxKRoWL+y3kHbr7CyIBrerWt5+WUy9EQEZCJjnGyjmVtiAqi5zHaz35bCp4VJ+2YA4nIUDH/ZV1sYhL+ehCGF+/jVGUxCUlYHHhfr+3tLEzQv6FXLkVHRKlxTRqif/WNMsvvEIiIiKiQiE1MQt8153HjxYcsbT+za2V80qAkh7IT5RF2jMng6BoSVkxhlMeREBERUWF1+n5oljrFB0Y1QRUPzgBOlNfYMSaDcyUqQms5u8VERESUFXJFMs49eof7IVF4H5uI5ScfZXof33asiI9resDZxjwXIiSijLBjTAZnRvBdreWcjZqIiIgySwiBkQFXceSfkCxt7+FgiZ1fNEQxe8scjoyIMoMdYzI4QTFRmoWccJOIiIj0FBadgOUnH+KfV5G4+zoSkfFJem1X3tUWfesVVz0uYmuOpmWKwt6Kq2IQ5Td2jMmghCTEay3vHsOJt4iIiChjQggM3HARt19GZnrbpX1roIKbXS5ERUTZxY4xGZSb0donwSiZxDuMiYiISJMQAg/eRuOfVx8gBPDifVymO8VO1mYY41OWnWIiCWPHmAzKhAfXtZYb8/5iIiIigxSbmASTxJSh0HJ5EhIUKWWmIuXY4MfA+1j7V3Cm9lnP2wkf1fBAPW8nFLU1h625CYyMeKxBJGXsGJNBiVRo3gNUNpFXi4mIiAxVvVnHYWRularEBF9ePJGlfZmZGKFv3eKY1rkyO8JEBYykewRz5sxB3bp1YWtrCxcXF3z00Ue4d++eWpv4+HiMGDECzs7OsLGxQffu3RESkrVZAalwS9axfnElOc8PkTQxBxKRoSpo+a9j1WK48LUPbk5rg+ldq7BTTFQASbpj/Oeff2LEiBE4f/48AgMDIZfL0aZNG8TExKjajBs3Dvv378eOHTvw559/4tWrV+jWrVs+Rk1SdSXqvdbysnJJfw3IgDEHEpGhKkj5z87CBKN9ysLVzgIWpsZ5/vxElDMkfans8OHDao83btwIFxcXXLlyBc2aNcOHDx+wbt06BAQEoFWrVgCADRs2oGLFijh//jwaNGiQH2GTRN2IitBazvWLSaqYA4nIUEk5/1Vws0Xbym4AAEcrU/hUdEVxJ6sMtiIiqZN0xzitDx9SZhR2cnICAFy5cgVyuRytW7dWtalQoQJKlCiBc+fO6UyKCQkJSEhIUD2OjEyZWVAul6t+lI+lSPLxJf0bH+SSWh/4SPgbjbJySUaQmUgoSAAy45R4hCxZuu+x1D+DaeKTapyZlRM5MKP8p/x/6n+lRurxKYQCwH/fZalRxiX1+JIUSZJ8j6X++dMWn1RjzYzczH/lXKxhYmENABAQiIqKhq2tjcaJcwcrU3xUwx3da3lo7DuvfseS//z9ewwo9fwi9fiShUKS77HkP3/ZzH8FpmOcnJyMsWPHonHjxqhSpQoA4M2bNzAzM4ODg4NaW1dXV7x5o9kJUpozZw6mT5+uUX706FFYWf13xi8wMDBngs8lko/PMlBSHeNLkZofd88SSfAomZgP0WQswfUJ/vjjSX6HkS7Jfwb/jS82NjafI8m+nMqB+uY/oOC8v1Ll3iom40b5SOrxnbxyPL9DSJfUP3+p4yvoOTC3899grw+wskp78Kx9eUe8CcUff9zI7EvIcVL//Ek9v0g9vhfiNl78cTu/w9BJ6p+/rOa/AtMxHjFiBG7fvo2///472/v66quvMH78eNXjyMhIFC9eHG3atIGdnR3kcjkCAwPh6+sLU1PTbD9fTpN8fOFyBJ4PhG+cL0ytpBPfGBzVKHN+ZI6X96V1P5DMWMC9VQzMQ7zgM6hSfoejleQ/g2niU14VKMhyKgdmlP+Agvf+Ss3dv17hcdR1vDphDaGQ3q0ayhwj9fha1vaBpat5foejQeqfP23xFfQcyPz3H8nH9+8xoNTzi9Tj85RVQbX2JfI7HA2S//xlM/8ViI7xyJEjceDAAZw+fRqenp6qcjc3NyQmJiIiIkLtjGFISAjc3Nx07s/c3Bzm5pp/bE1NTdXe5LSPpUay8f37qTKFKUxl0ogvOE77mcGiicYQQnqJEQBkwkia728qkv0M/ksZn5Rj1EdO5kB985+uMimRanzGspSTbUIhg0iSZn4BpB+fibGJJN9fJal+/pRSxyflODPC/KedZOP79xhQ6vlF6vEZyYyl+f7+S7Kfv39lNf9JejpeIQRGjhyJ3bt348SJE/D29larr127NkxNTXH8+H/Dre7du4dnz56hYcOGeR0uSVjLq6e0lltJaKg3UVrMgURkqJj/iCivSfqK8YgRIxAQEIC9e/fC1tZWdc+Ivb09LC0tYW9vj8GDB2P8+PFwcnKCnZ0dRo0ahYYNG3I2VtILZ6QmKWMOJCJDxfxHRHlN0h3jlStXAgBatGihVr5hwwYMHDgQAPDjjz/CyMgI3bt3R0JCAtq2bYsVK1bkcaQkZclC+2Xh/rFmeRwJUeYwBxKRoWL+I6K8JumOsdDRoUnNwsICy5cvx/Lly/MgIiqIrulYv9g1mVeLSdqYA4nIUDH/EVFek/Q9xkQ5Yepj7dPdcxg1EREREREB7BiTAbgTU7CXqSAiIiIiotzFjjEZpDGVk/I7BCIiIiIikgh2jKlQ0zXxVim7PA6EiIiIiIgkix1jKtQ4jJqIiIiIiDLCjjEVWkIIfHTzTH6HQUREREREEseOMRVaT+NjkaRlKLUjl2kiIiIiIqJU2DGmQmtv6Cut5Y0SJb18NxERERER5TF2jKnQuhb9Xmt5hSR+7ImIiIiI6D/sIVChdSVSs2PsLTeCMTiUmoiIiIiI/sOOMRVaUQrNtYrLyY3zIRIiIiIiIpIydoypUNK1fnFxDqMmIiIiIqI02EugQulxXIzWcsdkfuSJiIiIiEgdewlUKHW4/ld+h0BERERERAUEO8ZUKCWK5PwOgYiIiIiICgh2jKnQSUzW3inuHGOax5EQEREREVFBwI4xFTpXorSvX1yeM1ITEREREZEWJvkdQE5Zvnw5FixYgDdv3qB69er46aefUK9evUzt43hQCKxtYqFQKHArXAazoLcwNpZeZ0ry8UX9G1/iWxjH5318w+5e0Vou4/rFVEjlRP4jIiqomAOJKCcUio7x9u3bMX78eKxatQr169fHkiVL0LZtW9y7dw8uLi5672fMtuswMrf695Exfrl3PVfizRkFID5cz+8gVErLOTiCCqecyn9ERAURcyAR5ZRC0TFevHgxhg4dikGDBgEAVq1ahYMHD2L9+vWYMmWKRvuEhAQkJCSoHn/48AEAkJwQmzcBU54zkxshPlkOAJAlCcTGxiI+CRDJ0ruKrIwvKT4S7969y+9wtJLL5YiNjcW7d+9gaiq9e7fTxhcVFQUAEDrWty7Icir/hYeHQy5P+Y4UtPdXaj5ERxSIHCP1+MIjwhFnZp7f4WiQ+udPW3zMgSmY/3Kf/L28QOQXqccXKYuQ5DGg5D9/2c1/ooBLSEgQxsbGYvfu3Wrl/fv3F126dNG6zbRp0wQA/vCHPwb28/z58zzISnmH+Y8//OFPZn4MPQcy//GHP4b7o0/+K/BXjMPCwqBQKODq6qpW7urqirt372rd5quvvsL48eNVj5OTkxEeHg5nZ2fIZDJERkaiePHieP78Oezs7HI1/qxgfNnD+LJP6jGmjU8IgaioKLi7u+d3aDkqN/IfUPDeX6lhfNnD+LJHW3zMgSmY/3If48sexpc92c1/Bb5jnBXm5uYwN1cfnuXg4KDRzs7OTpJvuhLjyx7Gl31SjzF1fPb29vkcjTTom/+AgvX+ShHjyx7Glz1p42MOZP7LS4wvexhf9mQ1/xX4GYmKFCkCY2NjhISEqJWHhITAzc0tn6IiIsp9zH9EZMiYA4koJxX4jrGZmRlq166N48ePq8qSk5Nx/PhxNGzYMB8jIyLKXcx/RGTImAOJKCcViqHU48ePx4ABA1CnTh3Uq1cPS5YsQUxMjGqGwswyNzfHtGnTNIbbSAXjyx7Gl31Sj1Hq8eWknM5/gPR/f4wvexhf9jA+aeExoLQwvuxhfNmT3fhkQhSOuft//vln1eLuNWrUwLJly1C/fv38DouIKNcx/xGRIWMOJKKcUGg6xkRERERERERZUeDvMSYiIiIiIiLKDnaMiYiIiIiIyKCxY0xEREREREQGjR1jIiIiIiIiMmjsGOeA4OBgtGzZEpUqVULVqlURExOT3yGp8fLyQrVq1VCjRg20bNkyv8PRKjY2FiVLlsTEiRPzOxQ1ERERqFOnDmrUqIEqVapg7dq1+R2SmufPn6NFixaoVKkSqlWrhh07duR3SBo+/vhjODo6okePHrn+XKdPn0bnzp3h7u4OmUyGPXv25PpzGjrmv+xj/ssa5j91zH95j/kv+6Sa/wDmwJyQVzkwp/JfoVjHOL8NHDgQP/zwA5o2bYrw8HBJru119uxZ2NjY5HcYOs2aNQsNGjTI7zA02Nra4vTp07CyskJMTAyqVKmCbt26wdnZOb9DAwCYmJhgyZIlqFGjBt68eYPatWujQ4cOsLa2zu/QVMaMGYPPPvsMmzZtyvXniomJQfXq1fHZZ5+hW7duuf58xPyXE5j/sob5Tx3zX95j/ss+qeY/gDkwJ+RVDsyp/MeOcTb9888/MDU1RdOmTQEATk5O+RxRwfPgwQPcvXsXnTt3xu3bt/M7HDXGxsawsrICACQkJEAIASmtcFasWDEUK1YMAODm5oYiRYogPDxcUkmxRYsWOHXqVJ48V/v27dG+ffs8eS5i/ssJzH9Zx/ynjvkvbzH/ZZ+U8x/AHJgT8ioH5lT+K/RDqfW5tL58+XJ4eXnBwsIC9evXx8WLF/Xe/4MHD2BjY4POnTujVq1amD17tqTiAwCZTIbmzZujbt268Pf3l1x8EydOxJw5czK1TV7GFxERgerVq8PT0xOTJk1CkSJFJBWf0pUrV6BQKFC8eHFJxkd5j/mP+Y/5TxrxUd5j/ivc+S+vYmQONKwcWOg7xspL68uXL9dav337dowfPx7Tpk3D1atXUb16dbRt2xZv375VtVHeW5D259WrV0hKSsJff/2FFStW4Ny5cwgMDERgYKBk4gOAv//+G1euXMG+ffswe/Zs3Lx5UzLx7d27F+XKlUO5cuX0jikv4wMABwcH3LhxA8HBwQgICEBISIik4gOA8PBw9O/fH2vWrNE7tryMj/IH8x/zH/Nf/sdH+YP5r3Dnv7yIEWAONLgcKAwIALF79261snr16okRI0aoHisUCuHu7i7mzJmj1z7Pnj0r2rRpo3o8f/58MX/+fMnEl9bEiRPFhg0bJBPflClThKenpyhZsqRwdnYWdnZ2Yvr06ZKJL60vvvhC7NixQ1LxxcfHi6ZNm4rNmzdnKa7cjk8IIU6ePCm6d++erfgyS9vrMWTMf8x/zH95H58QzH9SwPxXuPNfbsWYFnNgwciB2cl/hf6KcXoSExNx5coVtG7dWlVmZGSE1q1b49y5c3rto27dunj79i3ev3+P5ORknD59GhUrVpRMfDExMYiKigIAREdH48SJE6hcubJk4pszZw6eP3+OJ0+eYOHChRg6dCi+++47ycQXEhKi+v19+PABp0+fRvny5SUTnxACAwcORKtWrfDpp5/mSFw5GR9JF/Nf/sfH/Mf8R/mD+S//48vN/JdTMTIHGl4ONOjJt8LCwqBQKODq6qpW7urqirt37+q1DxMTE8yePRvNmjWDEAJt2rRBp06dJBNfSEgIPv74YwCAQqHA0KFDUbduXcnEl5tyIr6nT59i2LBhqgkXRo0ahapVq0omvjNnzmD79u2oVq2a6t6QX3/9NUdizKn3t3Xr1rhx4wZiYmLg6emJHTt2oGHDhtmOj7KH+S//48tNzH/5Hx/A/CdVzH/5H19uYw7M//iAgpcDDbpjnFOkPBNkqVKlcOPGjfwOQy8DBw7M7xA01KtXD9evX8/vMHRq0qQJkpOT8zuMdB07dizPnis6OhoPHz5UPQ4ODsb169fh5OSEEiVK5FkchoT5L2cw/2Ue85865r+8x/yXM6SY/wDmwJyQVzkwp/KfQXeMixQpAmNjY40b6UNCQuDm5pZPUf2H8WUP48seqcenzeXLl9GyZUvV4/HjxwMABgwYgI0bN+ZTVNIk9feX8WUP48seqcenDfOf/qT+/jK+7JN6jIwvZ+VU/jPoe4zNzMxQu3ZtHD9+XFWWnJyM48ePS+IyP+PLHsaXPVKPT5sWLVqohjyl/uFBoSapv7+ML3sYX/ZIPT5tmP/0J/X3l/Fln9RjZHw5K6fyX6G/YpzRpfXx48djwIABqFOnDurVq4clS5YgJiYGgwYNYnyMj/Hlc3yUPVJ/fxkf42N8lFuk/v4yvsIfI+MrgLI0l3UBcvLkSQFA42fAgAGqNj/99JMoUaKEMDMzE/Xq1RPnz59nfIyP8UkgPsoeqb+/jI/xMT7KLVJ/fxlf4Y+R8RU8MiGE0LcTTURERERERFTYGPQ9xkRERERERETsGBMREREREZFBY8eYiIiIiIiIDBo7xkRERERERGTQ2DEmIiIiIiIig8aOMRERERERERk0doyJiIiIiIjIoLFjTERERERERAaNHWMiIiIiIiIyaOwYExERERERkUFjx5gKpIEDB0Imk2n8PHz4UK3OzMwMZcqUwYwZM5CUlAQAOHXqlNo2RYsWRYcOHXDr1q18flVERPphDiQiQ8X8R7mFHWMqsNq1a4fXr1+r/Xh7e6vVPXjwABMmTMD333+PBQsWqG1/7949vH79GkeOHEFCQgI6duyIxMTE/HgpRESZxhxIRIaK+Y9yAzvGVGCZm5vDzc1N7cfY2FitrmTJkvjiiy/QunVr7Nu3T217FxcXuLm5oVatWhg7diyeP3+Ou3fvqupbtGiB0aNH48svv4STkxPc3Nzw/fff5+VLJCLSiTmQiAwV8x/lBnaMySBYWlrqPBP44cMHbNu2DQBgZmamVrdp0yZYW1vjwoULmD9/PmbMmIHAwMBcj5eIKCcxBxKRoWL+I32xY0wF1oEDB2BjY6P66dmzp0YbIQSOHTuGI0eOoFWrVmp1np6esLGxgYODAwICAtClSxdUqFBBrU21atUwbdo0lC1bFv3790edOnVw/PjxXH1dRET6YA4kIkPF/Ee5wSS/AyDKqpYtW2LlypWqx9bW1qr/KxOmXC5HcnIy/Pz8NIbA/PXXX7CyssL58+cxe/ZsrFq1SuM5qlWrpva4WLFiePv2bc6+ECKiLGAOJCJDxfxHuYEdYyqwrK2tUaZMGa11yoRpZmYGd3d3mJhoftS9vb3h4OCA8uXL4+3bt+jduzdOnz6t1sbU1FTtsUwmQ3Jycs69CCKiLGIOJCJDxfxHuYFDqalQUibMEiVKaE2IaY0YMQK3b9/G7t278yA6IqLcxRxIRIaK+Y+yih1jIgBWVlYYOnQopk2bBiFEfodDRJSnmAOJyFAx/5ESO8ZE/xo5ciSCgoKwY8eO/A6FiCjPMQcSkaFi/iMAkAmeGiEiIiIiIiIDxivGREREREREZNDYMSYiIiIiIiKDxo4xERERERERGTR2jImIiIiIiMigsWNMREREREREBo0dYyIiIiIiIjJo7BgTERERERGRQWPHmIiIiIiIiAwaO8ZERERERERk0NgxJiIiIiIiIoPGjjEREREREREZNHaMiYiIiIiIyKCxY0xEREREREQGjR1jIiIiIiIiMmjsGBMREREREZFBY8eYiIiIiIiIDBo7xkRERERERGTQ2DEmyUtOTkaVKlUwa9as/A6FcplcLkfx4sWxYsWK/A6FSDI6dOiAoUOH5ncYlAcaNGiAL7/8Mr/DICIySOwY57CNGzdCJpPh8uXL+R1KrgsJCcHw4cPh4eEBCwsLeHl5YfDgwWptdu/ejbZt28Ld3R3m5ubw9PREjx49cPv2bb2fZ+vWrXj+/DlGjhypKouOjsa0adPQrl07ODk5QSaTYePGjTr3ERQUhHbt2sHGxgZOTk749NNPERoaqtEuOTkZ8+fPh7e3NywsLFCtWjVs3bpVo92ePXtQoUIF2Nvbo3Pnznj16pVGmy5dumDYsGF6v04lhUIBd3d3yGQyHDp0SGubgQMHwsbGRuc+bGxsMHDgQNXjU6dOQSaTqX5MTU1RqlQp9O/fH48fP1a1e/LkCWQyGRYuXKh12y1btmh9vsaNG0Mmk6FKlSoadXK5HMuWLUPdunVha2sLGxsb1K1bF8uWLYNcLldra2pqivHjx2PWrFmIj4/X+fpImgwh/z1//hzTp09HvXr14OjoiCJFiqBFixY4duyYRtvjx4/js88+Q7ly5WBlZYVSpUphyJAheP36td7Pd+bMGRw9ehSTJ09WK581axa6dOkCV1dXyGQyfP/99zr38fLlS/Tq1QsODg6ws7ND165d1b73qa1btw4VK1aEhYUFypYti59++klrTLVq1YKtrS1atGiBu3fvarQZPXo02rZtq/frTK1evXqQyWRYuXKl1vrvv/8eMpkMYWFhWuurVKmCFi1aqB4r85ryx9jYGCVKlMDHH3+M69evq20rk8nU/tak3vaHH37Q+nz9+vWDTCbTmpOFEPj111/RrFkzODg4wMrKClWrVsWMGTMQExOj0X7y5MlYvnw53rx5o/W5iIgo97BjTFny/Plz1K1bF4cOHcLnn3+OFStWYMiQIRqdzVu3bsHR0RFjxozBihUr8MUXX+DatWuoV68ebty4oddzLViwAH369IG9vb2qLCwsDDNmzEBQUBCqV6+e7vYvXrxAs2bN8PDhQ8yePRsTJ07EwYMH4evri8TERLW233zzDSZPngxfX1/89NNPKFGiBPz8/LBt2zZVm8ePH6N3796oV68e5s6di/v372PQoEFq+zly5AhOnz6dpavcJ06cwOvXr+Hl5QV/f/9Mb5+e0aNH49dff8WaNWvQsWNHbN++HXXr1tXasU/LwsICAQEBGuVPnjzB2bNnYWFhoVEXExMDX19fjBkzBm5ubpg7dy4WLFgAd3d3jBkzBr6+vhoHh4MGDUJYWJjW5yLKb3v37sW8efNQpkwZ/PDDD5g6dSqioqLg6+uLDRs2qLWdPHkyTp06hY8//hjLli1Dnz598Ntvv6FmzZp6d3wWLFgAHx8flClTRq3822+/xaVLl1CzZs10t4+OjkbLli3x559/4uuvv8b06dNx7do1NG/eHO/evVNru3r1agwZMgSVK1fGTz/9hIYNG2L06NGYN2+eqs2HDx/QtWtXuLu7Y8GCBYiPj0f37t2hUChUbf755x+sXbsWP/74o16vMbUHDx7g0qVLuZL/+vbti19//RXr16+Hn58fTpw4gQYNGmh0jrWxsLDQepI0JiYGe/fu1Zr/FAoF+vTpg/79+wNI6dAvWbIENWrUwPTp09GgQQOEhISobdO1a1fY2dlx1AwRUX4QlKM2bNggAIhLly7ldyi5qn379sLb21uEhYVlets3b94IExMTMXz48AzbXr16VQAQx44dUyuPj48Xr1+/FkIIcenSJQFAbNiwQes+vvjiC2FpaSmePn2qKgsMDBQAxOrVq1VlL168EKampmLEiBGqsuTkZNG0aVPh6ekpkpKShBBCrFy5UpQqVUokJycLIYQ4efKkkMlkIi4uTgghhFwuFxUrVhSLFi3S47ehqX///qJWrVpi6dKlwtraWkRHR2u0GTBggLC2tta5D2trazFgwADV45MnTwoAYseOHWrtli1bJgCI2bNnCyGECA4OFgDEggULNLbt1q2bMDExEaGhoWr7mDVrlnB1dRVNmjQRlStXVqsbNmyYACB++uknjRh//vlnAUB8/vnnGnWdOnUSTZs21fn6SJoMIf/dvn1b4zsQHx8vKlSoIDw9PdXK//zzT6FQKDTKAIhvvvkmw+cKCQkRJiYm4pdfftGoCw4OFkIIERoaKgCIadOmad3HvHnzBABx8eJFVVlQUJAwNjYWX331laosNjZWODs7i44dO6pt369fP2FtbS3Cw8OFEEIcOnRIWFlZqfKdMmfcvXtXtU3r1q3FqFGjMnx92nz33XfCxcVF7Nq1S8hkMtXrTG3atGkCgMb7oFS5cmXRvHlz1WNteU0IIfbt2ycAiGHDhqnKAKj9DVBu261bNwFAXL9+XW0f/v7+wtTUVHTu3FkjJ8+ePVsAEBMnTtSIcd++fcLIyEi0a9dOo27kyJGiZMmSqr8xRESUN3jFOA8oh70+e/YMnTp1go2NDTw8PLB8+XIAKVdVW7VqBWtra5QsWVLjSll4eDgmTpyIqlWrwsbGBnZ2dmjfvr3WK65Pnz5Fly5dYG1tDRcXF4wbNw5HjhyBTCbDqVOn1NpeuHAB7dq1g729PaysrNC8eXOcOXMmw9dz9+5dHDp0CJMmTYKzszPi4+M1hsSmx8XFBVZWVoiIiMiw7Z49e2BmZoZmzZqplZubm8PNzU2v59u1axc6deqEEiVKqMpat26NcuXK4bffflOV7d27F3K5HP/73/9UZTKZDF988QVevHiBc+fOAQDi4uLg4OAAmUwGAHBycoIQAnFxcQCAn3/+GQqFAqNGjdIrvtTi4uKwe/du9OnTB7169UJcXBz27t2b6f3oq1WrVgCA4ODgDNt27doV5ubm2LFjh1p5QEAAevXqBWNjY7XyFy9eYN26dWjVqpXa0ESlESNGoGXLlvjll1/w4sULtTpfX1/8/fffCA8Pz+xLIokpbPmvcuXKKFKkiFqZubk5OnTogBcvXiAqKkpV3qxZMxgZqf+ZbdasGZycnBAUFJThcx08eBBJSUlo3bq1Rp2Xl1eG2wPAzp07UbduXdStW1dVVqFCBfj4+Kjlv5MnT+Ldu3dq+Q9I+Z7GxMTg4MGDAFJylIWFheoKqZOTEwAgNjYWQErOvnbtGqZPn65XfGkFBASgR48e6NSpE+zt7XN15Ehm8l/Dhg3h7e2tEY+/v7/qlp7U4uLisGDBApQrVw5z5szR2F/nzp0xYMAAHD58GOfPn1er8/X1xdOnT/W6kk1ERDmHHeM8olAo0L59exQvXhzz58+Hl5cXRo4ciY0bN6Jdu3aoU6cO5s2bB1tbW/Tv31/tD/Xjx4+xZ88edOrUCYsXL8akSZNw69YtNG/eXG0IbExMDFq1aoVjx45h9OjR+Oabb3D27FmNe9OAlOG6zZo1Q2RkJKZNm4bZs2cjIiICrVq1wsWLF9N9Lcp76VxdXeHj4wNLS0tYWlqiffv2ePLkidZtIiIiEBoailu3bmHIkCGIjIyEj49Phr+3s2fPokqVKjA1Nc2wrTYvX77E27dvUadOHY26evXq4dq1a6rH165dg7W1NSpWrKjRTlkPAHXr1sW1a9ewdetWBAcHY9asWShTpgwcHR0RGhqK6dOnY/HixVmKed++fYiOjkafPn3g5uaGFi1a5PhwwtQePXoEAHB2ds6wrZWVFbp27ao2nPDGjRv4559/4Ofnp9H+0KFDUCgUqmGE2vTv3x9JSUk4fPiwWnnt2rUhhMDZs2f1fSkkYYUp/+ny5s0bWFlZwcrKKt120dHRiI6O1uhca3P27Fk4OzujZMmSWYopOTkZN2/e1Jn/Hj16pOrIK/Nb2ra1a9eGkZGRqr5mzZr48OEDFi1ahKdPn2LatGmwt7dH+fLlkZCQgAkTJmD69OlwdHTMdLwXLlzAw4cP0bdvX5iZmaFbt26SyX9AylDsbdu2QQgBIOWWnqNHj2rNf3///Tfev38PPz8/mJiYaN2fMjceOHBArbx27doAoNeJGiIiykH5fMW60NE2lHDAgAFqw1WFEOL9+/fC0tJSyGQysW3bNlX53bt3NYbFxcfHawzHCw4OFubm5mLGjBmqskWLFgkAYs+ePaqyuLg4UaFCBQFAnDx5UgiRMjy4bNmyom3btmpDtWJjY4W3t7fw9fVN9zWOHj1aABDOzs6iXbt2Yvv27WLBggXCxsZGlC5dWsTExGhsU758eQFAABA2Njbi22+/1XhN2nh6eoru3bun2ya9odTKus2bN2vUTZo0SQAQ8fHxQgghOnbsKEqVKqXRLiYmRgAQU6ZMUZUpfwcAhJOTkzhx4oQQQoihQ4dqHRqnr06dOonGjRurHq9Zs0aYmJiIt2/fqrXL6lDq9evXi9DQUPHq1Stx8OBB4eXlJWQymerzmt5Q6h07dogDBw4ImUwmnj17JoRI+R0qf2fNmzdXG0o9duxYAUBcu3ZNZ5zKofLjx49XK3/16pUAIObNm6dzW5IeQ8h/2jx48EBYWFiITz/9NMO2M2fOFADE8ePHM2zbpEkTUbt27XTbpDeUWlmX+vektHz5crUh0CNGjBDGxsZan6No0aKiT58+qscLFiwQxsbGAoCwtLQUAQEBQoiU2yqqVKmiuu0ks0aOHCmKFy+uel+OHj2qNYdkdSj19OnTRWhoqHjz5o04deqUqFmzpgAgdu3apWoLHUOpFyxYIG7fvi0AiL/++ksIkfI7tLGxETExMRo5ecmSJQKA2L17t87XGx4erhqmnZaZmZn44osvdG5LREQ5j1eM89CQIUNU/3dwcED58uVhbW2NXr16qcrLly8PBwcHtRlDzc3NVcPxFAoF3r17BxsbG5QvXx5Xr15VtTt8+DA8PDzQpUsXVZmFhYXGMh/Xr1/HgwcP4Ofnh3fv3iEsLAxhYWGIiYmBj48PTp8+jeTkZJ2vIzo6GgDg5uaGgwcPolevXpg4cSLWrl2LR48eaR36tmHDBhw+fBgrVqxAxYoVERcXpzZZiy7v3r3L0pUHJeXwZnNzc4065VBAZZu4uDi92gHA0qVL8fTpU1y4cAFPnz5Fy5Ytcf36dWzevBk//vgjPnz4gE8++QQeHh5o0aKFXsMm3717hyNHjqBv376qsu7du0Mmk6kNecyOzz77DEWLFoW7uzs6duyImJgYbNq0SesVJW3atGkDJycn1VWTbdu2qcWbmvJKlK2trc79KesiIyPVypXvua5ZZ6ngKSz5L63Y2Fj07NkTlpaWmDt3brptT58+jenTp6NXr16qYbzpyev8Z2ZmpnU/FhYWavlv4sSJePnyJc6dO4eXL1+ib9++ePXqFebMmYMlS5YgKSkJo0aNQokSJVCvXj29rnwmJSVh+/bt6N27t+o2lVatWsHFxSXHrhpPmzYNRYsWVY3GefToEebNm4du3brptX3lypXVVioICAhA165dtY4SyE7+A1JyIPMfEVHe0j6+h3KchYUFihYtqlZmb28PT09P1UFA6vL379+rHicnJ2Pp0qVYsWIFgoOD1TqUqYeAPX36FKVLl9bYX9rZTB88eAAAGDBggM54P3z4oPOAzNLSEgDQq1cvtfvnevbsiU8//RRnz55VOwgGUu7PUurTp49quHLqZYF0Ef8OW8sKZawJCQkadcrlgJRtLC0t9WqnVKJECbX7lkePHo3PP/8cFSpUwCeffILnz59j79692LRpEzp37oy7d+/qHFIHANu3b4dcLkfNmjXx8OFDVXn9+vXh7++PESNG6PuyAUDjcwAA3333HZo2bQpjY2MUKVIEFStWTDemtExNTdGzZ08EBASgXr16eP78udZhhMB/B32p77lMS9fBo/I91/YaqOApTPkvNeWsw3fu3MGhQ4fg7u6us+3du3fx8ccfo0qVKvjll18y3LdSXua/tLP0p26bNv+5urrC1dVV9Xjy5Mnw8fGBj48Pvv32Wxw/fhzbt2/HyZMn0bFjRzx58gQODg46Yz169ChCQ0NRr149tfzXsmVLbN26FfPmzdO4Xzs92nLHsGHD0LNnTxgZGcHBwQGVK1fWetIgPX5+fli0aBHGjRuHs2fP4uuvv9baLjv5D0h535n/iIjyFjvGeSTtxEQZlac+GJo9ezamTp2Kzz77DDNnzoSTkxOMjIwwduzYTF3ZUFJus2DBAtSoUUNrm/TWyFUe/KU+KAJSXouzs7PaQa02jo6OaNWqFfz9/TPsGOuzv/QUK1YMALSuG/r69Ws4OTmpDoyKFSuGkydPahyQKLdN76B3+/btCAoKwr59+6BQKPDbb7/h6NGjqFOnDipXroy1a9fi/PnzaNKkic59KK+KNG7cWGv948ePUapUKQApHY2EhAStB09CCMTHx2tdPqRq1apaJ/LJDD8/P6xatQrff/89qlevjkqVKmltpzz5cfPmTZ2fs5s3bwKAxj6U77k+92GS9BWm/Jfa0KFDceDAAfj7+6d7Bfj58+do06YN7O3t8ccff6R7FTG17OY/ZX7Tlf+A//JasWLFoFAo8PbtW7i4uKjaJSYm4t27d+nmv/Pnz2Pnzp2q9em3bt2KqVOnomHDhmjYsCFWr16NAwcO4JNPPtG5D2X+Sz2CILU///wTLVu2BKB9FE9qsbGxWvNf2bJls53/+vbti6+++gpDhw6Fs7Mz2rRpo7Vd6vz30UcfaW2jK/8BKfNyMP8REeUtdowLgJ07d6Jly5ZYt26dWnnaP5wlS5bEnTt3NDpLqc++A0Dp0qUBAHZ2dlk6SFBODPLy5Uu18sTERISFhWlcGdImLi4OHz58yLBdhQoV9JoxVBcPDw8ULVoUly9f1qi7ePGi2oFxjRo18MsvvyAoKEjtQOXChQuqem1iY2MxadIkzJw5Ew4ODggJCYFcLlcdSFpaWsLR0VHj95VacHAwzp49i5EjR6J58+ZqdcnJyfj0008REBCAb7/9FkDKe52UlIRHjx5pXBF7+PAhFApFlifsyUiTJk1QokQJnDp1Sm1907Tat28PY2Nj/Prrrzon4Nq8eTNMTEzQrl07tXLle552IjQyPFLLf0qTJk3Chg0bsGTJEp23EwApw6HbtGmDhIQEHD9+XHWyTh8VKlTArl27shyjkZERqlatqjX/XbhwAaVKlVJ10pX57fLly+jQoYOq3eXLl5GcnKwz/wkhMHr0aIwZM0b1u3316pVaR9rd3T3d/KdcC7h3797o0aOHRv3o0aPh7++v6hgrc9u9e/dQvHhxtbaxsbGqExG5oUSJEmjcuDFOnTqFL774QueImyZNmsDBwQEBAQH45ptvtJ4E2rx5MwCgU6dOauUvX75EYmIi8x8RUR7jPcYFgLGxscZwuh07dmgcaLRt2xYvX77Evn37VGXx8fFYu3atWrvatWujdOnSWLhwoep+4dRCQ0PTjadFixaq+76Uw/EAYOPGjVAoFPD19VWVvX37VmP7J0+e4Pjx43rd19qwYUPcvn1b61BAfXXv3h0HDhzA8+fPVWXHjx/H/fv30bNnT1VZ165dYWpqihUrVqjKhBBYtWoVPDw80KhRI637nzdvHhwdHVX3Mjo7O8PExAR3794FkHKfbGhoaLrLSymvlnz55Zfo0aOH2k+vXr3QvHlztfvs2rdvDyBlaai0lMvgKNvkNJlMhmXLlmHatGn49NNPdbYrXrw4Bg0ahGPHjmHlypUa9atWrcKJEycwePBgeHp6qtVduXIFMplMbQg+GSap5T8g5WrzwoUL8fXXX2PMmDE628XExKBDhw54+fIl/vjjD5QtWzbDfafWsGFDvH//Xu2e68zq0aMHLl26pNY5vnfvHk6cOKGW/1q1agUnJyeN7+rKlSthZWWFjh07at3/xo0b8fz5c3zzzTeqMldXV1X+k8vlePjwYbr5b/fu3YiJicGIESM08p9y6aZdu3ap/g74+PjAzMwMK1eu1Bg1sGbNGiQlJeVa/gOAH374AdOmTUt3ST4rKytMnDgR9+7dU/vdKB08eBAbN25E27Zt0aBBA7W6K1euAIDOvzlERJQ7eMW4AOjUqRNmzJiBQYMGoVGjRrh16xb8/f1Vw2qVhg8fjp9//hl9+/bFmDFjUKxYMfj7+6uGlCmvohgZGeGXX35B+/btUblyZQwaNAgeHh54+fIlTp48CTs7O+zfv19nPObm5liwYAEGDBiAZs2a4dNPP8WzZ8+wdOlSNG3aVG0ik6pVq8LHxwc1atSAo6MjHjx4gHXr1kEul2c4UQ2Q0lmdOXMm/vzzT40rAD///DMiIiJUS7bs379ftR7uqFGjYG9vDwD4+uuvsWPHDrRs2RJjxoxBdHQ0FixYgKpVq2LQoEGq/Xl6emLs2LFYsGAB5HI56tatiz179uCvv/6Cv7+/1jP+z549w4IFC3Dw4EFVvYmJCbp27YqxY8fi2bNn2L17N9zd3dPt5Pn7+6NGjRoaVz+UunTpglGjRuHq1auoVasWatSogSFDhmDp0qV48OCB6mREYGAg/vjjDwwZMgTVq1fP8PebVV27dkXXrl0zbPfjjz/i7t27+N///ofDhw+rrgwfOXIEe/fuRfPmzbFo0SKN7QIDA9G4cWO9l1Ghwktq+W/37t348ssvUbZsWVSsWBFbtmxRq/f19VXdZtKvXz9cvHgRn332GYKCgtQm4bOxsdE5xFapY8eOMDExwbFjxzBs2DC1ul9//RVPnz5VrR98+vRp/PDDDwCATz/9VHVV9X//+x/Wrl2Ljh07YuLEiTA1NcXixYvh6uqKCRMmqPZnaWmJmTNnYsSIEejZsyfatm2Lv/76C1u2bMGsWbM01ukFUu6R/frrrzF79my14eE9evTAjBkzkJycjDNnziA+Pl7tKnRa/v7+cHZ21tkR7NKlC9auXYuDBw+iW7ducHFxwXfffYdvv/0WzZo1Q5cuXWBlZYWzZ89i69ataNOmDTp37pzu7zY7mjdvrjGyR5spU6bg2rVrmDdvHs6dO4fu3bvD0tISf//9N7Zs2YKKFSti06ZNGtsFBgaiRIkSqFmzZm6ET0REuuT9RNiFm67lSrQtrZN2eRulkiVLio4dO6oex8fHiwkTJohixYoJS0tL0bhxY3Hu3DnRvHlztSUphBDi8ePHomPHjsLS0lIULVpUTJgwQezatUsAEOfPn1dre+3aNdGtWzfh7OwszM3NRcmSJUWvXr30WkZECCG2bt0qqlevLszNzYWrq6sYOXKkiIyMVGszbdo0UadOHeHo6ChMTEyEu7u76NOnj7h586ZezyGEENWqVRODBw/WKC9ZsqRqyaS0P8HBwWptb9++Ldq0aSOsrKyEg4OD6Nevn3jz5o3GPhUKhZg9e7YoWbKkMDMzE5UrVxZbtmzRGVvPnj21LrUREhIiOnfuLGxtbUWtWrXE5cuXde7jypUrAoCYOnWqzjZPnjwRAMS4cePUYl26dKmoXr26sLCwEBYWFqJ69epi2bJlGsvbpF5yKT0ZLdeUHl2f54SEBPHjjz+K2rVrC2tra2FlZSVq1aollixZIhITEzXaR0RECDMzM/HLL7+k+3wkPYaQ/5RLBen6US4LpXwtutqVLFky3edR6tKli/Dx8dEob968uV4xCCHE8+fPRY8ePYSdnZ2wsbERnTp1Eg8ePND6fGvWrBHly5cXZmZmonTp0uLHH39UW9YqtUmTJok6depo1EdHR4v+/fsLBwcHUaFCBXH48GGdry8kJESYmJiku9RVbGyssLKyEh9//LFa+Zb/t3fncVHV6wPHPzPDvgsKgoi7ISrirrmkuaXhtbRb3qumLWY3NbXUlDAzDbtaV7Os1Ba161Jey8qlxNzyipZbLoC44IqCgOzbwJzfH/yYKzDsoxzgeb9evHTOnPnOcxYe5pnzPd/vv/+t9OjRQ7G3t1esra0VX19fZcGCBcYp+AqYymsloZTpmkpT0nmel5enfPXVV0qvXr0UJycnxcbGRmnbtq2yYMECJS0tzeT6np6eSnBwcJmxCiGEMC+NolRhyEtRIyxfvpwZM2Zw48YNGjVqVN3hVNjXX3/N5MmTuXbtWqmjmoraYfny5SxZsoRLly4VGwlXiIqq6fnvt99+o1+/fkRGRla4K7aoebZt28bf//53Ll26VKH70YUQQlSdFMa1TGZmZqFiIisri44dO5KXl0dUVFQ1RlZ5BoMBf39//va3v5m8V0vUHnq9nhYtWjBnzhxeeeWV6g5H1DC1Mf9B/ngB3t7exe6XFrVPz5496dOnD0uWLKnuUIQQos6RwriWGTp0KD4+PgQEBJCcnMy///1vzp07x4YNG0qcb1YIIWoDyX9CCCGEqCwZfKuWGTJkCJ9//jkbNmwgLy8PPz8/Nm/ezDPPPFPdoQkhxH0l+U8IIYQQlSVXjIUQQgghhBBC1Gkyj7EQQgghhBBCiDpNulKTP7hTTEwMjo6OxrkuhRC1h6IopKam4uXlhVYr3wfeS/KfELWf5EAhhCibFMZATEwMjRs3ru4whBD32fXr1/H29q7uMFRF8p8QdYfkQCGEKFm1FsYHDx5k6dKlHD9+nFu3bvH999/zxBNPGJ9XFIX58+ezZs0akpKS6NWrF59++mmhuRwTExOZOnUqP/30E1qtllGjRvHhhx/i4OBQ7jgcHR2B/D8YTk5O6PV6du/ezeDBg7G0tDTb9pqL6uNL1LM7bDeDkwZjaa3C+NCz23Y3gzMHY4kK48vWs9tlN4N7DsbSVX3xQQ04B4vEl5KSQuPGjY2/62qhhhxYNP9BzTu+aiM5sGrUngNVf/6ZiE+tOVAIIdSkWgvj9PR0OnTowPPPP8/IkSOLPb9kyRJWrFjBunXraNasGfPmzWPIkCGEh4djY2MDwJgxY7h16xahoaHo9Xqee+45XnrpJTZu3FjuOAq6Dzo5ORkLYzs7O5ycnFT7R69GxKdxwtK+cvFlG/K4mJGG4f8fX8hI5XJmOtZaLZ/HRNPY2hZnC0v+m5xQySidmMuRSr72AbjqxNw/VRwfAGqPMT++K+89blyitq7CasiBRfMfqCPH6PMMnL6RzJ3U7GLP5eXlcjbNnv07LvNLeBwNnWzQ5xlISM+phkhLovIco/b4VJ8Da0Z8i0e252/dfIxL1ZYDhRBCTaq1MB46dChDhw41+ZyiKCxfvpzg4GBGjBgBwPr16/Hw8GDbtm2MHj2aiIgIfv75Z/744w+6dOkCwEcffcSwYcN4//338fLyMtl2dnY22dn/+7CVkpIC5H8YLPgpeKxGqo8v9//jQw//P+b59awM9t2Nx9LEH+XUvFxWXL9EtsGADg15lD1QenKuOrddqM+9v9NqUx05sKz8V/D/e/+tDINBIeJ2KrdTskpc56fTt9lx5rbxsYU2Pz/kGsozWYIOiAMo9T2EqMvy8vJUnQOFEEJNVHuPcXR0NLdv32bgwIHGZc7OznTv3p2wsDBGjx5NWFgYLi4uxg+EAAMHDkSr1XL06FGefPJJk20vXryYBQsWFFu+e/du7OzsjI9DQ0PNuEXmp/b43k7Zwx+XtESnlv8b6vIUxUJUxM6dO8nIyKjuMCrsfuXAkvLfm+tCsba1u2eJhr3r9hgfxWRoiEnX0Mj+f7+jv9/JH8THUlv491ZvqNxVqfIVxEKI8jpz5gyOcadrZA4UQogHTbWF8e3b+VcRPDw8Ci338PAwPnf79m3c3d0LPW9hYYGrq6txHVPmzp3La6+9ZnxccO/N4MGDjV2pQ0NDGTRokGq7Kqs1PkVRWLbjAp8evVLdoQgBwLBhw4xXRWuS+5UDS8p/O6/r0FrryozrZkbxoreyhbAQ4v5q3749w7p418gcWJrg4GBsbGyMI2yPHz+ea9eu8f333+Pi4oKiKIwaNQpfX1/jugaDgWbNmjFmzBizjsx9+vRpPvvsM0JCQnBxcTFbu6VZv349Fy5cYMSIEYW+GD148CB2dnaFllXU1atXOX78uMnbe+6H06dPs3XrVry9vZk4cWKp64aFhRmPMUDz5s0ZPXq0yXWjo6PZtGkTt27dYunSpcbbj8whISGBd955Bw8PD3Jzc+nVqxcDBgyoVFsl7e+CcQKK+ve//82QIUNo0KBBpd7PlILzydraGgcHByZMmFDmuRwWFka7du2qPG7BH3/8QVpaGv379y8Ui62tLQD9+/endevWJvd3aXF//fXXDB48uNhnqLKotjC+n6ytrbG2ti623NLSslChWfSx2qgpvp/P3uLlf5+o7jBwziv7A7pGAzpbA3mZWhQVXqAqiE+TZ4mdk1V1h2OSoihkZGRgZ2enynvW7o1PTb8nalBS/qttHK0tqGdfPb8/OZm56A3Zqs8xao/PzsYOjU7d+UXt+c/JzrrW5sCZM2cWKnauXbtG9+7dGTVqFDExMXz22We88847xnWtrKz48MMPOXHiRJUKx6IiIiJo2rQpkZGR9OjRw2ztluXpp5+mffv2hZb17du3yu02adKEJk2aVLmd8vL398fGxoYDBw6Ua/2CY1yWZs2aERQURHBwcFVDNMnT05M5c+aQlZXF4sWL6dChA/Xr169wOyXt79DQUJOF8dixYysVb1kKzqcff/yRnTt38ve//73U9Y8cOUKTJk2qXBjv37+fV1991WQsBRISEkzu79Li7t27N3v27GHMmDEVike1hXHDhg0BiI2NxdPT07g8NjaWgIAA4zpxcXGFXpebm0tiYqLx9eL+iY5Pp//7++/7+7TO0aJBQ6ZGQa9RaJdjgQFwz9PgoOR/KHE0aNBRvg8oGguFRr3SuBlqi5Krvg81BfHZ3G7CkEnty35BNdDr9ezcuZNhw/qo8gPXvfHVVHU1B9pYanG1yy9o8xSF2JRseresj5+Xk3GdvLw8oi9H06x5My7eyeCR1g0Y3NaD+g7W6LQaLHXVO09r+L4bXEg5rvoco/b4BnV7GDtP813lMZealP/UGN+D4OXlRU5ODgaDwbhMq9XSrFkz7t69C+Rfdfb39yc8PBxfX99CVx7Xrl1LQECAMdeWJioqihEjRnD8+HF69OhBQkICX375JbNmzQJg165d2Nra0q9fP3755ReOHj2Kt7c34eHhvP/++2bb5lWrVhEdHc1jjz1Gv379gPyreidPniQrK4ukpCRefvnlEsffKYj1yJEjha7ebt++ncuXL5OamoqPjw83btxg9uzZJCYmsnbtWnJzc7GxsWHs2LE0aNCAxMREPv/8cxRFwdHRER8fHwIDA4mJiWHz5s1kZ2fj6enJuHHj0OnK7qVUHj/99BNnzpxBo9EQEBBQ4tgdAAaDgbVr1xITEwPAM888Q6tWrcjIyGDDhg0kJCRgbW3NhAkTqFevXpnvbWNjQ+PGjUlISMDFxYWvv/6amzdvYmdnx4QJE3B1dSUpKYk1a9ag1+vRarW8+uqr2NnZmdzfJ06c4OeffyYzM5OQkBCcnZ2ZPHkyaWlprFixgjt37jBr1izjcVywYAFvvPEGNjY2XLp0ib179zJx4sRK7+8WLVqwf/9+AE6dOsWuXbvQaDS4u7szduxYUlNTWbVqFXfu3GHNmjVYWlryyiuv4OLiwokTJ9i9eze5ubn07NmzzKvot27dwt7evkJf1t+7v0uKG6Bp06asW7cOg8FQoR4iqi2MmzVrRsOGDfn111+NiSklJYWjR4/yj3/8A4CePXuSlJTE8ePH6dy5MwB79+7FYDDQvXv36gq9VsvS5+E77+cqtdE70wJTv5o5Gmiq19IoL78QFqIuq4k50N2x8B83BbiTmo2/tzNdmriafI2CQrP69jzRsRFONuX7EJ//wf8Swx57qM5+8Beirnv//ffRarXY2toyY8aMQs9dunQJe3v7Qh+I9Xo9165d46mnnjIu8/X1ZdSoUcyfP5+0tDTjNHd3794lMzOzzBgSExOxtramTZs2fPvttyiKgpubG7m5uaSmpuLo6MiZM2eYOHEiCQkJ/P7778ydO5dr165x7NgxM+2JfJMmTWL79u3FlqekpDB79mwOHTrEoUOHePrpp0tsY+jQobRo0aLY1dvevXtz4cIFWrVqhbW1Nbdu3cLNzY2pU6diY2PDuXPn2LFjBxMmTGDHjh307duXHj16sGLFCmMbGzdu5LnnnsPNzY2tW7dy/PhxunXrVuHtPHr0KOfPnwdg8ODBdOnShT59+jB8+HAUReH999+nS5cuJXY1vnHjBklJSQQHB5OXl2ccjHLnzp107NiRLl26cObMGXbu3Fmuq42pqalcvXqVp556imPHjqHT6QgODubw4cPs2LGDcePGcezYMdq0aUNgYCAZGRlYWVmVuL87depEp06dmDVrFkFBQcblDg4OBAUFsWzZskLv7+fnR0REBB07duT06dP4+/sDld/fkZGRdOrUCcjvqv7GG2+g1WrZtm0bf/zxB7169TLG8cwzzxgL9JSUFPbu3cvrr7+OTqfjgw8+oGPHjri6mv7bD/nd3U3Nq/7tt9/y008/ATBx4sRCv8f37u+S4ob8EfgLbisr7cugoqq1ME5LS+PixYvGx9HR0Zw6dQpXV1d8fHyYPn06ixYtolWrVsapSry8vIzzfLZp04bHHnuMiRMn8tlnn6HX65kyZQqjR4+u0E4Q5WMwKJUuiv+WaoVXnhatFLxCGKkpBwYHtsHWPr9LVF5eHufOnqVtu3aFvmE2GBTSc3Lp2LgeOu3/fpdbuTtUW7dlIUTdVLQrNfyvaLK1teXZZ581Ln///fdJSEjgkUceKZQbW7ZsiU6nw9XVldTUVGNhXLTQLklkZCStWrXC0tISV1dXbt68ibe3N+3bt+fs2bP4+fmhKAr16tXj5MmTtGzZEktLS1q0aPHAvtRr3rw5Wq0WDw8PoqKiKtWGvb298cfOzo6srCwMBgObN2/m9u3b5Obm4uzsDOTfM1swk4Kvry85OTlkZmZy/fp1Vq1aBUBOTg729vaVisVUV+qLFy+yZ88eDAYDCQkJJCUllVgYu7q6cvfuXb777jt8fX3x8/MD8q/8R0ZGsnv3bgwGQ6kFHeRf7QwJCUGr1RIYGIiLiwvXrl0ztteuXTv27dsHQOPGjdm0aRMWFha0a9fOZDFYWR06dODIkSN07NiRc+fOMWTIkErt72+//ZYNGzbg7u5u/IyRnJzMl19+SUZGBmlpafTu3bvE10dHRxMXF8fSpUsByMzMJD4+vtT9mJKSYvydu5eprtSm9ndJcRdwdHQkOTm55hTGx44dM95sDRgHhBk/fjxr165l9uzZpKen89JLL5GUlETv3r35+eefCyXCDRs2MGXKFAYMGIBWq2XUqFGFvqES5qEoCs2DdlboNe55GsakWmMhxbAQJqkpB47u6lNoHuOd8WcY1q2xXJEVQtQYJd1/OnPmTDIzM/nnP/9Jly5djIVJwZUojUaDUokb7iMjI7l48SKnT58mPT2dyMhIvL296dChA7t27SIvL894Ba+6FHy5WXQb16xZw507d2jfvj3Dhw8vtY2Ce+kL/jUYDOzbt48GDRrwwgsvEB0dzbZt20ptw8XFpdAVUHPR6/Vs3bqVuXPn4uTkxMcff1xoO4uOA+Dg4MCbb75JeHg427dvJzY2lv79+6PRaJg8eXK5uk/D/+4xLo+HHnqIadOmcebMGT799FNefvllGjduXP6NLEXLli3ZtGkTsbGxODk5YWdnR2ZmZoX399NPP42vry+ffPIJv/32G/369WPLli0MGzYMX19fdu/eTU5OTomv12g0+Pv7V+geaEtLy3JPJVfS/jYVdwG9Xl/hzzDVeiNWv379UBSl2M/atWuB/J38zjvvcPv2bbKystizZw+tW7cu1IarqysbN24kNTXV+M2GqW8fRNU0m1v+onjHhJ582DOXCZlSFAtRGsmBQgjxYNSrV4/Bgwezd+/eMtddu3Ytp06dKnUdRVG4cOEC8+fPZ/78+bz00ktERkYC+VcI4+LiOHHihHGQIB8fHy5evIher+fSpUvFCoIFCxaQlJRUqW2rjIkTJxIUFFRmUVySrKwsYxF5/Phx4/ImTZoQEREBYNwftra22NjYGK9Yp6amFrtHtLIK7tu1t7cnKSmJy5cvF3rezs6O5ORk4+O0tDQURaFTp048/PDDxjhatWpFWFiYsc0bN25UOBYfHx/jtp87dw4fHx8gv8u9s7Mzffv2pUWLFiQmJpbZlkajITc3t8z1Cu6d/+GHH4xfwlR2f1taWjJy5Ej27duHwWAwHuPc3Nxivw82Njakp6cbHzdp0oTz58+TmpoKwM2bN8ssej08PIiPjy8zrorGXSA+Pr7C462o9h5joQ7rDl9h/o/nylzvs7Gdeaxd/smnT9Bz8fz9jkwIIYQQovx69erF22+/Xeb0VeW5x/jGjRu4uroaBw5q2rQp165dM16leuihhzh79qyxG6ebmxvdunVj8eLF+Pj4FBvNNzY2lry8vEpt182bN1m3bh0pKSlotVoOHz7Myy+/XOF2lixZQnp6OmlpaYSEhDBs2LAS1+3Tpw9r1qzht99+K/SF7eOPP87nn3/OgQMHcHJywsIiv9QYP348mzZtIjMzE51Ox5gxY3Bzc6v4xhZhZ2dH165dWbhwIa6urjRt2rTQ84MGDeKTTz6hfv36TJ06leTkZNatWwfkF1QTJkwwxr1hwwYWLVqEoigMHDiwwl2eu3TpQkREBIsWLTIOvgVw4cIFdu/ebRzEqqC7tan9XTCmSO/evXnvvffw8PBg4sSJhIWFsW/fPuOgV66urkydOhXIH9l79erV/PWvfzXGUtn93bhxYxo0aMDp06cZOnQon3zyCfb29oUGAYX836VNmzZhb2/Piy++iLOzMyNHjmTFihUoioK9vT2vvPJKqe/VsmVLk/fFV8a9cQcEBJCWloaVlVWFLxRolMr0HallUlJScHZ2Jjk52TiPcf6IjsNU2Y3wQcSXkqXH/+3dZa535b3Hiy3TJ+jZeWgnN0Md1Dvi6aA01cdnc7t5DRiVumb8jhT9HRf/Y2rf1LTjqzb/G5Va3TlG7fEN6jZE5aNSq/P8MxWf5EB1yMrKwsbGhvj4eFavXl3prsXr16+nY8eOxaZrUoOcnBwsLCzQarWsX78ef3//Mkf3joqK4sCBA2XOYyxqn40bN9KrVy+zTxF28OBBdDodvXr1qtDr5IqxKMZgUMpVFF94t+Th8IUQQgghxP98++23XLt2Da1WW2xU3YqwsbHhu+++Izs726xzMptDTEwMX3/9NVqtlkaNGpV5j/Xp06fZtm0bLVq0eEARCjUZOnQot2/fNnu71tbWlfrdkMJYFLL0l0hW7rtU5nqn3hpU7XOFCiGEEELUFPeOlF0VpU25VN2aNm3KvHnzyr2+v79/tQ9QJqpPvXr1yj3gWUVUdspKqWyE0Qtr/yhXUXw0aAAudjI1ixBCCCFETbN+/XrefPNNtm7dWu7XHDx40OxzL1eHAwcO8M477/Dee+9VuS2DwcCHH35YbJCsZcuWlXtwsaioKNavX19oWVxcnPE+aPFgyRXjOk5RFM7FpBD40aEy1z311iApiIUQQggharBnn32WsLAwYmJiyv2avn373seIHpxHHnmEdu3asWbNmiq3dfr0aZo3b24cYMxc3N3dSUtLIzExscz5lIV5SWFch+0+d5uXvj5e9orA6bcH42SjvkFGhBBCCCGqQ2JiImvXriUjI4NGjRoxbtw4LCwsCA4Oxt/fn/DwcHx9fRk9enSJbYSFhXHy5EmysrJISkri5ZdfxsvLq8S2AWbNmsXDDz/MuXPn8PT0xMPDg8uXL5OamoqPjw83btxg9uzZxjZyc3OxsbFh7NixNGjQoMLbuWrVKqKjo3nssceM88QuW7YMa2trtFotmZmZuLu7M2bMGE6dOsWuXbuMIzCPHTsWKysrIiIi+Pbbb3F2diYnJ4cnnniC1q1bc+LECXbv3k1ubi49e/ZkwIABGAwG1q5dayzcn3nmGVq1alVifOfPn+c///kPBoOBHj16MGjQIBISEvjkk09wd3fn+vXrDB8+vNTutYsWLeLVV1/FycmJmJgYvv32W6ZPn17qfjl27BiDBg0qttzOzq7Q/Mnr169HURRiYmKwsLBg2rRpWFnlX2jS6XTY2BQfZLBt27acPHmSAQMGlBqDMC/pSl1HZebklasoHt21MZdDhklRLIQQQghxjx07dvDwww8THByMRqMp1NXY19eXefPmcfbsWdLS0kptJyUlhenTpzNw4EAOHTpUZtsZGRk0btyY4OBg/va3vwH50/u0bNmStm3b0qJFC27duoWDgwNTp05l7ty5DB48mB07dlRqOydNmkTv3r2LLR83bhzx8fG8+OKLxMXFAdC8eXPeeOMN5syZg6urK3/88QcA33zzDZMnT2bSpEncuXPHuN179+7l9ddfJygoiBMnTpCYmMiNGzdISkoiODiYuXPn0qhRo1Lj27hxIy+99BJz5szh8OHDxrlx4+Pj+dvf/saUKVPYs2dPqW106dKFEydOAPnzMnft2rXM/XLlyhWTsU2aNKnYlV57e3vmzp2Lq6sr4eHhxuUtWrQwec9448aNi83JLO4/uWJcR7V56+cy19n8Ug96NK/6HHNCCCGEELXN1atXGTFiBADt2rXj8uXL9OjRA8ifo1Wn0+Hq6kpqamqp86k2b94crVaLh4cHUVFRZbat0+no3LkzkH91EvILr4IfOzs7srKyMBgMbN68mdu3b5Obm4uzs7NZt9/BwQE7O7tC25acnMyXX35JRkYGaWlp9O7dm/T0dDQaDfXr1wcwzjUcHR1NXFwcS5cuBSAzM5P4+Hi8vLy4e/cu3333Hb6+vsZ5f01JT09Hq9Uar4S3bt2aGzdu0LhxY9zd3XFycsLR0ZHk5ORSt6Vr166sXbuWfv36cfr0aWbMmFHm9hdMTVUeLVu2BMDDw6PMWIByxSzMTwrjOuZ6YgZ9luwrc73V4zpLUSyEEEIIUQlabX6nTI1Gg6Iopa6r0+nKvS7kT0Vzb1fdgtfe+6/BYGDfvn00aNCAF154gejoaLZt21Zs/arQaDTGnwJbtmxh2LBh+Pr6snv3bnJyckp9vb+/P2PHji323Jtvvkl4eDjbt28nNjaW/v37Vzi+iuxXNzc3tFotf/75Jw0aNDB+4WAuBbEA5TrGer1elfOk13bSlbqOOHo5gaZzdpRZFAc/3oaoRUMZ3LbhA4pMCCGEEKLmadKkCREREQCEh4fj4+OjqrazsrKMU+EcP1749jk7O7v7ckWy4D1zc3M5deoUkH81W1EU4uPjyczM5MqVK0D+Np4/f57U1FQAbt68iV6vJy0tDUVR6NSpEw8//HCpIzzf27Zer+fChQt4e3uXGqOdnR2pqakYDIZCy7t27cqmTZvKPf+tm5sbSUlJ5Vq3ou7cuYOnp2ex5QsWLLhv7ynkinGd8I9/H2fX2bInz77y3uMPIBohhBBCiJrv8ccfZ+3atYSGhtKoUaNyF1QPqu0+ffqwZs0afvvtN1q3bl3ouTZt2rBnzx4WLVpEYGAgAQEBJtu4efMm69atIyUlBa1Wy+HDh3n55ZdLfM+hQ4fyySefYG9vX6iwe+aZZ1i5ciUuLi64u7tjYWGBs7MzI0eOZMWKFSiKgr29Pa+88grJycnG6YosLS2ZMGFCqds5evRoVq1ahcFgoGfPntSvX7/UYtrW1paOHTuycOFCAgICjF3WAwIC2Lp1K+3atSv1/Qq0bduWixcvmvW4F7h48SJt27Yttjw2Npa8vDyzv5/Ip1HKcz2/lktJScHZ2Znk5GScnJzQ6/Xs3LmTYcOGqbIbQ1nx5RkUjl5OIPJ2Ku9sDzfRQnH7Z/ajaX1788SXoGfnoZ3cDHVAya16Vx1z01goNBqUpvr4bG43Z8ik9tUdjkk17Xek6O+4+B9T+6amHV+1Cd93gwspx1WfY9Qe36BuQ7DzLD5aa3VT+/lnKj7JgaK6ZWVlYWNjQ25uLosXL2b69Ok4OjpWd1iFnD59mhMnTpRZiBdITExky5YtTJo0yaxx5OXlsXz5cmbMmGHski8eDLliXAskZeRw5HIiqVl6AGb953SFXr96XGezFcVCCCGEEELc6/fff+fAgQNA/gjaaiuKf/jhB06dOlXq1fCiXF1d6dSpE7m5uWadyzgpKYnAwEApiquBFMY13NdhV5j3w7lKvfbD0QGMCCh9CHwhhBBCCCGqom/fvvTt27e6wyjRiBEjjF2qK6I80zpVlJubG25uMgBudZDCuAY5fSOJ3y7Ek5aVw8VrWqbN212pdj4b25nH2sngWkIIIYQQQggBKh+VOi8vj3nz5tGsWTNsbW1p0aIFCxcuLDTMuaIovPXWW3h6emJra8vAgQO5cOFCNUZtHrl5BtYcvMyL6/7g2S9/p+mcHfzl4/+y9JfzfHogmtCblTt02yb3kqJYiBqiLudAIYQQQogHSdVXjP/5z3/y6aefsm7dOtq2bcuxY8d47rnncHZ25tVXXwVgyZIlrFixgnXr1tGsWTPmzZvHkCFDCA8Px8ZGfYN2lNfC7eGsC7tqlrZmP/YQ7byc6dHcDSsLVX8XIoS4R13OgUIIIYQQD5KqC+PDhw8zYsQIHn88fxqhpk2bsmnTJn7//Xcg/0rJ8uXLCQ4ONt4XsH79ejw8PNi2bRujR4+uttgrSp9n4P3d59kfeYdMfR7XEjMq1U6Hxi4M8HUnS59HY1c7RndtbJZJ3IUQD15dyoFCCCGEENVJ1YXxww8/zOrVq4mKiqJ169b8+eefHDp0iH/9618AREdHc/v2bQYOHGh8jbOzM927dycsLKzED4XZ2dlkZ2cbH6ekpAD5UxwU/BQ8NqeiM2MpChy4EM/Ja0l8ejC6yu1/Pq4jj7RuUGhZbm5uldutKH1u/n7T6NQ5E1hBXGqPT9EYzH4Omsv9+h0xl6LxqTXOstyPHFhW/iv4/73/qo3a48tT8ueYVHuOUXt8uXm5qjzGaj//TMWn1liFEEJNVF0Yz5kzh5SUFHx9fdHpdOTl5fHuu+8yZswYAG7fvg2Ah4dHodd5eHgYnzNl8eLFLFiwoNjy3bt3Y2dnZ3wcGhpaZox5CtzOgFwFcvI07LmpIStPg6X2fx84LqZoUDDvVdtBjQx42SkYFPBxUHC3hfSLf7Dzolnfpkq8Hk2v7hBKpfb4sj2usHPnleoOo1Tl+R2pTgXxZWRUrgdGdbsfObC8+Q9qzvFVK7XnGLXHt+/4r9UdQqnUfv7dG19NzYFCCPEgqbow/vbbb9mwYQMbN26kbdu2nDp1iunTp+Pl5cX48eMr3e7cuXN57bXXjI9TUlJo3LgxgwcPxsnJCb1eT2hoKIMGDcLS0rLEdv68kczEr09wN8PUN7HmLYS7Na1HyBNtsdRpqG+nY8+ePWXGV130iXpCj4QSs9ceJU993bg1OgWvR9NVH591bFMGPOdX3eGYVN7fkepSNL6Cq6I1zf3IgWXlP6h5x1dtIn+L4XLqKdXnGLXH17/zAGw9rKs7nGLUfv6Ziq+m5kAhhHiQVF0Yz5o1izlz5hi7A7Zv356rV6+yePFixo8fT8OG+aMrx8bG4unpaXxdbGwsAQEBJbZrbW2NtXXxP7aWlpaF/sgVfQyQnKlHn2cAYNo3p0sois3HxlJLv9buLPmrP042+bEUdIkyFZ8q/P9ZpeRpUHLV96GrgNrj0yhadR7fe6j2HPx/BfGpOcbS3I8cWN78V9IyNVFrfDqNDlB/jlF7fBY6C1Ue3wJqPf8K3BufmuMUQgi1UHVhnJGRgVZbeBRlnU6HwZBfmDZr1oyGDRvy66+/Gj8EpqSkcPToUf7xj39U+P0ORt3B3iGL3Lxczt3VYBd1Bwtd/i6Kjk/nne3hVdugcrLUafh0TGcG+nmUvbIQotZ60DlQCCGEEKKuUnVhPHz4cN599118fHxo27YtJ0+e5F//+hfPP/88ABqNhunTp7No0SJatWplnKrEy8uLJ554osLv98qGE2itC+6x07E68qT5NuYejVxssdT971v6uNRsvOvZ8uqAVthY6Ojo44Kbg/q6jwkhHqwHnQOFEEIIIeoqVRfGH330EfPmzeOVV14hLi4OLy8vJk2axFtvvWVcZ/bs2aSnp/PSSy+RlJRE7969+fnnn6tt/s7hHbzIMxjo06oBfVrVL/Sci50VDtaq3uVCCBWpiTlQCCGEEKImUnWV5ujoyPLly1m+fHmJ62g0Gt555x3eeeedBxdYCaYNaMWMQa2rOwwhRC1R03KgEEIIIURNperCWO06+rgAYGupo99DDXixd/PqDUgIIYQQohZbs2YNd+7cITExEVtbW2xtbXnkkUfQarV8//33uLi4oCgKo0aNwtfXl+DgYGxsbDAYDDRr1owxY8YUG7uhPBISEnjnnXdo2rQpM2bMKPTcsmXLii2rqH//+98MGTKEBg0aVKmd8lq5ciUXL15k1qxZeHl5lbheSfu7V69e9y22sLAwk8eyMg4ePIidnR1dunQxLktISODKlSt07ty52PrmOJZFVeYc3L17N4MHD67ye2/cuJFHHnmERo0ame13x2Aw8NFHHzFlyhR0Ol2VY1QTKYzvMaa7D9Z2DuQZDFy9coUmTZuiK3Li2ljq6Nu6Pg+3qF9CK0IIIYQQ4n6YOHEiAOvXr6djx460b98eyC+munfvzqhRo4iJieGzzz4z9qSZOXMmVlZWfPjhh5w4caJQkVQRnp6eJosmcxRSY8eOrXIbFTF58mSWLVtW5nol7e/7raRjWVF9+/YttiwhIYETJ06YLIzNXRQXqOg5GBoaWuXCODU1lTt37tCoUSPAfL87Wq0WX19fTpw4QdeuXasUo9pIYXyPucPaGOcx3rnzMsOG+coUB0IIIYQQNYiXlxc5OTnGEfwBtFotzZo14+7du8Zl+/fvJykpqdKDFYaHh7Nt2zbu3r3L0qVLjcuDg4Px9/cnPDwcX19f45R7pqSlpbFixQru3LlT6OptcHAw9vb2NG7cmCtXrjBgwAB69uzJTz/9xJkzZ9BoNAQEBDB06FAAfvnlF44ePYq3tzfh4eG8//77APzwww+Eh+fPqjJy5EgeeuihSm2rKdu3byctLY1r166RkZHBs88+y6FDh4xF1/bt23FwcKBfv37ExMSwefNmsrOz8fT0ZNy4ceW62njvsUxKSmLt2rVkZGTQqFEjxo0bh4WFBefPn2fLli1oNBoaNmzICy+8AMCqVauIjo7mscceo1+/fgBs27aNkydPkpaWRkhICO3bt2f48OEmj2VCQgJffvkls2bNAmDXrl3Y2trSr18/Tpw4we7du8nNzaVnz54MGDCgzG0peg6aOpYnTpzg559/JjMzk5CQEJydnZk8eTJQ8WN54sQJ/Pz8yoyrpP1dUtyQP33kDz/8UOsK44r3JRFCCCGEEEKlLl26hL29faHuqnq9nmvXrtG2bVvjsrS0NFJSUir9Pn5+fgQFBZl8ztfXl3nz5nH27FnS0tJKbMPBwYGgoCB8fHwKLbe2tmbq1KlER0czY8YMTp8+DUCfPn0ICgpizpw5nD17ljt37pCQkMDvv//O3LlzeeSRR8jIyADg9OnTZGRkMHfuXKZMmcKWLVsqva0luXLlCtOnT+ett97C09OzxPU2btzI+PHjmTt3Lo6Ojhw/frxc7d97LHfs2MHDDz9McHAwGo2GY8eOAfDrr7/y17/+lTfffJOnn37a+NpJkybRu3fvQu098cQTjBkzBl9fX4KCghg+fDhg+li6ubmRm5tLamoqAGfOnKFDhw6kpKSwd+9eXn/9dYKCgjhx4gSJiYllbkvRc9DUsezUqRNBQUHY2toSFBRkLIorcyyjo6Np3Lhxmevdq7y/Ow0bNuT69esVarsmkCvGQgghhBCixjt69Cjnz5/H1taWZ5991rj8/fffJyEhgUceeaTQ/bSBgYH3LZaWLVui0+lwdXUlNTUVBweHCr3e3t7e+GNnZ0dWVhYAFy9eZM+ePRgMBhISEkhKSiItLY2WLVtiaWlJixYtjL0do6KiCA8PJyQkBICMjAxyc3OxsDDfx/+AgACsrKwAsLW1NblOZmYm169fZ9WqVQDk5ORgb29farumjuXVq1cZMWIEAO3atePy5cv06NGDZs2asX37dm7fvk1AQICZtixf+/btOXv2LH5+fiiKQr169fjzzz+Ji4szXlnOzMwkPj4eV1fXEtsxdQ6aOpYl3WNemWOZkpJS5n4uUNHfHa1Wi6Io5OXl1ar7jKUwFkIIIYQQNV7BfZJFzZw5k8zMTP75z3/SpUsXvL2973ssBVfcNBoNiqIAEBkZyXfffQfAK6+8gouLS4mv12g0hX4MBgN6vZ6tW7cyd+5cnJyc+Pjjj41tlyQwMJDu3bubZ6NMKDo1oEajMf7/3u64Li4uJV5dN6WkY2nK0KFDad++PadOnWLJkiUsWLDAbMV/hw4d2LVrF3l5efj7+wP52+jv71+h+8KLnoMeHh73/VhaWlqSm5tbrnUr87ujKEqtKopBulILIYQQQoharl69egwePJi9e/cal+3fv59t27Y9sBgKuu8GBQWVWhSXRK/Xo9Vqsbe3JykpicuXLwPg4+PDxYsX0ev1XLp0Cb1eD0Dr1q35/fffycvLA/K7Pd9vTk5OJCUlAXDjxg0g/0qyjY0NUVFRQP6gUAkJCRVuu0mTJkRERAD593cXdD+Pj4/H29uboUOHotFoyMnJKbUdGxsb0tPTy/WejRs3Ji4ujhMnTtChQwdjHOfPnzd2sb5586Zxn5fm3nOwpGNZQKPRFCpqK3MsPTw8iI+PL9d2ljfuAhkZGTg6OhZbd/369Wzfvr3K71ldyvV1SqdOnSrUqEaj4ccffzSOgiaEEDWZ5EAhhKj5evXqxdtvv01KSgpOTk5Vvsd48+bNXL582ThQUtu2bY1dfcsrLCyMffv2cefOHdasWYOrqytTp041ua6dnR1du3Zl4cKFuLq60rRpUyD/Xthu3bqxePFifHx8jAWLv78/V69eZfHixeTl5eHr62t8zf3So0cPvvjiCy5dulToPtXx48ezadMmMjMz0el0jBkzBjc3twq1/fjjj7N27VpCQ0Np1KiRcWTnvXv3cv78eRRFoVevXtjZ2XHz5k3WrVtHSkoKWq2Ww4cP8/LLL+Pq6kqjRo3QarUsWbIEf39/HnvssVKP5UMPPcTZs2eNXYmdnZ0ZOXIkK1asQFEU7O3teeWVV8q1DQXnYG5ursljWaB379689957eHh4MHHixEody3bt2nHs2DGzDJBV9HfnwoULJgf2unv3Lh07dqzy+1UXjVLWdXvyu4O8/vrr5bo/QlEU3nvvPcLDw2nevGbM65uSkoKzszPJycn3jEq9k2HDhqlyVGrVx5egZ+ehndwMdUDJ1ZT9ggdMY6HQaFCa6uOzud2cIZMezLQIFaX6c7BIfEV/xyuqNudAU/umph1ftQnfd4MLKcdVn2PUHt+gbkOw87Qp+wUPmNrPP1PxVTUHivwRitesWcOcOXOqOxSTsrKysLGxIT4+ntWrV5er2/KyZct45plnSp3HWNRciqKwbNkypk6davZc9eWXX/L444/j4eFhXJaXl8fSpUt54403CnWpr0nK3QF/1qxZuLu7l2vdDz74oNIBCSGEGkkOFEKIukuj0ZCSksKyZcvu21y3VfHtt99y7do1tFotTz31VJnrr1y5kvj4+Fp3j6j4H41Gw1/+8hfu3r1b7s8v5WEwGPDz8ytUFAPodDrVfnFUXuUqjKOjo0scJc2U8PBw+fZJCFFrSA4UQoi6zdXV1TgisBrdO5JweRRMAyRqt5YtW5q9Ta1WS48ePczerhqUqzBu0qRJhRqt6JxZQgihZpIDhRBCCCFqt0qPZZ6bm8uqVavYv38/eXl59OrVi8mTJxcbtl0IIWojyYFCCCHKKywsjO+//944GvVTTz1F69atzdJ2dHQ0mzZt4tatWyxdutT4d+jAgQMcOHAAKyurGt/FVYgHodLTNb366qt8//339O/fn0ceeYSNGzfy3HPPmTM2IYRQLcmBQgghKqJ79+4EBQUxcuRINm3aZLZ2mzVrRlBQEM7OzoWWP/LII9JlWogKKPcV4++//54nn3zS+Hj37t2cP3/eeNP+kCFDam1/cyGEkBwohBB1U25uLl9//TU3b97Ezs6OCRMm4OrqCuSP7NyyZUvOnDmDXq/nzTffxMKi9I/XLVq04O7du8bHP/zwA+Hh4QCMHDmShx56CIAff/yR06dPA/Dkk0/Stm1bfvrpJ86cOYNGoyEgIIChQ4fej00Wok4qd2H85Zdfsm7dOj755BO8vLzo1KkTL7/8MqNGjUKv17NmzRqzzJMlhBBqJDlQCCHqpmPHjqHT6QgODubw4cPs2LGDcePGGZ9PT08nKCiIzMzMQnP3liQyMtI41+vp06fJyMhg7ty5pKam8uGHHxIcHMyff/7J9evXmTt3LhqNxlhI9+nTh+HDh6MoCu+//z5dunSp0OCQQoiSlbsw/umnn/jmm2/o168fU6dOZfXq1SxcuJA333zTeH/d22+/fR9DFUKI6iM5UAgh6qZr167h5+cHQLt27di3b1+h5wu+FLW1tS21naNHj3L69GlycnKYOXMmAFFRUYSHhxtHvM7IyCA3N5cLFy7QrVs3Y68kNzc3AC5evMiePXswGAwkJCSQlJQkhbEQZlKhe4yfeeYZfv/9d86cOcOQIUMYO3Ysx48f59SpU6xcufK+/GLevHmTsWPH4ubmhq2tLe3bt+fYsWPG5xVF4a233sLT0xNbW1sGDhzIhQsXzB6HEEJIDhRCCFFUWQVxge7duzN//nwefvhhvvvuO+PywMBAgoKCCAoKIiQkxNgVW1GUQq/X6/Vs3bqVV155haCgIJo1a1ZoHY1GY4atEaLuqvDgWy4uLqxevZqlS5fy7LPPMmvWLLKysu5HbNy9e5devXphaWnJrl27CA8P54MPPqBevXrGdZYsWcKKFSv47LPPOHr0KPb29gwZMuS+xSSEqNskBwohRN3i4+NDREQEAOfOncPHx6fSbWm1Wh577DEuX75MfHw8rVu35vfffycvLw+AK1euANC6dWv++OMP8vLyMBgMJCYmotfr0Wq12Nvbk5SUxOXLlwu1bWdnR3JycrFlqampGAyGQsuvXLnChx9+WOntEKI2KndX6mvXrjFz5kwiIiLw9/fn/fff5/jx47z77rt06NCB5cuXm30AgH/+8580btyYr776yrisWbNmxv8risLy5csJDg5mxIgRAKxfvx4PDw+2bdvG6NGjTbabnZ1Ndna28XFKSgqQ/01cwU/BYzVSfXy5+XFpdEoZa1aPgrjUHp+iMaj3GKv9HCwSnznirC05sKz8V/D/e/9VG7XHl6fkf8BVe45Re3y5ebmqPMZqP/9MxafWWGuKLl26EBERwaJFi4yDb1WFpaUl/fr1Y9++ffz1r3/l6tWrLF68mLy8PHx9fWnatCn+/v5cuXKFxYsXo9FoeOKJJ2jbti1du3Zl4cKFuLq60rRp00LtDho0iE8++YT69eszdepUIP9qdseOHVm4cCEBAQHGvxU5OTncuXOnStshRG2jUYr20yhBv379aNiwIRMmTOCXX37h0qVL/PjjjwBEREQwadIkGjZsyLfffmu24Pz8/BgyZAg3btzgwIEDNGrUiFdeeYWJEycCcPnyZVq0aMHJkycJCAgwvu6RRx4hICCgxG/C3n77bRYsWFBs+caNG7GzszNb/EIIdcjIyODvf/87ycnJODk5VaqN2pIDJf8JUfeYIwcKIURtV+7C2MHBgT///JMWLVqgKArNmjUzdvcosHr1al566SWzBVcwQflrr73GX//6V/744w+mTZvGZ599xvjx4zl8+DC9evUiJiYGT09P4+uefvppNBoN33zzjcl2TV0xady4MfHx8Tg5OaHX6wkNDWXQoEFYWlqabXvMRfXxJeoJPRJKzF57lDz13e+i0Sl4PZqu+visY5sy4Dm/6g7HJNWfg0XiS0lJoX79+lX6UFhbcmBZ+Q9q3vFVm8jfYricekr1OUbt8fXvPABbD+vqDqcYtZ9/puIzRw4UQojartxdqTt37sxbb73F+PHj2bNnD+3bty+2jjk/EAIYDAa6dOliHKmvY8eOnD171vihsLKsra2xti7+x9bS0rLQH7mij9VGtfH9/1ml5GlQctX3oauA2uPTKFp1Ht97qPYc/H8F8ZkjxtqSA8ub/0papiZqjU+nyR9FVu05Ru3xWegsVHl8C6j1/Ctwb3xqjlMIIdSi3INvrV+/nuzsbGbMmMHNmzdZtWrV/YwLAE9PT+Pw+AXatGnDtWvXAGjYsCEAsbGxhdaJjY01PieEEOYgOVAIIYQQovYq9xXjJk2a8J///Od+xlJMr169OH/+fKFlUVFRNGnSBMgfhKZhw4b8+uuvxvvrUlJSOHr0KP/4xz8eaKxCiNpNcqAQQgghRO1VrivGBaOWlldqamqlgilqxowZHDlyhJCQEC5evMjGjRtZvXo1kydPBvLna5s+fTqLFi3ixx9/5MyZMzz77LN4eXnxxBNPmCUGIYSQHCiEEEIIUbuVqzCuV68ecXFx5W60UaNGxeZWq4yuXbvy/fffs2nTJtq1a8fChQtZvnw5Y8aMMa4ze/Zspk6dyksvvUTXrl1JS0vj559/Ng5aI4QQVSU5UAghhBCiditXV2pFUfj8889xcHAoV6PmnC8vMDCQwMDAEp/XaDS88847vPPOO2Z7TyGEuJfkQCGEEEKI2q1chbGPjw9r1qwpd6MNGzaUERCFELWG5EAhhBBCiNqtXIVx0bk6hRCiLpEcKIQQQghRu5V7uiYhhBBCCCGEEKI2ksJYCCGEEEIIIUSdJoWxEEIIIYQQQog6TQpjIYQQQgghhBB1mhTGQgghhBBCCCHqNLMVxt999x3+/v7mak4IIWoUyYFCCHH/paWlERISwsyZM5k3bx7r168v8zVRUVEPfHaBsLAwUlNTiy0/ePAgx44de6Cx3E///ve/uXPnTqVeGx0dTUhICFOnTiUrK6vSbQcHB5t8PcDu3bsrFdu9rl69ynfffVfmegkJCbz33ntVfr/K2r9/P9u3b6+2968NKlQYr1q1iqeeeoq///3vHD16FIC9e/fSsWNHxo0bR69eve5LkEIIoQaSA4UQono5ODgQFBSEv78/Tz/9NM8++2yZr6mOwvjIkSMmC+O+ffvSpUuXBxrL/TR27FgaNGhQqdc2a9aMoKAgnJ2dzd52gdDQ0Cq9HqBJkyaMHDmyyu0I9SvXPMYA7733Hm+99Rb+/v5ERkbyww8/8Oabb/LRRx8xbdo0Jk2aRL169e5nrEIIUW0kBwohhHqFhYVx8uRJsrKySEpK4uWXX8bLy4sPP/yQW7duodVqOXz4MIGBgfj7+xMTE8PmzZvJzs7G09OTcePGodPp+OWXXzh69Cje3t6Eh4fz/vvvA7B9+3bS0tK4du0aGRkZPPvss6SkpLBr1y40Gg3u7u6MHTuW1NRUVq1axZ07d1izZg2Wlpa88soruLi4sGrVKqKjo3nsscfo168fALm5uXz99dfcvHkTOzs7JkyYgKurK+vXr0dRFGJiYrCwsGDatGlYWVmVuP0fffQRqampKIpCYmIiH3zwQYXbLmmfmJKWlsaKFSu4c+cOs2bNwsvLC6DCcVek7cTERD7//HMURcHR0REfHx8CAwMB+PHHHwkPD8fX15fRo0dz4sQJfv75ZzIzMwkJCcHZ2ZnJkyeX+J7r169Hr9cTFxeHk5MTL774ItbW1uzatYsjR47g7e3NxIkTgfwrw5988gnu7u5cv36d4cOH071790LtHTp0iAsXLjBhwgQ0Gk2Ftn/lypUkJyej1WoJDAykXbt2JZ7fCQkJfPHFFyiKgouLC40aNapw22D6/G7YsCEbNmwgISEBa2trJkyYQL169fjpp584c+YMGo2GgIAAhg4dWqHtU7NyXzH+6quvWLNmDceOHWPXrl1kZmZy+PBhLl68yJw5c+QDoRCiVpMcKIQQ6paSksL06dMZOHAghw4dAmDatGn07t2bwYMHG680A2zcuJHx48czd+5cHB0dOX78OAkJCfz+++/MnTuXRx55hIyMjELtX7lyhenTp/PWW2/h6elJ8+bNeeONN5gzZw6urq788ccfuLm5ERQUhI+PDxMnTiQoKAgXFxcAJk2aRO/evQu1eezYMXQ6HcHBwfTo0YMdO3YYn7O3t2fu3Lm4uroSHh5e6rZPnTqVoKAgunbtSt++fSvVtql9UpKCK/c+Pj7FnqtI3BVpe8eOHfTt25c33niD3NzcQs/5+voyb948zp49S1paGp06dSIoKAhbW1uCgoJKLYoL2NjYMHfuXDw8PAgLCwNg6NChjBkzpti68fHx/O1vf2PKlCns2bOn0HOnTp3izz//5Nlnn61wUQwwZswYgoKCePXVV9myZYtxuanze8eOHfTr14/Zs2eX2J28PG1D8fN7586ddOzYkTlz5jBw4EB27twJQJ8+fQgKCmLOnDmcPXu20l3p1ajcV4yvXbvGo48+CuTvEEtLSxYsWIC9vf19C04IIdRCcqAQQqhb8+bN0Wq1eHh4EBUVVeJ6mZmZXL9+nVWrVgGQk5ODvb09lpaWtGzZEktLS1q0aIGlpWWh1wUEBBivftra2hIfH8+XX35JRkYGaWlpxYre8rh27Rp+fn4AtGvXjn379hmfa9myJQAeHh4kJyeX2db169c5c+YM06dPr3DbJe2Tyqho3OV19epVRowYAeQXwjk5OYXeU6fT4erqSmpqKg4ODhVu39fX1/jvyZMnS13X3d0dJycnHB0dC21jQkICX331FZMnTy7xantZ9u/fb/xCITEx0bjc1Pl97do1nnzySTQaDW3atCm0TyrSNhQ/v6OiooiMjGT37t0YDAZcXV0BuHjxInv27MFgMJCQkEBSUlKVu7yrRbkL4+zsbGxsbIyPraysjDtICCFqO8mBQgihbgWFiEajQVGUUtd1cXEhKCio0LKyiqF7/wYAbNmyhWHDhuHr68vu3bvLLEoq6t7CqqztycnJYcOGDTz//PPlKshMtW1qn1RGReIGKnVVtSitVmtsqzzvWVUlnWsWFha88MILbN26ldmzZ1e4OD5//jxXr15l9uzZxq7oZb2nOdqG4ue3RqNh8uTJhXrE6fV6tm7dyty5c3FycuLjjz9+IPv7QSl3YQwwb9487OzsgPxfwEWLFhW7Yf5f//qX+aITQggVkRwohBA1j62tbaFu0ba2ttjY2BAVFUXr1q1JTU0lJycHHx8ftm/fjl6v59q1a+j1+lLbzcrKol69euTm5nLq1Cnj1VnILzLS09PLjM3Hx4eIiAi6dOnCuXPnTHZNLo+tW7fSu3dv3N3dK9V2SfvEzc2tUvFUhJ2dHcnJycUKs6KaNGlCREQE3bt3JzIykubNm5fZtkajITc3FwuLskue8+fP07lzZ86fP1/p4+Ds7Iyvry8tWrQgNDSUxx57DMi/kjxv3jw++eSTUl+flZWFo6MjFhYWnD59usxzsEmTJkRGRtKlSxciIiJo0aKF2dpu1aoVYWFhDBs2DL1eT2xsLK6urmi1Wuzt7UlKSuLy5cultlHTlLsw7tu3L+fPnzc+fvjhh4vtDHN84yOEEGokOVAIIapfwcBMiYmJXLhwgZMnT5Y5MnWHDh1YvXo14eHhDB06lLZt2zJ+/Hg2bdpEZmYmOp2OMWPG4OPjQ7du3Vi8eDE+Pj44OjqW2u7QoUP55JNPsLe3x9PTs9BzvXr1YtOmTdjb2/Piiy+SlpbGunXrSElJMQ4E9vLLLxsLmkWLFhkHyKqMQ4cO0ahRIw4ePIi1tTWvv/56hds2tU9KKozDwsLYt2+fcZAxV1dXpk6dWqnYBw0axCeffEL9+vWZOnVqiW0//vjjfP755xw4cAAnJ6dyFbu9e/fmvffew8PDwzh4VkkyMjJYvHgxzs7ODB8+HIAlS5aQnp5unCZs2LBhNG7cuMz3/ctf/sLixYvp2LEjHh4e3L17t1zFtp+fHwcOHGDhwoW0aNECJyenUtcfNmwYX3zxBXv37i1zrJOKtv3444+zYcMGFi1ahKIoDBw4EG9vb7p27crChQtxdXWladOmZW5TTaJRatP170pKSUnB2dmZ5ORknJyc0Ov17Ny5k2HDhhW7v0QNVB9fgp6dh3ZyM9QBJVd9hYLGQqHRoDTVx2dzuzlDJrWv7nBMUv05WCS+or/j4n9M7ZuadnzVJnzfDS6kHFd9jlF7fIO6DcHOs/QrSNVB7eefqfgkB5ZfVlYWNjY2xMfHs3r1arN0LRbmkZOTg4WFBVqtlvXr1+Pv709AQIBZ2l6/fj0dO3akffv787nr559/xs3Nja5du96X9oV5VGge45SUFEJDQ9mxY0e1jED23nvvodFojIMKQH4Cmzx5Mm5ubjg4ODBq1ChiY2MfeGxCiNpPcqAQQtRu3377LYsWLWL16tU89dRT1R2OuEdMTAzvvvsu7777LgaDwTjCeE3w2GOPSVFcA5S7K/WpU6cYNmwYt2/fBsDR0ZFvv/2WIUOG3Lfg7vXHH3+watWqYr8EM2bMYMeOHWzZsgVnZ2emTJnCyJEj+e9///tA4hJC1A2SA4UQovYrq1u2qD5NmzZl3rx596VtOe4CKlAYv/HGGzRr1oytW7diY2PDwoULmTJlChcuXLif8QH595OMGTOGNWvWsGjRIuPy5ORkvvjiCzZu3GicRuWrr76iTZs2HDlyhB49ephsLzs7m+zsbOPjlJQUIL/7UcFPwWM1Un18uflxaXTq7KVfEJfa41M0BvUeY7Wfg0XiM0ectSUHlpX/Cv5/779qo/b48pQ8QP05Ru3x5eblqvIYq/38MxWfWmMVQgg1Kfc9xvXr12f37t106tQJgKSkJFxdXUlKSrrv96uMHz8eV1dXli1bRr9+/QgICGD58uXs3buXAQMGcPfuXePk6ZA/Qtv06dOZMWOGyfbefvttFixYUGz5xo0bjSPOCiFqj4yMDP7+979X6f662pIDJf8JUfeYIwfWBvv37yctLY3AwECztRkVFYWVlVWhQYiCg4MJDg4uc5TlmsRgMPDRRx8xefJk46BXGRkZzJo1iwkTJhi7CS9btoxnnnkGLy8vzpw5YxwcLTMzk/Xr13P79m10Oh0vvPACnp6exMXFsWvXLsaPH1/uWJYtW1biZ/zqdODAAQ4cOICVlRVz5syp7nBEJZT7inFiYiLe3t7Gxy4uLtjb25OQkHBfk+zmzZs5ceIEf/zxR7Hnbt++jZWVVaEPhJA/oXhBd0dT5s6dy2uvvWZ8nJKSQuPGjRk8eLBx8K3Q0FAGDRqk2oE1VB1fop7QI6HE7LVHyVPhwC46Ba9H01Ufn3VsUwY851f2C6qB6s/BIvEVXBWtitqSA8vKf1Dzjq/aRP4Ww+XUU6rPMWqPr3/nAdh6WFd3OMWo/fwzFZ85cqAwLSoqCgcHh1o3Om9Rp0+fpnnz5oVGgj5//rxxCqWy7p/96aef8PX1ZdKkSaSlpZGXl9+zxt3dnbS0NBITE3F1dS1XLGosigEeeeQR2rVrx5o1a6o7FFFJFZrHODw8vNCHLUVRiIiIIDU11bjMnDfCX79+nWnTphEaGmrWb92sra2xti7+x9bS0rLQH7mij9VGtfH9/1ml5GlUOeJpAbXHp1G06jy+91DtOfj/CuIzV4y1IQeWN/+VtExN1BqfTqMD1J9j1B6fhc5Clce3gFrPvwL3xqfmOCsqISGBNWvWGK/IzZo1i6VLlxIWFsbJkyfJysoiKSmJl19+GS8vLxISEvjiiy9QFAUXFxcaNWoE5A/ktHnzZrKzs/H09GTcuHHodDqioqIIDQ1Fo9GQkJDAww8/zIABA0zG8uGHH3Lr1i3j9EuBgYHGvwE//vgj4eHh+Pr6Mnr0aABOnDjB7t27yc3NpWfPniW2W+DHH3/k9OnTADz55JO0bdu20NXoe6/Omor75s2bdOnSBT8/P/R6PQsXLuTtt98mKyuLDRs2kJCQgLW1NRMmTChzqp9jx44xaNCgQssiIiIYPHgw33//fRlHLX+cjoULFwLg4OBQ6Lm2bdty8uTJMvdHeHg427Zt4+7duyxdutS4PDg4GH9//2L725SwsDCOHz9OZmYmer2e559/noYNGxYalXr79u04ODjQr18/IP8ce/jhhzl37hyenp488cQTfPzxx7i7uxMXF8fw4cONvclMycjIqPD+FtWjQoXxgAEDKNrzOjAwEI1Gg6IoaDQa4zdA5nD8+HHi4uIKnWx5eXkcPHiQjz/+mF9++YWcnBySkpIKXTGJjY2lYcOGZotDCCFAcqAQQqhZSkoKs2fP5tChQxw6dIinn36aHTt20K9fP7p27cqKFSuM627cuJHnnnsONzc3tm7dyvHjx+nWrRsAFy9eJCgoiAYNGpCRkVHi+02bNq1YEVXA19eXUaNGMX/+fNLS0jAYDOzdu5fXX38dnU7HBx98QMeOHUu8Svrnn39y/fp15s6di0aj4e7du2Vuf9G4o6KiOHPmDH5+fkRFRdG6dWu0Wi07d+6kY8eOdOnShTNnzrBz507GjBlTattXrlwxfqlQICoqipEjR+Ls7MytW7eKzeVcID09HRsbG3Q6ncnnGzdubLw1qDR+fn74+fkxa9asYs8V3d9Fi+973b17l6CgIM6dO8dPP/1UrvmNGzduzJNPPklGRgaZmZncuXOHKVOmYGlpydKlSwkICECrNT3ZT2X2t6ge5S6Mo6Oj72ccJg0YMIAzZ84UWvbcc8/h6+vLG2+8QePGjbG0tOTXX39l1KhRQH63jmvXrtGzZ88HHq8QovaSHCiEEOrWvHlztFotHh4eREVFAXDt2jWefPJJNBoNbdq0IScnh8zMTK5fv86qVauA/Plx7e3tje00a9aMBg0aAFR67IWWLVui0+lwdXUlNTWVuLg44uLijFc6MzMziY+PL7EwvnDhAt26dTMWk25ubmW+Z9G4/fz8+PHHH4H8rtAdOnQA8gvayMhIdu/ejcFgKFcX5oI5hAvEx8djZ2eHjY0NrVu3JjIyssTCuCyOjo4kJydX6rUFiu7v0grjgnV9fX35z3/+U2bbOp2Ozp07A/n7NTMzE3d3d+MxcXBwMI47Ykpl9reoHuUujNetW8fMmTMf6OAsjo6OtGvXrtAye3t73NzcjMtfeOEFXnvtNVxdXXFycmLq1Kn07NmzxBGphRCiMiQHCiFE9dNo/tf9v2gPnYIisqAXT2lcXFwICgoy+ZytrW0Vo8R49fDeHkX+/v6MHTu23G2Y2oZ7t99gMBR6rmjcVlZWuLu7c+PGDaKioozzMms0GiZPnlyl7rwRERHcuXOHBQsWoNfr8fLyon///oWuCiuKgoWFBfb29mRmZpKXl2fyqrFer69yd/+i+7u8CtYtbb9aW1sXer6izLG/xYNh+pq/CQsWLCAtLe1+xlIpy5YtIzAwkFGjRtG3b18aNmzId999V91hCSFqGcmBQghR/RwcHEhNTUVRFG7evFnm+k2aNCEyMtI4JgTkF5A2NjbGq8qpqakkJCRUKh5bW9tSu1vfG8f58+eNY1LcvHmz1Gm0WrduzR9//EFeXh4Gg4HExEQAnJycSE5ORq/XExsbW+b7dujQgZ07d+Ll5WUsPlu1akVYWBiQX5TeuHGjzHbc3NxISkoyPo6MjOS5555j/vz5vP3221y9epW8vDwaNGhATEwMkH8ft7u7O5A//sahQ4eA/K7V914hvnPnTqWvNlfGxYsXycvLIzIyEh8fHyB/vxZsX3n2R1xcHImJiaSmppKWlma8ncnOzo7U1NRCxXVp+3v//v1s27bNPBsmqqzcV4wr8u3L/bR///5Cj21sbFi5ciUrV66snoCEEHWC5EAhhKh+VlZWdOzYkRUrVtCyZcsy1x82bBhffPEFe/fuLXTFbvz48WzatInMzEx0Oh1jxowpV3flojp06MDq1asJDw9n6NChtG3b1uR6zs7OjBw5khUrVqAoCvb29rzyyisltuvv78+VK1dYvHgxGo2GJ554AldXVx599FG+/PJLmjZtWmxGAlPat2/Phg0bGDdunHHZ448/zoYNG1i0aBGKojBw4MBCsy6Y0rZtWy5evEiXLl0wGAxcuHDB2KaFhQVeXl5ER0fz2GOPsXbtWn7++WdcXV15/vnnARgxYgTr169n37596HQ6XnzxRZydnYH8QrWk/XavzZs3c/nyZTIzMwkJCaFt27aMGDGizNcV5eLiwgcffEBubi4vvPACAD169OCLL77g0qVLJd4rfK8GDRqwadMm4uPjGTlypPE1tra2dOzYkYULFxIQEMCIESNK3d9paWkyaryKlHseY61WS2xsrPHehdokJSUFZ2dn4/x+er2enTt3MmzYMFWO5Kj6+BL07Dy0k5uhDqoc8VRjodBoUJrq47O53Zwhk9pXdzgmqf4cLBJf0d/xyqitOdDUvqlpx1dtwvfd4ELKcdXnGLXHN6jbEOw81TcPrNrPP1PxmSMHirotMTGRLVu2MGnSJLO2m5eXx/Lly5kxY0a5CtKqCgsLIyYmxjguR2UUHRld1B4VGpW6devWZfaxL+jqIYQQtY3kQCGEEHWRq6srnTp1Ijc3t9AgXFWVlJREYGDgAymKhShLhc7sBQsWGLs9CCFEXSM5UAghRF3VtWtXs7fp5uZWqS7slWWOGRvc3NzkanEtVaHCePTo0cab6IUQoq6RHCiEEEIIUTuVu99CVYYpF0KImk5yoBBCCCFE7VXuwlgtI7IKIUR1kBwohBBCCFF7lbsrddHJroUQoi6RHCiEEEIIUXvJEHBCCCGEEEIIIeo0KYyFEEIIIYQQQtRpUhgLIYQQQgghhKjTpDAWQgghhBBCCFGnSWEshBBCCCGEEKJOk8JYCCGEEEIIIUSdJoWxEEIIIYQQQog6TQpjIYQQQgghhBB1mhTGQgghhBCiRlizZg0hISHMnDmTefPmERISwn//+1/CwsLYunVria9btmzZA4zSfGpa3OvXr+fNN980eSyuXr3Kd999Z5b32b9/P9u3by/x+YSEBN577z2zvFdYWBipqan3pe2aoKadg1Wh6sJ48eLFdO3aFUdHR9zd3XniiSc4f/58oXWysrKYPHkybm5uODg4MGrUKGJjY6spYiGEMB/JgUIIUdjEiRMJCgrC39+fp59+mqCgIHr16lXm62bMmPEAojO/mhb3s88+S2BgoMnnmjRpwsiRIx9wRFV35MiRQoVxXVPTzsGqsKjuAEpz4MABJk+eTNeuXcnNzSUoKIjBgwcTHh6Ovb09kH+wduzYwZYtW3B2dmbKlCmMHDmS//73v9UcvRBCVI3kQCGEKL/Y2Fj+9a9/kZSUxMsvv4yXlxfh4eFs27aNu3fvsnTpUuO658+fZ8uWLWg0Gho2bMgLL7xQYrt37txh7dq15ObmYmNjw9ixY2nQoAGQfzWtZcuWnDlzBr1ez5tvvklcXBybN28mOzsbT09Pxo0bh06nY+XKlSQnJ6PVagkMDKRdu3Ylvqc54g4LC+PkyZNkZWUV2ieJiYmsXbuWjIwMGjVqxLhx47CwyC8JZs2axcMPP8y5c+fw9PTEw8ODy5cvk5qaio+PDzdu3GD27NnGNkztE1N27drFkSNH8Pb2ZuLEicblH330EampqSiKQmJiIh988AEAP/zwA+Hh4QCMHDmShx56iISEBL744gsURcHFxYVGjRqV+H4A2dnZfPrpp8TFxTF8+HA6derEpk2beOihh+jUqRMGg4EFCxbw5ptvYmVlVez1CQkJrFq1ijt37rBmzRosLS155ZVXANDr9axatYrr168zfPhwunfvXmLcpSm6v1944QWT50lpx/Lzzz9HURQcHR3x8fEhMDCQmJgYk+egKQaDgbVr1xITEwPAM888Q6tWrUo8B3/44QfOnTsHQFxcHDNnzsTb25sTJ06we/ducnNz6dmzJwMGDCh129VI1YXxzz//XOjx2rVrcXd35/jx4/Tt25fk5GS++OILNm7cyKOPPgrAV199RZs2bThy5Ag9evSojrCFEMIsJAcKIUT5paSkMHv2bA4dOsShQ4d4+umn8fPzw8/Pj1mzZhVa99dff+Wvf/0rDz30UJlXAx0cHJg6dSo2NjacO3eOHTt2MGHCBOPz6enpBAUFkZmZiVarZePGjTz33HO4ubmxdetWjh8/Trdu3RgzZgwuLi5kZGTwz3/+s9TC2Bxxl7RPduzYwcMPP0yPHj1Yu3Ytx44dM/69yMjIoHHjxjz55JNkZGSwd+9eevfuzYULF2jVqhXW1tbcunULNze3UvdJUUOHDqVFixYcOHCg0PKpU6cCsHv3bjIzMwE4ffo0GRkZzJ07l9TUVD788EOCg4PZsWMH/fr1o2vXrqxYsaLMbb9z5w5TpkzB0tKSJUuW0KFDB7p168bevXvp1KkTFy5coFmzZiaLYgA3NzeCgoJYtmwZzzzzDF5eXkB+wRwfH8+0adPIyMjgiy++oHv37iXGXZqi+xso8Twp6Vj27duXHj16FNonJZ2Dpty4cYOkpCSCg4PJy8sjOzsbKPkcHDFiBCNGjODy5cv88MMPeHl5kZKSwt69e3n99dfR6XR88MEHdOzYEVdX1zKPk5qoujAuKjk5GcC4k48fP45er2fgwIHGdXx9ffHx8SEsLKzED4XZ2dnGgw75Jxrkf/tT8FPwWI1UH19uflwanVLNkZhWEJfa41M0BvUeY7Wfg0XiU2ucFWWOHFhW/iv4/73/qo3a48tT8gD15xi1x5ebl6vKY6z2889UfGqN1dyaN2+OVqvFw8ODqKioUtdt1qwZ27dv5/bt2wQEBJS6rsFgYPPmzdy+fZvc3FycnZ0LPd+1a1cAbG1tyczM5Pr166xatQqAnJwcYw+f/fv3G68mJiYmVmYTKxQ3mN4nV69eZcSIEQC0a9eOy5cvG/9e6HQ6OnfuDICdnR0A9vb2xh87OzuysrLK3CcVcf36dc6cOcP06dMBiIqKIjw8nJCQECC/eMzNzeXatWs8+eSTaDQa2rRpQ05OTqnturu74+bmBoCjoyPJycm0aNGCjRs3kpWVxfHjx43HrqLc3d1xcnIytlta3AVX400xtb9LOk/KOpa+vr7k5OSUeg6a4urqyt27d/nuu+/w9fXFz8+vzO3Pysrim2++4aWXXkKr1RIdHU1cXJzxynJmZibx8fFSGN8vBoOB6dOn06tXL+M3J7dv38bKygoXF5dC63p4eHD79u0S21q8eDELFiwotnz37t3GkxIgNDTUPMHfJ2qPz+vR9OoOoVRqjy/b4wo7d16p7jBKpfZzsCC+gm9hazJz5cDy5j+oOcdXrdSeY9Qe377jv1Z3CKVS+/l3b3y1IQeWR0FXUY1Gg6KU/sXP0KFDad++PadOnWLJkiUsWLCgxAJm3759NGjQgBdeeIHo6Gi2bdtW6HlbW9tCj11cXAgKCiq07Pz581y9epXZs2djYWHBtGnTKrh1FY8bKrZPAKytrdFoNIWWFTwu+NdgMJS6T4q+vjQ5OTls2LCB559/vlBX38DAQGP3ZHMLCAjg5MmTXLp0iWeeeaZSbZS0Xysad9H9Xdp5UpFjaeocLImDgwNvvvkm4eHhbN++ndjYWPr371/qa7755hsGDhxo/OJBo9Hg7+/P2LFjy/WealVjCuPJkydz9uxZDh06VOW25s6dy2uvvWZ8nJKSQuPGjRk8eDBOTk7o9XpCQ0MZNGgQlpaWVX4/c1N9fIl6Qo+EErPXHiWv/MnxQdHoFLweTVd9fNaxTRnwXNnf2lUH1Z+DReIruCpak5krB5aV/6DmHV+1ifwthsupp1SfY9QeX//OA7D1sK7ucIpR+/lnKr7akAPNLT4+Hm9vbzw9PTly5Ag5OTklFphZWVl4eHgA+T11SmNra4uNjQ1RUVG0bt2a1NRUcnJyyMrKwtHREQsLC06fPl3pq/gVibskTZo0ISIigu7duxMeHk7r1q0rHEdp+8TOzs54FbUsW7dupXfv3ri7uxuXtW7dmgMHDtClSxd0Oh1XrlyhadOmNGnShMjISLp06UJERAQtWrQote24uDgSExOxtLQkLS3N+CVyt27dWLZsGQEBASXed3svGxsb0tPL/iKxpLgroqLnyb3HMjIykubNm5d4DhYUsUWlpaWh0+no1KkTGRkZpV5chPzjbTAYCl1tb9KkCVu2bCE1NRVHR0du3ryJu7u7KnNkaWpEYTxlyhS2b9/OwYMH8fb2Ni5v2LAhOTk5JCUlFbpiEhsbS8OGDUtsz9raGmvr4n9sLS0tCx3Aoo/VRrXx/f9ZpeRpUHLV96GrgNrj0yhadR7fe6j2HPx/BfGpOcbyMGcOLG/+K2mZmqg1Pp0m/4OW2nOM2uOz0Fmo8vgWUOv5V+De+NQc5/22efNmLl++TGZmJiEhIbRt25YRI0awd+9ezp8/j6Io9OrVq1iPmXv16dOHNWvW8Ntvv5WriBw/fjybNm0iMzMTnU7HmDFj8PPz48CBAyxcuJAWLVoYv4i8n3GX5PHHH2ft2rWEhobSqFEjunTpUuE2Stsnbdq0Yc+ePSxatIjAwEACAgJYsmQJ6enppKWlERISwrBhwwgICODQoUM0atSIgwcPYm1tzeuvv46/vz9Xr15l8eLF5OXl4evrS9OmTRk2bBhffPEFe/fupV69emXG2KBBAzZt2kR8fDwjR45Eq82fjMfDwwMHBwdjF+ay9OrVi02bNmFvb8+LL75Y4nolxV0RFT1PHn/8cT7//HMOHDiAk5OT8UsSU+dgSYVxcnIy69atA/JzRcG94iWdgwcPHiQ5OdnYZfyFF17Aw8ODkSNHsmLFChRFwd7e3jhQWU2iUcrTr6KaKIrC1KlT+f7779m/fz+tWrUq9HxycrLxpB81ahSQ3wXB19e31HuMi0pJScHZ2Znk5GTjFeOdO3cybNgwVf4xUX18CXp2HtrJzVAHVX7o0lgoNBqUpvr4bG43Z8ik9tUdjkmqPweLxFf0d7ymeBA50NS+qWnHV23C993gQspx1ecYtcc3qNsQ7DxtqjucYtR+/pmKr6bmQCHuh9TUVD744APmz59foW7falTQY0Cr1bJ+/Xr8/f3Lde+5ME3VV4wnT57Mxo0b+eGHH3B0dDRe2nd2dsbW1hZnZ2deeOEFXnvtNVxdXXFycmLq1Kn07NlTRmMVQtR4kgOFEEII8zl16hRbt241DuJV08XExPD111+j1Wpp1KgR/v7+1R1SjabqwvjTTz8FoF+/foWWf/XVV8bL/MuWLUOr1TJq1Ciys7MZMmQIn3zyyQOOVAghzE9yoBBCCGE+AQEBteqKatOmTZk3b151h1FrqLowLk8vbxsbG1auXMnKlSsfQERCCPHgSA4UQgghhHgwtNUdgBBCCCGEEA9aWFgYqampxscJCQm89957FWojKiqKK1eulHv9xYsXs2HDBuPj7du3s3//fiB/ROLg4GAA1q9fz7x58wgJCWHZsmUkJCQAsGrVKt58801mz55NSEgIBoOB9evXs3DhQiB/hOHJkycTFhZGXl4eX3/9NYsWLWLlypXlmrbr6tWrfPfdd+XenpIsW7asSq9PSEhg2rRphdrJyMhg+fLlvPXWW2zYsKFcXx4XlZmZyb/+9S9effVV434vsHLlSmbMmEFMTEyVYhc1lxTGQgghhBCizjly5EihwrgyKlIYp6amoigKFy9eLNf6Tz/9NEFBQXTu3JkdO3YAMGnSJONcuUFBQcaRlhVFISEhgbNnzxqnUTp69Cg6nY7g4GD8/PzYtWtXme/ZpEkTRo4cWa74SjNjxowqt+Hp6VmonYMHD9K6dWveeecdkpOTiYqKqnCbOp2Ov/zlLwwYMKDYc5MnT8bHx6dKMYuaTdVdqYUQQgghhLjXr7/+yqFDh9DpdPTs2ZMBAwaQm5vL119/zc2bN7Gzs2PChAm4urqafH1CQgKrVq3izp07rFmzBktLS+PUMnq9nlWrVnH9+nWGDx9O9+7duXPnDmvXriU3NxcbGxvGjh1LgwYN+PDDD7l16xZarZbDhw8TGBhY6uBH58+fp02bNkRHRxMXF1do7t7StGrVimPHjpW6Trt27Th79ixXr141Tp8UFRVlnGu2Q4cOrFq1qtQ2du3axZEjR/D29mbixInG5ab2d0nCw8PZtm0bd+/eZenSpcblwcHB+Pv7Ex4ejq+vL6NHjy5zu4uKiIgwvq5jx46Eh4fz0EMPVagNKysrWrZsSWRkZIXfX9R+UhgLIYQQQoga4+effyYkJARLS0vjFd9jx44Zr44ePnyYHTt2MG7cOJOvd3NzIygoiGXLlvHMM8/g5eUF5BfM8fHxTJs2jYyMDL744gu6d++Og4MDU6dOxcbGhnPnzrFjxw4mTJjAtGnT2L59Ow4ODsUGSTQlIiKCTp06YWlpSWRkZLkL49OnT+Pp6VnqOs2bN+fYsWNotVrs7e2B/Gm6HB0dAXB0dCQlJaXUNoYOHUqLFi04cOBAoeWm9ndJ/Pz88PPzY9asWcWe8/X1ZdSoUcyfP5+0tDQcHBxKbauogivu69evp3PnzpW6YixEaaQrtRBCCCGEqDG8vb3597//zR9//IG1tTUA165dw8/PD8i/enrt2rVKte3u7o6TkxMeHh4kJycDYDAY2LBhA++++y7/+c9/SEpKqlTbUVFRtGjRglatWhEREQFQbMqgex9/++23hISEcP36dQIDA0ttW6fTAfkFsrmZ2t+V0bJlS3Q6Ha6urpXuwu7k5MSzzz5b6RiEKI1cMRZCCCGEEDXGlClTuHjxIocPH+bkyZO89NJLZmu7oMDUaDTGwZ327dtHgwYNeOGFF4iOjmbbtm0Vbjc2NpaUlBT++c9/oigK6enpGAwG4/sVfX/Iv8e4ffv25X6P0aNHY2VlxU8//QTkF5EFBWhaWhpOTk4VjhvMt78L7oe+d99WRMH2ODg4kJKSUuntEaIkcsVYCCGEEELUCIqikJSUxEMPPcSwYcOIj48HwMfHx3gV9ty5c+UaRMnGxob09PQy18vKyqJevXoAHD9+vNBztra25RrtOSIigv79+zN//nzefvttvL29uXr1Kg0aNDCOghwTE1Pu7tWmODg4YGVlZXzcqlUrTp8+DcCff/5pvPe4Ikra39XB19eXU6dOAXDq1CljDwHIH2G8qiNhl2T//v2V+jJE1DxyxVgIIYQQQtQIiqLw1VdfkZWVBcCIESMA6NKlCxERESxatMg4+FZZevXqxaZNm7C3t+fFF18scb0+ffqwZs0afvvtt2LFZYcOHVi9ejXh4eEMHTqUtm3bmmwjMjKSvn37Gh+3bt2aiIgIHnvsMc6cOcOiRYuwsrJizJgxpca8atUqrl27hl6v5/z588yZM6fEdXv06MGlS5dYtGgRLi4uPP/886W2vWTJEtLT00lLSyMkJIRhw4bh7+9vcn+XZPPmzVy+fJnMzExCQkJo27Ztma8pr759+7Jq1SoOHz6Mr69voWORlJREkyZNytXO/PnzSU1NRaPR8Mcff5i8H/peaWlpZd6fLWoHKYyFEEIIIUSNoNVqmTlzZrHlFhYWPPfccxVqy9/fv9go0vcWmgWjKnt4eBjnFy6qfv36BAUFlfleL7/8cqHHQ4cONf7fVBFf0n20kyZNKnPdUaNGldmOKbNnzza53NT+LklJo00vWrTI+P/KTuVkZ2dX4msvX77M3/72t3K1s2DBggq9b1n3d4vaQ7pSCyGEEEIIIVRDo9GQkpJS7u7RkydPLnF6rvJauXIl8fHxxe77FnWHXDEWQgghhBBCqIarqyshISEP9D0nT578QN9PqI9cMRZCCCGEEEIIUadJYSyEEEIIIYQQok6TwlgIIYQQQgghRJ0mhbEQQgghhBBCiDpNCmMhhBBCCCGEEHWaFMZCCCGEEEIIIeq0WlMYr1y5kqZNm2JjY0P37t35/fffqzskIYR4ICT/CSGEEEJUTa0ojL/55htee+015s+fz4kTJ+jQoQNDhgwhLi6uukMTQoj7SvKfEEIIIUTVWVR3AObwr3/9i4kTJ/Lcc88B8Nlnn7Fjxw6+/PJL5syZU2z97OxssrOzjY+Tk5MBSExMRK/Xo9frycjIICEhAUtLywezERWg+vju5seXlQuKQVPd4RSjyVVqRHy5WSkkJCRUdzgmqf4cLBJfamoqAIqiVHNk5mfu/Ac17/iqTXJaUo3IMWqPLzEpkUwr6+oOpxi1n3+m4qvNOVAIIcxFo9TwLJmTk4OdnR3/+c9/eOKJJ4zLx48fT1JSEj/88EOx17z99tssWLDgAUYphFCD69ev4+3tXd1hmI3kPyFERdS2HCiEEOZU468Yx8fHk5eXh4eHR6HlHh4eREZGmnzN3Llzee2114yPDQYDiYmJuLm5odFoSElJoXHjxly/fh0nJ6f7Gn9lSHxVI/FVndpjLBqfoiikpqbi5eVV3aGZ1f3If1Dzjq/aSHxVI/FVjan4amsOFEIIc6rxhXFlWFtbY21duHuWi4tLsfWcnJxU+UevgMRXNRJf1ak9xnvjc3Z2ruZo1KG8+Q9q1vFVI4mvaiS+qikan+RAIYQoXY0ffKt+/frodDpiY2MLLY+NjaVhw4bVFJUQQtx/kv+EEEIIIcyjxhfGVlZWdO7cmV9//dW4zGAw8Ouvv9KzZ89qjEwIIe4vyX9CCCGEEOZRK7pSv/baa4wfP54uXbrQrVs3li9fTnp6unGU1oqytrZm/vz5xbobqoXEVzUSX9WpPUa1x2dO5s5/oP79J/FVjcRXNRKfEELUTjV+VOoCH3/8MUuXLuX27dsEBASwYsUKunfvXt1hCSHEfSf5TwghhBCiampNYSyEEEIIIYQQQlRGjb/HWAghhBBCCCGEqAopjIUQQgghhBBC1GlSGAshhBBCCCGEqNOkMBZCCCGEEEIIUadJYWwG0dHR9O/fHz8/P9q3b096enp1h1RI06ZN8ff3JyAggP79+1d3OCZlZGTQpEkTZs6cWd2hFJKUlESXLl0ICAigXbt2rFmzprpDKuT69ev069cPPz8//P392bJlS3WHVMyTTz5JvXr1eOqpp+77ex08eJDhw4fj5eWFRqNh27Zt9/096zrJf1Un+a9yJP8VJvlPCCGqplbMY1zdJkyYwKJFi+jTpw+JiYmqnDvw8OHDODg4VHcYJXr33Xfp0aNHdYdRjKOjIwcPHsTOzo709HTatWvHyJEjcXNzq+7QALCwsGD58uUEBARw+/ZtOnfuzLBhw7C3t6/u0IymTZvG888/z7p16+77e6Wnp9OhQweef/55Ro4ced/fT0j+MwfJf5Uj+a8wyX9CCFE1UhhX0blz57C0tKRPnz4AuLq6VnNENc+FCxeIjIxk+PDhnD17trrDKUSn02FnZwdAdnY2iqKgphnOPD098fT0BKBhw4bUr1+fxMREVX0w7NevH/v3738g7zV06FCGDh36QN5LSP4zB8l/lSf5rzDJf0IIUTW1vit1eboWrVy5kqZNm2JjY0P37t35/fffy93+hQsXcHBwYPjw4XTq1ImQkBBVxQeg0Wh45JFH6Nq1Kxs2bFBdfDNnzmTx4sUVes2DjC8pKYkOHTrg7e3NrFmzqF+/vqriK3D8+HHy8vJo3LixKuMTD57kP8l/kv/UEZ8QQgj1q/WFcUHXopUrV5p8/ptvvuG1115j/vz5nDhxgg4dOjBkyBDi4uKM6xTcX1X0JyYmhtzcXH777Tc++eQTwsLCCA0NJTQ0VDXxARw6dIjjx4/z448/EhISwunTp1UT3w8//EDr1q1p3bp1uWN6kPEBuLi48OeffxIdHc3GjRuJjY1VVXwAiYmJPPvss6xevbrcsT3I+ET1kPwn+U/yX/XHJ4QQooZQ6hBA+f777wst69atmzJ58mTj47y8PMXLy0tZvHhxudo8fPiwMnjwYOPjJUuWKEuWLFFNfEXNnDlT+eqrr1QT35w5cxRvb2+lSZMmipubm+Lk5KQsWLBANfEV9Y9//EPZsmWLquLLyspS+vTpo6xfv75Scd3v+BRFUfbt26eMGjWqSvFVlKntqcsk/0n+k/z34ONTFMl/QghRU9T6K8alycnJ4fjx4wwcONC4TKvVMnDgQMLCwsrVRteuXYmLi+Pu3bsYDAYOHjxImzZtVBNfeno6qampAKSlpbF3717atm2rmvgWL17M9evXuXLlCu+//z4TJ07krbfeUk18sbGxxv2XnJzMwYMHeeihh1QTn6IoTJgwgUcffZRx48aZJS5zxifUS/Jf9ccn+U/ynxBCCPWo04NvxcfHk5eXh4eHR6HlHh4eREZGlqsNCwsLQkJC6Nu3L4qiMHjwYAIDA1UTX2xsLE8++SQAeXl5TJw4ka5du6omvvvJHPFdvXqVl156yTjozNSpU2nfvr1q4vvvf//LN998g7+/v/H+uK+//tosMZrr+A4cOJA///yT9PR0vL292bJlCz179qxyfKJqJP9Vf3z3k+S/6o8PJP8JIURNUqcLY3NR80iQzZs3588//6zuMMplwoQJ1R1CMd26dePUqVPVHUaJevfujcFgqO4wSrVnz54H9l5paWlcvHjR+Dg6OppTp07h6uqKj4/PA4ujLpH8Zx6S/ypO8l9hkv+EEKJq6nRhXL9+fXQ6XbHBRGJjY2nYsGE1RfU/El/VSHxVo/b4TDl27Bj9+/c3Pn7ttdcAGD9+PGvXrq2mqNRJ7cdX4qsaia9q1B6fKZL/hBCiaur0PcZWVlZ07tyZX3/91bjMYDDw66+/qqKrk8RXNRJf1ag9PlP69etn7PZ57498KCxO7cdX4qsaia9q1B6fKZL/hBCiamr9FeOyuha99tprjB8/ni5dutCtWzeWL19Oeno6zz33nMQn8Ul81RyfqBq1H1+JT+KT+IQQQqjGgx4G+0Hbt2+fAhT7GT9+vHGdjz76SPHx8VGsrKyUbt26KUeOHJH4JD6JTwXxiapR+/GV+CQ+iU8IIYRaaBRFUcxTYgshhBBCCCGEEDVPnb7HWAghhBBCCCGEkMJYCCGEEEIIIUSdJoWxEEIIIYQQQog6TQpjIYQQQgghhBB1mhTGQgghhBBCCCHqNCmMhRBCCCGEEELUaVIYCyGEEEIIIYSo06QwFkIIIYQQQghRp0lhLIQQQgghhBCiTpPCWAghhBBCCCFEnSaFsaiRJkyYgEajKfZz8eLFQs9ZWVnRsmVL3nnnHXJzcwHYv39/odc0aNCAYcOGcebMmWreKiGEKB/JgUIIIYR5SWEsaqzHHnuMW7duFfpp1qxZoecuXLjA66+/zttvv83SpUsLvf78+fPcunWLX375hezsbB5//HFycnKqY1OEEKLCJAcKIYQQ5iOFsaixrK2tadiwYaEfnU5X6LkmTZrwj3/8g4EDB/Ljjz8Wer27uzsNGzakU6dOTJ8+nevXrxMZGWl8vl+/frz66qvMnj0bV1dXGjZsyNtvv/0gN1EIIUokOVAIIYQwHymMRZ1ga2tb4pWQ5ORkNm/eDICVlVWh59atW4e9vT1Hjx5lyZIlvPPOO4SGht73eIUQwpwkBwohhBClk8JY1Fjbt2/HwcHB+PPXv/612DqKorBnzx5++eUXHn300ULPeXt74+DggIuLCxs3buQvf/kLvr6+hdbx9/dn/vz5tGrVimeffZYuXbrw66+/3tftEkKI8pAcKIQQQpiPRXUHIERl9e/fn08//dT42N7e3vj/gg+Mer0eg8HA3//+92JdAH/77Tfs7Ow4cuQIISEhfPbZZ8Xew9/fv9BjT09P4uLizLshQghRCZIDhRBCCPORwljUWPb29rRs2dLkcwUfGK2srPDy8sLCovip3qxZM1xcXHjooYeIi4vjmWee4eDBg4XWsbS0LPRYo9FgMBjMtxFCCFFJkgOFEEII85Gu1KJWKvjA6OPjY/IDYVGTJ0/m7NmzfP/99w8gOiGEuL8kBwohhBAVI4WxEICdnR0TJ05k/vz5KIpS3eEIIcQDJTlQCCFEXSeFsRD/b8qUKURERLBly5bqDkUIIR44yYFCCCHqMo0iXw0LIYQQQgghhKjD5IqxEEIIIYQQQog6TQpjIYQQQgghhBB1mhTGQgghhBBCCCHqNCmMhRBCCCGEEELUaVIYCyGEEEIIIYSo06QwFkIIIYQQQghRp0lhLIQQQgghhBCiTpPCWAghhBBCCCFEnSaFsRBCCCGEEEKIOk0KYyGEEEIIIYQQdZoUxkIIIYQQltdsHgAAAAtJREFUQggh6rT/AxO6lHdQN+NIAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fig, axes = plt.subplots(2, 3, figsize=(10, 5), layout=\"tight\")\n", + "\n", + "for ax, index in zip(axes.flatten(), samples, strict=False):\n", + " score = aupimo_result_custom.aupimos[index].item()\n", + " tpr = pimo_result_custom.per_image_tprs[index]\n", + " fpr = pimo_result_custom.shared_fpr\n", + " lower_bound, upper_bound = aupimo_custom.fpr_bounds\n", + " threshs_auc_mask = (pimo_result_custom.thresholds > aupimo_result_custom.thresh_lower_bound) & (\n", + " pimo_result_custom.thresholds < aupimo_result_custom.thresh_upper_bound\n", + " )\n", + " fpr_in_auc = fpr[threshs_auc_mask]\n", + " tpr_in_auc = tpr[threshs_auc_mask]\n", + "\n", + " plot_pimo_with_auc_zone(ax, tpr, fpr, lower_bound, upper_bound, fpr_in_auc, tpr_in_auc)\n", + " ax.set_title(f\"Image {index} ({score:.0%} AUPIMO)\")\n", + "\n", + "axes[-1, -1].axis(\"off\")\n", + "axes[-1, -1].text(\n", + " -0.08,\n", + " 0,\n", + " \"\"\"\n", + "FPRn: Avg. [in-image] False Positive Rate (FPR)\n", + " on normal images only ('n').\n", + "\n", + "TPR: [in-image] True Positive Rate (TPR),\n", + " or Recall.\n", + "\n", + "Integration zone in light pink, and area\n", + "under the curve (AUC) in purple.\n", + "\n", + "This area is normalized by the range size\n", + "so that AUPIMO is in [0, 1].\n", + "\"\"\",\n", + " ha=\"left\",\n", + " va=\"bottom\",\n", + " fontsize=\"x-small\",\n", + " color=\"dimgray\",\n", + " font=\"monospace\",\n", + ")\n", + "\n", + "fig.suptitle(\"PIMO curves\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice how the AUPIMO score increased with the easier task :) " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cite Us\n", + "\n", + "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "\n", + "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "\n", + "```bibtex\n", + "@misc{bertoldo2024aupimo,\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", + " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " year={2024},\n", + " eprint={2401.01984},\n", + " archivePrefix={arXiv},\n", + " primaryClass={cs.CV},\n", + " url={https://arxiv.org/abs/2401.01984}, \n", + "}\n", + "```\n", + "\n", + "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", + "\n", + "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "anomalib-dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/README.md b/notebooks/README.md index 2f93aa5c8c..8d8724a228 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -54,7 +54,8 @@ To install Python, Git and other required tools, [OpenVINO Notebooks](https://gi ## 7. Metrics -| Notebook | GitHub | Colab | -| ----------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| AUPIMO basics | [701a_aupimo](/notebooks/700_metrics/701a_aupimo.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701a_aupimo.ipynb) | -| AUPIMO representative samples and visualization | [701b_aupimo_advanced_i](/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | +| Notebook | GitHub | Colab | +| ----------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AUPIMO basics | [701a_aupimo](/notebooks/700_metrics/701a_aupimo.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701a_aupimo.ipynb) | +| AUPIMO representative samples and visualization | [701b_aupimo_advanced_i](/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | +| PIMO curve and integration bounds | [701c_aupimo_advanced_ii](/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb) | From 3acf51ad66591d00c0da2aca9f3099ab761affad Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Mon, 14 Oct 2024 09:36:23 +0100 Subject: [PATCH 09/32] Create epic.yaml --- .github/ISSUE_TEMPLATE/epic.yaml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/epic.yaml diff --git a/.github/ISSUE_TEMPLATE/epic.yaml b/.github/ISSUE_TEMPLATE/epic.yaml new file mode 100644 index 0000000000..b3e2cc6b71 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/epic.yaml @@ -0,0 +1,30 @@ +name: Epic +description: A large body of work that can be broken down into smaller stories +title: "[EPIC] " +labels: ["epic"] +assignees: [] +body: + - type: markdown + attributes: + value: "## Epic Description" + - type: textarea + id: description + attributes: + label: Describe the epic + description: Provide a clear and concise description of what this epic encompasses + validations: + required: true + - type: textarea + id: goals + attributes: + label: Goals + description: What are the main goals of this epic? + validations: + required: true + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance Criteria + description: List the high-level acceptance criteria for this epic + validations: + required: true From 71e48240a55d2b6f96df6900c64be3e443ff520b Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Mon, 14 Oct 2024 09:53:54 +0100 Subject: [PATCH 10/32] =?UTF-8?q?=F0=9F=94=A8=20Update=20the=20issue=20tem?= =?UTF-8?q?plates=20(#2363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update epic.yaml * Update epic.yaml * Update epic.yaml * Update epic.yaml --- .github/ISSUE_TEMPLATE/epic.yaml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/epic.yaml b/.github/ISSUE_TEMPLATE/epic.yaml index b3e2cc6b71..e6bdd31e8a 100644 --- a/.github/ISSUE_TEMPLATE/epic.yaml +++ b/.github/ISSUE_TEMPLATE/epic.yaml @@ -1,12 +1,12 @@ -name: Epic +name: 🎯 Epic description: A large body of work that can be broken down into smaller stories -title: "[EPIC] " +title: "🎯 [EPIC] " labels: ["epic"] assignees: [] body: - type: markdown attributes: - value: "## Epic Description" + value: "## 🎯 Epic Description" - type: textarea id: description attributes: @@ -22,9 +22,18 @@ body: validations: required: true - type: textarea - id: acceptance-criteria + id: tasks attributes: - label: Acceptance Criteria - description: List the high-level acceptance criteria for this epic + label: Tasks + description: Break down the epic into smaller tasks. Add or remove tasks as needed. + value: | + - [ ] Task 1: + - [ ] Task 2: + - [ ] Task 3: + - [ ] Task 4: + - [ ] Task 5: validations: required: true + - type: markdown + attributes: + value: "Remember to create separate issues for each task and link them to this epic." From e5dd67f2b22877cc945d01007ad79a3c299254e3 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Mon, 14 Oct 2024 10:16:49 +0100 Subject: [PATCH 11/32] Update task.yaml --- .github/ISSUE_TEMPLATE/task.yaml | 74 ++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/task.yaml b/.github/ISSUE_TEMPLATE/task.yaml index 9369e33c96..065712d1dc 100644 --- a/.github/ISSUE_TEMPLATE/task.yaml +++ b/.github/ISSUE_TEMPLATE/task.yaml @@ -1,35 +1,65 @@ -name: Tasks -description: This is used to capture tasks being implemented/to implement such as features, maintenance, refactor, etc. -title: "[Task]: " -labels: ["Task"] +name: πŸ“‹ Task +description: A specific piece of work to be completed +title: "πŸ“‹ [TASK] " +labels: ["task"] +assignees: [] body: - type: markdown attributes: - value: | - We encourage our users to submit feature requests in our [Discussion forum](https://github.com/openvinotoolkit/anomalib/discussions/categories/ideas-feature-requests). You can use this template for consistency. - + value: "## πŸ“‹ Task Description" - type: textarea - id: motivation + id: description attributes: - label: What is the motivation for this task? - description: A clear and concise description of what the problem is. - placeholder: | - 1. I'm always frustrated when [...]. It would be better if we could [...] - 2. I would like to have [...] model/dataset to be supported in Anomalib. + label: Describe the task + description: Provide a clear and concise description of the task to be completed validations: required: true - type: textarea - id: solution + id: acceptance-criteria attributes: - label: Describe the solution you'd like - description: A clear and concise description of what you want to happen. Add screenshots or code-blocks if necessary. - placeholder: | - I would like to have [...] to do this we would need to [...] - Here is what I would like to see [...] + label: Acceptance Criteria + description: List the specific criteria that must be met for this task to be considered complete + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Priority + options: + - Low + - Medium + - High + validations: + required: true + - type: input + id: epic-link + attributes: + label: Related Epic + description: If this task is part of an epic, provide the epic's issue number (e.g., #123) + validations: + required: false + - type: input + id: estimated-time + attributes: + label: Estimated Time + description: Provide an estimate of how long this task will take (e.g., 2h, 1d) + validations: + required: false + - type: dropdown + id: status + attributes: + label: Current Status + options: + - Not Started + - In Progress + - Blocked + - Ready for Review validations: required: true - type: textarea - id: additional-context + id: additional-info attributes: - label: Additional context - description: Add any other context or screenshots about the feature request here. + label: Additional Information + description: Any other relevant details or context for this task + validations: + required: false From 2c2fac1a32cb2101262971a1991d625d15e61005 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Mon, 14 Oct 2024 11:10:20 +0100 Subject: [PATCH 12/32] Create user_story.yaml --- .github/ISSUE_TEMPLATE/user_story.yaml | 69 ++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/user_story.yaml diff --git a/.github/ISSUE_TEMPLATE/user_story.yaml b/.github/ISSUE_TEMPLATE/user_story.yaml new file mode 100644 index 0000000000..c32d1e45ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/user_story.yaml @@ -0,0 +1,69 @@ +name: πŸ“– User Story +description: A small, self-contained unit of development work describing a feature from an end-user perspective +title: "πŸ“– [STORY] " +labels: ["user-story"] +assignees: [] +body: + - type: markdown + attributes: + value: "## πŸ“– User Story Description" + - type: textarea + id: user-story + attributes: + label: User Story + description: As a [type of user], I want [an action] so that [a benefit/a value] + placeholder: As a computer vision researcher, I want to implement a new anomaly detection algorithm so that I can improve detection accuracy for industrial defect scenarios. + validations: + required: true + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance Criteria + description: List the acceptance criteria for this user story + placeholder: | + 1. The new algorithm is implemented and integrated into the anomalib framework + 2. Unit tests are written and pass for the new implementation + 3. Performance benchmarks show improvement over existing methods on specified datasets + 4. Documentation is updated to include usage instructions and theory behind the new algorithm + 5. An example notebook is provided demonstrating the algorithm's application + validations: + required: true + - type: input + id: story-points + attributes: + label: Story Points + description: Estimate the complexity of this story (e.g., 1, 2, 3, 5, 8, 13) + validations: + required: true + - type: input + id: epic-link + attributes: + label: Related Epic + description: If this story is part of an epic, provide the epic's issue number (e.g., #123) + validations: + required: false + - type: dropdown + id: model-category + attributes: + label: Category + description: Select the category this story primarily relates to + options: + - Data + - Anomaly Detection Algorithms + - Pre-processing + - Post-processing + - Evaluation Metrics + - Visualization + - Performance Optimization + - API/Interface + - Documentation + - Others + validations: + required: true + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context, background, or relevant research papers about the user story here + validations: + required: false From 4e13e8abc5378bdf84e07af6edac5e3f3314eb65 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Tue, 15 Oct 2024 06:02:21 +0100 Subject: [PATCH 13/32] Update epic.yaml --- .github/ISSUE_TEMPLATE/epic.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/epic.yaml b/.github/ISSUE_TEMPLATE/epic.yaml index e6bdd31e8a..23c3bf51d3 100644 --- a/.github/ISSUE_TEMPLATE/epic.yaml +++ b/.github/ISSUE_TEMPLATE/epic.yaml @@ -27,11 +27,11 @@ body: label: Tasks description: Break down the epic into smaller tasks. Add or remove tasks as needed. value: | - - [ ] Task 1: - - [ ] Task 2: - - [ ] Task 3: - - [ ] Task 4: - - [ ] Task 5: + - [ ] Task 1: + - [ ] Task 2: + - [ ] Task 3: + - [ ] Task 4: + - [ ] Task 5: validations: required: true - type: markdown From 71cb1365396a36f5423f7cb59c9e22031364b93b Mon Sep 17 00:00:00 2001 From: Joao P C Bertoldo <24547377+jpcbertoldo@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:19:21 +0200 Subject: [PATCH 14/32] Pimo tutorials/03 advanced iii (#2348) * add aupimo notebook advanced iii (aupimo score of a random model) Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add cite us Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * update notebooks readme Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --------- Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> Co-authored-by: Samet Akcay --- .../701d_aupimo_advanced_iii.ipynb | 372 ++++++++++++++++++ notebooks/README.md | 11 +- 2 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb diff --git a/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb b/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb new file mode 100644 index 0000000000..6d446d171e --- /dev/null +++ b/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb @@ -0,0 +1,372 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# AUPIMO Score of a Random Model\n", + "\n", + "If model randomly assigns scores to the pixels -- i.e. no discrimination -- its AUROC score will be 50%. \n", + "\n", + "What would be its AUPIMO score?\n", + "\n", + "> AUPIMO is pronounced \"a-u-pee-mo\".\n", + "\n", + "> For basic usage, please check the notebook [701a_aupimo.ipynb](./701a_aupimo.ipynb).\n", + "\n", + "> For PIMO curve plots, please check the notebook [701c_aupimo_advanced_ii.ipynb](./701c_aupimo_advanced_ii.ipynb)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "# What is AUPIMO?\n", + "\n", + "The `Area Under the Per-Image Overlap [curve]` (AUPIMO) is a metric of recall (higher is better) designed for visual anomaly detection.\n", + "\n", + "Inspired by the [ROC](https://en.wikipedia.org/wiki/Receiver_operating_characteristic) and [PRO](https://link.springer.com/article/10.1007/s11263-020-01400-4) curves, \n", + "\n", + "> AUPIMO is the area under a curve of True Positive Rate (TPR or _recall_) as a function of False Positive Rate (FPR) restricted to a fixed range. \n", + "\n", + "But:\n", + "- the TPR (Y-axis) is *per-image* (1 image = 1 curve/score);\n", + "- the FPR (X-axis) considers the (average of) **normal** images only; \n", + "- the FPR (X-axis) is in log scale and its range is [1e-5, 1e-4]\\* (harder detection task!).\n", + "\n", + "\\* The score (the area under the curve) is normalized to be in [0, 1].\n", + "\n", + "AUPIMO can be interpreted as\n", + "\n", + "> average segmentation recall in an image given that the model (nearly) does not yield false positives in normal images.\n", + "\n", + "References in the last cell.\n", + "\n", + "![AUROC vs. AUPRO vs. AUPIMO](./roc_pro_pimo.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Setup" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install `anomalib` using `pip`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO(jpcbertoldo): replace by `pip install anomalib` when AUPIMO is released # noqa: TD003\n", + "%pip install ../.." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import torch\n", + "from matplotlib import pyplot as plt\n", + "from matplotlib.axes import Axes\n", + "from matplotlib.ticker import FixedLocator, PercentFormatter\n", + "from numpy import ndarray" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Random Model\n", + "\n", + "If a model cannot discriminate between normal and anomalous images, the survival fuctions\\* of the anomaly scores conditioned to each class would be the same.\n", + "\n", + "> \\* https://en.wikipedia.org/wiki/Survival_function\n", + "\n", + "In other words, FPR and TPR would be the same.\n", + "\n", + "Let's simulate this situation." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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9ttv8cYbb+DKlSvw8/N77vru7u56zy0tLQEAbm5uhdrL8o+1U6dOYe7cuThz5kyh6zWSk5N1cTzL3bt3ce3aNdjZ2RW5vGBCioiICDg5OcHMzExveb169UoUb6NGjdCjR4/n9vH09MS6desgCAKMjY1Rp06dQtd+AYCxsTF2794NIP9an6+//hqxsbF6Rc/z3Lx5E5988gmOHj2KlJQUvWVPF5aurq6FigFra2tcu3ZN9zwiIgI+Pj6F+pX0PTLUe++9h0GDBkEmk8HKygoNGjQocsKQ1157DePHj0d2djbOnz+PL7/8EhqNxqBZF0NCQlCvXj0oFGWX9hUKBVxdXQu1DxkyBEuXLsWuXbswbNgwpKWlYe/evRgzZozuPb179y6Af6+vepqFhQWA/IlTFi5ciKlTp8LBwQFt27bFq6++iuHDhxcqxomIygKLIyKqloyMjNCqVSu0atUKdevWxciRI/H7779j7ty5z12v4HoIQ9rFJyZkeNa3709PClCUkJAQvPTSS6hfvz6++eYbuLm5wcjICHv37sW3335baEKFomi1Wrz88suYPn16kcvr1q1b7DbKmqmpabEFFJD/3j7Zz9/fH/Xr18eYMWOKPeOXlJSELl26wMLCAp999hm8vb1hbGyMS5cu4eOPPy703j3r833ys6xoderUMeh9cnV11fXr3bs3bG1tMX78eHTr1q3QWabSKOnvsEqlKrIwa9u2LTw9PbFlyxYMGzYMu3fvRkZGBoYMGaLrU/C5bNy4scgi58kibtKkSejbty927NiBAwcO4NNPP8WCBQtw9OhRNGvWrET7SERUHBZHRFTttWzZEgAQFRVVbq9RMHHA07O+RUREFLvu7t27kZWVhV27dumduSoYWvSkZ/0B6+3tjbS0tGL/yPbw8MCRI0eQlpamd/YoKCio2DgripOTEyZPnqybFKNt27bP7BsYGIhHjx5h27Zt6Ny5s669uJnensfDwwM3btyAKIp673dleo+A/KnRv/32W3zyySfo37//c4fHeXt74++//0ZOTo5uCvWnvcjv8NMGDx6MZcuWISUlBZs3b4anp6fe51gwAYS9vb1BhaG3tzemTp2KqVOn4u7du2jatCmWLFmC//u//ytxbEREz8Nrjoio2jh27FiRZwD27t0LoPyGRQH5f1DL5XLdtT0FVq1aVey6BWcznow9OTkZ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vAqO5SW5cwXzABFR9WJoLqj09zmSwv379+Hm5iZ1GEREVIbu3bsHV1dXg/oyDxARVU/F5QIWR0XQarV4+PAhzM3NdTf0IiKiqkkURaSmpsLZ2VlvpMHzMA8QEVUvhuYCFkdERERERESo5LPVERERERERVRQWR0RERERERGBxREREREREBIDFEREREREREQAWR0SVVmBgIARBQFJSUoW+7oYNG2BlZfVC2wgPD4cgCLhy5coz+0i1f0REVQlzAVHFYnFEJAFBEJ77mDdvntQhEhFROWMuIKp8FFIHQFQTRUVF6X7evHkz5syZg6CgIF2bmZkZLly4UOLtZmdnw8jIqExiJCKi8sVcQFT58MwRkQQcHR11D0tLSwiCoNdmZmam63vx4kW0bNkSarUa7du310uc8+bNQ9OmTfHDDz/Ay8sLxsbGAICkpCS8++67sLOzg4WFBbp3746rV6/q1rt69Sq6desGc3NzWFhYoEWLFoUS8IEDB+Dr6wszMzO88soreklcq9Xis88+g6urK1QqFZo2bYr9+/c/d5/37t2LunXrwsTEBN26dUN4ePiLvIVERFUecwFzAVU+LI6IKrnZs2djyZIluHDhAhQKBd555x295cHBwdi6dSu2bdumG9c9aNAgxMbGYt++fbh48SKaN2+Ol156CQkJCQCAt956C66urjh//jwuXryIGTNmQKlU6rap0WiwePFibNy4ESdOnEBkZCSmTZumW75s2TIsWbIEixcvxrVr1+Dv749+/frh7t27Re7DvXv3MGDAAPTt2xdXrlzBu+++ixkzZpTxO0VEVH0xFxBVEJGIJLV+/XrR0tKyUPuxY8dEAOLhw4d1bXv27BEBiBkZGaIoiuLcuXNFpVIpxsbG6vr89ddfooWFhZiZmam3PW9vb/H7778XRVEUzc3NxQ0bNjwzHgBicHCwrm3lypWig4OD7rmzs7P4xRdf6K3XqlUr8f333xdFURTDwsJEAOLly5dFURTFmTNnin5+fnr9P/74YxGAmJiYWGQcREQ1CXNBYpFxEFU0njkiquQaN26s+9nJyQkAEBsbq2vz8PCAnZ2d7vnVq1eRlpYGGxsbmJmZ6R5hYWEICQkBAEyZMgXvvvsuevToga+++krXXkCtVsPb21vvdQteMyUlBQ8fPkSHDh301unQoQNu3bpV5D7cunULbdq00Wtr166dwe8BEVFNx1xAVDE4IQNRJffkEAdBEADkj/MuYGpqqtc/LS0NTk5OCAwMLLStgmlZ582bh2HDhmHPnj3Yt28f5s6di99++w39+/cv9JoFryuKYlnsDhERlQJzAVHF4JkjomqmefPmiI6OhkKhgI+Pj97D1tZW169u3bqYPHkyDh48iAEDBmD9+vUGbd/CwgLOzs44deqUXvupU6fg5+dX5Dq+vr44d+6cXtvZs2dLuGdERGQo5gKi0mFxRFTN9OjRA+3atcPrr7+OgwcPIjw8HKdPn8bs2bNx4cIFZGRkYPz48QgMDERERAROnTqF8+fPw9fX1+DX+Oijj7Bw4UJs3rwZQUFBmDFjBq5cuYIPP/ywyP5jx47F3bt38dFHHyEoKAi//vorNmzYUEZ7TERET2MuICodDqsjqmYEQcDevXsxe/ZsjBw5EnFxcXB0dETnzp3h4OAAuVyOR48eYfjw4YiJiYGtrS0GDBiA+fPnG/waEydORHJyMqZOnYrY2Fj4+flh165dqFOnTpH93d3dsXXrVkyePBnLly9H69at8eWXXxaabYmIiMoGcwFR6QgiB48SERERERFxWB0RERERERHA4oiIiIiIiAgAiyMiIiIiIiIALI6IiIiIiIgAsDgiIiIiIiICwOKIiIiIiIgIAIsjIiIiIiIiACyOiIiIiIiIALA4IiIiIiIiAsDiiIiIiIiICACLIyIiIiIiIgDA/wNDastb/9BDfwAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "thresholds = torch.linspace(0, 1, 1001)\n", + "\n", + "# fpr and tpr as a function of the threshold (i.e. the survival functions)\n", + "# generaly look like logistic functions flipped horizontally\n", + "# their actual shapes don't matter much, but rather how they compare to each other\n", + "# in this case, since they're the same, this choice is arbitrary as long as\n", + "# they're monotonically decreasing with the threshold\n", + "fpr = 1 - 1e2 / (1e2 + torch.exp(-20 * (thresholds - 0.5)))\n", + "tpr = fpr.clone()\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(8, 2), constrained_layout=True, sharey=True)\n", + "\n", + "axes[0].plot(thresholds, fpr, label=\"FPR\")\n", + "axes[1].plot(thresholds, tpr, label=\"TPR\")\n", + "\n", + "for ax in axes:\n", + " ax.set_xlabel(\"Threshold\")\n", + " ax.legend(loc=\"upper right\")\n", + " ax.set_yticks([0, 0.5, 1])\n", + " ax.set_xticks([])\n", + " ax.grid()\n", + "\n", + "fig.supylabel(\"FPR or TPR\", x=-0.03)\n", + "fig.suptitle(\"Simulated FPR and TPR curves\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# PIMO curve\n", + "\n", + "In the ROC curve, the FPR = TPR looks like a straight line.\n", + "\n", + "What does it look like in the PIMO curve?" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# utility plot functions (from the previous notebook)\n", + "\n", + "\n", + "def fmt_pow10(value: float) -> str:\n", + " \"\"\"Format the power of 10.\"\"\"\n", + " return \"1\" if value == 1 else f\"$10^{{{int(np.log10(value))}}}$\"\n", + "\n", + "\n", + "def plot_pimo_with_auc_zone(\n", + " ax: Axes,\n", + " tpr: ndarray,\n", + " fpr: ndarray,\n", + " lower_bound: float,\n", + " upper_bound: float,\n", + " fpr_in_auc: ndarray,\n", + " tpr_in_auc: ndarray,\n", + ") -> None:\n", + " \"\"\"Helper function to plot the PIMO curve with the AUC zone.\"\"\"\n", + " # plot\n", + " ax.plot(fpr, tpr, linewidth=3.5)\n", + " ax.axvspan(lower_bound, upper_bound, color=\"magenta\", alpha=0.3, zorder=-1)\n", + " ax.fill_between(fpr_in_auc, tpr_in_auc, alpha=1, color=\"tab:purple\", zorder=1)\n", + "\n", + " # config plots\n", + " ax.set_ylabel(\"TPR [%]\")\n", + " ax.yaxis.set_major_locator(FixedLocator(np.linspace(0, 1, 6)))\n", + " ax.yaxis.set_major_formatter(PercentFormatter(1, 0, symbol=\"\"))\n", + " ax.set_ylim(0, 1 + 3e-2)\n", + " ax.set_xlabel(\"FPR\")\n", + " ax.set_xscale(\"log\")\n", + " ax.xaxis.set_major_locator(FixedLocator(np.logspace(-6, 0, 7)))\n", + " ax.xaxis.set_major_formatter(lambda x, _: fmt_pow10(x))\n", + " ax.set_xlim(1e-6 / (eps := (1 + 3e-1)), 1 * eps)\n", + " ax.grid()\n", + "\n", + "\n", + "# simulate a random model's curve\n", + "lower_bound, upper_bound = 1e-5, 1e-4\n", + "threshs_auc_mask = (fpr > lower_bound) & (fpr < upper_bound)\n", + "fpr_in_auc = fpr[threshs_auc_mask]\n", + "tpr_in_auc = tpr[threshs_auc_mask]\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 4.5))\n", + "plot_pimo_with_auc_zone(ax, tpr, fpr, lower_bound, upper_bound, fpr_in_auc, tpr_in_auc)\n", + "\n", + "fig.text(\n", + " 0.15,\n", + " -0.01,\n", + " \"\"\"\n", + "FPR: Avg. [in-image] False Positive Rate (FPR) on normal images only.\n", + "\n", + "TPR: [in-image] True Positive Rate (TPR), or Recall.\n", + "\n", + "Integration zone in light pink, and area under the curve (AUC) in purple.\n", + "\n", + "This area is normalized by the range size so that AUPIMO is in [0, 1].\n", + "\"\"\",\n", + " ha=\"left\",\n", + " va=\"top\",\n", + " fontsize=\"x-small\",\n", + " color=\"dimgray\",\n", + " font=\"monospace\",\n", + ")\n", + "\n", + "fig.suptitle(\"Random model's PIMO curve\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# AUPIMO Score\n", + "\n", + "Recall that AUPIMO is computed from this integral:\n", + "\n", + "$$\n", + " \\frac{1}{\\log(U/L)}\n", + " \\int_{\\log(L)}^{\\log(U)} \n", + " \\operatorname{TPR}^{i}\\left( \\operatorname{FRP^{-1}}( z ) \\right)\n", + " \\, \n", + " \\mathrm{d}\\log(z) \n", + "$$\n", + "\n", + "where the integration bounds -- $L$[ower] and $U$[pper] -- are the FPR bounds.\n", + "\n", + "By assuming $\\operatorname{TPR}^{i} = \\operatorname{FPR}$, the AUPIMO score only depends on the FPR bounds:\n", + "\n", + "$$\n", + " \\text{AUPIMO of a random model} = \\frac{U - L}{\\log(U/L)}\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "random_model_aupimo(1e-4, 1e-5)=0.004%\n" + ] + } + ], + "source": [ + "def random_model_aupimo(lower_bound: float, upper_bound: float) -> float:\n", + " \"\"\"AUPIMO score obtained by a random model (no class discrimination).\"\"\"\n", + " return (upper_bound - lower_bound) / np.log(upper_bound / lower_bound)\n", + "\n", + "\n", + "print(f\"{random_model_aupimo(1e-4, 1e-5)=:.3%}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice how a random model's AUPIMO score of $0.004%$ is numerically neglegible in the scale up to 100% -- while its AUROC is 50%.\n", + "\n", + "It's easier to interpret the meaning of AUPIMO scores: \n", + "- $0$%: random or worse, \n", + "- $100$%: perfect." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cite Us\n", + "\n", + "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "\n", + "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "\n", + "```bibtex\n", + "@misc{bertoldo2024aupimo,\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", + " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " year={2024},\n", + " eprint={2401.01984},\n", + " archivePrefix={arXiv},\n", + " primaryClass={cs.CV},\n", + " url={https://arxiv.org/abs/2401.01984}, \n", + "}\n", + "```\n", + "\n", + "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", + "\n", + "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "anomalib-dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/README.md b/notebooks/README.md index 8d8724a228..15935b93cf 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -54,8 +54,9 @@ To install Python, Git and other required tools, [OpenVINO Notebooks](https://gi ## 7. Metrics -| Notebook | GitHub | Colab | -| ----------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| AUPIMO basics | [701a_aupimo](/notebooks/700_metrics/701a_aupimo.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701a_aupimo.ipynb) | -| AUPIMO representative samples and visualization | [701b_aupimo_advanced_i](/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | -| PIMO curve and integration bounds | [701c_aupimo_advanced_ii](/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb) | +| Notebook | GitHub | Colab | +| ----------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AUPIMO basics | [701a_aupimo](/notebooks/700_metrics/701a_aupimo.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701a_aupimo.ipynb) | +| AUPIMO representative samples and visualization | [701b_aupimo_advanced_i](/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | +| PIMO curve and integration bounds | [701c_aupimo_advanced_ii](/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb) | +| (AU)PIMO of a random model | [701d_aupimo_advanced_iii](/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb) | From b36b780bfe447367549109eca22e5d75da8d0f3c Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Tue, 15 Oct 2024 15:21:59 +0100 Subject: [PATCH 15/32] =?UTF-8?q?=F0=9F=94=A8=20Deprecate=20try=20import?= =?UTF-8?q?=20and=20replace=20it=20with=20Lightning's=20package=5Favailabl?= =?UTF-8?q?e=20(#2373)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace try_import with lightnings package_available function Signed-off-by: Samet Akcay --- src/anomalib/cli/pipelines.py | 4 ++-- src/anomalib/cli/utils/openvino.py | 5 ++--- src/anomalib/loggers/wandb.py | 5 ++--- src/anomalib/models/components/base/export_mixin.py | 6 +++--- src/anomalib/utils/exceptions/imports.py | 9 +++++++++ 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/anomalib/cli/pipelines.py b/src/anomalib/cli/pipelines.py index a76e57c298..8cfb04fd2e 100644 --- a/src/anomalib/cli/pipelines.py +++ b/src/anomalib/cli/pipelines.py @@ -6,13 +6,13 @@ import logging from jsonargparse import Namespace +from lightning_utilities.core.imports import package_available from anomalib.cli.utils.help_formatter import get_short_docstring -from anomalib.utils.exceptions import try_import logger = logging.getLogger(__name__) -if try_import("anomalib.pipelines"): +if package_available("anomalib.pipelines"): from anomalib.pipelines import Benchmark from anomalib.pipelines.components.base import Pipeline diff --git a/src/anomalib/cli/utils/openvino.py b/src/anomalib/cli/utils/openvino.py index 40046ac615..ee54bf09b2 100644 --- a/src/anomalib/cli/utils/openvino.py +++ b/src/anomalib/cli/utils/openvino.py @@ -6,13 +6,12 @@ import logging from jsonargparse import ArgumentParser - -from anomalib.utils.exceptions import try_import +from lightning_utilities.core.imports import package_available logger = logging.getLogger(__name__) -if try_import("openvino"): +if package_available("openvino"): from openvino.tools.ovc.cli_parser import get_common_cli_parser else: get_common_cli_parser = None diff --git a/src/anomalib/loggers/wandb.py b/src/anomalib/loggers/wandb.py index 0a23c25192..55e65e6d54 100644 --- a/src/anomalib/loggers/wandb.py +++ b/src/anomalib/loggers/wandb.py @@ -9,13 +9,12 @@ from lightning.fabric.utilities.types import _PATH from lightning.pytorch.loggers.wandb import WandbLogger from lightning.pytorch.utilities import rank_zero_only +from lightning_utilities.core.imports import package_available from matplotlib.figure import Figure -from anomalib.utils.exceptions import try_import - from .base import ImageLoggerBase -if try_import("wandb"): +if package_available("wandb"): import wandb if TYPE_CHECKING: diff --git a/src/anomalib/models/components/base/export_mixin.py b/src/anomalib/models/components/base/export_mixin.py index 5e7e5e9481..d11b50ff99 100644 --- a/src/anomalib/models/components/base/export_mixin.py +++ b/src/anomalib/models/components/base/export_mixin.py @@ -12,6 +12,7 @@ import numpy as np import torch +from lightning_utilities.core.imports import package_available from torch import nn from torchmetrics import Metric from torchvision.transforms.v2 import Transform @@ -20,7 +21,6 @@ from anomalib.data import AnomalibDataModule from anomalib.deploy.export import CompressionType, ExportType, InferenceModel from anomalib.metrics import create_metric_collection -from anomalib.utils.exceptions import try_import if TYPE_CHECKING: from importlib.util import find_spec @@ -245,7 +245,7 @@ def to_openvino( ... task="segmentation", ... ) """ - if not try_import("openvino"): + if not package_available("openvino"): logger.exception("Could not find OpenVINO. Please check OpenVINO installation.") raise ModuleNotFoundError @@ -294,7 +294,7 @@ def _compress_ov_model( Returns: model (CompiledModel): Model in the OpenVINO format compressed with NNCF quantization. """ - if not try_import("nncf"): + if not package_available("nncf"): logger.exception("Could not find NCCF. Please check NNCF installation.") raise ModuleNotFoundError diff --git a/src/anomalib/utils/exceptions/imports.py b/src/anomalib/utils/exceptions/imports.py index ebf6f11c61..dac22ba056 100644 --- a/src/anomalib/utils/exceptions/imports.py +++ b/src/anomalib/utils/exceptions/imports.py @@ -18,6 +18,15 @@ def try_import(import_path: str) -> bool: Returns: bool: True if import succeeds, False otherwise. """ + import warnings + + warnings.warn( + "The 'try_import' function is deprecated and will be removed in v2.0.0. " + "Use 'package_available' from lightning-utilities instead.", + DeprecationWarning, + stacklevel=2, + ) + try: import_module(import_path) except ImportError: From c99f8686f68d7c6276ce726cea24d7ca0e079d9b Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Wed, 16 Oct 2024 13:13:40 +0100 Subject: [PATCH 16/32] Refactor folder3d to avoid complex-structure (C901) issue (#2185) * Refactored-make_folder3d_dataset-ruff-error-C901 (#1926) Signed-off-by: sahusiddharth * Simplify folder 3d dataset (#2184) --------- Signed-off-by: sahusiddharth Co-authored-by: Siddharth Sahu <112792547+sahusiddharth@users.noreply.github.com> --- src/anomalib/data/depth/folder_3d.py | 60 +++++++++++++--------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/src/anomalib/data/depth/folder_3d.py b/src/anomalib/data/depth/folder_3d.py index 41a12fbf40..0fac137850 100644 --- a/src/anomalib/data/depth/folder_3d.py +++ b/src/anomalib/data/depth/folder_3d.py @@ -24,7 +24,7 @@ from anomalib.data.utils.path import _prepare_files_labels, validate_and_resolve_path -def make_folder3d_dataset( # noqa: C901 +def make_folder3d_dataset( normal_dir: str | Path, root: str | Path | None = None, abnormal_dir: str | Path | None = None, @@ -78,37 +78,28 @@ def make_folder3d_dataset( # noqa: C901 msg = "A folder location must be provided in normal_dir." raise ValueError(msg) - filenames = [] - labels = [] - dirs = {DirType.NORMAL: normal_dir} - - if abnormal_dir: - dirs[DirType.ABNORMAL] = abnormal_dir - - if normal_test_dir: - dirs[DirType.NORMAL_TEST] = normal_test_dir - - if normal_depth_dir: - dirs[DirType.NORMAL_DEPTH] = normal_depth_dir - - if abnormal_depth_dir: - dirs[DirType.ABNORMAL_DEPTH] = abnormal_depth_dir - - if normal_test_depth_dir: - dirs[DirType.NORMAL_TEST_DEPTH] = normal_test_depth_dir - - if mask_dir: - dirs[DirType.MASK] = mask_dir - - for dir_type, path in dirs.items(): - filename, label = _prepare_files_labels(path, dir_type, extensions) - filenames += filename - labels += label + dirs = { + DirType.NORMAL: normal_dir, + DirType.ABNORMAL: abnormal_dir, + DirType.NORMAL_TEST: normal_test_dir, + DirType.NORMAL_DEPTH: normal_depth_dir, + DirType.ABNORMAL_DEPTH: abnormal_depth_dir, + DirType.NORMAL_TEST_DEPTH: normal_test_depth_dir, + DirType.MASK: mask_dir, + } + + filenames: list[Path] = [] + labels: list[str] = [] + + for dir_type, dir_path in dirs.items(): + if dir_path is not None: + filename, label = _prepare_files_labels(dir_path, dir_type, extensions) + filenames += filename + labels += label samples = DataFrame({"image_path": filenames, "label": labels}) samples = samples.sort_values(by="image_path", ignore_index=True) - # Create label index for normal (0) and abnormal (1) images. samples.loc[ (samples.label == DirType.NORMAL) | (samples.label == DirType.NORMAL_TEST), "label_index", @@ -137,9 +128,12 @@ def make_folder3d_dataset( # noqa: C901 .all() ) if not mismatch: - msg = """Mismatch between anomalous images and depth images. Make sure the mask files - in 'xyz' folder follow the same naming convention as the anomalous images in the dataset - (e.g. image: '000.png', depth: '000.tiff').""" + msg = ( + "Mismatch between anomalous images and depth images. " + "Make sure the mask files in 'xyz' folder follow the same naming " + "convention as the anomalous images in the dataset" + "(e.g. image: '000.png', depth: '000.tiff')." + ) raise MisMatchError(msg) missing_depth_files = samples.depth_path.apply( @@ -159,7 +153,7 @@ def make_folder3d_dataset( # noqa: C901 samples["mask_path"] = samples["mask_path"].fillna("") samples = samples.astype({"mask_path": "str"}) - # make sure all the files exist + # Make sure all the files exist if not samples.mask_path.apply( lambda x: Path(x).exists() if x != "" else True, ).all(): @@ -168,7 +162,7 @@ def make_folder3d_dataset( # noqa: C901 else: samples["mask_path"] = "" - # remove all the rows with temporal image samples that have already been assigned + # Remove all the rows with temporal image samples that have already been assigned samples = samples.loc[ (samples.label == DirType.NORMAL) | (samples.label == DirType.ABNORMAL) | (samples.label == DirType.NORMAL_TEST) ] From 0823ab80d937e9c67596a1d27456c04b44fc5b44 Mon Sep 17 00:00:00 2001 From: Ashwin Vaidya Date: Thu, 17 Oct 2024 15:36:17 +0200 Subject: [PATCH 17/32] =?UTF-8?q?=F0=9F=9A=80=20Add=20datumaro=20annotatio?= =?UTF-8?q?n=20dataloader=20(#2377)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add datumaro annotation dataloader Signed-off-by: Ashwin Vaidya * Update changelog Signed-off-by: Ashwin Vaidya * Add examples Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya --- CHANGELOG.md | 1 + configs/data/datumaro.yaml | 15 ++ src/anomalib/data/__init__.py | 3 +- src/anomalib/data/image/__init__.py | 10 +- src/anomalib/data/image/datumaro.py | 226 +++++++++++++++++++++++++ tests/helpers/data.py | 38 +++++ tests/unit/data/image/test_datumaro.py | 39 +++++ 7 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 configs/data/datumaro.yaml create mode 100644 src/anomalib/data/image/datumaro.py create mode 100644 tests/unit/data/image/test_datumaro.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 340641fb7c..cf9807af26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Add `Datumaro` annotation format support by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/2377 - Add `AUPIMO` tutorials notebooks in https://github.com/openvinotoolkit/anomalib/pull/2330 and https://github.com/openvinotoolkit/anomalib/pull/2336 - Add `AUPIMO` metric by [jpcbertoldo](https://github.com/jpcbertoldo) in https://github.com/openvinotoolkit/anomalib/pull/1726 and refactored by [ashwinvaidya17](https://github.com/ashwinvaidya17) in https://github.com/openvinotoolkit/anomalib/pull/2329 diff --git a/configs/data/datumaro.yaml b/configs/data/datumaro.yaml new file mode 100644 index 0000000000..31867f34fa --- /dev/null +++ b/configs/data/datumaro.yaml @@ -0,0 +1,15 @@ +class_path: anomalib.data.Datumaro +init_args: + root: "datasets/datumaro" + train_batch_size: 32 + eval_batch_size: 32 + num_workers: 8 + image_size: null + transform: null + train_transform: null + eval_transform: null + test_split_mode: FROM_DIR + test_split_ratio: 0.2 + val_split_mode: FROM_TEST + val_split_ratio: 0.5 + seed: null diff --git a/src/anomalib/data/__init__.py b/src/anomalib/data/__init__.py index e7eaf11156..0ad469ac69 100644 --- a/src/anomalib/data/__init__.py +++ b/src/anomalib/data/__init__.py @@ -14,7 +14,7 @@ from .base import AnomalibDataModule, AnomalibDataset from .depth import DepthDataFormat, Folder3D, MVTec3D -from .image import BTech, Folder, ImageDataFormat, Kolektor, MVTec, Visa +from .image import BTech, Datumaro, Folder, ImageDataFormat, Kolektor, MVTec, Visa from .predict import PredictDataset from .utils import LabelName from .video import Avenue, ShanghaiTech, UCSDped, VideoDataFormat @@ -70,6 +70,7 @@ def get_datamodule(config: DictConfig | ListConfig | dict) -> AnomalibDataModule "VideoDataFormat", "get_datamodule", "BTech", + "Datumaro", "Folder", "Folder3D", "PredictDataset", diff --git a/src/anomalib/data/image/__init__.py b/src/anomalib/data/image/__init__.py index 0bea0f07ad..147db09418 100644 --- a/src/anomalib/data/image/__init__.py +++ b/src/anomalib/data/image/__init__.py @@ -9,6 +9,7 @@ from enum import Enum from .btech import BTech +from .datumaro import Datumaro from .folder import Folder from .kolektor import Kolektor from .mvtec import MVTec @@ -18,13 +19,14 @@ class ImageDataFormat(str, Enum): """Supported Image Dataset Types.""" - MVTEC = "mvtec" - MVTEC_3D = "mvtec_3d" BTECH = "btech" - KOLEKTOR = "kolektor" + DATUMARO = "datumaro" FOLDER = "folder" FOLDER_3D = "folder_3d" + KOLEKTOR = "kolektor" + MVTEC = "mvtec" + MVTEC_3D = "mvtec_3d" VISA = "visa" -__all__ = ["BTech", "Folder", "Kolektor", "MVTec", "Visa"] +__all__ = ["BTech", "Datumaro", "Folder", "Kolektor", "MVTec", "Visa"] diff --git a/src/anomalib/data/image/datumaro.py b/src/anomalib/data/image/datumaro.py new file mode 100644 index 0000000000..b4836990ec --- /dev/null +++ b/src/anomalib/data/image/datumaro.py @@ -0,0 +1,226 @@ +"""Dataloader for Datumaro format. + +Note: This currently only works for annotations exported from Intel Getiβ„’. +""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import json +from pathlib import Path + +import pandas as pd +from torchvision.transforms.v2 import Transform + +from anomalib import TaskType +from anomalib.data.base import AnomalibDataModule, AnomalibDataset +from anomalib.data.utils import LabelName, Split, TestSplitMode, ValSplitMode + + +def make_datumaro_dataset(root: str | Path, split: str | Split | None = None) -> pd.DataFrame: + """Make Datumaro Dataset. + + Assumes the following directory structure: + + dataset + β”œβ”€β”€ annotations + β”‚ └── default.json + └── images + └── default + β”œβ”€β”€ image1.jpg + β”œβ”€β”€ image2.jpg + └── ... + + Args: + root (str | Path): Path to the dataset root directory. + split (str | Split | None): Split of the dataset, usually Split.TRAIN or Split.TEST. + Defaults to ``None``. + + Examples: + >>> root = Path("path/to/dataset") + >>> samples = make_datumaro_dataset(root) + >>> samples.head() + image_path label label_index split mask_path + 0 path/to/dataset... Normal 0 Split.TRAIN + 1 path/to/dataset... Normal 0 Split.TRAIN + 2 path/to/dataset... Normal 0 Split.TRAIN + 3 path/to/dataset... Normal 0 Split.TRAIN + 4 path/to/dataset... Normal 0 Split.TRAIN + + + Returns: + DataFrame: an output dataframe containing samples for the requested split (ie., train or test). + """ + annotation_file = Path(root) / "annotations" / "default.json" + with annotation_file.open() as f: + annotations = json.load(f) + + categories = annotations["categories"] + categories = {idx: label["name"] for idx, label in enumerate(categories["label"]["labels"])} + + samples = [] + for item in annotations["items"]: + image_path = Path(root) / "images" / "default" / item["image"]["path"] + label_index = item["annotations"][0]["label_id"] + label = categories[label_index] + samples.append({ + "image_path": str(image_path), + "label": label, + "label_index": label_index, + "split": None, + "mask_path": "", # mask is provided in the annotation file and is not on disk. + }) + samples_df = pd.DataFrame( + samples, + columns=["image_path", "label", "label_index", "split", "mask_path"], + index=range(len(samples)), + ) + # Create test/train split + # By default assign all "Normal" samples to train and all "Anomalous" samples to test + samples_df.loc[samples_df["label_index"] == LabelName.NORMAL, "split"] = Split.TRAIN + samples_df.loc[samples_df["label_index"] == LabelName.ABNORMAL, "split"] = Split.TEST + + # Get the data frame for the split. + if split: + samples_df = samples_df[samples_df.split == split].reset_index(drop=True) + + return samples_df + + +class DatumaroDataset(AnomalibDataset): + """Datumaro dataset class. + + Args: + task (TaskType): Task type, ``classification``, ``detection`` or ``segmentation``. + root (str | Path): Path to the dataset root directory. + transform (Transform, optional): Transforms that should be applied to the input images. + Defaults to ``None``. + split (str | Split | None): Split of the dataset, usually Split.TRAIN or Split.TEST + Defaults to ``None``. + + + Examples: + .. code-block:: python + + from anomalib.data.image.datumaro import DatumaroDataset + from torchvision.transforms.v2 import Resize + + dataset = DatumaroDataset(root=root, + task="classification", + transform=Resize((256, 256)), + ) + print(dataset[0].keys()) + # Output: dict_keys(['dm_format_version', 'infos', 'categories', 'items']) + + """ + + def __init__( + self, + task: TaskType, + root: str | Path, + transform: Transform | None = None, + split: str | Split | None = None, + ) -> None: + super().__init__(task, transform) + self.split = split + self.samples = make_datumaro_dataset(root, split) + + +class Datumaro(AnomalibDataModule): + """Datumaro datamodule. + + Args: + root (str | Path): Path to the dataset root directory. + train_batch_size (int): Batch size for training dataloader. + Defaults to ``32``. + eval_batch_size (int): Batch size for evaluation dataloader. + Defaults to ``32``. + num_workers (int): Number of workers for dataloaders. + Defaults to ``8``. + task (TaskType): Task type, ``classification``, ``detection`` or ``segmentation``. + Defaults to ``TaskType.CLASSIFICATION``. Currently only supports classification. + image_size (tuple[int, int], optional): Size to which input images should be resized. + Defaults to ``None``. + transform (Transform, optional): Transforms that should be applied to the input images. + Defaults to ``None``. + train_transform (Transform, optional): Transforms that should be applied to the input images during training. + Defaults to ``None``. + eval_transform (Transform, optional): Transforms that should be applied to the input images during evaluation. + Defaults to ``None``. + test_split_mode (TestSplitMode): Setting that determines how the testing subset is obtained. + Defaults to ``TestSplitMode.FROM_DIR``. + test_split_ratio (float): Fraction of images from the train set that will be reserved for testing. + Defaults to ``0.2``. + val_split_mode (ValSplitMode): Setting that determines how the validation subset is obtained. + Defaults to ``ValSplitMode.SAME_AS_TEST``. + val_split_ratio (float): Fraction of train or test images that will be reserved for validation. + Defaults to ``0.5``. + seed (int | None, optional): Seed which may be set to a fixed value for reproducibility. + Defualts to ``None``. + + Examples: + To create a Datumaro datamodule + + >>> from pathlib import Path + >>> from torchvision.transforms.v2 import Resize + >>> root = Path("path/to/dataset") + >>> datamodule = Datumaro(root, transform=Resize((256, 256))) + >>> datamodule.setup() + >>> i, data = next(enumerate(datamodule.train_dataloader())) + >>> data.keys() + dict_keys(['image_path', 'label', 'image']) + + >>> data["image"].shape + torch.Size([32, 3, 256, 256]) + """ + + def __init__( + self, + root: str | Path, + train_batch_size: int = 32, + eval_batch_size: int = 32, + num_workers: int = 8, + task: TaskType = TaskType.CLASSIFICATION, + image_size: tuple[int, int] | None = None, + transform: Transform | None = None, + train_transform: Transform | None = None, + eval_transform: Transform | None = None, + test_split_mode: TestSplitMode | str = TestSplitMode.FROM_DIR, + test_split_ratio: float = 0.5, + val_split_mode: ValSplitMode | str = ValSplitMode.FROM_TEST, + val_split_ratio: float = 0.5, + seed: int | None = None, + ) -> None: + if task != TaskType.CLASSIFICATION: + msg = "Datumaro dataloader currently only supports classification task." + raise ValueError(msg) + super().__init__( + train_batch_size=train_batch_size, + eval_batch_size=eval_batch_size, + num_workers=num_workers, + val_split_mode=val_split_mode, + val_split_ratio=val_split_ratio, + test_split_mode=test_split_mode, + test_split_ratio=test_split_ratio, + image_size=image_size, + transform=transform, + train_transform=train_transform, + eval_transform=eval_transform, + seed=seed, + ) + self.root = root + self.task = task + + def _setup(self, _stage: str | None = None) -> None: + self.train_data = DatumaroDataset( + task=self.task, + root=self.root, + transform=self.train_transform, + split=Split.TRAIN, + ) + self.test_data = DatumaroDataset( + task=self.task, + root=self.root, + transform=self.eval_transform, + split=Split.TEST, + ) diff --git a/tests/helpers/data.py b/tests/helpers/data.py index 0ad699fb2f..60433df9eb 100644 --- a/tests/helpers/data.py +++ b/tests/helpers/data.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json import shutil from contextlib import ContextDecorator from pathlib import Path @@ -319,6 +320,43 @@ def __init__( self.min_size = min_size self.image_generator = DummyImageGenerator(image_shape=image_shape, rng=self.rng) + def _generate_dummy_datumaro_dataset(self) -> None: + """Generates dummy Datumaro dataset in a temporary directory.""" + # generate images + image_root = self.dataset_root / "images" / "default" + image_root.mkdir(parents=True, exist_ok=True) + + file_names: list[str] = [] + + # Create normal images + for i in range(self.num_train + self.num_test): + label = LabelName.NORMAL + image_filename = image_root / f"normal_{i:03}.png" + file_names.append(image_filename) + self.image_generator.generate_image(label, image_filename) + + # Create abnormal images + for i in range(self.num_test): + label = LabelName.ABNORMAL + image_filename = image_root / f"abnormal_{i:03}.png" + file_names.append(image_filename) + self.image_generator.generate_image(label, image_filename) + + # create annotation file + annotation_file = self.dataset_root / "annotations" / "default.json" + annotation_file.parent.mkdir(parents=True, exist_ok=True) + annotations = { + "categories": {"label": {"labels": [{"name": "Normal"}, {"name": "Anomalous"}]}}, + "items": [], + } + for file_name in file_names: + annotations["items"].append({ + "annotations": [{"label_id": 1 if "abnormal" in str(file_name) else 0}], + "image": {"path": file_name.name}, + }) + with annotation_file.open("w") as f: + json.dump(annotations, f) + def _generate_dummy_mvtec_dataset( self, normal_dir: str = "good", diff --git a/tests/unit/data/image/test_datumaro.py b/tests/unit/data/image/test_datumaro.py new file mode 100644 index 0000000000..2aef9ae715 --- /dev/null +++ b/tests/unit/data/image/test_datumaro.py @@ -0,0 +1,39 @@ +"""Unit tests - Datumaro Datamodule.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + +import pytest + +from anomalib import TaskType +from anomalib.data import Datumaro +from tests.unit.data.base.image import _TestAnomalibImageDatamodule + + +class TestDatumaro(_TestAnomalibImageDatamodule): + """Datumaro Datamodule Unit Tests.""" + + @pytest.fixture() + @staticmethod + def datamodule(dataset_path: Path, task_type: TaskType) -> Datumaro: + """Create and return a Datumaro datamodule.""" + if task_type != TaskType.CLASSIFICATION: + pytest.skip("Datumaro only supports classification tasks.") + + _datamodule = Datumaro( + root=dataset_path / "datumaro", + task=task_type, + train_batch_size=4, + eval_batch_size=4, + ) + _datamodule.setup() + + return _datamodule + + @pytest.fixture() + @staticmethod + def fxt_data_config_path() -> str: + """Return the path to the test data config.""" + return "configs/data/datumaro.yaml" From 1465b05fd9ff5c20bfe6df661187e6866e04cec7 Mon Sep 17 00:00:00 2001 From: Joao P C Bertoldo <24547377+jpcbertoldo@users.noreply.github.com> Date: Thu, 17 Oct 2024 21:54:24 +0200 Subject: [PATCH 18/32] Pimo tutorials/04 advanced iv (#2352) * add notebook 701e_aupimo_advanced_iv on load/save and statistical comparisons Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * make `AUPIMOResult.num_thresholds` optional Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * add aupimo notebook advanced iv (load/save and statistical tests) Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * simplify cite us and mention intal Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> * fix readme Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> --------- Signed-off-by: jpcbertoldo <24547377+jpcbertoldo@users.noreply.github.com> Co-authored-by: Samet Akcay --- notebooks/700_metrics/701a_aupimo.ipynb | 21 +- .../700_metrics/701b_aupimo_advanced_i.ipynb | 42 +- .../700_metrics/701c_aupimo_advanced_ii.ipynb | 21 +- .../701d_aupimo_advanced_iii.ipynb | 21 +- .../700_metrics/701e_aupimo_advanced_iv.ipynb | 1507 +++++++++++++++++ notebooks/README.md | 1 + src/anomalib/metrics/pimo/dataclasses.py | 6 +- 7 files changed, 1541 insertions(+), 78 deletions(-) create mode 100644 notebooks/700_metrics/701e_aupimo_advanced_iv.ipynb diff --git a/notebooks/700_metrics/701a_aupimo.ipynb b/notebooks/700_metrics/701a_aupimo.ipynb index 5c5497b3b8..d780c5a964 100644 --- a/notebooks/700_metrics/701a_aupimo.ipynb +++ b/notebooks/700_metrics/701a_aupimo.ipynb @@ -492,29 +492,20 @@ "source": [ "# Cite Us\n", "\n", - "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "AUPIMO was developed during [Google Summer of Code 2023 (GSoC 2023)](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd) with the `anomalib` team from Intel's OpenVINO Toolkit.\n", "\n", - "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", "\n", "```bibtex\n", "@misc{bertoldo2024aupimo,\n", - " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " year={2024},\n", - " eprint={2401.01984},\n", - " archivePrefix={arXiv},\n", - " primaryClass={cs.CV},\n", " url={https://arxiv.org/abs/2401.01984}, \n", "}\n", - "```\n", - "\n", - "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", - "\n", - "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", - "\n", - "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", - "\n", - "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + "```" ] } ], diff --git a/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb b/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb index a785075060..ea322102f8 100644 --- a/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb +++ b/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb @@ -775,29 +775,20 @@ "source": [ "# Cite Us\n", "\n", - "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "AUPIMO was developed during [Google Summer of Code 2023 (GSoC 2023)](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd) with the `anomalib` team from Intel's OpenVINO Toolkit.\n", "\n", - "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", "\n", "```bibtex\n", "@misc{bertoldo2024aupimo,\n", - " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " year={2024},\n", - " eprint={2401.01984},\n", - " archivePrefix={arXiv},\n", - " primaryClass={cs.CV},\n", " url={https://arxiv.org/abs/2401.01984}, \n", "}\n", - "```\n", - "\n", - "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", - "\n", - "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", - "\n", - "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", - "\n", - "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + "```" ] }, { @@ -1382,29 +1373,20 @@ "source": [ "# Cite Us\n", "\n", - "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "AUPIMO was developed during [Google Summer of Code 2023 (GSoC 2023)](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd) with the `anomalib` team from Intel's OpenVINO Toolkit.\n", "\n", - "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", "\n", "```bibtex\n", "@misc{bertoldo2024aupimo,\n", - " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " year={2024},\n", - " eprint={2401.01984},\n", - " archivePrefix={arXiv},\n", - " primaryClass={cs.CV},\n", " url={https://arxiv.org/abs/2401.01984}, \n", "}\n", - "```\n", - "\n", - "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", - "\n", - "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", - "\n", - "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", - "\n", - "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + "```" ] } ], diff --git a/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb b/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb index ed647ef666..6911b9c546 100644 --- a/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb +++ b/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb @@ -885,29 +885,20 @@ "source": [ "# Cite Us\n", "\n", - "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "AUPIMO was developed during [Google Summer of Code 2023 (GSoC 2023)](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd) with the `anomalib` team from Intel's OpenVINO Toolkit.\n", "\n", - "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", "\n", "```bibtex\n", "@misc{bertoldo2024aupimo,\n", - " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " year={2024},\n", - " eprint={2401.01984},\n", - " archivePrefix={arXiv},\n", - " primaryClass={cs.CV},\n", " url={https://arxiv.org/abs/2401.01984}, \n", "}\n", - "```\n", - "\n", - "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", - "\n", - "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", - "\n", - "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", - "\n", - "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + "```" ] } ], diff --git a/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb b/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb index 6d446d171e..7cbd29823b 100644 --- a/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb +++ b/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb @@ -321,29 +321,20 @@ "source": [ "# Cite Us\n", "\n", - "AUPIMO was developed during Google Summer of Code 2023 (GSoC 2023) with the `anomalib` team from OpenVINO Toolkit.\n", + "AUPIMO was developed during [Google Summer of Code 2023 (GSoC 2023)](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd) with the `anomalib` team from Intel's OpenVINO Toolkit.\n", "\n", - "Our work was accepted to the British Machine Vision Conference 2024 (BMVC 2024).\n", + "arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", "\n", "```bibtex\n", "@misc{bertoldo2024aupimo,\n", - " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", " year={2024},\n", - " eprint={2401.01984},\n", - " archivePrefix={arXiv},\n", - " primaryClass={cs.CV},\n", " url={https://arxiv.org/abs/2401.01984}, \n", "}\n", - "```\n", - "\n", - "Paper on arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", - "\n", - "Medium post: [medium.com/p/c653ac30e802](https://medium.com/p/c653ac30e802)\n", - "\n", - "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", - "\n", - "GSoC 2023 page: [summerofcode.withgoogle.com/archive/2023/projects/SPMopugd](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd)" + "```" ] } ], diff --git a/notebooks/700_metrics/701e_aupimo_advanced_iv.ipynb b/notebooks/700_metrics/701e_aupimo_advanced_iv.ipynb new file mode 100644 index 0000000000..e117006951 --- /dev/null +++ b/notebooks/700_metrics/701e_aupimo_advanced_iv.ipynb @@ -0,0 +1,1507 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# AUPIMO statistical comparison between two models\n", + "\n", + "Model A has a higher average AUPIMO than model B. Can you be _sure_ that A is better than B? \n", + "\n", + "We'll use statistical tests here to make informed decisions about this.\n", + "\n", + "This notebook covers:\n", + "- load/save functions to import/export AUPIMO scores;\n", + "- statistical tests between two models, in particular:\n", + " - parametrical test with Student's t-test;\n", + " - non-parametrical test with Wilcoxon signed-rank test;\n", + "\n", + "> AUPIMO is pronounced \"a-u-pee-mo\".\n", + "\n", + "> For basic usage, please check the notebook [701a_aupimo.ipynb](./701a_aupimo.ipynb)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "# What is AUPIMO?\n", + "\n", + "The `Area Under the Per-Image Overlap [curve]` (AUPIMO) is a metric of recall (higher is better) designed for visual anomaly detection.\n", + "\n", + "Inspired by the [ROC](https://en.wikipedia.org/wiki/Receiver_operating_characteristic) and [PRO](https://link.springer.com/article/10.1007/s11263-020-01400-4) curves, \n", + "\n", + "> AUPIMO is the area under a curve of True Positive Rate (TPR or _recall_) as a function of False Positive Rate (FPR) restricted to a fixed range. \n", + "\n", + "But:\n", + "- the TPR (Y-axis) is *per-image* (1 image = 1 curve/score);\n", + "- the FPR (X-axis) considers the (average of) **normal** images only; \n", + "- the FPR (X-axis) is in log scale and its range is [1e-5, 1e-4]\\* (harder detection task!).\n", + "\n", + "\\* The score (the area under the curve) is normalized to be in [0, 1].\n", + "\n", + "AUPIMO can be interpreted as\n", + "\n", + "> average segmentation recall in an image given that the model (nearly) does not yield false positives in normal images.\n", + "\n", + "References in the last cell.\n", + "\n", + "![AUROC vs. AUPRO vs. AUPIMO](./roc_pro_pimo.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Setup" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install `anomalib` using `pip`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO(jpcbertoldo): replace by `pip install anomalib` when AUPIMO is released # noqa: TD003\n", + "%pip install ../.." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import urllib.request\n", + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "import torch\n", + "from matplotlib import pyplot as plt\n", + "from matplotlib.ticker import FixedLocator, IndexLocator, MaxNLocator, PercentFormatter\n", + "from scipy import stats\n", + "\n", + "from anomalib.metrics.pimo import AUPIMOResult" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "pd.options.display.float_format = \"{:.3f}\".format" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Load AUPIMO scores\n", + "\n", + "Unlike previous notebook, we will not train and evaluate the models here.\n", + "\n", + "We'll load the AUPIMO scores from the benchmark presented in our paper (check the reference in the last cell).\n", + "\n", + "These scores can be found in AUPIMO's official repository in [`jpcbertoldo:aupimo/data/experiments/benchmark`](https://github.com/jpcbertoldo/aupimo/tree/main/data/experiments/benchmark). " + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loading benchmark results for model 'patchcore_wr101' and dataset 'mvtec/capsule'\n", + "Dowloading JSON file from https://raw.githubusercontent.com/jpcbertoldo/aupimo/refs/heads/main/data/experiments/benchmark/patchcore_wr101/mvtec/capsule/aupimo/aupimos.json\n", + "Converting payload to dataclass\n", + "Done!\n", + "Loading benchmark results for model 'patchcore_wr50' and dataset 'mvtec/capsule'\n", + "Dowloading JSON file from https://raw.githubusercontent.com/jpcbertoldo/aupimo/refs/heads/main/data/experiments/benchmark/patchcore_wr50/mvtec/capsule/aupimo/aupimos.json\n", + "Converting payload to dataclass\n", + "Done!\n" + ] + } + ], + "source": [ + "def get_benchmark_scores_url(model: str, dataset: str) -> str:\n", + " \"\"\"Generate the URL for the JSON file of a specific model and dataset.\"\"\"\n", + " root_url = \"https://raw.githubusercontent.com/jpcbertoldo/aupimo/refs/heads/main/data/experiments/benchmark\"\n", + " models = {\n", + " \"efficientad_wr101_m_ext\",\n", + " \"efficientad_wr101_s_ext\",\n", + " \"fastflow_cait_m48_448\",\n", + " \"fastflow_wr50\",\n", + " \"padim_r18\",\n", + " \"padim_wr50\",\n", + " \"patchcore_wr101\",\n", + " \"patchcore_wr50\",\n", + " \"pyramidflow_fnf_ext\",\n", + " \"pyramidflow_r18_ext\",\n", + " \"rd++_wr50_ext\",\n", + " \"simplenet_wr50_ext\",\n", + " \"uflow_ext\",\n", + " }\n", + " if model not in models:\n", + " msg = f\"Model '{model}' not available. Choose one of {sorted(models)}.\"\n", + " raise ValueError(msg)\n", + " datasets = {\n", + " \"mvtec/bottle\",\n", + " \"mvtec/cable\",\n", + " \"mvtec/capsule\",\n", + " \"mvtec/carpet\",\n", + " \"mvtec/grid\",\n", + " \"mvtec/hazelnut\",\n", + " \"mvtec/leather\",\n", + " \"mvtec/metal_nut\",\n", + " \"mvtec/pill\",\n", + " \"mvtec/screw\",\n", + " \"mvtec/tile\",\n", + " \"mvtec/toothbrush\",\n", + " \"mvtec/transistor\",\n", + " \"mvtec/wood\",\n", + " \"mvtec/zipper\",\n", + " \"visa/candle\",\n", + " \"visa/capsules\",\n", + " \"visa/cashew\",\n", + " \"visa/chewinggum\",\n", + " \"visa/fryum\",\n", + " \"visa/macaroni1\",\n", + " \"visa/macaroni2\",\n", + " \"visa/pcb1\",\n", + " \"visa/pcb2\",\n", + " \"visa/pcb3\",\n", + " \"visa/pcb4\",\n", + " \"visa/pipe_fryum\",\n", + " }\n", + " if dataset not in datasets:\n", + " msg = f\"Dataset '{dataset}' not available. Choose one of {sorted(datasets)}.\"\n", + " raise ValueError(msg)\n", + " return f\"{root_url}/{model}/{dataset}/aupimo/aupimos.json\"\n", + "\n", + "\n", + "def download_json(url_str: str) -> dict[str, str | float | int | list[str]]:\n", + " \"\"\"Download the JSON content from an URL.\"\"\"\n", + " with urllib.request.urlopen(url_str) as url: # noqa: S310\n", + " return json.load(url)\n", + "\n", + "\n", + "def load_aupimo_result_from_json_dict(payload: dict[str, str | float | int | list[str]]) -> AUPIMOResult:\n", + " \"\"\"Convert the JSON payload to an AUPIMOResult dataclass.\"\"\"\n", + " if not isinstance(payload, dict):\n", + " msg = f\"Invalid payload. Must be a dictionary. Got {type(payload)}.\"\n", + " raise TypeError(msg)\n", + " try:\n", + " return AUPIMOResult(\n", + " fpr_lower_bound=payload[\"fpr_lower_bound\"],\n", + " fpr_upper_bound=payload[\"fpr_upper_bound\"],\n", + " # `num_threshs` vs `num_thresholds` is an inconsistency with an older version of the JSON file\n", + " num_thresholds=payload[\"num_threshs\"] if \"num_threshs\" in payload else payload[\"num_thresholds\"],\n", + " thresh_lower_bound=payload[\"thresh_lower_bound\"],\n", + " thresh_upper_bound=payload[\"thresh_upper_bound\"],\n", + " aupimos=torch.tensor(payload[\"aupimos\"], dtype=torch.float64),\n", + " )\n", + "\n", + " except KeyError as ex:\n", + " msg = f\"Invalid payload. Missing key {ex}.\"\n", + " raise ValueError(msg) from ex\n", + "\n", + " except (TypeError, ValueError) as ex:\n", + " msg = f\"Invalid payload. Cause: {ex}.\"\n", + " raise ValueError(msg) from ex\n", + "\n", + "\n", + "def get_benchmark_aupimo_scores(model: str, dataset: str, verbose: bool = True) -> AUPIMOResult:\n", + " \"\"\"Get the benchmark AUPIMO scores for a specific model and dataset.\n", + "\n", + " Args:\n", + " model: The model name. See `_get_json_url` for the available models.\n", + " dataset: The \"collection/dataset\", where 'collection' is either 'mvtec' or 'visa', and 'dataset' is\n", + " the name of the dataset within the collection. See `_get_json_url` for the available datasets.\n", + " verbose: Whether to print the progress.\n", + "\n", + " Returns:\n", + " A `AUPIMOResult` dataclass with the AUPIMO scores from the benchmark results.\n", + "\n", + " More details in our paper: https://arxiv.org/abs/2401.01984\n", + " \"\"\"\n", + " if verbose:\n", + " print(f\"Loading benchmark results for model '{model}' and dataset '{dataset}'\")\n", + " url = get_benchmark_scores_url(model, dataset)\n", + " if verbose:\n", + " print(f\"Dowloading JSON file from {url}\")\n", + " payload = download_json(url)\n", + " if verbose:\n", + " print(\"Converting payload to dataclass\")\n", + " aupimo_result = load_aupimo_result_from_json_dict(payload)\n", + " if verbose:\n", + " print(\"Done!\")\n", + " return payload, aupimo_result\n", + "\n", + "\n", + "json_model_a, aupimo_result_model_a = get_benchmark_aupimo_scores(\"patchcore_wr101\", \"mvtec/capsule\")\n", + "_, aupimo_result_model_b = get_benchmark_aupimo_scores(\"patchcore_wr50\", \"mvtec/capsule\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's remove the `nan` values from the normal images." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "modela.shape=(109,) modelb.shape=(109,) labels.shape=(109,)\n" + ] + } + ], + "source": [ + "# corresponding paths to the images\n", + "# where the AUPIMO scores were computed from\n", + "paths = json_model_a[\"paths\"]\n", + "\n", + "# extract the labels (i.e. anomaly type or 'good')\n", + "labels = np.array([p.split(\"/\")[-2] for p in paths])\n", + "\n", + "# let's extract only the AUPIMO scores from anomalies\n", + "modela = aupimo_result_model_a.aupimos[labels != \"good\"].numpy()\n", + "modelb = aupimo_result_model_b.aupimos[labels != \"good\"].numpy()\n", + "labels = labels[labels != \"good\"]\n", + "print(f\"{modela.shape=} {modelb.shape=} {labels.shape=}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(6, 3))\n", + "ax.boxplot(\n", + " [modela, modelb],\n", + " tick_labels=[f\"A mean: {modela.mean():.0%}\", f\"B mean: {modelb.mean():.0%}\"],\n", + " vert=False,\n", + " showmeans=True,\n", + " meanline=True,\n", + " widths=0.5,\n", + ")\n", + "ax.invert_yaxis()\n", + "ax.set_title(\"AUPIMO scores distributions from two models\")\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Is this difference significant?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Image by image comparison\n", + "\n", + "Since we have the scores of each model for each image, we can compare them image by image." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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1bhPkyZNEBoaigkTJqjXhYSEGHVWjr17AVVfo8WLwSRJpKusY8VaWemniZTjz5KByN70OnHiRAwePBgNGzZEo0aNsGTJEiQnJ2Po0KEAgEGDBsHd3R3z5s0DAIwfPx4tWrTAt99+i06dOiE4OBj//PMPVqxYYdS4Bw6UEiabW4kKyNlZ+qfPjiqqYxLpkeyJMjAwEHFxcZg+fToePXqEunXrYv/+/eoOO3fu3IGZ2aunWJo2bYpNmzbh888/x6effoo333wTv//+O2rVqiXXRyAiomJM9kQJAGPHjs21qTUsLCzHut69e6N3794GjoqIiMgEBhwgIiIyZUyUREREWjBREhERacFESUREpAUTJRERkRZMlERERFowURIREWnBRElERKQFEyUREZEWTJRERERamMQQdsYkhACQ/3nIcqNUKpGSkoLExEROf5MFyyV3LBvNWC65Y9lopq9yUeUBVV7ITYlLlM+fPwcAeHp6yhwJERGZgufPn8PR0THX9xUir1RazGRmZuLBgwcoXbo0FApFgY+TmJgIT09P3L17Fw4ODnqMsGhjueSOZaMZyyV3LBvN9FUuQgg8f/4cFSpUyDZL1etKXI3SzMwMHh4eejueg4MDL2ANWC65Y9loxnLJHctGM32Ui7aapAo78xAREWnBRElERKQFE2UBWVlZYcaMGbCyspI7FJPCcskdy0YzlkvuWDaaGbtcSlxnHiIiIl2wRklERKQFEyUREZEWTJRERERaMFESERFpwUSpxbJly+Dl5QVra2s0btwYp0+f1rr91q1bUb16dVhbW6N27drYu3evkSI1Ll3KZeXKlWjevDmcnZ3h7OwMPz+/PMuxKNP1mlEJDg6GQqFAt27dDBugTHQtl4SEBIwZMwbly5eHlZUV3nrrLf4+/c+SJUtQrVo12NjYwNPTEx999BFevnxppGiN49ixYwgICECFChWgUCjw+++/57lPWFgY6tevDysrK1StWhXr1q3TX0CCNAoODhaWlpZizZo14r///hMjRowQTk5OIiYmRuP2x48fF+bm5mLBggXi8uXL4vPPPxcWFhbi0qVLRo7csHQtl379+olly5aJ8+fPi8jISDFkyBDh6Ogo7t27Z+TIDU/XslGJiooS7u7uonnz5qJr167GCdaIdC2X1NRU0bBhQ9GxY0cRHh4uoqKiRFhYmIiIiDBy5Ian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iejoaBEdHS2sra1FXFyc6Nq1q3qqoaSkJGFhYSE2btyo3j8tLU1UqFBBLFiwQAghxLRp00SNGjWynWPKlCkCgHpqpqCgIDFy5Mhs2/z111/CzMxMvHjxQgghTZe2ePFirbH369dPTJgwQb1cp04dvU51RCSXUnInaiLKycXFBZ06dcK6desghECnTp1QtmzZbNvcvHkTSqUSzZo1U6+zsLBAo0aNEBkZCQCIjIxE48aNs+33+qzvFy5cwMWLF7Fx40b1OiEEMjMzERUVBR8fnzzjTUhIwI4dOxAeHq5eN2DAAKxevdqkOx8R5QcTJZGJGjZsmLr5c9myZQY7T1JSEkaNGoVx48bleC+/nYc2bdqEly9fZkvKqmR77do1vPXWW3qLl8jYeI+SyES1b98eaWlpUCqV8Pf3z/F+lSpVYGlpiePHj6vXKZVKnDlzBjVq1AAA+Pj44PTp09n2+/vvv7Mt169fH5cvX0bVqlVz/LO0tMxXrKtXr8bHH3+MiIgI9b8LFy6gefPmWLNmja4fncikMFESmShzc3NERkbi8uXLMDc3z/G+nZ0dPvzwQ0yePBn79+/H5cuXMWLECKSkpCAoKAgA8MEHH+D69euYPHkyrl69ik2bNmHdunXZjjNlyhScOHECY8eORUREBK5fv45du3bluzNPREQEzp07h+HDh6NWrVrZ/vXt2xfr169Henp6ocuDSC5MlEQmzMHBAQ4ODrm+P3/+fPTs2RMDBw5E/fr1cePGDRw4cADOzs4ApKbT7du34/fff0edOnWwfPlyzJ07N9sx3n77bRw9ehTXrl1D8+bNUa9ePUyfPh0VKlTIV4yrV69GjRo1UL169Rzvde/eHbGxsdi7d68On5rItCiEEELuIIiIiEwVa5RERERaMFESERFpwURJRESkBRMlERGRFkyUREREWjBREhERacFESUREpAUTJRERkRZMlERERFowURIREWnBRElERKTF/wN1z1wlOOSlmQAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(5, 5))\n", + "modela_is_better = modela > modelb\n", + "ax.scatter(modela[modela_is_better], modelb[modela_is_better], alpha=0.3, s=10, color=\"red\", marker=\"o\")\n", + "ax.scatter(modela[~modela_is_better], modelb[~modela_is_better], alpha=0.3, s=10, color=\"blue\", marker=\"o\")\n", + "ax.plot([0, 1], [0, 1], color=\"black\", linestyle=\"--\")\n", + "ax.set_xlabel(\"Model A\")\n", + "ax.set_ylabel(\"Model B\")\n", + "ax.set_title(\"AUPIMO scores direct comparison\")\n", + "ax.grid()\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The dashed line is where both models have the same AUPIMO score.\n", + "\n", + "Notice that there are images where one performs better than the other and vice-versa." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Parametric Comparison\n", + "\n", + "Before using the statistical test, let's first visualize the data seen by the test.\n", + "\n", + "We'll use a _paired_ t-test, which means we'll compare the AUPIMO scores of the same image one by one." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABcsAAAGXCAYAAABslwhJAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOydd3gc1dX/vzPbd9WsLndZlixjWuhYlm0M2NhgIIRQE0oMpuVN8ibhDSQQSAK8tCQEEnoJyc8EQglvCKaYYrBlTDdusootd3VZbVfaNvf3x2pXs6stM7Pa8dXqfJ6HB2v3nr33e++ZMzN37pwrMMYYCIIgCIIgCIIgCIIgCIIgCGIcIx7uBhAEQRAEQRAEQRAEQRAEQRDE4YYmywmCIAiCIAiCIAiCIAiCIIhxD02WEwRBEARBEARBEARBEARBEOMemiwnCIIgCIIgCIIgCIIgCIIgxj00WU4QBEEQBEEQBEEQBEEQBEGMe2iynCAIgiAIgiAIgiAIgiAIghj30GQ5QRAEQRAEQRAEQRAEQRAEMe6hyXKCIAiCIAiCIAiCIAiCIAhi3EOT5QRBEARBEARBEARBEARBEMS4hybLCYIgCIIgCGKcc+edd0IQhLDPpk+fjquuuurwNIhQxFVXXYXp06cf7mYQBEEQBEGkDTRZThAEQRAEMc549NFHIQgCTj755Kjf7969G4Ig4MEHH4z6/YMPPghBELB79+7QZwsXLoQgCKH/cnNzceKJJ+LZZ5+FJEmhcldddRUyMjLCfi9oW15eHrW+NWvWhH73lVdeGfH9tm3b8L3vfQ+TJk2CxWLBxIkTcfnll2Pbtm2JuoLgkA0bNuDOO+9Ed3f34W4KQRAEQRAEMc6gyXKCIAiCIIhxxqpVqzB9+nR89tlnaGxsHLXfnTx5Mv7+97/j73//O26//Xb4fD6sWLECv/zlLxPaWq1WNDY24rPPPovaXqvVGtXutddew3HHHYf3338fV199NR599FGsWLECH374IY477jj861//SlrXeKWurg5PPfWU7vVu2LABv/nNb2iyXAFPPfUU6urqDnczCIIgCIIg0gaaLCcIgiAIghhHNDU1YcOGDfjDH/6AgoICrFq1atR+Ozs7G9/73vfwve99D//93/+NmpoaTJ48GX/+85/h9Xrj2paVlWHWrFn4xz/+Efb54OAg/vWvf+Hss88eYbNz5058//vfx4wZM7B582bcddddWLFiBX73u99h8+bNmDFjBr7//e9j165do6YxVQwODoatwOcBi8UCk8kUt4zT6dSpNYScYL+bTCZYLJbD3BqCIAiCIIj0gSbLCYIgCIIgxhGrVq3ChAkTcPbZZ+PCCy8c1cnySOx2O0455RQ4nU60t7cnLH/ppZfipZdeCps0fuONN+ByuXDRRReNKP/AAw/A5XLhySefREFBQdh3+fn5eOKJJ+B0OnH//fcnrPuRRx7BnDlzYLfbMWHCBJxwwgl44YUXwsocOHAAK1aswMSJE2GxWFBaWoobbrgBHo8nVGbXrl347ne/i9zc3JD+N998M+x31q5dC0EQ8OKLL+K2227DpEmTYLfb0dvbCwD49NNPcdZZZyE7Oxt2ux0LFixATU1N2G/09fXhJz/5CaZPnw6LxYLCwkKceeaZ+OqrrxJqXb9+PU488URYrVaUlZXhiSeeiFouMmf5X//6VwiCgI8++gg33ngjCgsLMXny5ND3b731Fqqrq+FwOJCZmYmzzz47aiqcHTt24KKLLkJBQQFsNhtmzZqFX/3qVwACudNvvvlmAEBpaWko/Y485U80Pv30UyxbtgwTJkyAw+HA0UcfjT/96U9hZT744INQ+3JycnDeeeehtrY2rEwwd3t9fT2+973vITs7GwUFBbj99tvBGMO+fftw3nnnISsrC8XFxfj9738fZh8c25deegm//OUvUVxcDIfDgXPPPRf79u0LK7tu3Tp897vfxdSpU2GxWDBlyhT893//NwYGBsLKBVMX7dy5E8uWLUNmZiYuv/zy0HeROctffPFFHH/88cjMzERWVhaOOuqoEX2hxk//+c9/4u6778bkyZNhtVpx+umnj+obKQRBEARBEDxhPNwNIAiCIAiCIPRj1apVuOCCC2A2m3HppZfisccew+eff44TTzwxJfXt2rULBoMBOTk5CctedtlluPPOO7F27VosWrQIAPDCCy/g9NNPR2Fh4Yjyb7zxBqZPn47q6uqovzd//nxMnz59xCRgJE899RR+9KMf4cILL8SPf/xjDA4OYvPmzfj0009x2WWXAQAOHjyIk046Cd3d3Vi5ciUqKytx4MABvPLKK3C5XDCbzWhtbcXcuXPhcrnwox/9CHl5eXj++edx7rnn4pVXXsG3v/3tsHp/97vfwWw24+c//zncbjfMZjM++OADLF26FMcffzzuuOMOiKKI5557DosWLcK6detw0kknAQCuv/56vPLKK/jhD3+II444Ap2dnVi/fj1qa2tx3HHHxdS6ZcsWLF68GAUFBbjzzjvh8/lwxx13oKioKG4fybnxxhtRUFCAX//616EVzn//+99x5ZVXYsmSJbjvvvvgcrnw2GOPYd68efj6669DE7qbN29GdXU1TCYTVq5cienTp2Pnzp144403cPfdd+OCCy5AfX09/vGPf+CPf/wj8vPzAWDEwxA5a9aswTnnnIOSkhL8+Mc/RnFxMWpra/Gf//wHP/7xjwEA7733HpYuXYoZM2bgzjvvxMDAAB555BFUVVXhq6++GjHhfPHFF2P27Nm499578eabb+Kuu+5Cbm4unnjiCSxatAj33XcfVq1ahZ///Oc48cQTMX/+/DD7u+++G4Ig4Be/+AXa2trw0EMP4YwzzsCmTZtgs9kAAC+//DJcLhduuOEG5OXl4bPPPsMjjzyC/fv34+WXXw77PZ/PhyVLlmDevHl48MEHYbfbY/bFpZdeitNPPx333XcfAKC2thY1NTWhvlDrp/feey9EUcTPf/5z9PT04P7778fll1+OTz/9NOaYEARBEARBjFkYQRAEQRAEMS744osvGAC2Zs0axhhjkiSxyZMnsx//+Mdh5ZqamhgA9sADD0T9nQceeIABYE1NTaHPFixYwCorK1l7eztrb29ntbW17Ec/+hEDwJYvXx4qd+WVVzKHwxH2ewsWLGBz5sxhjDF2wgknsBUrVjDGGDt06BAzm83s+eefZx9++CEDwF5++WXGGGPd3d0MADvvvPPiaj733HMZANbb2xuzzHnnnReqPxZXXHEFE0WRff755yO+kySJMcbYT37yEwaArVu3LvRdX18fKy0tZdOnT2d+v58xxkJaZsyYwVwuV9jvlJeXsyVLloR+kzHGXC4XKy0tZWeeeWbos+zsbHbTTTfFbXM0zj//fGa1WtmePXtCn23fvp0ZDAYWeWswbdo0duWVV4b+fu655xgANm/ePObz+cI05uTksGuvvTbMvqWlhWVnZ4d9Pn/+fJaZmRlWf1B7kGj+FQufz8dKS0vZtGnT2KFDh2L+5rHHHssKCwtZZ2dn6LNvvvmGiaLIrrjiitBnd9xxBwPAVq5cGVbH5MmTmSAI7N577w19fujQIWaz2cL6KDi2kyZNCvO5f/7znwwA+9Of/hT6TD72Qf73f/+XCYIQ1j9XXnklA8BuueWWEeWvvPJKNm3atNDfP/7xj1lWVlbY+ESi1k9nz57N3G53qOyf/vQnBoBt2bIlZh0EQRAEQRBjFUrDQhAEQRAEMU5YtWoVioqKcNpppwEABEHAxRdfjBdffBF+vz/p39+xYwcKCgpQUFCA2bNn45FHHsHZZ5+NZ599VvFvXHbZZXjttdfg8XjwyiuvwGAwjFjpCgTSkABAZmZm3N8Lfh9McRKNnJwc7N+/H59//nnU7yVJwuuvv47ly5fjhBNOGPG9IAgAgNWrV+Okk07CvHnzQt9lZGRg5cqV2L17N7Zv3x5md+WVV4ZWGQPApk2b0NDQgMsuuwydnZ3o6OhAR0cHnE4nTj/9dHz88cehFDU5OTn49NNPcfDgwbj65fj9frzzzjs4//zzMXXq1NDns2fPxpIlSxT/zrXXXguDwRD6e82aNeju7sall14aanNHRwcMBgNOPvlkfPjhhwCA9vZ2fPzxx/jBD34QVj8w3Idq+frrr9HU1ISf/OQnI95eCP5mc3MzNm3ahKuuugq5ubmh748++miceeaZWL169Yjfveaaa0L/NhgMOOGEE8AYw4oVK0Kf5+TkYNasWVFz4l9xxRVhvnnhhReipKQkrC752DudTnR0dGDu3LlgjOHrr78e8Zs33HBDvK4ItcnpdGLNmjUxy6j106uvvhpmszn0d/BNjrGwFwBBEARBEIRaaLKcIAiCIAhiHOD3+/Hiiy/itNNOQ1NTExobG9HY2IiTTz4Zra2teP/991X/ZuQE5/Tp07FmzRq89957WL9+PVpaWvCf//wnlEpDCZdccgl6enrw1ltvYdWqVTjnnHOiTogHPwtOmsdCyaT6L37xC2RkZOCkk05CeXk5brrpprAc4e3t7ejt7cWRRx4Zt649e/Zg1qxZIz6fPXt26Hs5paWlYX83NDQACEyiBx86BP97+umn4Xa70dPTAwC4//77sXXrVkyZMgUnnXQS7rzzzoSTl+3t7RgYGEB5efmI76K1Oxax2r1o0aIR7X733XfR1tYGYHhyNVE/qmHnzp0JfzPY77HGJvhAQk7kZH52djasVusIX87OzsahQ4dG/G5kHwuCgJkzZ4blXt+7d29oAj8jIwMFBQVYsGABAITGOYjRaAzLDx+LG2+8ERUVFVi6dCkmT56MH/zgB3j77bfDyqj108i+mDBhAgBE1U0QBEEQBDHWoZzlBEEQBEEQ44APPvgAzc3NePHFF/Hiiy+O+H7VqlVYvHgxAMBqtQLAiI0Gg7hcrrByQRwOB84444yk2llSUoKFCxfi97//PWpqavDqq69GLZednY2SkhJs3rw57u9t3rwZkyZNQlZWVswys2fPRl1dHf7zn//g7bffxquvvopHH30Uv/71r/Gb3/wmKT3xkK8sBhBaNf7AAw/g2GOPjWqTkZEBALjoootQXV2Nf/3rX3j33XfxwAMP4L777sNrr72GpUuXpqzN8dr997//HcXFxSPKG41j75ZDvnI+3mcAwBhT/ft+vx9nnnkmurq68Itf/AKVlZVwOBw4cOAArrrqqrBNbgHAYrFAFBOvcyosLMSmTZvwzjvv4K233sJbb72F5557DldccQWef/551e0ERlc3QRAEQRAE74y9K1eCIAiCIAhCNatWrUJhYSH+8pe/jPjutddew7/+9S88/vjjsNlsKCgogN1uR11dXdTfqqurg91uV7ViXA2XXXYZrrnmGuTk5GDZsmUxy51zzjl46qmnsH79+rCUEkHWrVuH3bt347rrrktYp8PhwMUXX4yLL74YHo8HF1xwAe6++27ceuutKCgoQFZWFrZu3Rr3N6ZNmxa1z3bs2BH6Ph5lZWUAgKysLEUPHUpKSnDjjTfixhtvRFtbG4477jjcfffdMSfLCwoKYLPZQivB5cQaayUE211YWBi33TNmzACAhP2oJiVLsO6tW7fGrDvY77HGJj8/Hw6HQ3GdSojsY8YYGhsbcfTRRwMIbLRaX1+P559/HldccUWoXLz0KUoxm81Yvnw5li9fDkmScOONN+KJJ57A7bffjpkzZybtpwRBEARBEOkMpWEhCIIgCIJIcwYGBvDaa6/hnHPOwYUXXjjivx/+8Ifo6+vDv//9bwCBlaSLFy/GG2+8gb1794b91t69e/HGG29g8eLFMVecJsuFF16IO+64A48++mhYruRIbr75ZthsNlx33XXo7OwM+66rqwvXX3897HY7br755rj1RdqazWYcccQRYIzB6/VCFEWcf/75eOONN/DFF1+MsA+usF22bBk+++wzfPLJJ6HvnE4nnnzySUyfPh1HHHFE3HYcf/zxKCsrw4MPPoj+/v4R37e3twMIrEqO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6VwurSznHEEQkJGRkVIbPerQAo86eNStl026aCd/J+2pqkMtvI5humgnfyftqapDLbzqIO18aee1r7QwXrXzqoO086Wd177SwnjVzqsO0s6Xdl77Si3pdC+ihtjZzAku8Pv9qK+vh9/vT5mNHnVogUcdPOrWyyZdtJO/k/ZU1aEWXscwXbSTv5P2VNWhFl51kHa+tPPaV1oYr9p51UHa+dLOa19pYbxq51UHaedLO699pZZ0uhdRA02WjwEkSUq5jR51aIFHHTzq1ssmXbSTv6feJtV18OonauF1DNNFO/l76m1SXQevfqIWXnWQ9tTCow49dGupJ12086qDtKcWHnXQsZ5aeNVB2lMLjzp41K2nTaqgyXKCIAiCIAiCIAiCIAiCIAhi3EOT5QRBEARBEARBEARBEARBEMS4hybLOUcURZSWlkIUlQ+VWhs96tACjzp41K2XTbpoJ38n7amqQy28jmG6aCd/J+2pqkMtvOog7Xxp57WvtDBetfOqg7TzpZ3XvtLCeNXOqw7Szpd2XvtKLel0L6IGPlpBxMVoNKbcRo86tMCjDh5162WTLtrJ31Nvk+o6ePUTtfA6huminfw99TaproNXP1ELrzpIe2rhUYceurXUky7aedVB2lMLjzroWE8tvOog7amFRx086tbTJlUc1snyjz/+GMuXL8fEiRMhCAJef/31sO8ZY/j1r3+NkpIS2Gw2nHHGGWhoaAgr09XVhcsvvxxZWVnIycnBihUr0N/fH/p+9+7dmD9/PhwOB+bPn4/du3eH2Z9zzjl49dVXUyVRM5LHg761a9Fy992oX7kSLXffjb61ayF5PIltJQkNDQ2Kk+OrKZ9Mu9SSSh161BHsq/Z770XxqlVov/feuH2lpW/1sEmmDqXaQ3Yp9EU9jimturXUkWo/UduuZBjrx7pa9BoPrfXwOOZabPSoQwu8naO1xlI1cU5PX+TpvK61TWpseI3vep5z00U7b/6eDDzGax7juxabdDm3abHRI5am8hpea3m96tDCeNXO6/U4b9q12tD81Nj1d71tUslhnbZ3Op045phj8IMf/AAXXHDBiO/vv/9+PPzww3j++edRWlqK22+/HUuWLMH27dthtVoBAJdffjmam5uxZs0aeL1eXH311Vi5ciVeeOEFAMDPfvYzTJo0Cc888wxuu+02/PznP8crr7wCAHjppZcg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sUpqDmaXlV3qWJkl9TOr3VyxWQ4As2fPPuSyK61lZWVh9+7dhzx+99134+6777axZ0REREREREREREQknSsuw0JEREREREREREREFA5ulgunaRpSUlIO3vnaphon2rBCYg6JuZ2qcUt2rndmt6sNs6TOoVuyc70zu11tmCU1B7PLyi51rKyI1uxSczC7rOxSx8qKaM0uNQezy8oudazMctNnETNccxkWt9J1HT179rS1xok2rJCYQ2Jup2rckp3rndntasMsqXPoluxc78xuVxtmSc3B7LKySx0rK6I1u9QczC4ru9SxsiJas0vNweyysksdK7Pc9FnEDJ5ZLpxhGNizZ4/pu8iaqXGiDSsk5pCY26kat2Tnemd2u9owS+ocuiU71zuz29WGWVJzMLus7FLHyopozS41B7PLyi51rKyI1uxSczC7rOxSx8osN30WMYOb5cIppVBVVQWllG01TrRhhcQcEnM7VeOW7FzvzG5XG2ZJnUO3ZOd6Z3a72jBLag5ml5Vd6lhZEa3ZpeZgdlnZpY6VFdGaXWoOZpeVXepYmeWmzyJmcLOciIiIiIiIiIiIiKIer1luUfNvO6qrq0OuaWxsRF1dHaqrqxEXFxdSTTAYRG1tLaqrqxETE2NLjRNtOJFd6liZze5EDis1bsnO9c7s0bTerdS4JTvXO7NzvUe+Jlqzc70zO9d75/fLLdmljhWz81i3ow0rNW7JLnWsJK53qzVWNO/hdnQGOzfLLaqpqQEA9OrVq5N7QkREREREREREREQdqampQUpKymGf15SUC8J0MYZh4Pvvv0dycjI0TQupprq6Gr169cI333yDbt26hdzWiSeeiHXr1pnqn9kau9twKrvEsbKS3YkcVmrckp3rndnteL3U9W6lxi3Zud6Z3Y7XR+t6B6I3O9c7s3O9d36/nGiD653Zeax3fr+caIPrXd56t1pjllIKNTU1OOqoo6Drh78yOc8st0jXdRxzzDGWart162bqgIyJiTH1eis1TrQB2J9d6lgB5rI7NR/Rmp3rndntagOQt96t1LglO9c7s9vVBhC96x2I3uxc78xuRxsSxwrgZ1audzn9kphdag6J691KjVuySx0rQN56t1pjxZHOKG/GG3x2AbNmzbK9xok2rJCYQ2Jup2rckp3r3f4au9uQuk7MkjqHbsnO9W5/jd1tSF0nZknNwez2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/fv3t3m9W+b9cLnbM3bsWABwxbx7PB4MGDAAJ5xwAh5++GGMHDkS//u//+v6+QYOn709bpnz9evXY+/evRg9ejRiY2MRGxuLlStX4qmnnkJsbCyysrJcO+8dZQ8Gg4fUuGXef6r1+5doONZb6+i9m1vmvGfPni1/KdNs6NChLZegcfN7uY6yt8dt7+e++uorfPDBB7jmmmtaHouWY7297O1xy7F+2223Ye7cubj44otx7LHH4rLLLsPNN9+Mhx9+GIC7j/WOsrfHTcd6//79sXLlStTW1uKbb77BP//5TzQ2NqJfv36unnfgyNnb46Z5bxbKHGdnZ2Pv3r1tnm9qasIPP/zQZdeB1bU9duxYNDU1Yffu3XZ2TyxuljvA4/HghBNOwIcfftjymGEY+PDDD9tc6zUa1NbWYufOnejZs2dnd8VROTk5yM7ObrMGqqursXbt2qhbA99++y0qKytdsQaUUpg9ezbefvttfPTRR8jJyWnz/AknnIC4uLg2875161Z8/fXXXXreO8rdns8//xwAXDHvP2UYBhoaGlw730fSnL09bpnz0047DRs2bMDnn3/e8t+YMWMwbdq0lv/v1nnvKHt7f5brlnn/qdbvX6LtWO/ovZtb5jwvLw9bt25t89i2bdvQp08fAO5+L9dR9va46f0cACxZsgSZmZmYMmVKy2PRcqy3l709bjnW6+rqoOttt0FiYmJgGAYAdx/rHWVvj9uOdQDw+Xzo2bMn9u3bh+XLl+Pcc8919by31l729rhx3kOZ4/Hjx2P//v1t/trgo48+gmEYLb8w7Gqsru3PP/8cuq4fclmaqNHZdxiNFq+//rryer1q6dKlatOmTeraa69VqampqrS0tLO7Zqtbb71VFRQUqJKSElVYWKhOP/10lZ6ervbu3dvZXYu4mpoa9a9//Uv961//UgDUE088of71r3+pr776Siml1Pz581Vqaqr685//rL744gt17rnnqpycHHXgwIFO7nl4jpS7pqZG/frXv1Zr1qxRJSUl6oMPPlCjR49WAwcOVPX19Z3d9bBdf/31KiUlRRUUFKg9e/a0/FdXV9fympkzZ6revXurjz76SH366adq/Pjxavz48Z3Y6/B1lHvHjh1q3rx56tNPP1UlJSXqz3/+s+rXr5865ZRTOrnn4Zs7d65auXKlKikpUV988YWaO3eu0jRN/eMf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3HPo378/AOC///u/8fLLL6OsrAxJSUkYNmwYTj31VHz88cdtNsvz8vIwd+5cAMCgQYNQWFiIBQsWtLtZ/sYbb8AwDLzwwgstl11asmQJUlNTUVBQgDPOOCOk3I2NjVi4cGFLX2fPnt3mbO8nn3wSd955J6ZOnQoAWLhwIZYvX97yfENDAx566CF88MEHGD9+PACgX79+WL16NZ5//nlMnDgRRx99NJ5//nlcfvnlKC0txXvvvYd//etfiI3lRxsiIiIiCg+vD2IjXt+ViIi6Ov4sI7LuggsuwPfff493330XZ511FgoKCjB69GgsXbq05TUffPABTjvtNBx99NFITk7GZZddhsrKStTV1bW8JjExsWXzGQCysrLQt2/flku1ND/WfNmkZs2bza3/vXnz5nb7+u9//xs7duxAcnJyy1nxPXr0QH19PXbu3Bly5p/2tWfPni39qqqqwp49ezB27NiW52NjYzFmzJiWf+/YsQN1dXWYPHlySz+SkpLwxz/+sU0/LrzwQpx//vmYP38+fve732HgwIEh95GIiIiI6HC4WU6OKigogKZp2L9/f8g1ffv2xZNPPmlbn4iiEY9FIiJnxMfHY/LkybjrrrtQVFSEK664Avfccw8AYPfu3fjZz36G4447Dv/3f/+H9evX49lnnwVw8GbxzeLi4tp8TU3T2n0snBvL19bW4oQTTsDnn3/e5r9t27bhkksuCfnrtNevn16TvKN+AMDf/va3Nv3YtGkT/t//+38tr6urq8P69esRExOD7du3h/z1iYiIiIiOhJvl1OKKK66ApmmYOXPmIc/NmjULmqbhiiuucL5jIfr222/h8XgwYsSIzu6KeF19rt2uq87PvffeC03TWv5LSUnBhAkTsHLlys7umlhdda6JyLphw4bB7/cDANavXw/DMPD4449j3LhxGDRoEL7//vuItVVcXHzIv4cOHdrua0ePHo3t27cjMzMTAwYMaPNfSkpKRPqTkpKCnj17Yu3atS2PNTU1Yf369S3/HjZsGLxeL77++utD+tGrV6+W1916663QdR3vv/8+nnrqKXz00UcR6SMRERERRTdullMbvXr1wuuvv44DBw60PFZfX49ly5ahd+/endizji1duhQXXXQRqqur23wIo/Z15bmOBl11foYPH449e/Zgz549WLNmDQYOHIif/exnqKqq6uyuidVV55qIjqyyshKTJk3CK6+8gi+++AIlJSV488038eijj+Lcc88FAAwYMACNjY14+umnsWvXLrz88stYuHBhxPpQWFiIRx99FNu2bcOzzz6LN998EzfddFO7r502bRrS09Nx7rnnYtWqVSgpKUFBQQFuvPFGfPvttxHr00033YT58+fjnXfewZYtW3DDDTe0+Sun5ORk/PrXv8bNN9+MP/zhD9i5cyc+++wzPP300/jDH/4A4OBZ5y+99BJeffVVTJ48GbfddhumT5+Offv2RayfRERERBSduFlObYwePRq9evXCW2+91fLYW2+9hd69e2PUqFFtXtvQ0IAbb7wRmZmZiI+Px8knn4x169a1ec17772HQYMGISEhAaeeeip27959SJurV6/GhAkTkJCQgF69euHGG29sOeMqVEopLFmyBJdddhkuueQSvPjii6bqo1Goc20YBh5++GHk5OQgISEBI0eObPNn0MFgEFdffXXL84MHD8b//u//tmnriiuuwHnnnYff/e536NmzJ9LS0jBr1iw0NjbaH7SL6qrHYmxsLLKzs5GdnY1hw4Zh3rx5qK2txbZt20x9nWjCY5HInZKSkjB27FgsWLAAp5xyCkaMGIG77roLM2bMwDPPPAMAGDlyJJ544gk88sgjGDFiBF599VU8/PDDEevDrbfeik8//RSjRo3CAw88gCeeeAJnnnlmu69NTEzEJ598gt69e2Pq1KkYOnQorr76atTX16Nbt24R7dNll12G6dOnY/z48UhOTsb555/f5jX3338/7rrrLjz88MMYOnQozjrrLPztb39DTk4OysvLcfXVV+Pee+/F6NGjAQD33XcfsrKy2v0rHSIiIiIiUxRF3IEDB9SmTZvUgQMHLH+N+sYm9eHmUnXvnzeqG15Zr+7980b14eZSVd/YFMGetjV9+nR17rnnqieeeEKddtppLY+fdtppasGCBercc89V06dPb3n8xhtvVEcddZR677331JdffqmmT5+uunfvriorK5VSSn399dfK6/WqW265RW3ZskW98sorKisrSwFQ+/btU0optWPHDuXz+dSCBQvUtm3bVGFhoRo1apS64oorWtrp06ePWrBgwRH7/uGHH6rs7GzV1NSkNmzYoJKTk1VtbW3ExsZtzMz1Aw88oIYMGaL+/ve/q507d6olS5Yor9erCgoKlFJKBQIBdffd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bdu2YcKECRg1ahTuvvtuHHXUUSH18cUXX8SwYcMO2SgHgPPPPx979+7Fe++9ZyJ1dDrSXN9///2466678PDDD2Po0KE466yz8Le//a1lA+66667D1KlT8Ytf/AJjx45FZWVlmzNbuzJfXi6UYcDw+9s8bvj9B2/ymZfrSD+6wrHY7Msvv0TPnj3Rs2dPHH/88fjTn/6E5557Dpdffrn54FGIxyIRERERERFR59GUUqqzO+E29fX1KCkpQU5ODuLj403XNzQFsbBgJwp3VkLXDl56xR84uFGe1z8NM/P7wxvb/mVaiChyjEAAlYsWw19cfPDa5YmJMOrqDm6UjxuHtGtnQPd4OrubRLYK92caERERERERUVfBy7AI5I2Nwcz8/jj2mBSs2laBvTUN6N0jERMGpSNvQDo3yokcons8SLt2BuJHDIe/sAhN5eXw9uoFX14ufLm53CgnIiIiIiIiInIRnlluA56FR0REbsGfaURERERERBQteM1yIiIiIiIiIiIiIop63CwnIiIiIiIiIiIioqjHzXIiIiIiIiIiIiIiinrcLLcRLwdPRERdHX+WERERERERUbTgZrkNYmJiAACBQKCTe0JERBSeuro6AEBcXFwn94SIiIiIiIjIXrGd3QE3io2NRWJiIsrLyxEXFwdd5+8kiIioa1FKoa6uDnv37kVqamrLL4KJiIiIiIiI3EpT/PtqWwQCAZSUlMAwjM7uChERkWWpqanIzs6Gpmmd3RUiIiIiIiIiW3Gz3EaGYfBSLERE1GXFxcXxjHIiIiIiIiKKGtwsJyIiIiIiIiIiIqKox4tpExEREREREREREVHU42Y5EREREREREREREUU9bpYTERERERERERERUdTjZjkRERERERERERERRT1ulhMRERERERERERFR1ONmORERERERERERERFFPW6WExEREREREREREVHU+/8BXMx9HHGlqgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "num_samples = modela.shape[0]\n", + "indexes = np.arange(num_samples)\n", + "\n", + "fig, ax = plt.subplots(figsize=(18, 4))\n", + "\n", + "# plot sample index vs score and their mean\n", + "ax.scatter(indexes, modela, s=30, color=\"tab:blue\", marker=\"o\", label=\"Model A\", zorder=3, alpha=0.6)\n", + "ax.axhline(modela.mean(), color=\"tab:blue\", linestyle=\"--\", label=\"Mean\", zorder=3)\n", + "ax.scatter(indexes, modelb, s=30, color=\"tab:red\", marker=\"o\", label=\"Model B\", zorder=3, alpha=0.6)\n", + "ax.axhline(modelb.mean(), color=\"tab:red\", linestyle=\"--\", label=\"Mean\", zorder=3)\n", + "\n", + "# configure the x-axis\n", + "ax.set_xlabel(\"Sample index\")\n", + "ax.set_xlim(0 - (eps := 0.01 * num_samples), num_samples + eps)\n", + "ax.xaxis.set_major_locator(IndexLocator(5, 0))\n", + "ax.xaxis.set_minor_locator(IndexLocator(1, 0))\n", + "\n", + "# configure the y-axis\n", + "ax.set_ylabel(\"AUPIMO [%]\")\n", + "ax.set_ylim(0 - 0.05, 1 + 0.05)\n", + "ax.yaxis.set_major_locator(MaxNLocator(6))\n", + "ax.yaxis.set_major_formatter(PercentFormatter(1))\n", + "\n", + "# configure the grid, legend, etc\n", + "ax.grid(axis=\"both\", which=\"major\", linestyle=\"-\")\n", + "ax.grid(axis=\"x\", which=\"minor\", linestyle=\"--\", alpha=0.5)\n", + "ax.legend(ncol=4, loc=\"upper left\", bbox_to_anchor=(0, -0.08))\n", + "ax.set_title(\"AUPIMO scores direct comparison\")\n", + "\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice that several images actually have the same AUPIMO score for both models (e.g. from 10 to 15).\n", + "\n", + "Others like 21 show a big difference -- model B didn't detect the anomaly at all, but model A did a good job (60% AUPIMO).\n", + "\n", + "Let's simplify this and only show the differences." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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FR5wFY1jSbkUc3BwMLpvTZWLpcQwZRyg+vStpeUCsG8NjaFQKBATE4OcdDWUtrIy47ik5wIA4rMKkFhUevv8dDXiY2KgUCikbfH4DH/Ozs4V/sBhCdvIlOMWqNjYfRrGnLniAOzbyooDcJ9bGTGKT9M3h3IVEAkJCXqnb5WC2dggKSmpwkmV5O7du0hJSUGtWrUq7TWIiMh8bGxsMGrUKOTl5cHGxqjL9IiIqJKVa+9cu3ZtXL58GUFBQXrXX7p0qVwf7rOysnDjxg3p8a1bt3DhwgW4uLjAxcUFCxcuxIABA+Dl5YWbN2/i7bffRlBQEEJCQsqTNhERPSWUSiUiIiKQmJio92gyERGZX7mOmfTu3RsffPAB8vLydNbl5uZiwYIF6NOnj8Hxzpw5g1atWqFVq1YAgFmzZqFVq1aYP38+rK2tcenSJfTr1w/169dHWFgYgoODceTIEf5RISIiIiIyk3IdgXj//fexdetW1K9fH9OmTUODBg0APLrIbuXKlSgqKsJ7771ncLyuXbuitGu49+zZU570iIjoKSeEQHZ2NnJyckr9+0BEROZTrgLC09MTx48fx+TJkzFv3jxp5y6TyRASEoKVK1fC09OzUhIlIqLqLycnR5pNJCMjo0IXURMRUeUo9xVqdevWxe+//47U1FTcuHEDQgjUq1cPNWvWrIz8iIiIiIjIghg9xUXNmjXRtm1bU+ZCREREREQWruITUhMRERER0TODBQQRERERERmMBQQRERERERmMBQQRERERERnM6IuoiYiITM3a2hoDBgxAfn4+rK2tzZ0OERHpwQKCiIgshkqlwk8//YTExESoVCpzp0NERHrwFCYiIiIiIjIYCwgiIiIiIjIYCwgiIrIY2dnZsLa2Rq1atZCdnW3udIiISA8WEEREREREZDAWEEREREREZDAWEEREREREZDAWEEREREREZDAWEEREREREZDAWEEREREREZDDeiZqIiCyGtbU1evXqBbVaDWtra3OnQ0REerCAICIii6FSqbBz504kJiZCpVKZOx0iItKDpzAREREREZHBWEAQEREREZHBWEAQEZHFyM7OhoODAwICApCdnW3udIiISA+zFhB//vkn+vbtC29vb8hkMmzfvl1rvRAC8+fPR61atWBra4sePXrg+vXr5kmWiIiqRE5ODnJzc82dBhERlcCsBUR2djZa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eustcfHiReHp6SkSExPF+vXrRevWrasi5afCpEmTRN26dcWdO3dKbbd//34BQNy4caPENt26dRPbt28XX3zxhXjppZeEWq0Wo0ePFrNmzTJ12k+N1NRU4ejoKP79738bVEDow37V1bNnT9GnT59S23DMGqayCogn5eXliSZNmogzZ86ImTNninHjxgm1Wi1efPFF8eWXX5YrZxvjj12YzuzZszFmzJhS2wQEBBgUy8vLS2tGEADS7ApeXl7Sv4/PuFDcxtHREba2tjox8/PzMWXKFHz//fe4ceMGCgsL0aVLFwBA/fr1cerUqadmZhFj+joiIgKurq7o169fmfHbt2+PvXv3lrh+4sSJWLZsGTQaDc6fP4+BAwfCzs4OXbp0weHDh6XDa0+riozl9u3bo7CwELGxsWjQoEGJ4xT431h+UnXu3/L27f3799GtWzd07NgRa9euLTP+sz52DeHm5gZra2u941LfmLx69So2btyI8+fPY/369ejcuTPc3d0xaNAgjBs3DpmZmXBwcKiq9C3StGnTsHPnTvz555+oU6dOqW3bt28PALhx4wYCAwN11kdERMDZ2RmvvPIKXnvtNfTv3x9yuRwDBw7E/PnzKyX/p4GzszPq16+PGzdu4KWXXoJarUZaWprWTI4ljWGA/arP7du3sW/fPmzdurXUdhyzxikeiwkJCahVq5a0PCEhAS1btpTaJCYmaj2vsLAQDx8+LHEsf/jhh+jZsyeCg4Mxfvx4LF68GHK5HK+99hoOHDiA6dOnG5yjRRQQ7u7ucHd3N0msDh06YMmSJUhMTISHhweAR9MxOjo6onHjxlKb33//Xet5e/fuRYcOHfTGXLx4MV5++WW0bt0a58+f1zpPvaCgAEVFRSbJvSqUt6+FEIiIiMCoUaMgl8vLbH/hwgWtwf64devWwcXFBf369UNqaiqAR/1X/O/T1I8lqchYvnDhAqysrKRx26FDB7z33nsoKCiQ+n7v3r1o0KABatasqfP86t6/5enbe/fuoVu3bggODkZERASsrMq+3OtZH7uGUCgUCA4Oxv79+9G/f38AgEajwf79+zFt2jSttkIITJw4EcuXL4e9vT2Kioq0+gzAM9Nv+gghMH36dGzbtg2HDh2Cv79/mc+5cOECAOgdp0lJSVi0aBGOHj0KADr9/Sz3dVZWFm7evImRI0ciODgYcrkc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", + "text/plain": [ + "
" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "differences = modela - modelb\n", + "\n", + "fig, ax = plt.subplots(figsize=(9, 3))\n", + "ax.hist(differences, bins=np.linspace(-1, 1, 61), edgecolor=\"black\")\n", + "ax.axvline(differences.mean(), color=\"black\", linestyle=\"--\", label=\"Mean\")\n", + "\n", + "# configure the x-axis\n", + "ax.set_xlabel(\"AUPIMO [%]\")\n", + "ax.set_xlim(-1, 1)\n", + "ax.xaxis.set_major_locator(MaxNLocator(9))\n", + "ax.xaxis.set_minor_locator(MaxNLocator(41))\n", + "ax.xaxis.set_major_formatter(PercentFormatter(1))\n", + "\n", + "# configure the y-axis\n", + "ax.set_ylabel(\"Count\")\n", + "\n", + "# configure the grid, legend, etc\n", + "ax.grid(axis=\"both\", which=\"major\", linestyle=\"-\", alpha=1, linewidth=1.0)\n", + "ax.grid(axis=\"x\", which=\"minor\", linestyle=\"-\", alpha=0.3)\n", + "ax.legend(loc=\"upper right\")\n", + "ax.set_title(\"AUPIMO scores differences distribution (Model A - Model B)\")\n", + "\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It looks like there is a bias to the right indeed (so model A > model B). \n", + "\n", + "Is that statistically significant or just random?\n", + "\n", + "> **Dependent t-test for paired samples**\n", + "> \n", + "> - null hypothesis: `average(A) == average(B)` \n", + "> - alternative hypothesis: `average(A) != average(B)`\n", + "> \n", + "> See [`scipy.stats.ttest_rel`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.ttest_rel.html) and [\" Wikipedia's page on \"Student's t-test\"](https://en.wikipedia.org/wiki/Student's_t-test#Dependent_t-test_for_paired_samples).\n", + ">\n", + "> **Confidence Level**\n", + "> \n", + "> Instead of reporting the p-value, we'll report the \"confidence level\" [that the null hypothesis is false], which is `1 - pvalue`.\n", + "> \n", + "> *Higher* confidence level *more confident* that `average(A) > average(B)`." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "test_result=TtestResult(statistic=2.8715471705520033, pvalue=0.004917091449731462, df=108)\n", + "confidence=99.5%\n" + ] + } + ], + "source": [ + "test_result = stats.ttest_rel(modela, modelb)\n", + "confidence = 1.0 - float(test_result.pvalue)\n", + "print(f\"{test_result=}\")\n", + "print(f\"{confidence=:.1%}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "So, we're very confident that model A has a higher AUPIMO score than model B.\n", + "\n", + "Maybe is that due to some big differences in a few images?\n", + "\n", + "What if we don't count much for these big differences?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Non-parametric (rank comparison)\n", + "\n", + "In non-parametric comparison, bigger differences don't matter more than smaller differences. \n", + "\n", + "It's all about their relative position.\n", + "\n", + "Let's look at the analogous plots for this type of comparison." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABboAAAEsCAYAAAAFEQVZAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAC2n0lEQVR4nOydd3hUxfrHv7tJNqSQhBAgdAKEIggXRBBQwUa1XxuiF0SsIFbsKJYr2PVarw0sIDbEBgoqIE1/CnJRQAQEaQEFJIGEtD3z+yNm2STb5iTn5M3Z7+d58mh2Z3bmM/POe2aHzVmXUkqBEEIIIYQQQgghhBBCCKmjuGu7A4QQQgghhBBCCCGEEEJIdeBBNyGEEEIIIYQQQgghhJA6DQ+6CSGEEEIIIYQQQgghhNRpeNBNCCGEEEIIIYQQQgghpE7Dg25CCCGEEEIIIYQQQgghdRoedBNCCCGEEEIIIYQQQgip0/CgmxBCCCGEEEIIIYQQQkidhgfdhBBCCCGEEEIIIYQQQuo0POgmhBBCCCGEEEIIIYQQUqfhQTchhBBCCCHE8bhcLowfP762u0EIIYQQQiyCB92EEEIIIaTWeP755+FyudCnT5+Az2/duhUulwuPPfZYwOcfe+wxuFwubN261ffYwIED4XK5fD/p6ek49thj8dprr8EwDF+50aNHIzk5ucLrldfNzs4O2N6CBQt8r/v+++9XeX7t2rW45JJL0Lx5c8THx6NZs2YYOXIk1q5dG24oCCGEEEIIIdWAB92EEEIIIaTWmDFjBtq0aYP/+7//w6ZNm2rsdVu0aIE333wTb775JiZNmoTS0lJcfvnluPPOO8PWrVevHjZt2oT/+7//C9jfevXqBaw3e/Zs9OzZE1999RUuu+wyPP/887j88suxcOFC9OzZEx9++GG1vQghhBBCCCGB4UE3IYQQQgipFbZs2YLly5fjiSeeQKNGjTBjxowae+3U1FRccskluOSSS3DjjTdi2bJlaNGiBZ599lmUlJSErNuuXTt07NgRb7/9doXHCwsL8eGHH2L48OFV6mzevBmXXnop2rZtizVr1uDBBx/E5ZdfjgceeABr1qxB27Ztcemll+K3336rMUerKCwsrPDJdzvJz8+vlXYJIYQQQkjdhwfdhBBCCCGkVpgxYwYaNGiA4cOH47zzzqvRg+7KJCYm4rjjjkN+fj7+/PPPsOVHjBiBd955p8KB7yeffIKCggJccMEFVco/+uijKCgowEsvvYRGjRpVeC4jIwP//e9/kZ+fj0ceeSRs28888wy6dOmCxMRENGjQAL169cLMmTMrlNm5cycuv/xyNGvWDPHx8cjKysI111yD4uJiX5nffvsN559/PtLT033+n332WYXXWbRoEVwuF2bNmoW7774bzZs3R2JiIvLy8gAA3333HYYMGYLU1FQkJiZiwIABWLZsWYXXOHjwIG644Qa0adMG8fHxaNy4MU477TSsWrUqpOfkyZPhcrmwbt06XHzxxWjQoAGOP/54AMCaNWswevRotG3bFvXq1UNmZibGjBmDffv2BXyNTZs2YfTo0UhLS0Nqaiouu+wyFBQUhB3rBx98EG63G88884zW+BNCCCGEEHnE1nYHCCGEEEJIdDJjxgyce+658Hg8GDFiBF544QV8//33OPbYYy1p77fffkNMTAzS0tLClr344osxefJkLFq0CCeffDIAYObMmTjllFPQuHHjKuU/+eQTtGnTBieccELA1zvxxBPRpk2bKgfNlXn55ZcxYcIEnHfeebj++utRWFiINWvW4LvvvsPFF18MANi1axd69+6NAwcO4Morr0SnTp2wc+dOvP/++ygoKIDH48GePXvQr18/FBQUYMKECWjYsCFef/11nHnmmXj//fdxzjnnVGj3gQcegMfjwS233IKioiJ4PB58/fXXGDp0KI455hjce++9cLvdmDZtGk4++WQsWbIEvXv3BgBcffXVeP/99zF+/HgcddRR2LdvH5YuXYr169ejZ8+eYcf6/PPPR3Z2Nh566CEopQCU3Qv9t99+w2WXXYbMzEysXbsWL730EtauXYtvv/0WLperwmtccMEFyMrKwpQpU7Bq1Sq88soraNy4MR5++OGg7d5999146KGH8N///hdXXHFFxONPCCGEEEJkwoNuQgghhBBiOytXrsQvv/zi+yTt8ccfjxYtWmDGjBk1ctDt9Xqxd+9eAMDevXvxwgsvYNWqVTjjjDOQmJgYtn52drbvk7wnn3wyDhw4gLlz5+Lll1+uUjY3Nxe7du3CWWedFfI1u3Xrho8//hgHDx5E/fr1A5b57LPP0KVLF7z33ntBX+eOO+7A7t278d1336FXr16+x++//37fQfHUqVOxZ88eLFmyxPcp6SuuuALdunXDTTfd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3fo6VrchNU50kepBd2uR6hGt7hK9zdRxypybqeMUd6ljZYZodZfqQXdrkegh0duuOk5xd0q8m6kj6YsoAY2DbgDYunUrFixYgOLiYgwcOBBdunSxql+OICYmBh06dLCsvF1t6CLVg+6y3KWOlS52zUe0ujPe6W5VG7pI9aC7LHeudXlxootUD7rLcpc6VmaIVnepHnSX5S51rHSRuj9xirtT4t1MHbuuVTpEfOy+cOFCdOnSBVdddRWuu+469OjRA2+99ZaVfavzKKVw6NAhKKUsKW9XG7pI9aC7LHepY6WLXfMRre6Md7pb1YYuUj3oLsuda11enOgi1YPustyljpUZotVdqgfdZblLHStdpO5PnOLulHg3U8eua5UOER90T5o0Caeddhp27tyJffv24YorrsCtt95qZd/qPIZhYMeOHVrfVqpT3q42dJHqQXdZ7lLHShe75iNa3RnvdLeqDV2ketBdljvXurw40UWqB91luUsdKzNEq7tUD7rLcpc6VrpI3Z84xd0p8W6mjl3XKh0iPuj++eef8dBDD6Fp06Zo0KABHn30Ufzxxx/Yt2+flf0jhBBCCCGEEEIIIYQQQkIS8UF3Xl4eMjIyfL8nJiYiISEBubm5lnSMEEIIIYQQQgghhBBCCIkErS+j/OKLL5Camur73TAMfPXVV/j55599j5155pk117s6jsvlgsfjgcvlsqS8XW3oItWD7rLcpY6VLnbNR7S6M97pblUbukj1oLssd651eXGii1QPustylzpWZohWd6kedJflLnWsdJG6P3GKu1Pi3Uwdu65VOmgddI8aNarKY1dddZXv/10uF7xeb/V75RDcbjfatm1rWXm72tBFqgfdZblLHStd7JqPaHVnvNPdqjZ0kepBd1nuXOvy4kQXqR50l+UudazMEK3uUj3oLstd6ljpInV/4hR3p8S7mTp2Xat0iPjWJYZhhP3hIXdFlFI4cOCA1reV6pS3qw1dpHrQXZa71LHSxa75iFZ3xjvdrWpDF6kedJflzrUuL050kepBd1nuUsfKDNHqLtWD7rLcpY6VLlL3J05xd0q8m6lj17VKh4gPuok+hmFg9+7dWt9WqlPerjZ0kepBd1nuUsdKF7vmI1rdGe90t6oNXaR60F2WO9e6vDjRRaoH3WW5Sx0rM0Sru1QPustylzpWukjdnzjF3SnxbqaOXdcq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7z0sXrwYu3btwrnnnluLva4ZInEHgCuuuKLCvD/yyCO11OOao0WLFpg6dSpWrlyJH374ASeffDLOOussrF27FoBz5zycN+DM+a7M999/j//+97/o1q1bhcedOu/+BHMHnDv3ofYwTp7zcHs3p873X3/9hf79+yMuLg7z5s3DunXr8Pjjj6NBgwa+Mk7dz0XiDjhzP/f9999XcFqwYAEA4Pzzzwfg3LUezhtw7lp/+OGH8cILL+DZZ5/F+vXr8fDDD+ORRx7BM8884yvj1LUeiTvgzLU+duxYLFiwAG+++SZ++uknDBo0CKeeeip27twJwLlzDoR3B5wx5zVxJjNy5EisXbsWCxYswKeffopvvvkGV155pV0Kpqmp86j777+/Qhxcd911dnRfJoqEpHfv3mrcuHG+371er2rWrJmaMmVKLfbKeu69917VvXv32u6G7QBQH374oe93wzBUZmamevTRR32PHThwQMXHx6u33367FnpoHZXdlVJq1KhR6qyzzqqV/tjJH3/8oQCoxYsXK6XK5jguLk699957vjLr169XANSKFStqq5uWUNldKaUGDBigrr/++trrlI00aNBAvfLKK1E150od8VYqOub74MGDKjs7Wy1YsKCCbzTMezB3pZw796H2ME6e83B7N6fOt1JK3Xbbber4448P+ryT93Ph3JWKnv3c9ddfr9q1a6cMw3D0Wq+Mv7dSzl7rw4cPV2PGjKnw2LnnnqtGjhyplHL2Wg/nrpQz13pBQYGKiYlRn376aYXHe/bsqe666y5Hz3k4d6WcOedmzmTWrVunAKjvv//eV2bevHnK5XKpnTt32tb36mL2PKp169bqySeftLGnsuEnukNQXFyMlStX4tRTT/U95na7ceqpp2LFihW12DN72LhxI5o1a4a2bdti5MiR2LZtW213yXa2bNmC3bt3V4iB1NRU9OnTJypiAAAWLVqExo0bo2PHjrjmmmuwb9++2u5SjZObmwsASE9PBwCsXLkSJSUlFea9U6dOaNWqlePmvbJ7OTNmzEBGRga6du2KO+64AwUFBbXRPcvwer2YNWsW8vPz0bdv36iZ88re5Th9vseNG4fhw4dXmF8gOtZ6MPdynDr3wfYwTp/zcHs3p873xx9/jF69euH8889H48aN0aNHD7z88su+5528nwvnXo7T93PFxcV46623MGbMGLhc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hBBCCCGEEELqNDzoJoQQQgghpI7hcrkwZ84c0/UXLVoEl8uFAwcOVKsfo0ePxtlnn12t1yCEEEIIIaQm4EE3IYQQQgghlfjzzz9xzTXXoFWrVoiPj0dmZiYGDx6MZcuW1XbXaoR+/fohJycHqamptd0VQgghhBBCaoTY2u4AIYQQQggh0vjnP/+J4uJivP7662jbti327NmDr776Cvv27avtrtUIHo8HmZmZtd0NQgghhBBCagx+opsQQgghhBA/Dhw4gCVLluDhhx/GSSedhNatW6N379644447cOaZZ/rKPfHEEzj66KORlJSEli1b4tprr8WhQ4d8z0+fPh1paWn49NNP0bFjRyQmJuK8885DQUEBXn/9dbRp0wYNGjTAhAkT4PV6ffXatGmDBx54ACNGjEBSUhKaN2+O5557LmSft2/fjgsuuABpaWlIT0/HWWedha1btwYtX/nWJeV9/eKLL9C5c2ckJydjyJAhyMnJ8dXxer246aabkJaWhoYNG+LWW2+FUqrC6xqGgSlTpiArKwsJCQno3r073n//fQCAUgqnnnoqBg8e7Ku3f/9+tGjRAvfcc0/oSSGEEEIIISQMPOgmhBBCCCHEj+TkZCQnJ2POnDkoKioKWs7tduM///kP1q5di9dffx1ff/01br311gplCgoK8J///AezZs3C559/jkWLFuGcc87B3LlzMXfuXLz55pv473//6zsMLufRRx9F9+7d8eOPP+L222/H9ddfjwULFgTsR0lJCQYPHoz69etjyZIlWLZsme+guri4OGLvgoICPPbYY3jzzTfxzTffYNu2bbjlllt8zz/++OOYPn06XnvtNSxduhT79+/Hhx9+WOE1pkyZgjfeeAMvvvgi1q5dixtvvBGXXHIJFi9eDJfLhddffx3ff/89/vOf/wAArr76ajRv3pwH3YQQQgghpNq4VOWPYRAAZZ9YKSkpqe1uEEIIIabweDxwu/nv2YSY5YMPPsAVV1yBw4cPo2fPnhgwYAAuuugidOvWLWid999/H1dffTX27t0LoOxT0pdddhk2bdqEdu3aASg72H3zzTexZ88eJCcnAwCGDBmCNm3a4MUXXwRQ9onuzp07Y968eb7Xvuiii5CXl4e5c+cCKPsyyg8//BBnn3023nrrLTz44INYv349XC4XAKC4uBhpaWmYM2cOBg0aVKWvixYt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bXeFEEIIqRa8phFCCCHELlq3bq2WLFmilFJqw4YNKjMzUz322GO+599++2114MABVVBQoC677DJ11lln+Z4DoM477zx18OBB9dNPP6n4+Hi1adMmpZRSo0aNUg888IDasWOH6ty5s3r55ZeVUkqtX79etWjRQuXk5CjDMNT69etVTk6OUkqpKVOmqOHDh4ft8+DBg9W0adNClhkwYIB68803lVJK9enTR7311ltKKaX279+vVq1aFbBOeZ/L2bJliwKgJkyYoIqKitQXX3yhkpKSVF5enlJKqWuvvVaNGDFCHTx4UO3cuVMdddRR6pNPPgn42q1bt1b/+Mc/VE5Ojq/sK6+8opRS6t1331Vdu3ZVW7duVQUFBWrEiBHq5ptvVkopdeGFF6qHHnpIGYahDh06pL777jullFILFy5U7dq1CztWwXj55ZdVnz59gj4PQG3fvl0dOnRI1a9fX/36669KKaW2bt2qfvvtt6CO5bGklFLTpk1TsbGxaubMmaq0tFTddddd6sQTT1RKKeX1elW3bt3U008/rUpKStTy5ctVRkaG2r17d5XXnTdvnjr++OOVUkrNmjVLZWVlqSuuuEIppdTdd9+tbrvtNqWUUnfeeacaMGCA2r9/v/r9999Vdna2L07uvfdeFR8fr7744gvl9XpDelWOg0hZsWKFat26dcgy5THVtGlT1apVK3X99der/Pz8gGVfeOEFdckllyillJo6darKyspS//7/9u49KMrq/wP4e1kusuAuKHeEHQQMUPNucftCkoqXMkRBCfCSAnkJQVAjCBV0HEcBdUKcHEczB8fRMAitqSS1Gq+Vt7wWsClbAXlZkGV35fP7g+EZVnY34Psrv9Tn9dfz7HPOec7l4Yif83B2wwYiIoqPj6edO3cSUfuz7ufnRzU1NXT//n0KCAigPXv2EBHR2bNnyd3dnS5fvkwajYYyMzMpOjqaiNqfIbFYTJs2bSKtVku7du0iT09Po3XveCY65ObmkrW1NZ04cYK0Wi0lJCRQYmIiERGpVCpyc3Ojw4cPk06no7KyMvLw8DD4/5vVq1fTm2++SVqtltRqNX3zzTdERPTTTz9R//79qaKignQ6HdXW1tLt27eJiCg4OJgyMjJIrVZTVVUV2draCtfCwsLI29ubbt++TS0tLaRQKMjBwYFOnTpFOp2Otm/fTmPHjhX6+JVX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", + "text/plain": [ + "
" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# the `-` sign is to sort in descending order because higher AUPIMO is better\n", + "# the rank values are 1 or 2 because there are only two models\n", + "# where 1 is the best and 2 is the worst\n", + "# when the scores are the same, 1.5 is assigned to both models\n", + "ranks = stats.rankdata(-np.stack([modela, modelb], axis=1), method=\"average\", axis=1)\n", + "ranksa, ranksb = ranks[:, 0], ranks[:, 1]\n", + "\n", + "num_samples = ranks.shape[0]\n", + "indexes = np.arange(num_samples)\n", + "\n", + "fig, ax = plt.subplots(figsize=(18, 2.5))\n", + "\n", + "# plot sample index vs score and their mean\n", + "ax.scatter(indexes, ranksa, s=30, color=\"tab:blue\", marker=\"o\", label=\"Model A\", zorder=3, alpha=0.6)\n", + "ax.axhline(ranksa.mean(), color=\"tab:blue\", linestyle=\"--\", label=\"Mean\", zorder=3)\n", + "ax.scatter(indexes, ranksb, s=30, color=\"tab:red\", marker=\"o\", label=\"Model B\", zorder=3, alpha=0.6)\n", + "ax.axhline(ranksb.mean(), color=\"tab:red\", linestyle=\"--\", label=\"Mean\", zorder=3)\n", + "\n", + "# configure the x-axis\n", + "ax.set_xlabel(\"Sample index\")\n", + "ax.set_xlim(0 - (eps := 0.01 * num_samples), num_samples + eps)\n", + "ax.xaxis.set_major_locator(IndexLocator(5, 0))\n", + "ax.xaxis.set_minor_locator(IndexLocator(1, 0))\n", + "\n", + "# configure the y-axis\n", + "ax.set_ylabel(\"AUPIMO Rank\")\n", + "ax.set_ylim(1 - 0.1, 2 + 0.1)\n", + "ax.yaxis.set_major_locator(FixedLocator([1, 1.5, 2]))\n", + "ax.invert_yaxis()\n", + "\n", + "# configure the grid, legend, etc\n", + "ax.grid(axis=\"both\", which=\"major\", linestyle=\"-\")\n", + "ax.grid(axis=\"x\", which=\"minor\", linestyle=\"--\", alpha=0.5)\n", + "ax.legend(ncol=4, loc=\"upper left\", bbox_to_anchor=(0, -0.15))\n", + "ax.set_title(\"AUPIMO scores ranks\")\n", + "\n", + "fig.text(\n", + " 0.9,\n", + " -0.1,\n", + " \"Ranks: 1 is the best, 2 is the worst, 1.5 when the scores are the same.\",\n", + " ha=\"right\",\n", + " va=\"top\",\n", + " fontsize=\"small\",\n", + ")\n", + "\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Again, blue seems to have a slight advantage, but -- again -- is it significant enough to be sure that model A is better than model B?\n", + "\n", + "Remember that AUPIMO is a recall metric, so it is basically a ratio of the area of anomalies. \n", + "\n", + "Is it relevant if model A has 1% more recall than model B in a given image?\n", + "\n", + "> You can check that out in [`701b_aupimo_advanced_i.ipybn`](./701b_aupimo_advanced_i.ipynb).\n", + "\n", + "We'll --arbitrarily -- assume that only differences above 5% are relevant." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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XW3oItWD7rLcpY6VLnbNR7y6M97pblUbukj1oLssd651eXGii1QPustylzpWZohXd6kedJflLnWsdJG6P3GKu1Pi3Uwdu65VOmgfdJ955pm46aabcPPNN+Pcc8/FGWecEfSPHMLn82Hbtm1a31aqU96uNnSR6kF3We5Sx0oXu+YjXt0Z73S3qg1dpHrQXZY717q8ONFFqgfdZblLHSszxKu7VA+6y3KXOla6SN2fOMXdKfFupo5d1yodtL+M8p577rGiH4QQQgghhBBCCCGEEEKIKWTdMZwQQgghhBBCCCGEEEII0UT7E91erxdPPPEE3n33XWzduhWVlZVBz+/duzdmnWvouFwuJCcnw+VyWVLerjZ0kepBd1nuUsdKF7vmI17dGe90t6oNXaR60F2WO9e6vDjRRaoH3WW5Sx0rM8Sru1QPustylzpWukjdnzjF3SnxbqaOXdcqHbQPuu+99168/PLLuOmmm3DnnXfin//8J7Zs2YJZs2bh7rvvtqKPDRa3241OnTpZVt6uNnSR6kF3We5Sx0oXu+YjXt0Z73S3qg1dpHrQXZY717q8ONFFqgfdZblLHSszxKu7VA+6y3KXOla6SN2fOMXdKfFupo5d1yodtG9dMm3aNLz00ku46aabkJiYiFGjRuHll1/G3Xffja+//tqKPjZYlFLYt2+f1reV6pS3qw1dpHrQXZa71LHSxa75iFd3xjvdrWpDF6kedJflzrUuL050kepBd1nuUsfKDPHqLtWD7rLcpY6VLlL3J05xd0q8m6lj17VKB+2D7p07d+KII44AAGRkZKCoqAgA8Ne//hWffvppbHvXwPH5fNi5c6fWt5XqlLerDV2ketBdlrvUsdLFrvmIV3fGO92takMXqR50l+XOtS4vTnSR6kF3We5Sx8oM8eou1YPustyljpUuUvcnTnF3SrybqWPXtUoH7VuXtG3bFoWFhWjfvj06d+6Mzz//HH369MG3336LlJQUK/oYc1xJKSir9CKxsqrWc26XC42SEvw/l/1ZxuOpQoX34M9JyhWxbDVerxflHt/BOokIKnug0gsFFbL8gUovMlIjl63G5w0OpnKPF74Iv0lJSXAZLpuWfCg8PL5g90hlKzyHvBMSar9+alKC//49FVVeeH0qaKwC64QqCyBk+UaJCXC7D5atrPKhqsZCC6yTnuKOWBY4NOden0LS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IYQQQgghhBBCCCGRMXzrkl27diEvLw8A0KJFC6SlpeHUU0+1rGNOwOVyISsrS+sb2HXK29WGLlI96C7LXepY6WLXfMSrO+Od7la1oYtUD7rLcudalxcnukj1oLssd6ljZYZ4dZfqQXdZ7lLHShep+xOnuDsl3s3UsetapYPWl1EGfyGUG8nJyTHvkJNwu91o1aqVZeXtakMXqR50l+Uudax0sWs+4tWd8U53q9rQRaoH3WW5c63LixNdpHrQXZa71LEyQ7y6S/Wguyx3qWOli9T9iVPcnRLvZurYda3SwfAnupVS6Nq1K7Kzs5GdnY39+/ejd+/e/p+r/5FD+Hw+FBYWwufzRS9sorxdbegi1YPustyljpUuds1HvLoz3uluVRu6SPWguyx3rnV5caKLVA+6y3KXOlZmiFd3qR50l+Uudax0kbo/cYq7U+LdTB27rlU6GD7ofu211/Dvf/8bTzzxBJ544gm89tprePLJJ/0/V/8jh1BKoaioSOvbSnXK29WGLlI96C7LXepY6WLXfMSrO+Od7la1oYtUD7rLcudalxcnukj1oLssd6ljZYZ4dZfqQXdZ7lLHShep+xOnuDsl3s3UsetapYPhW5eMGTPGyn4QQgghhBBCCCGEEEIIIaYw/IluQgghhBBCCCGEEEIIIUQiPOi2EJfLhZycHK1vK9Upb1cbukj1oLssd6ljpYtd8xGv7ox3ulvVhi5SPeguy51rXV6c6CLVg+6y3KWOlRni1V2qB91luUsdK12k7k+c4u6UeDdTx65rlQ6Gb11C9HG73cjJybGsvF1t6CLVg+6y3KWOlS52zUe8ujPe6W5VG7pI9aC7LHeudXlxootUD7rLcpc6VmaIV3epHnSX5S51rHSRuj9xirtT4t1MHbuuVTrwE90W4vP58Ouvv2p9W6lOebva0EWqB91luUsdK13smo94dWe8092qNnSR6kF3We5c6/LiRBepHnSX5S51rMwQr+5SPeguy13qWOkidX/iFHenxLuZOnZdq3QwfdC9e/du7N69O5Z9cRxKKZSWlmp9W6lOebva0EWqB91luUsdK13smo94dWe8092qNnSR6kF3We5c6/LiRBepHnSX5S51rMwQr+5SPeguy13qWOkidX/iFHenxLuZOnZdq3TQOujet28fJkyYgJycHLRs2RItW7ZETk4OrrnmGuzbt8+i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++23/jJz5sxRLpdLbd++3ba+1xWz51EdOnRQTzzxhI09lQ0/0R2ByspKrFixAqeccor/MbfbjVNOOQVfffVVPfbMHjZu3IjWrVujU6dOGD16NLZu3VrfXbKdgoIC7Ny5MygGsrKy0K9fv7iIAQBYsGABWrRogW7duuGqq67Cnj176rtLMaeoqAgAkJ2dDQBYsWIFPB5P0Lx3794d7du3d9y813SvZtq0acjJycHhhx+O22+/HWVlZfXRPcvwer2YPn06SktL0b9//7iZ85re1Th9vidMmICRI0cGzS8QH2s9nHs1Tp37cHsYp895tL2bU+f7o48+Qt++fXHeeeehRYsW6N27N1566SX/807ez0Vzr8bp+7nKykq8+eabGDduHFwul+PXejU1vatx6lofMGAA5s2bhw0bNgAA/u///g9LlizBqaeeCsDZaz2aezVOW+tVVVXwer1o1KhR0OOpqalYsmSJo+c8mns1TpvzmhiZ46+++gpNmjRB3759/WVOOeUUuN1uLF++3PY+xwqd+J4yZQqaNWuG3r1745FHHkFVVZXd3RVDYn13QDK7d++G1+tFy5Ytgx5v2bIl1q1bV0+9sod+/fph6tSp6NatGwoLC3Hvvffi+OOPx08//YTGjRvXd/dsY+fOnQAQMgaqn3Myw4cPx9lnn428vDxs3rwZd9xxB0499VR89dVXSEhIqO/uxQSfz4frr78eAwcOxOGHHw7g4LwnJyejSZMmQWWdNu+h3AHgwgsvRIcOHdC6dWusWrUKt956K9avX48ZM2bUY29jw48//oj+/fujvLwcGRkZmDlzJg477DCsXLnS0XMezhtw9nwDwPTp0/H9998H3c+wGqev9UjugHPnPtIexslzHm3v5tT5BoCff/4Zzz33HG688Ubccccd+Pbbb3HttdciOTkZY8aMcfR+Lpo7EB/7uVmzZmHfvn0YO3YsAOfn92pqegPOze0AcNttt6G4uBjdu3dHQkICvF4v/vWvf2H06NEAnP3eLZo74My13rhxY/Tv3x/3338/evTogZYtW+Ltt9/GV199hS5dujh6zqO5A86c85oYmeOdO3eiRYsWQc8nJiYiOzu7QceB0fi+9tpr0adPH2RnZ2PZsmW4/fbbUVhYiMcff9zW/kqBB90kJIG/GT7yyCPRr18/dOjQAe+++y4uvfTSeuwZsZO//e1v/v8/4ogjcOSRR6Jz585YsGABTj755HrsWeyYMGECfvrpJ8fegz4S4dwD72V2xBFHoFWrVjj55JOxefNmdO7c2e5uxpRu3bph5cqVKCoqwvvvv48xY8Zg4cKF9d0tywnnfdhhhzl6vn/99Vdcd911mDt3bq1PwzgdI+5OnftIe5jU1NR67Jm1RNu7OXW+gYO/uO3bty8efPBBAEDv3r3x008/4fnnn/cf9joVI+7xsJ975ZVXcOqpp6J169b13RVbCeXt5LX+7rvvYtq0aXjrrbfQs2dPrFy5Etdffz1at27t+LVuxN2pa/2NN97AuHHj0KZNGyQkJKBPnz4YNWoUVqxYUd9ds5xo7k6dc6LHjTfe6P//I488EsnJybjiiiswefJkpKSk1GPP6gfeuiQCOTk5SEhIqPXt3Lt27UJubm499ap+aNKkCbp27YpNmzbVd1dspXqeGQMH6dSpE3JychwTB9dccw0++eQTzJ8/H23btvU/npubi8rKSuzbty+ovJPmPZx7KPr16wcAjpj35ORkdOnSBUcddRQmT56MXr164cknn3T8nIfzDoWT5nvFihX47bff0KdPHyQmJiIxMRELFy7Ef/7zHyQmJqJly5aOnfdo7l6vt1YdJ819IIF7GKev9UCi7d2cNN+tWrXy/5VKNT169PDfusXJ+7lo7qFw2n7ul19+wRdffIHx48f7H4uHtR7KOxROWusTJ07Ebbfdhr/97W844ogjcPHFF+OGG27A5MmTATh7rUdzD4VT1nrnzp2xcOFC7N+/H7/++iu++eYbeDwedOrUydFzDkR2D4VT5jwQI3Ocm5uL3377Lej5qqoq7N27t0HHgdn47tevH6qqqrBlyxYruycWHnRHIDk5GUcddRTmzZvnf8zn82HevHlB9zaNB/bv34/NmzejVatW9d0VW8nLy0Nubm5QDBQXF2P58uVxFwMAsG3bNuzZs6fBx4FSCtdccw1mzpyJL7/8Enl5eUHPH3XUUUhKSgqa9/Xr12Pr1q0Nft6juYdi5cqVANDg5z0UPp8PFRUVjp7zUFR7h8JJ833yySfjxx9/xMqVK/3/+vbti9GjR/v/36nzHs091J+zOmnuAwncw8TTWo+2d3PSfA8cOBDr168PemzDhg3o0KEDAGfv56K5h8Ip+7lqXnvtNbRo0QIjR470PxYPaz2UdyictNbLysrgdgcfYSQkJMDn8wFw9lqP5h4Kp6319PR0tGrVCn/88Qc+++wznHHGGY6e80BCuYfCaXMOGFvX/fv3x759+4I+5f/ll1/C5/P5f9nXEDEb3ytXroTb7a51O5e4ob6/DVM606dPVykpKWrq1KlqzZo16vLLL1dNmjRRO3furO+uWcpNN92kFixYoAoKCtTSpUvVKaeconJyctRvv/1W312LOSUlJeqHH35QP/zwgwKgHn/8cfXDDz+oX375RSml1JQpU1STJk3Uhx9+qFatWqXOOOMMlZeXpw4cOFDPPa87kdxLSkrUzTffrL766itVUFCgvvjiC9WnTx+Vn5+vysvL67vrdeKqq65SWVlZasGCBaqwsND/r6yszF/myiuvVO3bt1dffvml+u6771T//v1V//7967HXsSGa+6ZNm9R9992nvvvuO1VQUKA+/PBD1alTJ3XCCSfUc8/rzm233aYWLlyoCgoK1KpVq9Rtt92mXC6X+vzzz5VSzp3zSN5Onu9wDBo0SF133XX+n50676EIdHfy3Efbwzh1ziN5O3m+lVLqm2++UYmJiepf//qX2rhxo5o2bZpKS0tTb775pr+MU/dz0dydvJ9TSimv16vat2+vbr311lrPOXWtKxXe2+lrfcyYMapNmzbqk08+UQUFBWrGjBkqJydH3XLLLf4yTl3r0dydvNb/97//qTlz5qiff/5Zff7556pXr16qX79+qrKyUinl3DlXKrK7k+Y8Fmcyw4cPV71791bLly9XS5YsUfn5+WrUqFH1pWSYurovW7ZMPfHEE2rlypVq8+bN6s0331TNmzdXf//73+tTq17hQbcBnnrqKdW+fXuVnJysjjnmGPX111/Xd5cs54ILLlCtWrVSycnJqk2bNuqCCy5QmzZtqu9uWcL8+fMVgFr/xowZo5RSyufzqbvuuku1bNlSpaSkqJNPPlmtX7++fjsdIyK5l5WVqaFDh6rmzZurpKQk1aFDB3XZZZc54pc8oZwBqNdee81f5sCBA+rqq69WTZs2VWlpaeqss85ShYWF9dfpGBHNfevWreqEE05Q2dnZKiUlRXXp0kVNnDhRFRUV1W/HY8C4ceNUhw4dVHJysmrevLk6+eST/YfcSjl3ziN5O3m+w1HzoNup8x6KQHcnz320PYxT5zySt5Pnu5qPP/5YHX744SolJUV1795dvfjii0HPO3k/F8ndyfs5pZT67LPPFICQc+nUta5UeG+nr/Xi4mJ13XXXqfbt26tGjRqpTp06qX/+85+qoqLCX8apaz2au5PX+jvvvKM6deqkkpOTVW5urpowYYLat2+f/3mnzrlSkd2dNOexOJPZs2ePGjVqlMrIyFCZmZnqkksuUSUlJfVgo0dd3VesWKH69eunsrKyVKNGjVSPHj3Ugw8+2OB+2RFLXEopZeUnxgkhhBBCCCGEEEIIIYQQK+E9ugkhhBBCCCGEEEIIIYQ0aHjQTQghhBBCCCGEEEIIIaRBw4NuQgghhBBCCCGEEEIIIQ0aHnQTQgghhBBCCCGEEEIIadDwoJsQQgghhBBCCCGEEEJIg4YH3YQQQgghhBBCCCGEEEIaNDzoJoQQQgghhBBCCCGEENKg4UE3IYQQQgghDQyXy4VZs2aZrr9gwQK4XC7s27evTv0YO3YszjzzzDq9BiGEEEIIIbGAB92EEEIIIYTU4Pfff8dVV12F9u3bIyUlBbm5uRg2bBiWLl1a312LCQMGDEBhYSGysrLquyuEEEIIIYTEhMT67gAhhBBCCCHSOOecc1BZWYnXX38dnTp1wq5duzBv3jzs2bOnvrsWE5KTk5Gbm1vf3SCEEEIIISRm8BPdhBBCCCGEBLBv3z4sXrwYDz30EE488UR06NABxxxzDG6//Xacfvrp/nKPP/44jjjiCKSnp6Ndu3a4+uqrsX//fv/zU6dORZMmTfDJJ5+gW7duSEtLw7nnnouysjK8/vrr6NixI5o2bYprr70WXq/XX69jx464//77MWrUKKSnp6NNmzZ45plnIvb5119/xfnnn48mTZogOzsbZ5xxBrZs2RK2fM1bl1T39bPPPkOPHj2QkZGB4cOHo7Cw0F/H6/XixhtvRJMmTdCsWTPccsstUEoFva7P58PkyZORl5eH1NRU9OrVC++//z4AQCmFU045BcOGDfPX27t3L9q2bYu777478qQQQgghhBASBR50E0IIIYQQEkBGRgYyMjIwa9YsVFRUhC3ndrvxn//8B6tXr8brr7+OL7/8ErfccktQmbKyMvznP//B9OnT8b///Q8LFizAWWedhdmzZ2P27Nl444038MILL/gPg6t55JFH0KtXL/zwww+47bbbcN1112Hu3Lkh++HxeDBs2DA0btwYixcvxtKlS/0H1ZWVlYa9y8rK8Oijj+KNN97AokWLsHXrVtx8883+5x977DFMnToVr776KpYsWYK9e/di5syZQa8xefJk/Pe//8Xzzz+P1atX44YbbsBFF12EhQsXwuVy4fXXX8e3336L//znPwCAK6+8Em3atOFBNyGEEEIIqTMuVfNjGATAwU+seDye+u4GIYQQYork5GS43fx9NiFm+eCDD3DZZZfhwIED6NOnDwYNGoS//e1vOPLII8PWef/993HllVdi9+7dAA5+SvqSSy7Bpk2b0LlzZwAHD3bfeOMN7Nq1CxkZGQCA4cOHo2PHjnj++ecBHPxEd48ePTBnzhz/a//tb39DcXExZs+eDeDgl1HOnDkTZ555Jt5880088MADWLt2LVwuFwCgsrISTZo0waxZszB06NBafV2wYAFOPPFE/PHHH2jSpEnIvj777LO47777sHPnTgBA69atccMNN2DixIkAgKqqKuTl5eGoo47y/1IgOzsbX3zxBfr37+9va/z48SgrK8Nbb70FAHjvvffw97//Hddffz2eeuop/PDDD8jPz9edIkIIIYQQQoLgPbproJTCzp076/wN9IQQQkh94na7kZeXh+Tk5PruCiENknPOOQcjR47E4sWL8fXXX2POnDl4+OGH8fLLL2Ps2LEAgC+++AKTJ0/GunXrUFxcjKqqKpSXl6OsrAxpaWkAgLS0NP/BMQC0bNkSHTt29B9yVz/222+/BbUfeFBc/fO///3vkH39v//7P2zatAmNGzcOery8vBybN2827Fyzr61atfL3q6ioCIWFhejXr5//+cTERPTt29d/G5JNmzahrKwMQ4YMCXrdyspK9O7d2//zeeedh5kzZ2LKlCl47rnneMhNCCGEEEJiAg+6a1B9yN2iRQukpaX5PxVDCCGENBR8Ph927NiBwsJCtG/fntcyQkzSqFEjDBkyBEOGDMFdd92F8ePH45577sHYsWOxZcsW/PWvf8VVV12Ff/3rX8jOzsaSJUtw6aWXorKy0n/QnZSUFPSaLpcr5GM+n890P/fv34+jjjoK06ZNq/Vc8+bNDb9OqH7p/PFn9f3JP/30U7Rp0ybouZSUFP//l5WVYcWKFUhISMDGjRsNvz4hhBBCCCGR4EF3AF6v13/I3axZs/ruDiGEEGKa5s2bY8eOHaiqqqp1eEUIMcdhhx2GWbNmAQBWrFgBn8+Hxx57zH+boHfffTdmbX399de1fu7Ro0fIsn369ME777yDFi1aIDMzM2Z9CCQrKwutWrXC8uXLccIJJwA4eOuSFStWoE+fPgAOjk9KSgq2bt2KQYMGhX2tm266CW63G3PmzMGIESMwcuRInHTSSZb0mxBCCCGExA886A6g+p7c1Z/AIYQQQhoq1bcs8Xq9POgmRJM9e/bgvPPOw7hx43DkkUeicePG+O677/Dwww/jjDPOAAB06dIFHo8HTz31FE477TQsXbrUf4/tWLB06VI8/PDDOPPMMzF37ly89957+PTTT0OWHT16NB555BGcccYZuO+++9C2bVv88ssvmDFjBm655Ra0bds2Jn267rrrMGXKFOTn56N79+54/PHHg27317hxY9x888244YYb4PP5cNxxx6GoqAhLly5FZmYmxowZg08//RSvvvoqvvrqK/Tp0wcTJ07EmDFjsGrVKjRt2jQm/SSEEEIIIfEJv6UqBPwTb0IIIQ0dXssIMU9GRgb69euHJ554AieccAIOP/xw3HXXXbjsssvw9NNPAwB69eqFxx9/HA899BAOP/xwTJs2DZMnT45ZH2666SZ899136N27Nx544AE8/vjjGDZsWMiyaWlpWLRoEdq3b4+zzz4bPXr0wKWXXory8vKYfsL7pptuwsUXX4wxY8agf//+aNy4Mc4666ygMvfffz/uuusuTJ48GT169MDw4cPx6aefIi8vD7///jsuvfRSTJo0yf8p8HvvvRctW7bElVdeGbN+EkIIIYSQ+MSldG6853DKy8tRUFCAvLw8NGrUqL67QwghhJiG1zRCGi4dO3bE9ddfj+uvv76+u0IIIYQQQkiDgZ/oJoZZsGABXC5X0J+oRqNjx47497//bVmfCIlHuBYJIYQQQgghhBBCguFBt0MYO3YsXC5XyD/7nDBhAlwuF8aOHWt/xwyybds2JCcn4/DDD6/vroimoc9zPNBQ52jSpElwuVz+f1lZWTj++OOxcOHC+u6aSBrqPBNCCCGEEEIIIU6FB90Ool27dpg+fToOHDjgf6y8vBxvvfUW2rdvX489i87UqVNx/vnno7i4GMuXL6/v7oimIc9zvNBQ56hnz54oLCxEYWEhvvrqK+Tn5+Ovf/0rioqK6rtrImmo80wIkc+WLVt42xJCCCGEEEI04UG3BVRUefHlul2496PVmDDte9z70Wp8uW4XKqq8lrbb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Cvx1fvzxR5xwwglo2rQpjjrqKHz33XchX/v333/HqaeeiiZNmiAnJwejRo3yP/fll1/6vfLz8/3t/vrrrxgxYgSaNm2Kww47DB9++KG/zuDBg3HXXXehb9++SE9Ph8fjwcKFC3HUUUehSZMmGDx4MDZv3uzv/6hRo5CdnY3s7Gwcf/zxIfv4xx9/YPjw4cjJyUHz5s1x+eWX+8e4ehzvuece5OTk4J577jGcG+6//34sXrwY48ePR0ZGBh588EH/c+HmZe/evbjwwgvRokULdOrUKSgnBeLz+XDttdciJycHTZo0wdFHH43du3cDAB588EF06NABmZmZ6N+/P1atWuWv17FjRzzyyCPo0aMHGjdujLvvvhvr169H3759kZWVVWtNhMtdplDEz4EDB9SaNWvUgQMH6rsrhBBCSJ3gNY0QQgghdtGhQwe1ePFipZRS69evV7m5uerRRx/1P//222+rffv2qbKyMnXJJZeoM844w/8cAHXuueeqkpIS9eOPP6qUlBS1adMmpZRSY8aMUffff7/atm2b6tGjh3rppZeUUkqtXbtWtW3bVhUWFiqfz6fWrl2rCgsLlVJKTZ48WY0cOTJqn4cNG6Zee+21iGUGDRqk3njjDaWUUv369VNvvvmmUkqpvXv3qu+//z5kneo+V1NQUKAAqGuvvVZVVFSozz77TKWnp6vi4mKllFJXX321GjVqlCopKVHbt29Xhx12mPr4449DvnaHDh3UX/7yF1VYWOgv+/LLLyullHr33XfV4YcfrrZs2aLKysrUqFGj1E033aSUUuqCCy5QDz74oPL5fGr//v1q+fLlSiml5s+frzp37hx1rMLx0ksvqX79+oV9HoD69ddf1f79+1Xjxo3Vhg0blFJKbdmyRf38889hHatjSSmlXnvtNZWYmKjeeustVVVVpf75z3+qE044QSmllNfrVUceeaR68sknlcfjUcuWLVM5OTlq586dtV53zpw56rjjjlNKKTV9+nSVl5enLrvsMqWUUnfeeae69dZblVJK3XHHHWrQoEFq79696pdfflH5+fn+OLnnnntUSkqK+uyzz5TX643oVTMOjPLVV1+pDh06RCxTHVOtWrVS7du3V9ddd50qLS0NWfa5555TF110kVJKqSlTpqi8vDz1r3/9Syml1EUXXaSee+45pdTBWO/evbvasmWL+uOPP9Rh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", + "text/plain": [ + "
" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "MIN_ABS_DIFF = 0.05\n", + "scores = np.stack([modela, modelb], axis=1)\n", + "ranks = stats.rankdata(-scores, method=\"average\", axis=1)\n", + "abs_diff = np.abs(np.diff(scores, axis=1)).flatten()\n", + "ranks[abs_diff < MIN_ABS_DIFF, :] = 1.5\n", + "ranksa, ranksb = ranks[:, 0], ranks[:, 1]\n", + "\n", + "num_samples = ranks.shape[0]\n", + "indexes = np.arange(num_samples)\n", + "\n", + "fig, ax = plt.subplots(figsize=(18, 2.5))\n", + "\n", + "# plot sample index vs score and their mean\n", + "ax.scatter(indexes, ranksa, s=30, color=\"tab:blue\", marker=\"o\", label=\"Model A\", zorder=3, alpha=0.6)\n", + "ax.axhline(ranksa.mean(), color=\"tab:blue\", linestyle=\"--\", label=\"Mean\", zorder=3)\n", + "ax.scatter(indexes, ranksb, s=30, color=\"tab:red\", marker=\"o\", label=\"Model B\", zorder=3, alpha=0.6)\n", + "ax.axhline(ranksb.mean(), color=\"tab:red\", linestyle=\"--\", label=\"Mean\", zorder=3)\n", + "\n", + "# configure the x-axis\n", + "ax.set_xlabel(\"Sample index\")\n", + "ax.set_xlim(0 - (eps := 0.01 * num_samples), num_samples + eps)\n", + "ax.xaxis.set_major_locator(IndexLocator(5, 0))\n", + "ax.xaxis.set_minor_locator(IndexLocator(1, 0))\n", + "\n", + "# configure the y-axis\n", + "ax.set_ylabel(\"AUPIMO Rank\")\n", + "ax.set_ylim(1 - 0.1, 2 + 0.1)\n", + "ax.yaxis.set_major_locator(FixedLocator([1, 1.5, 2]))\n", + "ax.invert_yaxis()\n", + "\n", + "# configure the grid, legend, etc\n", + "ax.grid(axis=\"both\", which=\"major\", linestyle=\"-\")\n", + "ax.grid(axis=\"x\", which=\"minor\", linestyle=\"--\", alpha=0.5)\n", + "ax.legend(ncol=4, loc=\"upper left\", bbox_to_anchor=(0, -0.15))\n", + "ax.set_title(\"AUPIMO scores ranks\")\n", + "\n", + "fig.text(\n", + " 0.9,\n", + " -0.1,\n", + " \"Ranks: 1 is the best, 2 is the worst, 1.5 when the scores are the same.\",\n", + " ha=\"right\",\n", + " va=\"top\",\n", + " fontsize=\"small\",\n", + ")\n", + "\n", + "fig # noqa: B018, RUF100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The advantage of A over B is clearer now.\n", + "\n", + "Most of cases where B was better were within the difference margin of 5%.\n", + "\n", + "The average ranks also got more distant.\n", + "\n", + "Could it be by chance or can we be confident that model A is better than model B?\n", + "\n", + "> **Wilcoxon signed rank test**\n", + "> \n", + "> - null hypothesis: `average(rankA) == average(rankB)` \n", + "> - alternative hypothesis: `average(rankA) != average(rankB)`\n", + "> \n", + "> See [`scipy.stats.wilcoxon`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.wilcoxon.html#scipy.stats.wilcoxon) and [\"Wilcoxon signed-rank test\" in Wikipedia](https://en.wikipedia.org/wiki/Wilcoxon_signed-rank_test).\n", + ">\n", + "> Confidence Level (reminder): *higher* confidence level *more confident* that `average(rankA) > average(rankB)`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "test_result=WilcoxonResult(statistic=1823.0, pvalue=0.0002876893285960681)\n", + "confidence=100.0%\n" + ] + } + ], + "source": [ + "MIN_ABS_DIFF = 0.05\n", + "differences = modela - modelb\n", + "differences[abs_diff < MIN_ABS_DIFF] = 0.0\n", + "test_result = stats.wilcoxon(differences, zero_method=\"zsplit\")\n", + "confidence = 1.0 - float(test_result.pvalue)\n", + "print(f\"{test_result=}\")\n", + "print(f\"{confidence=:.1%}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We got such a high confidence that we can say for sure that these differences are not due to chance.\n", + "\n", + "So we can say that model A is _consistently_ better than model B -- even though some counter examples exist as we saw in the image by image comparison." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cite Us\n", + "\n", + "AUPIMO was developed during [Google Summer of Code 2023 (GSoC 2023)](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd) with the `anomalib` team from Intel's OpenVINO Toolkit.\n", + "\n", + "arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", + "\n", + "```bibtex\n", + "@misc{bertoldo2024aupimo,\n", + " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", + " year={2024},\n", + " url={https://arxiv.org/abs/2401.01984}, \n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Utils\n", + "\n", + "Some utility functions to expand what this notebook shows." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Save AUPIMO scores\n", + "\n", + "At the begin of the notebook we defined a function `load_aupimo_result_from_json_dict()` that deserializes `AUPIMOResult` objects.\n", + "\n", + "Let's define the opposite operator so you can save and publish your AUPIMO scores." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "payload.keys()=dict_keys(['fpr_lower_bound', 'fpr_upper_bound', 'num_thresholds', 'thresh_lower_bound', 'thresh_upper_bound', 'aupimos'])\n" + ] + } + ], + "source": [ + "def save_aupimo_result_to_json_dict(\n", + " aupimo_result: AUPIMOResult,\n", + " paths: list[str | Path] | None = None,\n", + ") -> dict[str, str | float | int | list[str]]:\n", + " \"\"\"Convert the AUPIMOResult dataclass to a JSON payload.\"\"\"\n", + " payload = {\n", + " \"fpr_lower_bound\": aupimo_result.fpr_lower_bound,\n", + " \"fpr_upper_bound\": aupimo_result.fpr_upper_bound,\n", + " \"num_thresholds\": aupimo_result.num_thresholds,\n", + " \"thresh_lower_bound\": aupimo_result.thresh_lower_bound,\n", + " \"thresh_upper_bound\": aupimo_result.thresh_upper_bound,\n", + " \"aupimos\": aupimo_result.aupimos.tolist(),\n", + " }\n", + " if paths is not None:\n", + " if len(paths) != aupimo_result.aupimos.shape[0]:\n", + " msg = (\n", + " \"Invalid paths. It must have the same length as the AUPIMO scores. \"\n", + " f\"Got {len(paths)} paths and {aupimo_result.aupimos.shape[0]} scores.\"\n", + " )\n", + " raise ValueError(msg)\n", + " # make sure the paths are strings, not pathlib.Path objects\n", + " payload[\"paths\"] = [str(p) for p in paths]\n", + " return payload\n", + "\n", + "\n", + "payload = save_aupimo_result_to_json_dict(aupimo_result_model_a)\n", + "print(f\"{payload.keys()=}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "payload.keys()=dict_keys(['fpr_lower_bound', 'fpr_upper_bound', 'num_thresholds', 'thresh_lower_bound', 'thresh_upper_bound', 'aupimos'])\n" + ] + } + ], + "source": [ + "payload = save_aupimo_result_to_json_dict(aupimo_result_model_a)\n", + "print(f\"{payload.keys()=}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "payload.keys()=dict_keys(['fpr_lower_bound', 'fpr_upper_bound', 'num_thresholds', 'thresh_lower_bound', 'thresh_upper_bound', 'aupimos', 'paths'])\n" + ] + } + ], + "source": [ + "# you can optionally save the paths to the images\n", + "# where the AUPIMO scores were computed from\n", + "payload = save_aupimo_result_to_json_dict(aupimo_result_model_a, paths)\n", + "print(f\"{payload.keys()=}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "8,0K\t/tmp/tmpsuauy_de/aupimo_result.json\n" + ] + } + ], + "source": [ + "# let's check that it can be saved to a file and loaded back\n", + "\n", + "from tempfile import TemporaryDirectory\n", + "\n", + "with TemporaryDirectory() as tmpdir:\n", + " cache_dir = Path(tmpdir)\n", + "\n", + " with (cache_dir / \"aupimo_result.json\").open(\"w\") as file:\n", + " json.dump(payload, file)\n", + "\n", + " !du -sh {cache_dir / \"aupimo_result.json\"}\n", + "\n", + " with (cache_dir / \"aupimo_result.json\").open(\"r\") as file:\n", + " payload_reloaded = json.load(file)\n", + "\n", + "aupimo_result_reloaded = load_aupimo_result_from_json_dict(payload_reloaded)\n", + "assert torch.allclose(aupimo_result_model_a.aupimos, aupimo_result_reloaded.aupimos, equal_nan=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pairwise statistical tests (multiple models)\n", + "\n", + "What if you have multiple models to compare?\n", + "\n", + "Here we define a functions that will return all the pairwise comparisons between the models." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "import itertools\n", + "from typing import Any, Literal\n", + "\n", + "import numpy as np\n", + "from numpy import ndarray\n", + "from scipy import stats\n", + "from torch import Tensor\n", + "\n", + "\n", + "def _validate_models(models: dict[str, Tensor | ndarray]) -> dict[str, ndarray]:\n", + " \"\"\"Make sure the input `models` is valid and convert all the dict's values to `ndarray`.\n", + "\n", + " Args:\n", + " models (dict[str, Tensor | ndarray]): {\"model name\": sequence of shape (num_images,)}.\n", + " Validations:\n", + " - keys are strings (model names)\n", + " - there are at least two models\n", + " - values are sequences of floats in [0, 1] or `nan`\n", + " - all sequences have the same shape\n", + " - all `nan` values are at the positions\n", + " Returns:\n", + " dict[str, ndarray]: {\"model name\": array (num_images,)}.\n", + " \"\"\"\n", + " if not isinstance(models, dict):\n", + " msg = f\"Expected argument `models` to be a dict, but got {type(models)}.\"\n", + " raise TypeError(msg)\n", + "\n", + " if len(models) < 2:\n", + " msg = \"Expected argument `models` to have at least one key, but got none.\"\n", + " raise ValueError(msg)\n", + "\n", + " ref_num_samples = None\n", + " ref_nans = None\n", + " for key in models:\n", + " if not isinstance(key, str):\n", + " msg = f\"Expected argument `models` to have all keys of type str. Found {type(key)}.\"\n", + " raise TypeError(msg)\n", + "\n", + " value = models[key]\n", + "\n", + " if not isinstance(value, Tensor | ndarray):\n", + " msg = (\n", + " \"Expected argument `models` to have all values of type Tensor or ndarray. \"\n", + " f\"Found {type(value)} on {key=}.\"\n", + " )\n", + " raise TypeError(msg)\n", + "\n", + " if isinstance(value, Tensor):\n", + " models[key] = value = value.numpy()\n", + "\n", + " if not np.issubdtype(value.dtype, np.floating):\n", + " msg = f\"Expected argument `models` to have all values of floating type. Found {value.dtype} on {key=}.\"\n", + " raise ValueError(msg)\n", + "\n", + " if value.ndim != 1:\n", + " msg = f\"Expected argument `models` to have all values of 1D arrays. Found {value.ndim} on {key=}.\"\n", + " raise ValueError(msg)\n", + "\n", + " if ref_num_samples is None:\n", + " ref_num_samples = num_samples = value.shape[0]\n", + " ref_nans = nans = np.isnan(value)\n", + "\n", + " if num_samples != ref_num_samples:\n", + " msg = \"Argument `models` has inconsistent number of samples.\"\n", + " raise ValueError(msg)\n", + "\n", + " if (nans != ref_nans).any():\n", + " msg = \"Argument `models` has inconsistent `nan` values (in different positions).\"\n", + " raise ValueError(msg)\n", + "\n", + " if (value[~nans] < 0).any() or (value[~nans] > 1).any():\n", + " msg = (\n", + " \"Expected argument `models` to have all sequences of floats \\\\in [0, 1]. \"\n", + " f\"Key {key} has values outside this range.\"\n", + " )\n", + " raise ValueError(msg)\n", + "\n", + " return models\n", + "\n", + "\n", + "def test_pairwise(\n", + " models: dict[str, Tensor | ndarray],\n", + " *,\n", + " test: Literal[\"ttest_rel\", \"wilcoxon\"],\n", + " min_abs_diff: float | None = None,\n", + ") -> list[dict[str, Any]]:\n", + " \"\"\"Compare all pairs of models using statistical tests.\n", + "\n", + " Scores are assumed to be *higher is better*.\n", + "\n", + " General hypothesis in the tests:\n", + " - Null hypothesis: two models are equivalent on average.\n", + " - Alternative hypothesis: one model is better than the other (two-sided test).\n", + "\n", + " Args:\n", + " models (dict[str, Tensor | ndarray]): {\"model name\": sequence of shape (num_images,)}.\n", + " test (Literal[\"ttest_rel\", \"wilcoxon\"]): The statistical test to use.\n", + " - \"ttest_rel\": Paired Student's t-test (parametric).\n", + " - \"wilcoxon\": Wilcoxon signed-rank test (non-parametric).\n", + " min_abs_diff (float | None): Minimum absolute difference to consider in the Wilcoxon test. If `None`, all\n", + " differences are considered. Default is `None`. Ignored in the t-test.\n", + " \"\"\"\n", + " models = _validate_models(models)\n", + " if test not in {\"ttest_rel\", \"wilcoxon\"}:\n", + " msg = f\"Expected argument `test` to be 'ttest_rel' or 'wilcoxon', but got '{test}'.\"\n", + " raise ValueError(msg)\n", + " # remove nan values\n", + " models = {k: v[~np.isnan(v)] for k, v in models.items()}\n", + " models_names = sorted(models.keys())\n", + " num_models = len(models)\n", + " comparisons = list(itertools.combinations(range(num_models), 2))\n", + "\n", + " # for each comparison, compute the test and confidence (1 - p-value)\n", + " test_results = []\n", + " for modela_idx, modelb_idx in comparisons: # indices of the sorted model names\n", + " modela = models_names[modela_idx]\n", + " modelb = models_names[modelb_idx]\n", + " modela_scores = models[modela]\n", + " modelb_scores = models[modelb]\n", + " if test == \"ttest_rel\":\n", + " test_result = stats.ttest_rel(modela_scores, modelb_scores, alternative=\"two-sided\")\n", + " else: # test == \"wilcoxon\"\n", + " differences = modela_scores - modelb_scores\n", + " if min_abs_diff is not None:\n", + " differences[np.abs(differences) < min_abs_diff] = 0.0\n", + " # extreme case\n", + " if (differences == 0).all():\n", + " test_result = stats._morestats.WilcoxonResult(np.nan, 1.0) # noqa: SLF001\n", + " else:\n", + " test_result = stats.wilcoxon(differences, zero_method=\"zsplit\", alternative=\"two-sided\")\n", + " test_results.append({\n", + " \"modela\": modela,\n", + " \"modelb\": modelb,\n", + " \"confidence\": 1 - test_result.pvalue,\n", + " \"pvalue\": test_result.pvalue,\n", + " \"statistic\": test_result.statistic,\n", + " })\n", + "\n", + " return test_results" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's first test it with the same two models we used before." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
modelamodelbconfidencepvaluestatistic
0AB0.9950.0052.872
\n", + "
" + ], + "text/plain": [ + " modela modelb confidence pvalue statistic\n", + "0 A B 0.995 0.005 2.872" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# parametric test\n", + "pd.DataFrame.from_records(test_pairwise({\"A\": modela, \"B\": modelb}, test=\"ttest_rel\"))" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
modelamodelbconfidencepvaluestatistic
0AB0.9980.0021965.500
\n", + "
" + ], + "text/plain": [ + " modela modelb confidence pvalue statistic\n", + "0 A B 0.998 0.002 1965.500" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# non-parametric test\n", + "pd.DataFrame.from_records(test_pairwise({\"A\": modela, \"B\": modelb}, test=\"wilcoxon\"))" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
modelamodelbconfidencepvaluestatistic
0AB1.0000.0001823.000
\n", + "
" + ], + "text/plain": [ + " modela modelb confidence pvalue statistic\n", + "0 A B 1.000 0.000 1823.000" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# non-parametric test with a minimum absolute difference\n", + "pd.DataFrame.from_records(test_pairwise({\"A\": modela, \"B\": modelb}, test=\"wilcoxon\", min_abs_diff=0.05))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's get the best models from the benchmark in our paper and compare them two by two.\n", + "\n", + "We'll look at the dataset `cashew` from `VisA`.\n", + "\n", + "> More details in the paper (see the last cell)." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
 modelamodelbconfidencepvaluestatistic
0efficientad_wr101_s_extpatchcore_wr1010.9994020.0005981580.000000
1efficientad_wr101_s_extrd++_wr50_ext0.7736590.2263412193.500000
2efficientad_wr101_s_extsimplenet_wr50_ext1.0000000.000000690.500000
3efficientad_wr101_s_extuflow_ext0.9994470.0005531550.500000
4patchcore_wr101rd++_wr50_ext0.9999800.0000201333.000000
5patchcore_wr101simplenet_wr50_ext1.0000000.000000351.500000
6patchcore_wr101uflow_ext0.7318750.2681252213.000000
7rd++_wr50_extsimplenet_wr50_ext1.0000000.000000967.000000
8rd++_wr50_extuflow_ext0.9999450.0000551383.000000
9simplenet_wr50_extuflow_ext1.0000000.000000318.500000
\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "models = {\n", + " model_name: get_benchmark_aupimo_scores(model_name, \"visa/cashew\", verbose=False)[1].aupimos.numpy()\n", + " for model_name in [\n", + " \"efficientad_wr101_s_ext\",\n", + " \"patchcore_wr101\",\n", + " \"rd++_wr50_ext\",\n", + " \"simplenet_wr50_ext\",\n", + " \"uflow_ext\",\n", + " ]\n", + "}\n", + "models = test_pairwise(models, test=\"wilcoxon\", min_abs_diff=0.1)\n", + "pd.DataFrame.from_records(models).style.background_gradient(cmap=\"jet\", vmin=0, vmax=1, subset=[\"confidence\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Compare to the benchmark (coming up)\n", + "\n", + "Compare your freshly trained models to the benchmark datasets in our paper." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO(jpcbertoldo): implement utility function to load and compare to the results from the benchmark # noqa: TD003" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cite Us\n", + "\n", + "AUPIMO was developed during [Google Summer of Code 2023 (GSoC 2023)](https://summerofcode.withgoogle.com/archive/2023/projects/SPMopugd) with the `anomalib` team from Intel's OpenVINO Toolkit.\n", + "\n", + "arXiv: [arxiv.org/abs/2401.01984](https://arxiv.org/abs/2401.01984) (accepted to BMVC 2024)\n", + "\n", + "Official repository: [github.com/jpcbertoldo/aupimo](https://github.com/jpcbertoldo/aupimo) (numpy-only API and numba-accelerated versions available)\n", + "\n", + "```bibtex\n", + "@misc{bertoldo2024aupimo,\n", + " author={Joao P. C. Bertoldo and Dick Ameln and Ashwin Vaidya and Samet AkΓ§ay},\n", + " title={{AUPIMO: Redefining Visual Anomaly Detection Benchmarks with High Speed and Low Tolerance}}, \n", + " year={2024},\n", + " url={https://arxiv.org/abs/2401.01984}, \n", + "}\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "anomalib-dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/README.md b/notebooks/README.md index 15935b93cf..de33e5b7e9 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -60,3 +60,4 @@ To install Python, Git and other required tools, [OpenVINO Notebooks](https://gi | AUPIMO representative samples and visualization | [701b_aupimo_advanced_i](/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701b_aupimo_advanced_i.ipynb) | | PIMO curve and integration bounds | [701c_aupimo_advanced_ii](/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701c_aupimo_advanced_ii.ipynb) | | (AU)PIMO of a random model | [701d_aupimo_advanced_iii](/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701d_aupimo_advanced_iii.ipynb) | +| AUPIMO load/save, statistical comparison | [701e_aupimo_advanced_iv](/notebooks/700_metrics/701e_aupimo_advanced_iv.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/openvinotoolkit/anomalib/blob/main/notebooks/700_metrics/701e_aupimo_advanced_iv.ipynb) | diff --git a/src/anomalib/metrics/pimo/dataclasses.py b/src/anomalib/metrics/pimo/dataclasses.py index 0c5aeb025d..3eaa04cd12 100644 --- a/src/anomalib/metrics/pimo/dataclasses.py +++ b/src/anomalib/metrics/pimo/dataclasses.py @@ -120,7 +120,7 @@ class AUPIMOResult: # metadata fpr_lower_bound: float fpr_upper_bound: float - num_thresholds: int + num_thresholds: int | None # data thresh_lower_bound: float = field(repr=False) @@ -169,7 +169,8 @@ def __post_init__(self) -> None: try: _validate.is_rate_range((self.fpr_lower_bound, self.fpr_upper_bound)) # TODO(jpcbertoldo): warn when it's too low (use parameters from the numpy code) # noqa: TD003 - _validate.is_num_thresholds_gte2(self.num_thresholds) + if self.num_thresholds is not None: + _validate.is_num_thresholds_gte2(self.num_thresholds) _validate.is_rates(self.aupimos, nan_allowed=True) # validate is_aupimos _validate.validate_threshold_bounds((self.thresh_lower_bound, self.thresh_upper_bound)) @@ -194,7 +195,6 @@ def from_pimo_result( num_thresholds_auc: number of thresholds used to effectively compute AUPIMO; NOT the number of thresholds used to compute the PIMO curve! aupimos: AUPIMO scores - paths: paths to the source images to which the AUPIMO scores correspond. """ if pimo_result.per_image_tprs.shape[0] != aupimos.shape[0]: msg = ( From 6eeb7f6755d95f7a02481ef76507f9ee3ca07961 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Tue, 22 Oct 2024 14:26:16 +0100 Subject: [PATCH 19/32] =?UTF-8?q?=F0=9F=90=9E=20Defer=20OpenVINO=20import?= =?UTF-8?q?=20to=20avoid=20unnecessary=20warnings=20(#2385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix openvino import issue Signed-off-by: Samet Akcay * Fix pre-commit issues Signed-off-by: Samet Akcay --------- Signed-off-by: Samet Akcay --- .../deploy/inferencers/openvino_inferencer.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/anomalib/deploy/inferencers/openvino_inferencer.py b/src/anomalib/deploy/inferencers/openvino_inferencer.py index bb57a8d65a..8dea77b92e 100644 --- a/src/anomalib/deploy/inferencers/openvino_inferencer.py +++ b/src/anomalib/deploy/inferencers/openvino_inferencer.py @@ -4,12 +4,12 @@ # SPDX-License-Identifier: Apache-2.0 import logging -from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any import cv2 import numpy as np +from lightning_utilities.core.imports import package_available from omegaconf import DictConfig from PIL import Image @@ -21,14 +21,6 @@ logger = logging.getLogger("anomalib") -if find_spec("openvino") is not None: - import openvino as ov - - if TYPE_CHECKING: - from openvino import CompiledModel -else: - logger.warning("OpenVINO is not installed. Please install OpenVINO to use OpenVINOInferencer.") - class OpenVINOInferencer(Inferencer): """OpenVINO implementation for the inference. @@ -102,6 +94,10 @@ def __init__( task: str | None = None, config: dict | None = None, ) -> None: + if not package_available("openvino"): + msg = "OpenVINO is not installed. Please install OpenVINO to use OpenVINOInferencer." + raise ImportError(msg) + self.device = device self.config = config @@ -110,7 +106,7 @@ def __init__( self.task = TaskType(task) if task else TaskType(self.metadata["task"]) - def load_model(self, path: str | Path | tuple[bytes, bytes]) -> tuple[Any, Any, "CompiledModel"]: + def load_model(self, path: str | Path | tuple[bytes, bytes]) -> tuple[Any, Any, Any]: """Load the OpenVINO model. Args: @@ -121,6 +117,8 @@ def load_model(self, path: str | Path | tuple[bytes, bytes]) -> tuple[Any, Any, [tuple[str, str, ExecutableNetwork]]: Input and Output blob names together with the Executable network. """ + import openvino as ov + core = ov.Core() # If tuple of bytes is passed if isinstance(path, tuple): From 3a403ae8c6cf91d9fb52aa58a198d559a71dfd00 Mon Sep 17 00:00:00 2001 From: Ashwin Vaidya Date: Tue, 22 Oct 2024 17:23:32 +0200 Subject: [PATCH 20/32] =?UTF-8?q?=F0=9F=9A=80=20Add=20VLM=20based=20Anomal?= =?UTF-8?q?y=20Model=20(#2344)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Draft] Llm on (#2165) * Add TaskType Explanation Signed-off-by: Bepitic * Add llm model Signed-off-by: Bepitic * add ollama Signed-off-by: Bepitic * better description for descr in title Signed-off-by: Bepitic * add text of llm into imageResult visualization * add text of llm into imageResult visualization Signed-off-by: Bepitic * latest changes Signed-off-by: Bepitic * add wip llava/llava_next Signed-off-by: Bepitic * add init Signed-off-by: Bepitic * add text of llm into imageResult visualization Signed-off-by: Bepitic * latest changes Signed-off-by: Bepitic * upd Lint Signed-off-by: Bepitic * fix visualization with description Signed-off-by: Bepitic * show the images every batch Signed-off-by: Bepitic * fix docstring and error management Signed-off-by: Bepitic * Add compatibility for TaskType.EXPLANATION. Signed-off-by: Bepitic * Remove, show in the engine-Visualization. * fix visualization and llm openai multishot. * fix Circular import problem * Add HugginFace To LLavaNext Signed-off-by: Bepitic --------- Signed-off-by: Bepitic * πŸ”¨ Scaffold for refactor (#2340) * initial scafold Signed-off-by: Ashwin Vaidya * Apply PR comments Signed-off-by: Ashwin Vaidya * rename dir Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * Add ChatGPT (#2341) * initial scafold Signed-off-by: Ashwin Vaidya * Apply PR comments Signed-off-by: Ashwin Vaidya * rename dir Signed-off-by: Ashwin Vaidya * delete llm_ollama Signed-off-by: Ashwin Vaidya * Add ChatGPT Signed-off-by: Ashwin Vaidya * Add ChatGPT Signed-off-by: Ashwin Vaidya * Remove LLM model Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * Add Huggingface (#2343) * initial scafold Signed-off-by: Ashwin Vaidya * Apply PR comments Signed-off-by: Ashwin Vaidya * rename dir Signed-off-by: Ashwin Vaidya * delete llm_ollama Signed-off-by: Ashwin Vaidya * Add ChatGPT Signed-off-by: Ashwin Vaidya * Add ChatGPT Signed-off-by: Ashwin Vaidya * Remove LLM model Signed-off-by: Ashwin Vaidya * Add transformers Signed-off-by: Ashwin Vaidya * Remove llava Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * πŸ”¨ Minor Refactor (#2345) Refactor Signed-off-by: Ashwin Vaidya * undo changes Signed-off-by: Ashwin Vaidya * undo changes Signed-off-by: Ashwin Vaidya * undo changes to image.py Signed-off-by: Ashwin Vaidya * Add explanation visualizer (#2351) * Add explanation visualizer Signed-off-by: Ashwin Vaidya * bug-fix Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * πŸ”¨ Allow setting API keys from env (#2353) Allow setting API keys from env Signed-off-by: Ashwin Vaidya * πŸ§ͺ Add tests (#2355) * Add tests Signed-off-by: Ashwin Vaidya * remove explanation task type Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Ashwin Vaidya * minor fixes Signed-off-by: Ashwin Vaidya * Update changelog Signed-off-by: Ashwin Vaidya * Fix tests Signed-off-by: Ashwin Vaidya * Address PR comments Signed-off-by: Ashwin Vaidya * update name Signed-off-by: Ashwin Vaidya * Update src/anomalib/models/image/vlm_ad/lightning_model.py Co-authored-by: Samet Akcay * update name Signed-off-by: Ashwin Vaidya --------- Signed-off-by: Bepitic Signed-off-by: Ashwin Vaidya Co-authored-by: Paco Co-authored-by: Samet Akcay --- CHANGELOG.md | 1 + pyproject.toml | 3 +- src/anomalib/callbacks/metrics.py | 5 +- src/anomalib/engine/engine.py | 16 ++- src/anomalib/models/__init__.py | 2 + src/anomalib/models/image/__init__.py | 2 + src/anomalib/models/image/vlm_ad/__init__.py | 8 ++ .../models/image/vlm_ad/backends/__init__.py | 11 ++ .../models/image/vlm_ad/backends/base.py | 30 +++++ .../models/image/vlm_ad/backends/chat_gpt.py | 109 +++++++++++++++++ .../image/vlm_ad/backends/huggingface.py | 96 +++++++++++++++ .../models/image/vlm_ad/backends/ollama.py | 73 +++++++++++ .../models/image/vlm_ad/lightning_model.py | 115 ++++++++++++++++++ src/anomalib/models/image/vlm_ad/utils.py | 25 ++++ src/anomalib/utils/visualization/__init__.py | 2 + .../utils/visualization/explanation.py | 106 ++++++++++++++++ tests/integration/model/test_models.py | 8 +- 17 files changed, 603 insertions(+), 9 deletions(-) create mode 100644 src/anomalib/models/image/vlm_ad/__init__.py create mode 100644 src/anomalib/models/image/vlm_ad/backends/__init__.py create mode 100644 src/anomalib/models/image/vlm_ad/backends/base.py create mode 100644 src/anomalib/models/image/vlm_ad/backends/chat_gpt.py create mode 100644 src/anomalib/models/image/vlm_ad/backends/huggingface.py create mode 100644 src/anomalib/models/image/vlm_ad/backends/ollama.py create mode 100644 src/anomalib/models/image/vlm_ad/lightning_model.py create mode 100644 src/anomalib/models/image/vlm_ad/utils.py create mode 100644 src/anomalib/utils/visualization/explanation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9807af26..b50bf09ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Add `VlmAd` metric by [Bepitic](https://github.com/Bepitic) and refactored by [ashwinvaidya17](https://github.com/ashwinvaidya17) in https://github.com/openvinotoolkit/anomalib/pull/2344 - Add `Datumaro` annotation format support by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/2377 - Add `AUPIMO` tutorials notebooks in https://github.com/openvinotoolkit/anomalib/pull/2330 and https://github.com/openvinotoolkit/anomalib/pull/2336 - Add `AUPIMO` metric by [jpcbertoldo](https://github.com/jpcbertoldo) in https://github.com/openvinotoolkit/anomalib/pull/1726 and refactored by [ashwinvaidya17](https://github.com/ashwinvaidya17) in https://github.com/openvinotoolkit/anomalib/pull/2329 diff --git a/pyproject.toml b/pyproject.toml index 2893ad20c4..268544ad2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ core = [ "open-clip-torch>=2.23.0,<2.26.1", ] openvino = ["openvino>=2024.0", "nncf>=2.10.0", "onnx>=1.16.0"] +vlm = ["ollama", "openai", "python-dotenv","transformers"] loggers = [ "comet-ml>=3.31.7", "gradio>=4", @@ -84,7 +85,7 @@ test = [ "coverage[toml]", "tox", ] -full = ["anomalib[core,openvino,loggers,notebooks]"] +full = ["anomalib[core,openvino,loggers,notebooks, vlm]"] dev = ["anomalib[full,docs,test]"] [project.scripts] diff --git a/src/anomalib/callbacks/metrics.py b/src/anomalib/callbacks/metrics.py index 5cee830dad..e09e622d41 100644 --- a/src/anomalib/callbacks/metrics.py +++ b/src/anomalib/callbacks/metrics.py @@ -78,9 +78,8 @@ def setup( elif self.task == TaskType.CLASSIFICATION: pixel_metric_names = [] logger.warning( - "Cannot perform pixel-level evaluation when task type is classification. " - "Ignoring the following pixel-level metrics: %s", - self.pixel_metric_names, + "Cannot perform pixel-level evaluation when task type is {self.task.value}. " + f"Ignoring the following pixel-level metrics: {self.pixel_metric_names}", ) else: pixel_metric_names = ( diff --git a/src/anomalib/engine/engine.py b/src/anomalib/engine/engine.py index 83b9714416..b537819729 100644 --- a/src/anomalib/engine/engine.py +++ b/src/anomalib/engine/engine.py @@ -32,7 +32,7 @@ from anomalib.utils.normalization import NormalizationMethod from anomalib.utils.path import create_versioned_dir from anomalib.utils.types import NORMALIZATION, THRESHOLD -from anomalib.utils.visualization import ImageVisualizer +from anomalib.utils.visualization import BaseVisualizer, ExplanationVisualizer, ImageVisualizer logger = logging.getLogger(__name__) @@ -322,7 +322,7 @@ def _setup_trainer(self, model: AnomalyModule) -> None: self._cache.update(model) # Setup anomalib callbacks to be used with the trainer - self._setup_anomalib_callbacks() + self._setup_anomalib_callbacks(model) # Temporarily set devices to 1 to avoid issues with multiple processes self._cache.args["devices"] = 1 @@ -405,7 +405,7 @@ def _setup_transform( if not getattr(dataloader.dataset, "transform", None): dataloader.dataset.transform = transform - def _setup_anomalib_callbacks(self) -> None: + def _setup_anomalib_callbacks(self, model: AnomalyModule) -> None: """Set up callbacks for the trainer.""" _callbacks: list[Callback] = [] @@ -432,9 +432,17 @@ def _setup_anomalib_callbacks(self) -> None: _callbacks.append(_ThresholdCallback(self.threshold)) _callbacks.append(_MetricsCallback(self.task, self.image_metric_names, self.pixel_metric_names)) + visualizer: BaseVisualizer + + # TODO(ashwinvaidya17): temporary # noqa: TD003 ignoring as visualizer is getting a complete overhaul + if model.__class__.__name__ == "VlmAd": + visualizer = ExplanationVisualizer() + else: + visualizer = ImageVisualizer(task=self.task, normalize=self.normalization == NormalizationMethod.NONE) + _callbacks.append( _VisualizationCallback( - visualizers=ImageVisualizer(task=self.task, normalize=self.normalization == NormalizationMethod.NONE), + visualizers=visualizer, save=True, root=self._cache.args["default_root_dir"] / "images", ), diff --git a/src/anomalib/models/__init__.py b/src/anomalib/models/__init__.py index b4bb36a875..ea091d1640 100644 --- a/src/anomalib/models/__init__.py +++ b/src/anomalib/models/__init__.py @@ -30,6 +30,7 @@ Rkde, Stfpm, Uflow, + VlmAd, WinClip, ) from .video import AiVad @@ -58,6 +59,7 @@ class UnknownModelError(ModuleNotFoundError): "Stfpm", "Uflow", "AiVad", + "VlmAd", "WinClip", ] diff --git a/src/anomalib/models/image/__init__.py b/src/anomalib/models/image/__init__.py index f3a5435038..b09da8b07b 100644 --- a/src/anomalib/models/image/__init__.py +++ b/src/anomalib/models/image/__init__.py @@ -20,6 +20,7 @@ from .rkde import Rkde from .stfpm import Stfpm from .uflow import Uflow +from .vlm_ad import VlmAd from .winclip import WinClip __all__ = [ @@ -40,5 +41,6 @@ "Rkde", "Stfpm", "Uflow", + "VlmAd", "WinClip", ] diff --git a/src/anomalib/models/image/vlm_ad/__init__.py b/src/anomalib/models/image/vlm_ad/__init__.py new file mode 100644 index 0000000000..46ab8e0fee --- /dev/null +++ b/src/anomalib/models/image/vlm_ad/__init__.py @@ -0,0 +1,8 @@ +"""Visual Anomaly Model.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from .lightning_model import VlmAd + +__all__ = ["VlmAd"] diff --git a/src/anomalib/models/image/vlm_ad/backends/__init__.py b/src/anomalib/models/image/vlm_ad/backends/__init__.py new file mode 100644 index 0000000000..44009f8f83 --- /dev/null +++ b/src/anomalib/models/image/vlm_ad/backends/__init__.py @@ -0,0 +1,11 @@ +"""VLM backends.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from .base import Backend +from .chat_gpt import ChatGPT +from .huggingface import Huggingface +from .ollama import Ollama + +__all__ = ["Backend", "ChatGPT", "Huggingface", "Ollama"] diff --git a/src/anomalib/models/image/vlm_ad/backends/base.py b/src/anomalib/models/image/vlm_ad/backends/base.py new file mode 100644 index 0000000000..b4aadf9a22 --- /dev/null +++ b/src/anomalib/models/image/vlm_ad/backends/base.py @@ -0,0 +1,30 @@ +"""Base backend.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from abc import ABC, abstractmethod +from pathlib import Path + +from anomalib.models.image.vlm_ad.utils import Prompt + + +class Backend(ABC): + """Base backend.""" + + @abstractmethod + def __init__(self, model_name: str) -> None: + """Initialize the backend.""" + + @abstractmethod + def add_reference_images(self, image: str | Path) -> None: + """Add reference images for k-shot.""" + + @abstractmethod + def predict(self, image: str | Path, prompt: Prompt) -> str: + """Predict the anomaly label.""" + + @property + @abstractmethod + def num_reference_images(self) -> int: + """Get the number of reference images.""" diff --git a/src/anomalib/models/image/vlm_ad/backends/chat_gpt.py b/src/anomalib/models/image/vlm_ad/backends/chat_gpt.py new file mode 100644 index 0000000000..741288354f --- /dev/null +++ b/src/anomalib/models/image/vlm_ad/backends/chat_gpt.py @@ -0,0 +1,109 @@ +"""ChatGPT backend.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import base64 +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from dotenv import load_dotenv +from lightning_utilities.core.imports import package_available + +from anomalib.models.image.vlm_ad.utils import Prompt + +from .base import Backend + +if package_available("openai"): + from openai import OpenAI +else: + OpenAI = None + +if TYPE_CHECKING: + from openai.types.chat import ChatCompletion + +logger = logging.getLogger(__name__) + + +class ChatGPT(Backend): + """ChatGPT backend.""" + + def __init__(self, model_name: str, api_key: str | None = None) -> None: + """Initialize the ChatGPT backend.""" + self._ref_images_encoded: list[str] = [] + self.model_name: str = model_name + self._client: OpenAI | None = None + self.api_key = self._get_api_key(api_key) + + @property + def client(self) -> OpenAI: + """Get the OpenAI client.""" + if OpenAI is None: + msg = "OpenAI is not installed. Please install it to use ChatGPT backend." + raise ImportError(msg) + if self._client is None: + self._client = OpenAI(api_key=self.api_key) + return self._client + + def add_reference_images(self, image: str | Path) -> None: + """Add reference images for k-shot.""" + self._ref_images_encoded.append(self._encode_image_to_url(image)) + + @property + def num_reference_images(self) -> int: + """Get the number of reference images.""" + return len(self._ref_images_encoded) + + def predict(self, image: str | Path, prompt: Prompt) -> str: + """Predict the anomaly label.""" + image_encoded = self._encode_image_to_url(image) + messages = [] + + # few-shot + if len(self._ref_images_encoded) > 0: + messages.append(self._generate_message(content=prompt.few_shot, images=self._ref_images_encoded)) + + messages.append(self._generate_message(content=prompt.predict, images=[image_encoded])) + + response: ChatCompletion = self.client.chat.completions.create(messages=messages, model=self.model_name) + return response.choices[0].message.content + + @staticmethod + def _generate_message(content: str, images: list[str] | None) -> dict: + """Generate a message.""" + message: dict[str, list[dict] | str] = {"role": "user"} + if images is not None: + _content: list[dict[str, str | dict]] = [{"type": "text", "text": content}] + _content.extend([{"type": "image_url", "image_url": {"url": image}} for image in images]) + message["content"] = _content + else: + message["content"] = content + return message + + def _encode_image_to_url(self, image: str | Path) -> str: + """Encode the image to base64 and embed in url string.""" + image_path = Path(image) + extension = image_path.suffix + base64_encoded = self._encode_image_to_base_64(image_path) + return f"data:image/{extension};base64,{base64_encoded}" + + @staticmethod + def _encode_image_to_base_64(image: str | Path) -> str: + """Encode the image to base64.""" + image = Path(image) + return base64.b64encode(image.read_bytes()).decode("utf-8") + + def _get_api_key(self, api_key: str | None = None) -> str: + if api_key is None: + load_dotenv() + api_key = os.getenv("OPENAI_API_KEY") + if api_key is None: + msg = ( + f"OpenAI API key must be provided to use {self.model_name}." + " Please provide the API key in the constructor, or set the OPENAI_API_KEY environment variable" + " or in a `.env` file." + ) + raise ValueError(msg) + return api_key diff --git a/src/anomalib/models/image/vlm_ad/backends/huggingface.py b/src/anomalib/models/image/vlm_ad/backends/huggingface.py new file mode 100644 index 0000000000..c234ecfbc5 --- /dev/null +++ b/src/anomalib/models/image/vlm_ad/backends/huggingface.py @@ -0,0 +1,96 @@ +"""Huggingface backend.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from pathlib import Path + +from lightning_utilities.core.imports import package_available +from PIL import Image +from transformers.modeling_utils import PreTrainedModel + +from anomalib.models.image.vlm_ad.utils import Prompt + +from .base import Backend + +if package_available("transformers"): + import transformers + from transformers.modeling_utils import PreTrainedModel + from transformers.processing_utils import ProcessorMixin +else: + transformers = None + + +logger = logging.getLogger(__name__) + + +class Huggingface(Backend): + """Huggingface backend.""" + + def __init__( + self, + model_name: str, + ) -> None: + """Initialize the Huggingface backend.""" + self.model_name: str = model_name + self._ref_images: list[str] = [] + self._processor: ProcessorMixin | None = None + self._model: PreTrainedModel | None = None + + @property + def processor(self) -> ProcessorMixin: + """Get the Huggingface processor.""" + if self._processor is None: + if transformers is None: + msg = "transformers is not installed." + raise ValueError(msg) + self._processor = transformers.LlavaNextProcessor.from_pretrained(self.model_name) + return self._processor + + @property + def model(self) -> PreTrainedModel: + """Get the Huggingface model.""" + if self._model is None: + if transformers is None: + msg = "transformers is not installed." + raise ValueError(msg) + self._model = transformers.LlavaNextForConditionalGeneration.from_pretrained(self.model_name) + return self._model + + @staticmethod + def _generate_message(content: str, images: list[str] | None) -> dict: + """Generate a message.""" + message: dict[str, str | list[dict]] = {"role": "user"} + _content: list[dict[str, str]] = [{"type": "text", "text": content}] + if images is not None: + _content.extend([{"type": "image"} for _ in images]) + message["content"] = _content + return message + + def add_reference_images(self, image: str | Path) -> None: + """Add reference images for k-shot.""" + self._ref_images.append(Image.open(image)) + + @property + def num_reference_images(self) -> int: + """Get the number of reference images.""" + return len(self._ref_images) + + def predict(self, image_path: str | Path, prompt: Prompt) -> str: + """Predict the anomaly label.""" + image = Image.open(image_path) + messages: list[dict] = [] + + if len(self._ref_images) > 0: + messages.append(self._generate_message(content=prompt.few_shot, images=self._ref_images)) + + messages.append(self._generate_message(content=prompt.predict, images=[image])) + processed_prompt = [self.processor.apply_chat_template(messages, add_generation_prompt=True)] + + images = [*self._ref_images, image] + inputs = self.processor(images, processed_prompt, return_tensors="pt", padding=True).to(self.model.device) + outputs = self.model.generate(**inputs, max_new_tokens=100) + result = self.processor.decode(outputs[0], skip_special_tokens=True) + print(result) + return result diff --git a/src/anomalib/models/image/vlm_ad/backends/ollama.py b/src/anomalib/models/image/vlm_ad/backends/ollama.py new file mode 100644 index 0000000000..db5a215bb3 --- /dev/null +++ b/src/anomalib/models/image/vlm_ad/backends/ollama.py @@ -0,0 +1,73 @@ +"""Ollama backend. + +Assumes that the Ollama service is running in the background. +See: https://github.com/ollama/ollama +Ensure that ollama is running. On linux: `ollama serve` +On Mac and Windows ensure that the ollama service is running by launching from the application list. +""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from pathlib import Path + +from lightning_utilities.core.imports import package_available + +from anomalib.models.image.vlm_ad.utils import Prompt + +from .base import Backend + +if package_available("ollama"): + from ollama import chat + from ollama._client import _encode_image +else: + chat = None + +logger = logging.getLogger(__name__) + + +class Ollama(Backend): + """Ollama backend.""" + + def __init__(self, model_name: str) -> None: + """Initialize the Ollama backend.""" + self.model_name: str = model_name + self._ref_images_encoded: list[str] = [] + + def add_reference_images(self, image: str | Path) -> None: + """Encode the image to base64.""" + self._ref_images_encoded.append(_encode_image(image)) + + @property + def num_reference_images(self) -> int: + """Get the number of reference images.""" + return len(self._ref_images_encoded) + + @staticmethod + def _generate_message(content: str, images: list[str] | None) -> dict: + """Generate a message.""" + message: dict[str, str | list[str]] = {"role": "user", "content": content} + if images: + message["images"] = images + return message + + def predict(self, image: str | Path, prompt: Prompt) -> str: + """Predict the anomaly label.""" + if not chat: + msg = "Ollama is not installed. Please install it using `pip install ollama`." + raise ImportError(msg) + image_encoded = _encode_image(image) + messages = [] + + # few-shot + if len(self._ref_images_encoded) > 0: + messages.append(self._generate_message(content=prompt.few_shot, images=self._ref_images_encoded)) + + messages.append(self._generate_message(content=prompt.predict, images=[image_encoded])) + + response = chat( + model=self.model_name, + messages=messages, + ) + return response["message"]["content"].strip() diff --git a/src/anomalib/models/image/vlm_ad/lightning_model.py b/src/anomalib/models/image/vlm_ad/lightning_model.py new file mode 100644 index 0000000000..1279f7a31e --- /dev/null +++ b/src/anomalib/models/image/vlm_ad/lightning_model.py @@ -0,0 +1,115 @@ +"""Visual Anomaly Model for Zero/Few-Shot Anomaly Classification.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import torch +from torch.utils.data import DataLoader + +from anomalib import LearningType +from anomalib.models import AnomalyModule + +from .backends import Backend, ChatGPT, Huggingface, Ollama +from .utils import ModelName, Prompt + +logger = logging.getLogger(__name__) + + +class VlmAd(AnomalyModule): + """Visual anomaly model.""" + + def __init__( + self, + model: ModelName | str = ModelName.LLAMA_OLLAMA, + api_key: str | None = None, + k_shot: int = 0, + ) -> None: + super().__init__() + self.k_shot = k_shot + model = ModelName(model) + self.vlm_backend: Backend = self._setup_vlm_backend(model, api_key) + + @staticmethod + def _setup_vlm_backend(model_name: ModelName, api_key: str | None) -> Backend: + if model_name == ModelName.LLAMA_OLLAMA: + return Ollama(model_name=model_name.value) + if model_name == ModelName.GPT_4O_MINI: + return ChatGPT(api_key=api_key, model_name=model_name.value) + if model_name in {ModelName.VICUNA_7B_HF, ModelName.VICUNA_13B_HF, ModelName.MISTRAL_7B_HF}: + return Huggingface(model_name=model_name.value) + + msg = f"Unsupported VLM model: {model_name}" + raise ValueError(msg) + + def _setup(self) -> None: + if self.k_shot > 0 and self.vlm_backend.num_reference_images != self.k_shot: + logger.info("Collecting reference images from training dataset.") + dataloader = self.trainer.datamodule.train_dataloader() + self.collect_reference_images(dataloader) + + def collect_reference_images(self, dataloader: DataLoader) -> None: + """Collect reference images for few-shot inference.""" + for batch in dataloader: + for img_path in batch["image_path"]: + self.vlm_backend.add_reference_images(img_path) + if self.vlm_backend.num_reference_images == self.k_shot: + return + + @property + def prompt(self) -> Prompt: + """Get the prompt.""" + return Prompt( + predict=( + "You are given an image. It is either normal or anomalous." + " First say 'YES' if the image is anomalous, or 'NO' if it is normal.\n" + "Then give the reason for your decision.\n" + "For example, 'YES: The image has a crack on the wall.'" + ), + few_shot=( + "These are a few examples of normal picture without any anomalies." + " You have to use these to determine if the image I provide in the next" + " chat is normal or anomalous." + ), + ) + + def validation_step(self, batch: dict[str, str | torch.Tensor], *args, **kwargs) -> dict: + """Validation step.""" + del args, kwargs # These variables are not used. + responses = [(self.vlm_backend.predict(img_path, self.prompt)) for img_path in batch["image_path"]] + batch["explanation"] = responses + batch["pred_scores"] = torch.tensor([1.0 if r.startswith("Y") else 0.0 for r in responses], device=self.device) + return batch + + @property + def learning_type(self) -> LearningType: + """The learning type of the model.""" + return LearningType.ZERO_SHOT if self.k_shot == 0 else LearningType.FEW_SHOT + + @property + def trainer_arguments(self) -> dict[str, int | float]: + """Doesn't need training.""" + return {} + + @staticmethod + def configure_transforms(image_size: tuple[int, int] | None = None) -> None: + """This modes does not require any transforms.""" + if image_size is not None: + logger.warning("Ignoring image_size argument as each backend has its own transforms.") + + @staticmethod + def _export_not_supported_message() -> None: + logging.warning("Exporting the model is not supported for VLM-AD model. Skipping...") + + def to_torch(self, *_, **__) -> None: # type: ignore[override] + """Skip export to torch.""" + return self._export_not_supported_message() + + def to_onnx(self, *_, **__) -> None: # type: ignore[override] + """Skip export to onnx.""" + return self._export_not_supported_message() + + def to_openvino(self, *_, **__) -> None: # type: ignore[override] + """Skip export to openvino.""" + return self._export_not_supported_message() diff --git a/src/anomalib/models/image/vlm_ad/utils.py b/src/anomalib/models/image/vlm_ad/utils.py new file mode 100644 index 0000000000..ce9b9067ac --- /dev/null +++ b/src/anomalib/models/image/vlm_ad/utils.py @@ -0,0 +1,25 @@ +"""Dataclasses.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass +from enum import Enum + + +@dataclass +class Prompt: + """Prompt.""" + + few_shot: str + predict: str + + +class ModelName(Enum): + """List of supported models.""" + + LLAMA_OLLAMA = "llava" + GPT_4O_MINI = "gpt-4o-mini" + VICUNA_7B_HF = "llava-hf/llava-v1.6-vicuna-7b-hf" + VICUNA_13B_HF = "llava-hf/llava-v1.6-vicuna-13b-hf" + MISTRAL_7B_HF = "llava-hf/llava-v1.6-mistral-7b-hf" diff --git a/src/anomalib/utils/visualization/__init__.py b/src/anomalib/utils/visualization/__init__.py index f68036ed78..404036dfad 100644 --- a/src/anomalib/utils/visualization/__init__.py +++ b/src/anomalib/utils/visualization/__init__.py @@ -4,11 +4,13 @@ # SPDX-License-Identifier: Apache-2.0 from .base import BaseVisualizer, GeneratorResult, VisualizationStep +from .explanation import ExplanationVisualizer from .image import ImageResult, ImageVisualizer from .metrics import MetricsVisualizer __all__ = [ "BaseVisualizer", + "ExplanationVisualizer", "ImageResult", "ImageVisualizer", "GeneratorResult", diff --git a/src/anomalib/utils/visualization/explanation.py b/src/anomalib/utils/visualization/explanation.py new file mode 100644 index 0000000000..10904161e3 --- /dev/null +++ b/src/anomalib/utils/visualization/explanation.py @@ -0,0 +1,106 @@ +"""Explanation visualization generator. + +Note: This is a temporary visualizer, and will be replaced with the new visualizer in the future. +""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Iterator +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from .base import BaseVisualizer, GeneratorResult, VisualizationStep + + +class ExplanationVisualizer(BaseVisualizer): + """Explanation visualization generator.""" + + def __init__(self) -> None: + super().__init__(visualize_on=VisualizationStep.BATCH) + self.padding = 3 + self.font = ImageFont.load_default(size=16) + + def generate(self, **kwargs) -> Iterator[GeneratorResult]: + """Generate images and return them as an iterator.""" + outputs = kwargs.get("outputs", None) + if outputs is None: + msg = "Outputs must be provided to generate images." + raise ValueError(msg) + return self._visualize_batch(outputs) + + def _visualize_batch(self, batch: dict) -> Iterator[GeneratorResult]: + """Visualize batch of images.""" + batch_size = batch["image"].shape[0] + height, width = batch["image"].shape[-2:] + for i in range(batch_size): + image = batch["image"][i] + explanation = batch["explanation"][i] + file_name = Path(batch["image_path"][i]) + image = Image.open(file_name) + image = image.resize((width, height)) + image = self._draw_image(width, height, image=image, explanation=explanation) + yield GeneratorResult(image=image, file_name=file_name) + + def _draw_image(self, width: int, height: int, image: Image, explanation: str) -> np.ndarray: + text_canvas: Image = self._get_explanation_image(width, height, image, explanation) + label_canvas: Image = self._get_label_image(explanation) + + final_width = max(text_canvas.size[0], width) + final_height = height + text_canvas.size[1] + combined_image = Image.new("RGB", (final_width, final_height), (255, 255, 255)) + combined_image.paste(image, (self.padding, 0)) + combined_image.paste(label_canvas, (10, 10)) + combined_image.paste(text_canvas, (0, height)) + return np.array(combined_image) + + def _get_label_image(self, explanation: str) -> Image: + # Draw label + # Can't use pred_labels as it is computed from the pred_scores using image_threshold. It gives incorrect value. + # So, using explanation. This will probably change with the new design. + label = "Anomalous" if explanation.startswith("Y") else "Normal" + label_color = "red" if label == "Anomalous" else "green" + label_canvas = Image.new("RGB", (100, 20), color=label_color) + draw = ImageDraw.Draw(label_canvas) + draw.text((0, 0), label, font=self.font, fill="white", align="center") + return label_canvas + + def _get_explanation_image(self, width: int, height: int, image: Image, explanation: str) -> Image: + # compute wrap width + text_canvas = Image.new("RGB", (width, height), color="white") + dummy_image = ImageDraw.Draw(image) + text_bbox = dummy_image.textbbox((0, 0), explanation, font=self.font, align="center") + text_canvas_width = text_bbox[2] - text_bbox[0] + self.padding + + # split lines based on the width + lines = list(explanation.split("\n")) + line_with_max_len = max(lines, key=len) + new_width = int(width * len(line_with_max_len) // text_canvas_width) + + # wrap text based on the new width + lines = [] + current_line: list[str] = [] + for word in explanation.split(" "): + test_line = " ".join([*current_line, word]) + if len(test_line) <= new_width: + current_line.append(word) + else: + lines.append(" ".join(current_line)) + current_line = [word] + lines.append(" ".join(current_line)) + wrapped_lines = "\n".join(lines) + + # recompute height + dummy_image = Image.new("RGB", (new_width, height), color="white") + draw = ImageDraw.Draw(dummy_image) + text_bbox = draw.textbbox((0, 0), wrapped_lines, font=self.font, align="center") + new_width = int(text_bbox[2] - text_bbox[0] + self.padding) + new_height = int(text_bbox[3] - text_bbox[1] + self.padding) + + # Final text image + text_canvas = Image.new("RGB", (new_width, new_height), color="white") + draw = ImageDraw.Draw(text_canvas) + draw.text((self.padding // 2, 0), wrapped_lines, font=self.font, fill="black", align="center") + return text_canvas diff --git a/tests/integration/model/test_models.py b/tests/integration/model/test_models.py index e743cd52f2..eea3d88e66 100644 --- a/tests/integration/model/test_models.py +++ b/tests/integration/model/test_models.py @@ -7,6 +7,7 @@ # SPDX-License-Identifier: Apache-2.0 from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -179,7 +180,7 @@ def _get_objects( # select task type if model_name in {"rkde", "ai_vad"}: task_type = TaskType.DETECTION - elif model_name in {"ganomaly", "dfkde"}: + elif model_name in {"ganomaly", "dfkde", "vlm_ad"}: task_type = TaskType.CLASSIFICATION else: task_type = TaskType.SEGMENTATION @@ -209,6 +210,11 @@ def _get_objects( ) model = get_model(model_name, **extra_args) + + if model_name == "vlm_ad": + model.vlm_backend = MagicMock() + model.vlm_backend.predict.return_value = "YES: Because reasons..." + engine = Engine( logger=False, default_root_dir=project_path, From db4c2850047eec8c1665826a2b4d854e102bf975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Rolih?= <61357777+blaz-r@users.noreply.github.com> Date: Thu, 24 Oct 2024 11:51:39 +0200 Subject: [PATCH 21/32] Add ensembling methods for tiling to Anomalib (#1226) * Fixed broken links in readme * Fixed inference command in readme * Add tiling for ensemble * Add tests for tiling for ensemble * Moved ensemble tiler to separate file * Modify padim config for ensemble * Add tiling to dataset * Revert changes to train * Add tiling to collate fn * Fix tiling in collate * Change val. function to protected * Add tile number logic * Move collate fn to separate file * Update tests for tiler * Add training loop for ensemble * Add model input size setup * Move ens config to separate file * Revert mvtec modifications * Remove unused imports in mvtec * Add batch adjustment to untiling * Add predict step to ensemble * Add comment and docstring to tile joining function * Move tile joining to separate function * Add joining for all tiled data * Add joining for all box data * Refactor pred. joining as modular class * Fix box joining * Add label and score joining * Add ensemble visualization * Add end of predict hook * Add metric computation * Fix metric thresholds * Add removal of individual visualization * Add demo1 notebook * Add docstrings and cleanup * Add memory benchmark * Add modular class for storing predictions * Add metric to separate class * Refactor to support prediction data class * Rename predictions class * Add filesystem predictions class * Add resized predictions class * Fix joiner for classification task * Add page peak to memory benchmark * Add global stats calculation * Add docstrings to stats calculation * Refactor joiner for pipeline * Refactor stats into pipeline * Refactor metrics as pipeline block * Refactor visualization as pipeline block * Refactor postprocessing into a pipeline * Add normalization and thresholding on joined predictions * Refactor tiler to accept config file * Add smoothing of tile joins. * Refactor ensemble datamodule preparation * Remove unused changes in dataloader * Fix metric configuration * Fix box coordinates in joining * Add ensemble callbacks preparation function * Fix box prediction bug in postprocess * Add ensemble params to config * Refactor postprocessing. * Refactor post-processing * Refactor predictions * Code cleanup * Optimize prediction storage * Make join smoothing configurable * Cleanup before PR * Fix stats pipeline * Fix logging strings * Fix memory benchmark * Fix tiler issues * Fix import issues * Fix naming in metrics and visualization * Fix cyclic import * Make logging lazy * Refactor tiler tests * Added collate tiling tests * Added ensemble helper functions tests * Refactor for dummy ensemble config * Refactor for dummy base config * Add tests for prediction storage * Add tests for prediction joiner * Add tests for visualization * Fix small issues in tests * Add metrics test * Add post-processing tests * Fix tiler to work with different instance * Move seed setting inside train loop * Fix pipeline stats bug * Rename ensemble config fixture * Add pipeline tests * Fix config in pipeline tests * Add training script test * Fix types and docstrings * Move and rename to tiled_ensemble * Fix bug in label joining. * Remove memory benchmark * Cleanup files * Fix metrics setup * Rename collate function * Add license to test files * Rename fixtures * Add more comments to tiled ensemble training * Add start of training log message * Refactor tiler to have explicit arguments * Refactor pred. storage to have explicit arguments * Refactor metrics to have explicit arguments * Refactor visualization to have explicit arguments * Refactor post-processing to have explicit arguments * Sort imports * Add test ensemble script * Fix join smoothing bug * Add more documentation to doc-strings * Remove unused import * Add brief tiled ensemble documentation * Update typehints * Make training args more clear * Revert addition of no threshold option. * Refactor normalization and threshold config * Remove tiled ensemble from docs index * Add comments to clarify parts of ensemble config * Improve ensemble config comments * Add num_tiles attribute to tiler. * Fix metrics process docstring * Fix visualization bug and cover with test * Replace strings with enum * Improve comments in joiner. * Fix bug when model doesn't have anomaly maps. * Improve docstrings (types, clarify). * Fix visualization tests * Fix dict membership checks * Add saving of ensemble config file * Update test script args * Cover test script with tests * Update export warning * Fix case when no test or val data * Improve documentation images * Add images for documentation * Add codacy suggestion * Refactor joiner to single class * Refactor storage names and config * Update normalization and threshold stage names * Add transforms independent input size to models Signed-off-by: blaz-r * Make collate function a datamodule attribute Signed-off-by: blaz-r * Refactor tiled ensemble train into pipeline step Signed-off-by: blaz-r * Refactor tiled ensemble prediction into pipeline step Signed-off-by: blaz-r * Refactor tiled ensemble merging into pipeline step Signed-off-by: blaz-r * Refactor tiled ensemble seam smoothing into pipeline step Signed-off-by: blaz-r * Refactor tiled stats calculation into pipeline step Signed-off-by: blaz-r * Fix ckpt loading when predicting on test set. Signed-off-by: blaz-r * Add logging and add tqdm to pipeline steps. Signed-off-by: blaz-r * Refactor normalization pipeline step Signed-off-by: blaz-r * Refactor thresholding into new pipeline job * Fix transforms issue when predicting with dataloader * Add visualization as new pipeline step * Add metrics as new pipeline step * Format the code and address some lint problems Signed-off-by: Blaz Rolih * Add code to skip test if test split is none Signed-off-by: Blaz Rolih * Add accelerator to metrics and smoothing Signed-off-by: Blaz Rolih * Make threshold acq helper function and add to threshold to metrics Signed-off-by: Blaz Rolih * Make a separate test pipeline Signed-off-by: Blaz Rolih * Restructure tiled ensemble files into directories Signed-off-by: Blaz Rolih * Pipeline code cleanup Signed-off-by: Blaz Rolih * Remove old tiled ensemble files Signed-off-by: blaz-r * Remove old post processing files Signed-off-by: blaz-r * Fix sigma value read in smoothing Signed-off-by: blaz-r * Update stats calc and normalization Signed-off-by: blaz-r * Update args naming convention Signed-off-by: blaz-r * Refactor code for nice config Signed-off-by: blaz-r * Update docs structure for new system Signed-off-by: blaz-r * Cleanup train code Signed-off-by: blaz-r * Fix test script args Signed-off-by: blaz-r * Update box merging Signed-off-by: blaz-r * Refactor helper function tests Signed-off-by: blaz-r * Small changes in helper and engine Signed-off-by: blaz-r * Refactor merging tests Signed-off-by: blaz-r * Refactor tiling tests Signed-off-by: blaz-r * Refactor metrics test Signed-off-by: blaz-r * Add support for different threshold methods Signed-off-by: blaz-r * Format tests Signed-off-by: blaz-r * Change test to predict Signed-off-by: blaz-r * Refactor stats calculation tests Signed-off-by: blaz-r * Refactor prediction data tests Signed-off-by: blaz-r * Update metrics tests Signed-off-by: blaz-r * Move metrics tests to components Signed-off-by: blaz-r * Refactor seam smoothing tests Signed-off-by: blaz-r * Refactor normalization tests Signed-off-by: blaz-r * Move mock stats to conftest Signed-off-by: blaz-r * Fix typehints for generator Signed-off-by: blaz-r * Refactor threshold tests Signed-off-by: blaz-r * Temporarily disable box minmax Signed-off-by: blaz-r * Add tiled ensemble integration test Signed-off-by: blaz-r * Fix normalization tests and add additional merging test Signed-off-by: blaz-r * Add tile collater tests Signed-off-by: blaz-r * Change dataset in tests to dummy Signed-off-by: blaz-r * Format and fix linter errors Signed-off-by: blaz-r * Format and some cleanup Signed-off-by: blaz-r * Rename predict to eval Signed-off-by: blaz-r * Update docs for refactored version of code Signed-off-by: blaz-r * Cleanup the docs Signed-off-by: blaz-r * Update ensemble engine Signed-off-by: blaz-r * Remove boxes from pipelines and tests Signed-off-by: blaz-r * Fix TODO comment issue Signed-off-by: blaz-r * Fix unused model in ens. engine Signed-off-by: blaz-r * Fix path case in test Signed-off-by: blaz-r * Change temporary dir to project_path Signed-off-by: blaz-r * Change mvtec to MVTec in test path Signed-off-by: Blaz Rolih --------- Signed-off-by: blaz-r Signed-off-by: Blaz Rolih Co-authored-by: Samet Akcay --- .../images/tiled_ensemble/ensemble_flow.png | Bin 0 -> 87660 bytes .../how_to/pipelines/custom_pipeline.md | 254 ++++++++++++ .../markdown/guides/how_to/pipelines/index.md | 264 +----------- .../guides/how_to/pipelines/tiled_ensemble.md | 157 +++++++ src/anomalib/data/base/datamodule.py | 7 +- src/anomalib/data/utils/tiler.py | 14 +- .../models/components/base/anomaly_module.py | 6 + .../pipelines/tiled_ensemble/__init__.py | 12 + .../tiled_ensemble/components/__init__.py | 30 ++ .../tiled_ensemble/components/merging.py | 110 +++++ .../components/metrics_calculation.py | 217 ++++++++++ .../components/model_training.py | 192 +++++++++ .../components/normalization.py | 120 ++++++ .../tiled_ensemble/components/prediction.py | 228 +++++++++++ .../tiled_ensemble/components/smoothing.py | 167 ++++++++ .../components/stats_calculation.py | 180 ++++++++ .../tiled_ensemble/components/thresholding.py | 114 ++++++ .../components/utils/__init__.py | 44 ++ .../components/utils/ensemble_engine.py | 92 +++++ .../components/utils/ensemble_tiling.py | 147 +++++++ .../components/utils/helper_functions.py | 179 ++++++++ .../components/utils/prediction_data.py | 45 ++ .../components/utils/prediction_merging.py | 167 ++++++++ .../components/visualization.py | 125 ++++++ .../pipelines/tiled_ensemble/test_pipeline.py | 124 ++++++ .../tiled_ensemble/train_pipeline.py | 123 ++++++ .../pipelines/test_tiled_ensemble.py | 62 +++ .../integration/pipelines/tiled_ensemble.yaml | 43 ++ tests/unit/pipelines/__init__.py | 4 + .../unit/pipelines/tiled_ensemble/__init__.py | 4 + .../unit/pipelines/tiled_ensemble/conftest.py | 151 +++++++ .../tiled_ensemble/dummy_config.yaml | 52 +++ .../tiled_ensemble/test_components.py | 387 ++++++++++++++++++ .../tiled_ensemble/test_helper_functions.py | 113 +++++ .../tiled_ensemble/test_prediction_data.py | 69 ++++ .../pipelines/tiled_ensemble/test_tiler.py | 119 ++++++ tools/tiled_ensemble/ens_config.yaml | 43 ++ tools/tiled_ensemble/eval.py | 28 ++ tools/tiled_ensemble/train.py | 17 + 39 files changed, 3961 insertions(+), 249 deletions(-) create mode 100644 docs/source/images/tiled_ensemble/ensemble_flow.png create mode 100644 docs/source/markdown/guides/how_to/pipelines/custom_pipeline.md create mode 100644 docs/source/markdown/guides/how_to/pipelines/tiled_ensemble.md create mode 100644 src/anomalib/pipelines/tiled_ensemble/__init__.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/__init__.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/merging.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/metrics_calculation.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/model_training.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/normalization.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/prediction.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/smoothing.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/stats_calculation.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/thresholding.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/__init__.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_engine.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_tiling.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/helper_functions.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_data.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_merging.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/components/visualization.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/test_pipeline.py create mode 100644 src/anomalib/pipelines/tiled_ensemble/train_pipeline.py create mode 100644 tests/integration/pipelines/test_tiled_ensemble.py create mode 100644 tests/integration/pipelines/tiled_ensemble.yaml create mode 100644 tests/unit/pipelines/__init__.py create mode 100644 tests/unit/pipelines/tiled_ensemble/__init__.py create mode 100644 tests/unit/pipelines/tiled_ensemble/conftest.py create mode 100644 tests/unit/pipelines/tiled_ensemble/dummy_config.yaml create mode 100644 tests/unit/pipelines/tiled_ensemble/test_components.py create mode 100644 tests/unit/pipelines/tiled_ensemble/test_helper_functions.py create mode 100644 tests/unit/pipelines/tiled_ensemble/test_prediction_data.py create mode 100644 tests/unit/pipelines/tiled_ensemble/test_tiler.py create mode 100644 tools/tiled_ensemble/ens_config.yaml create mode 100644 tools/tiled_ensemble/eval.py create mode 100644 tools/tiled_ensemble/train.py diff --git a/docs/source/images/tiled_ensemble/ensemble_flow.png b/docs/source/images/tiled_ensemble/ensemble_flow.png new file mode 100644 index 0000000000000000000000000000000000000000..7a5a81fa79819261f77be24d3e74b1dc8cd495ac GIT binary patch literal 87660 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 z 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 dict: + print(f"Training with lr: {self.lr}, backbone: {self.backbone}, stride: {self.stride}") + time.sleep(2) + score = np.random.uniform(0.7, 0.1) + return {"lr": self.lr, "backbone": self.backbone, "stride": self.stride, "score": score} +``` + +Ignore the `task_id` for now. It is used for parallel jobs. We will come back to it later. + +````{note} +The `name` attribute is important and is used to identify the arguments in the job config file. +So, in our case the config `yaml` file will contain an entry like this: + +```yaml +... +train: + lr: + backbone: + stride: +... +```` + +Of course, it is up to us to choose what parameters should be shown under the `train` key. + +Let's also add the `collect` method so that we return a nice dict object that can be used by the next job. + +```python +def collect(results: list[dict]) -> dict: + output: dict = {} + for key in results[0]: + output[key] = [] + for result in results: + for key, value in result.items(): + output[key].append(value) + return output +``` + +We can also define a `save` method that writes the dictionary as a csv file. + +```python +@staticmethod +def save(results: dict) -> None: + """Save results in a csv file.""" + results_df = pd.DataFrame(results) + file_path = Path("runs") / TrainJob.name + file_path.mkdir(parents=True, exist_ok=True) + results_df.to_csv(file_path / "results.csv", index=False) +``` + +The entire job class is shown below. + +```{literalinclude} ../../../../snippets/pipelines/dummy/train_job.txt +:language: python +``` + +Now we need a way to generate this job when the pipeline is run. To do this we need to subclass the [JobGenerator](../../reference/pipelines/base/generator.md) class. + +The job generator is the actual object that is attached to a runner and is responsible for parsing the configuration and generating jobs. It has two methods that need to be implemented. + +- `generate_job`: This method accepts the configuration as a dictionary and, optionally, the results of the previous job. For the train job, we don't need results for previous jobs, so we will ignore it. +- `job_class`: This holds the reference to the class of the job that the generator will yield. It is used to inform the runner about the job that is being run, and is used to access the static attributes of the job such as its name, collect method, etc. + +Let's first start by defining the configuration that the generator will accept. The train job requires three parameters: `lr`, `backbone`, and `stride`. We will also add another parameter that defines the number of experiments we want to run. One way to define it would be as follows: + +```yaml +train: + experiments: 10 + lr: [0.1, 0.99] + backbone: + - resnet18 + - wide_resnet50 + stride: + - 3 + - 5 +``` + +For this example the specification is defined as follows. + +1. The number of experiments is set to 10. +2. Learning rate is sampled from a uniform distribution in the range `[0.1, 0.99]`. +3. The backbone is chosen from the list `["resnet18", "wide_resnet50"]`. +4. The stride is chosen from the list `[3, 5]`. + +```{note} +While the `[ ]` and `-` syntax in `yaml` both signify a list, for visual disambiguation this example uses `[ ]` to denote closed interval and `-` for a list of options. +``` + +With this defined, we can define the generator class as follows. + +```{literalinclude} ../../../../snippets/pipelines/dummy/train_generator.txt +:language: python +``` + +Since this is a dummy example, we generate the next experiment randomly. In practice, you would use a more sophisticated method that relies on your validation metrics to generate the next experiment. + +```{admonition} Challenge +:class: tip +For a challenge define your own configuration and a generator to parse that configuration. +``` + +Okay, so now we can train the model. We still need a way to find out which parameters contribute the most to the final score. We will do this by computing the shapely values to find out the contribution of each parameter to the final score. + +Let's first start by adding the library to our environment + +```bash +pip install shap +``` + +The following listing shows the job that computes the shapely values and saves a plot that shows the contribution of each parameter to the final score. A quick rundown without going into the details of the job (as it is irrelevant to the pipeline) is as follows. We create a `RandomForestRegressor` that is trained on the parameters to predict the final score. We then compute the shapely values to identify the parameters that have the most significant impact on the model performance. Finally, the `save` method saves the plot so we can visually inspect the results. + +```{literalinclude} ../../../../snippets/pipelines/dummy/significance_job.txt + +``` + +Great! Now we have the job, as before, we need the generator. Since we only need the results from the previous stage, we don't need to define the config. Let's quickly write that as well. + +```{literalinclude} ../../../../snippets/pipelines/dummy/significance_job_generator.txt + +``` + +## Experiment Pipeline + +So now we have the jobs, and a way to generate them. Let's look at how we can chain them together to achieve what we want. We will use the [Pipeline](../../reference/pipelines/base/pipeline.md) class to define the pipeline. + +When creating a custom pipeline, there is only one important method that we need to implement. That is the `_setup_runners` method. This is where we chain the runners together. + +```{literalinclude} ../../../../snippets/pipelines/dummy/pipeline_serial.txt +:language: python +``` + +In this example we use `SerialRunner` for running each job. It is a simple runner that runs the jobs in a serial manner. For more information on `SerialRunner` look [here](../../reference/pipelines/runners/serial.md). + +Okay, so we have the pipeline. How do we run it? To do this let's create a simple entrypoint in `tools` folder of Anomalib. + +Here is how the directory looks. + +```{literalinclude} ../../../../snippets/pipelines/dummy/tools_dir_structure.txt +:language: bash +``` + +As you can see, we have the `config.yaml` file in the same directory. Let's quickly populate `experiment.py`. + +```python +from anomalib.pipelines.experiment_pipeline import ExperimentPipeline + +if __name__ == "__main__": + ExperimentPipeline().run() +``` + +Alright! Time to take it on the road. + +```bash +python tools/experimental/experiment/experiment.py --config tools/experimental/experiment/config.yaml +``` + +If all goes well you should see the summary plot in `runs/significant_feature/summary_plot.png`. + +## Exposing to the CLI + +Now that you have your shiny new pipeline, you can expose it as a subcommand to `anomalib` by adding an entry to the pipeline registry in `anomalib/cli/pipelines.py`. + +```python +if try_import("anomalib.pipelines"): + ... + from anomalib.pipelines import ExperimentPipeline + +PIPELINE_REGISTRY: dict[str, type[Pipeline]] | None = { + "experiment": ExperimentPipeline, + ... +} +``` + +With this you can now call + +```{literalinclude} ../../../../snippets/pipelines/dummy/anomalib_cli.txt +:language: bash +``` + +Congratulations! You have successfully created a pipeline that trains a model and computes the significance of the parameters to the final score πŸŽ‰ + +```{admonition} Challenge +:class: tip +This example used a random model hence the scores were meaningless. Try to implement a real model and compute the scores. Look into which parameters lead to the most significant contribution to your score. +``` + +## Final Tweaks + +Before we end, let's look at a few final tweaks that you can make to the pipeline. + +First, let's run the initial model training in parallel. Since all jobs are independent, we can use the [ParallelRunner](../../reference/pipelines/runners/parallel.md). Since the `TrainJob` is a dummy job in this example, the pool of parallel jobs is set to the number of experiments. + +```{literalinclude} ../../../../snippets/pipelines/dummy/pipeline_parallel.txt + +``` + +You now notice that the entire pipeline takes lesser time to run. This is handy when you have large number of experiments, and when each job takes substantial time to run. + +Now on to the second one. When running the pipeline we don't want our terminal cluttered with the outputs from each run. Anomalib provides a handy decorator that temporarily hides the output of a function. It suppresses all outputs to the standard out and the standard error unless an exception is raised. Let's add this to the `TrainJob` + +```python +from anomalib.utils.logging import hide_output + +class TrainJob(Job): + ... + + @hide_output + def run(self, task_id: int | None = None) -> dict: + ... +``` + +You will no longer see the output of the `print` statement in the `TrainJob` method in the terminal. diff --git a/docs/source/markdown/guides/how_to/pipelines/index.md b/docs/source/markdown/guides/how_to/pipelines/index.md index ed3d66f81d..c7f2c44706 100644 --- a/docs/source/markdown/guides/how_to/pipelines/index.md +++ b/docs/source/markdown/guides/how_to/pipelines/index.md @@ -1,254 +1,30 @@ -# Pipelines +# Pipeline Tutorials -This guide demonstrates how to create a [Pipeline](../../reference/pipelines/index.md) for your custom task. +This section contains tutorials on how to use different pipelines of Anomalib and how to creat your own. -A pipeline is made up of runners. These runners are responsible for running a single type of job. A job is the smallest unit of work that is independent, such as, training a model or statistical comparison of the outputs of two models. Each job should be designed to be independent of other jobs so that they are agnostic to the runner that is running them. This ensures that the job can be run in parallel or serially without any changes to the job itself. The runner does not directly instantiate a job but rather has a job generator that generates the job based on the configuration. This generator is responsible for parsing the config and generating the job. +::::{grid} +:margin: 1 1 0 0 +:gutter: 1 -## Birds Eye View +:::{grid-item-card} {octicon}`stack` Tiled Ensemble +:link: ./tiled_ensemble +:link-type: doc -In this guide we are going to create a dummy significant parameter search pipeline. The pipeline will have two jobs. The first job trains a model and computes the metric. The second job computes the significance of the parameters to the final score using shapely values. The final output of the pipeline is a plot that shows the contribution of each parameter to the final score. This will help teach you how to create a pipeline, a job, a job generator, and how to expose it to the `anomalib` CLI. The pipeline is going to be named `experiment`. So by the end of this you will be able to generate significance plot using +Learn more about how to use the tiled ensemble pipelines. +::: -```{literalinclude} ../../../../snippets/pipelines/dummy/anomalib_cli.txt -:language: bash -``` - -The final directory structure will look as follows: - -```{literalinclude} ../../../../snippets/pipelines/dummy/src_dir_structure.txt - -``` - -```{literalinclude} ../../../../snippets/pipelines/dummy/tools_dir_structure.txt -:language: bash -``` - -## Creating the Jobs - -Let's first look at the base class for the [jobs](../../reference/pipelines/base/job.md). It has a few methods defined. - -- The `run` method is the main method that is called by the runner. This is where we will train the model and return the model metrics. -- The `collect` method is used to gather the results from all the runs and collate them. This is handy as we want to pass a single object to the next job that contains details of all the runs including the final score. -- The `save` method is used to write any artifacts to the disk. It accepts the gathered results as a parameter. This is useful in a variety of situations. Say, when we want to write the results in a csv file or write the raw anomaly maps for further processing. - -Let's create the first job that trains the model and computes the metric. Since it is a dummy example, we will just return a random number as the metric. - -```python -class TrainJob(Job): - name = "train" +:::{grid-item-card} {octicon}`gear` Custom Pipeline +:link: ./custom_pipeline +:link-type: doc - def __init__(self, lr: float, backbone: str, stride: int): - self.lr = lr - self.backbone = backbone - self.stride = stride - - def run(self, task_id: int | None = None) -> dict: - print(f"Training with lr: {self.lr}, backbone: {self.backbone}, stride: {self.stride}") - time.sleep(2) - score = np.random.uniform(0.7, 0.1) - return {"lr": self.lr, "backbone": self.backbone, "stride": self.stride, "score": score} -``` - -Ignore the `task_id` for now. It is used for parallel jobs. We will come back to it later. - -````{note} -The `name` attribute is important and is used to identify the arguments in the job config file. -So, in our case the config `yaml` file will contain an entry like this: - -```yaml -... -train: - lr: - backbone: - stride: -... -```` - -Of course, it is up to us to choose what parameters should be shown under the `train` key. - -Let's also add the `collect` method so that we return a nice dict object that can be used by the next job. - -```python -def collect(results: list[dict]) -> dict: - output: dict = {} - for key in results[0]: - output[key] = [] - for result in results: - for key, value in result.items(): - output[key].append(value) - return output -``` - -We can also define a `save` method that writes the dictionary as a csv file. - -```python -@staticmethod -def save(results: dict) -> None: - """Save results in a csv file.""" - results_df = pd.DataFrame(results) - file_path = Path("runs") / TrainJob.name - file_path.mkdir(parents=True, exist_ok=True) - results_df.to_csv(file_path / "results.csv", index=False) -``` - -The entire job class is shown below. - -```{literalinclude} ../../../../snippets/pipelines/dummy/train_job.txt -:language: python -``` - -Now we need a way to generate this job when the pipeline is run. To do this we need to subclass the [JobGenerator](../../reference/pipelines/base/generator.md) class. - -The job generator is the actual object that is attached to a runner and is responsible for parsing the configuration and generating jobs. It has two methods that need to be implemented. - -- `generate_job`: This method accepts the configuration as a dictionary and, optionally, the results of the previous job. For the train job, we don't need results for previous jobs, so we will ignore it. -- `job_class`: This holds the reference to the class of the job that the generator will yield. It is used to inform the runner about the job that is being run, and is used to access the static attributes of the job such as its name, collect method, etc. - -Let's first start by defining the configuration that the generator will accept. The train job requires three parameters: `lr`, `backbone`, and `stride`. We will also add another parameter that defines the number of experiments we want to run. One way to define it would be as follows: - -```yaml -train: - experiments: 10 - lr: [0.1, 0.99] - backbone: - - resnet18 - - wide_resnet50 - stride: - - 3 - - 5 -``` - -For this example the specification is defined as follows. - -1. The number of experiments is set to 10. -2. Learning rate is sampled from a uniform distribution in the range `[0.1, 0.99]`. -3. The backbone is chosen from the list `["resnet18", "wide_resnet50"]`. -4. The stride is chosen from the list `[3, 5]`. - -```{note} -While the `[ ]` and `-` syntax in `yaml` both signify a list, for visual disambiguation this example uses `[ ]` to denote closed interval and `-` for a list of options. -``` - -With this defined, we can define the generator class as follows. - -```{literalinclude} ../../../../snippets/pipelines/dummy/train_generator.txt -:language: python -``` - -Since this is a dummy example, we generate the next experiment randomly. In practice, you would use a more sophisticated method that relies on your validation metrics to generate the next experiment. - -```{admonition} Challenge -:class: tip -For a challenge define your own configuration and a generator to parse that configuration. -``` - -Okay, so now we can train the model. We still need a way to find out which parameters contribute the most to the final score. We will do this by computing the shapely values to find out the contribution of each parameter to the final score. - -Let's first start by adding the library to our environment - -```bash -pip install shap -``` +Learn more about how to create a new custom pipeline. +::: -The following listing shows the job that computes the shapely values and saves a plot that shows the contribution of each parameter to the final score. A quick rundown without going into the details of the job (as it is irrelevant to the pipeline) is as follows. We create a `RandomForestRegressor` that is trained on the parameters to predict the final score. We then compute the shapely values to identify the parameters that have the most significant impact on the model performance. Finally, the `save` method saves the plot so we can visually inspect the results. +:::: -```{literalinclude} ../../../../snippets/pipelines/dummy/significance_job.txt +```{toctree} +:caption: Model Tutorials +:hidden: +./feature_extractors ``` - -Great! Now we have the job, as before, we need the generator. Since we only need the results from the previous stage, we don't need to define the config. Let's quickly write that as well. - -```{literalinclude} ../../../../snippets/pipelines/dummy/significance_job_generator.txt - -``` - -## Experiment Pipeline - -So now we have the jobs, and a way to generate them. Let's look at how we can chain them together to achieve what we want. We will use the [Pipeline](../../reference/pipelines/base/pipeline.md) class to define the pipeline. - -When creating a custom pipeline, there is only one important method that we need to implement. That is the `_setup_runners` method. This is where we chain the runners together. - -```{literalinclude} ../../../../snippets/pipelines/dummy/pipeline_serial.txt -:language: python -``` - -In this example we use `SerialRunner` for running each job. It is a simple runner that runs the jobs in a serial manner. For more information on `SerialRunner` look [here](../../reference/pipelines/runners/serial.md). - -Okay, so we have the pipeline. How do we run it? To do this let's create a simple entrypoint in `tools` folder of Anomalib. - -Here is how the directory looks. - -```{literalinclude} ../../../../snippets/pipelines/dummy/tools_dir_structure.txt -:language: bash -``` - -As you can see, we have the `config.yaml` file in the same directory. Let's quickly populate `experiment.py`. - -```python -from anomalib.pipelines.experiment_pipeline import ExperimentPipeline - -if __name__ == "__main__": - ExperimentPipeline().run() -``` - -Alright! Time to take it on the road. - -```bash -python tools/experimental/experiment/experiment.py --config tools/experimental/experiment/config.yaml -``` - -If all goes well you should see the summary plot in `runs/significant_feature/summary_plot.png`. - -## Exposing to the CLI - -Now that you have your shiny new pipeline, you can expose it as a subcommand to `anomalib` by adding an entry to the pipeline registry in `anomalib/cli/pipelines.py`. - -```python -if try_import("anomalib.pipelines"): - ... - from anomalib.pipelines import ExperimentPipeline - -PIPELINE_REGISTRY: dict[str, type[Pipeline]] | None = { - "experiment": ExperimentPipeline, - ... -} -``` - -With this you can now call - -```{literalinclude} ../../../../snippets/pipelines/dummy/anomalib_cli.txt -:language: bash -``` - -Congratulations! You have successfully created a pipeline that trains a model and computes the significance of the parameters to the final score πŸŽ‰ - -```{admonition} Challenge -:class: tip -This example used a random model hence the scores were meaningless. Try to implement a real model and compute the scores. Look into which parameters lead to the most significant contribution to your score. -``` - -## Final Tweaks - -Before we end, let's look at a few final tweaks that you can make to the pipeline. - -First, let's run the initial model training in parallel. Since all jobs are independent, we can use the [ParallelRunner](../../reference/pipelines/runners/parallel.md). Since the `TrainJob` is a dummy job in this example, the pool of parallel jobs is set to the number of experiments. - -```{literalinclude} ../../../../snippets/pipelines/dummy/pipeline_parallel.txt - -``` - -You now notice that the entire pipeline takes lesser time to run. This is handy when you have large number of experiments, and when each job takes substantial time to run. - -Now on to the second one. When running the pipeline we don't want our terminal cluttered with the outputs from each run. Anomalib provides a handy decorator that temporarily hides the output of a function. It suppresses all outputs to the standard out and the standard error unless an exception is raised. Let's add this to the `TrainJob` - -```python -from anomalib.utils.logging import hide_output - -class TrainJob(Job): - ... - - @hide_output - def run(self, task_id: int | None = None) -> dict: - ... -``` - -You will no longer see the output of the `print` statement in the `TrainJob` method in the terminal. diff --git a/docs/source/markdown/guides/how_to/pipelines/tiled_ensemble.md b/docs/source/markdown/guides/how_to/pipelines/tiled_ensemble.md new file mode 100644 index 0000000000..3550efb5fd --- /dev/null +++ b/docs/source/markdown/guides/how_to/pipelines/tiled_ensemble.md @@ -0,0 +1,157 @@ +# Tiled ensemble + +This guide will show you how to use **The Tiled Ensemble** method for anomaly detection. For more details, refer to the official [Paper](https://openaccess.thecvf.com/content/CVPR2024W/VAND/html/Rolih_Divide_and_Conquer_High-Resolution_Industrial_Anomaly_Detection_via_Memory_Efficient_CVPRW_2024_paper.html). + +The tiled ensemble approach reduces memory consumption by dividing input images into a grid of tiles and training a dedicated model for each tile location. +It is compatible with any existing image anomaly detection model without the need for any modification of the underlying architecture. + +![Tiled ensemble flow](../../../../images/tiled_ensemble/ensemble_flow.png) + +```{note} +This feature is experimental and may not work as expected. +For any problems refer to [Issues](https://github.com/openvinotoolkit/anomalib/issues) and feel free to ask any question in [Discussions](https://github.com/openvinotoolkit/anomalib/discussions). +``` + +## Training + +You can train a tiled ensemble using the training script located inside `tools/tiled_ensemble` directory: + +```{code-block} bash + +python tools/tiled_ensemble/train_ensemble.py \ + --config tools/tiled_ensemble/ens_config.yaml +``` + +By default, the Padim model is trained on **MVTec AD bottle** category using image size of 256x256, divided into non-overlapping 128x128 tiles. +You can modify these parameters in the [config file](#ensemble-configuration). + +## Evaluation + +After training, you can evaluate the tiled ensemble on test data using: + +```{code-block} bash + +python tools/tiled_ensemble/eval.py \ + --config tools/tiled_ensemble/ens_config.yaml \ + --root path_to_results_dir + +``` + +Ensure that `root` points to the directory containing the training results, typically `results/padim/mvtec/bottle/runX`. + +## Ensemble configuration + +Tiled ensemble is configured using `ens_config.yaml` file in the `tools/tiled_ensemble` directory. +It contains general settings and tiled ensemble specific settings. + +### General + +General settings at the top of the config file are used to set up the random `seed`, `accelerator` (device) and the path to where results will be saved `default_root_dir`. + +```{code-block} yaml +seed: 42 +accelerator: "gpu" +default_root_dir: "results" +``` + +### Tiling + +This section contains the following settings, used for image tiling: + +```{code-block} yaml + +tiling: + tile_size: 256 + stride: 256 +``` + +These settings determine the tile size and stride. Another important parameter is image_size from `data` section later in the config. It determines the original size of the image. + +Input image is split into tiles, where each tile is of shape set by `tile_size` and tiles are taken with step set by `stride`. +For example: having image_size: 512, tile_size: 256, and stride: 256, results in 4 non-overlapping tile locations. + +### Normalization and thresholding + +Next up are the normalization and thresholding settings: + +```{code-block} yaml +normalization_stage: image +thresholding: + method: F1AdaptiveThreshold + stage: image +``` + +- **Normalization**: Can be applied per each tile location separately (`tile` option), after combining prediction (`image` option), or skipped (`none` option). + +- **Thresholding**: Can also be applied at different stages, but it is limited to `tile` and `image`. Another setting for thresholding is the method used. It can be specified as a string or by the class path. + +### Data + +The `data` section is used to configure the input `image_size` and other parameters for the dataset used. + +```{code-block} yaml +data: + class_path: anomalib.data.MVTec + init_args: + root: ./datasets/MVTec + category: bottle + train_batch_size: 32 + eval_batch_size: 32 + num_workers: 8 + task: segmentation + transform: null + train_transform: null + eval_transform: null + test_split_mode: from_dir + test_split_ratio: 0.2 + val_split_mode: same_as_test + val_split_ratio: 0.5 + image_size: [256, 256] +``` + +Refer to [Data](../../reference/data/image/index.md) for more details on parameters. + +### SeamSmoothing + +This section contains settings for `SeamSmoothing` block of pipeline: + +```{code-block} yaml +SeamSmoothing: + apply: True + sigma: 2 + width: 0.1 + +``` + +SeamSmoothing job is responsible for smoothing of regions where tiles meet - called tile seams. + +- **apply**: If True, smoothing will be applied. +- **sigma**: Controls the sigma of Gaussian filter used for smoothing. +- **width**: Sets the percentage of the region around the seam to be smoothed. + +### TrainModels + +The last section `TrainModels` contains the setup for model training: + +```{code-block} yaml +TrainModels: + model: + class_path: Fastflow + + metrics: + pixel: AUROC + image: AUROC + + trainer: + max_epochs: 500 + callbacks: + - class_path: lightning.pytorch.callbacks.EarlyStopping + init_args: + patience: 42 + monitor: pixel_AUROC + mode: max +``` + +- **Model**: Specifies the model used. Refer to [Models](../../reference/models/image/index.md) for more details on the model parameters. +- **Metrics**: Defines evaluation metrics for pixel and image level. +- **Trainer**: _optional_ parameters, used to control the training process. Refer to [Engine](../../reference/engine/index.md) for more details. diff --git a/src/anomalib/data/base/datamodule.py b/src/anomalib/data/base/datamodule.py index cb95ca8171..a9197f6670 100644 --- a/src/anomalib/data/base/datamodule.py +++ b/src/anomalib/data/base/datamodule.py @@ -119,6 +119,8 @@ def __init__( self._is_setup = False # flag to track if setup has been called from the trainer + self.collate_fn = collate_fn + @property def name(self) -> str: """Name of the datamodule.""" @@ -224,6 +226,7 @@ def train_dataloader(self) -> TRAIN_DATALOADERS: shuffle=True, batch_size=self.train_batch_size, num_workers=self.num_workers, + collate_fn=self.collate_fn, ) def val_dataloader(self) -> EVAL_DATALOADERS: @@ -233,7 +236,7 @@ def val_dataloader(self) -> EVAL_DATALOADERS: shuffle=False, batch_size=self.eval_batch_size, num_workers=self.num_workers, - collate_fn=collate_fn, + collate_fn=self.collate_fn, ) def test_dataloader(self) -> EVAL_DATALOADERS: @@ -243,7 +246,7 @@ def test_dataloader(self) -> EVAL_DATALOADERS: shuffle=False, batch_size=self.eval_batch_size, num_workers=self.num_workers, - collate_fn=collate_fn, + collate_fn=self.collate_fn, ) def predict_dataloader(self) -> EVAL_DATALOADERS: diff --git a/src/anomalib/data/utils/tiler.py b/src/anomalib/data/utils/tiler.py index 089aeaae91..2c1e949e45 100644 --- a/src/anomalib/data/utils/tiler.py +++ b/src/anomalib/data/utils/tiler.py @@ -162,11 +162,11 @@ def __init__( remove_border_count: int = 0, mode: ImageUpscaleMode = ImageUpscaleMode.PADDING, ) -> None: - self.tile_size_h, self.tile_size_w = self.__validate_size_type(tile_size) + self.tile_size_h, self.tile_size_w = self.validate_size_type(tile_size) self.random_tile_count = 4 if stride is not None: - self.stride_h, self.stride_w = self.__validate_size_type(stride) + self.stride_h, self.stride_w = self.validate_size_type(stride) self.remove_border_count = remove_border_count self.overlapping = not (self.stride_h == self.tile_size_h and self.stride_w == self.tile_size_w) @@ -201,7 +201,15 @@ def __init__( self.num_patches_w: int @staticmethod - def __validate_size_type(parameter: int | Sequence) -> tuple[int, ...]: + def validate_size_type(parameter: int | Sequence) -> tuple[int, ...]: + """Validate size type and return tuple of form [tile_h, tile_w]. + + Args: + parameter (int | Sequence): input tile size parameter. + + Returns: + tuple[int, ...]: Validated tile size in tuple form. + """ if isinstance(parameter, int): output = (parameter, parameter) elif isinstance(parameter, Sequence): diff --git a/src/anomalib/models/components/base/anomaly_module.py b/src/anomalib/models/components/base/anomaly_module.py index 963ce485a3..ecd4c62d13 100644 --- a/src/anomalib/models/components/base/anomaly_module.py +++ b/src/anomalib/models/components/base/anomaly_module.py @@ -266,6 +266,8 @@ def input_size(self) -> tuple[int, int] | None: The effective input size is the size of the input tensor after the transform has been applied. If the transform is not set, or if the transform does not change the shape of the input tensor, this method will return None. """ + if self._input_size: + return self._input_size transform = self.transform or self.configure_transforms() if transform is None: return None @@ -275,6 +277,10 @@ def input_size(self) -> tuple[int, int] | None: return None return output_shape[-2:] + def set_input_size(self, input_size: tuple[int, int]) -> None: + """Update the effective input size of the model.""" + self._input_size = input_size + def on_save_checkpoint(self, checkpoint: dict[str, Any]) -> None: """Called when saving the model to a checkpoint. diff --git a/src/anomalib/pipelines/tiled_ensemble/__init__.py b/src/anomalib/pipelines/tiled_ensemble/__init__.py new file mode 100644 index 0000000000..1a068562b7 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/__init__.py @@ -0,0 +1,12 @@ +"""Tiled ensemble pipelines.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from .test_pipeline import EvalTiledEnsemble +from .train_pipeline import TrainTiledEnsemble + +__all__ = [ + "TrainTiledEnsemble", + "EvalTiledEnsemble", +] diff --git a/src/anomalib/pipelines/tiled_ensemble/components/__init__.py b/src/anomalib/pipelines/tiled_ensemble/components/__init__.py new file mode 100644 index 0000000000..619dc2e673 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/__init__.py @@ -0,0 +1,30 @@ +"""Tiled ensemble pipeline components.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from .merging import MergeJobGenerator +from .metrics_calculation import MetricsCalculationJobGenerator +from .model_training import TrainModelJobGenerator +from .normalization import NormalizationJobGenerator +from .prediction import PredictJobGenerator +from .smoothing import SmoothingJobGenerator +from .stats_calculation import StatisticsJobGenerator +from .thresholding import ThresholdingJobGenerator +from .utils import NormalizationStage, PredictData, ThresholdStage +from .visualization import VisualizationJobGenerator + +__all__ = [ + "NormalizationStage", + "ThresholdStage", + "PredictData", + "TrainModelJobGenerator", + "PredictJobGenerator", + "MergeJobGenerator", + "SmoothingJobGenerator", + "StatisticsJobGenerator", + "NormalizationJobGenerator", + "ThresholdingJobGenerator", + "VisualizationJobGenerator", + "MetricsCalculationJobGenerator", +] diff --git a/src/anomalib/pipelines/tiled_ensemble/components/merging.py b/src/anomalib/pipelines/tiled_ensemble/components/merging.py new file mode 100644 index 0000000000..6e8d5fc84c --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/merging.py @@ -0,0 +1,110 @@ +"""Tiled ensemble - prediction merging job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Generator +from typing import Any + +from tqdm import tqdm + +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS + +from .utils.ensemble_tiling import EnsembleTiler +from .utils.helper_functions import get_ensemble_tiler +from .utils.prediction_data import EnsemblePredictions +from .utils.prediction_merging import PredictionMergingMechanism + +logger = logging.getLogger(__name__) + + +class MergeJob(Job): + """Job for merging tile-level predictions into image-level predictions. + + Args: + predictions (EnsemblePredictions): Object containing ensemble predictions. + tiler (EnsembleTiler): Ensemble tiler used for untiling. + """ + + name = "Merge" + + def __init__(self, predictions: EnsemblePredictions, tiler: EnsembleTiler) -> None: + super().__init__() + self.predictions = predictions + self.tiler = tiler + + def run(self, task_id: int | None = None) -> list[Any]: + """Run merging job that merges all batches of tile-level predictions into image-level predictions. + + Args: + task_id: Not used in this case. + + Returns: + list[Any]: List of merged predictions. + """ + del task_id # not needed here + + merger = PredictionMergingMechanism(self.predictions, self.tiler) + + logger.info("Merging predictions.") + + # merge all batches + merged_predictions = [ + merger.merge_tile_predictions(batch_idx) + for batch_idx in tqdm(range(merger.num_batches), desc="Prediction merging") + ] + + return merged_predictions # noqa: RET504 + + @staticmethod + def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: + """Nothing to collect in this job. + + Returns: + list[Any]: List of predictions. + """ + # take the first element as result is list of lists here + return results[0] + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """Nothing to save in this job.""" + + +class MergeJobGenerator(JobGenerator): + """Generate MergeJob.""" + + def __init__(self, tiling_args: dict, data_args: dict) -> None: + super().__init__() + self.tiling_args = tiling_args + self.data_args = data_args + + @property + def job_class(self) -> type: + """Return the job class.""" + return MergeJob + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: EnsemblePredictions | None = None, + ) -> Generator[MergeJob, None, None]: + """Return a generator producing a single merging job. + + Args: + args (dict): Tiled ensemble pipeline args. + prev_stage_result (EnsemblePredictions): Ensemble predictions from predict step. + + Returns: + Generator[MergeJob, None, None]: MergeJob generator + """ + del args # args not used here + + tiler = get_ensemble_tiler(self.tiling_args, self.data_args) + if prev_stage_result is not None: + yield MergeJob(prev_stage_result, tiler) + else: + msg = "Merging job requires tile level predictions from previous step." + raise ValueError(msg) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/metrics_calculation.py b/src/anomalib/pipelines/tiled_ensemble/components/metrics_calculation.py new file mode 100644 index 0000000000..530662b1d3 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/metrics_calculation.py @@ -0,0 +1,217 @@ +"""Tiled ensemble - metrics calculation job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import pandas as pd +from tqdm import tqdm + +from anomalib import TaskType +from anomalib.metrics import AnomalibMetricCollection, create_metric_collection +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.types import GATHERED_RESULTS, PREV_STAGE_RESULT, RUN_RESULTS + +from .utils import NormalizationStage +from .utils.helper_functions import get_threshold_values + +logger = logging.getLogger(__name__) + + +class MetricsCalculationJob(Job): + """Job for image and pixel metrics calculation. + + Args: + accelerator (str): Accelerator (device) to use. + predictions (list[Any]): List of batch predictions. + root_dir (Path): Root directory to save checkpoints, stats and images. + image_metrics (AnomalibMetricCollection): Collection of all image-level metrics. + pixel_metrics (AnomalibMetricCollection): Collection of all pixel-level metrics. + """ + + name = "Metrics" + + def __init__( + self, + accelerator: str, + predictions: list[Any] | None, + root_dir: Path, + image_metrics: AnomalibMetricCollection, + pixel_metrics: AnomalibMetricCollection, + ) -> None: + super().__init__() + self.accelerator = accelerator + self.predictions = predictions + self.root_dir = root_dir + self.image_metrics = image_metrics + self.pixel_metrics = pixel_metrics + + def run(self, task_id: int | None = None) -> dict: + """Run a job that calculates image and pixel level metrics. + + Args: + task_id: Not used in this case. + + Returns: + dict[str, float]: Dictionary containing calculated metric values. + """ + del task_id # not needed here + + logger.info("Starting metrics calculation.") + + # add predicted data to metrics + for data in tqdm(self.predictions, desc="Calculating metrics"): + self.image_metrics.update(data["pred_scores"], data["label"].int()) + if "mask" in data and "anomaly_maps" in data: + self.pixel_metrics.update(data["anomaly_maps"], data["mask"].int()) + + # compute all metrics on specified accelerator + metrics_dict = {} + for name, metric in self.image_metrics.items(): + metric.to(self.accelerator) + metrics_dict[name] = metric.compute().item() + metric.cpu() + + if self.pixel_metrics.update_called: + for name, metric in self.pixel_metrics.items(): + metric.to(self.accelerator) + metrics_dict[name] = metric.compute().item() + metric.cpu() + + for name, value in metrics_dict.items(): + print(f"{name}: {value:.4f}") + + # save path used in `save` method + metrics_dict["save_path"] = self.root_dir / "metric_results.csv" + + return metrics_dict + + @staticmethod + def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: + """Nothing to collect in this job. + + Returns: + list[Any]: list of predictions. + """ + # take the first element as result is list of dict here + return results[0] + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """Save metrics values to csv.""" + logger.info("Saving metrics to csv.") + + # get and remove path from stats dict + results_path: Path = results.pop("save_path") + results_path.parent.mkdir(parents=True, exist_ok=True) + + df_dict = {k: [v] for k, v in results.items()} + metrics_df = pd.DataFrame(df_dict) + metrics_df.to_csv(results_path, index=False) + + +class MetricsCalculationJobGenerator(JobGenerator): + """Generate MetricsCalculationJob. + + Args: + root_dir (Path): Root directory to save checkpoints, stats and images. + """ + + def __init__( + self, + accelerator: str, + root_dir: Path, + task: TaskType, + metrics: dict, + normalization_stage: NormalizationStage, + ) -> None: + self.accelerator = accelerator + self.root_dir = root_dir + self.task = task + self.metrics = metrics + self.normalization_stage = normalization_stage + + @property + def job_class(self) -> type: + """Return the job class.""" + return MetricsCalculationJob + + def configure_ensemble_metrics( + self, + image_metrics: list[str] | dict[str, dict[str, Any]] | None = None, + pixel_metrics: list[str] | dict[str, dict[str, Any]] | None = None, + ) -> tuple[AnomalibMetricCollection, AnomalibMetricCollection]: + """Configure image and pixel metrics and put them into a collection. + + Args: + image_metrics (list[str] | None): List of image-level metric names. + pixel_metrics (list[str] | None): List of pixel-level metric names. + + Returns: + tuple[AnomalibMetricCollection, AnomalibMetricCollection]: + Image-metrics collection and pixel-metrics collection + """ + image_metrics = [] if image_metrics is None else image_metrics + + if pixel_metrics is None: + pixel_metrics = [] + elif self.task == TaskType.CLASSIFICATION: + pixel_metrics = [] + logger.warning( + "Cannot perform pixel-level evaluation when task type is classification. " + "Ignoring the following pixel-level metrics: %s", + pixel_metrics, + ) + + # if a single metric is passed, transform to list to fit the creation function + if isinstance(image_metrics, str): + image_metrics = [image_metrics] + if isinstance(pixel_metrics, str): + pixel_metrics = [pixel_metrics] + + image_metrics_collection = create_metric_collection(image_metrics, "image_") + pixel_metrics_collection = create_metric_collection(pixel_metrics, "pixel_") + + return image_metrics_collection, pixel_metrics_collection + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: PREV_STAGE_RESULT = None, + ) -> Generator[MetricsCalculationJob, None, None]: + """Make a generator that yields a single metrics calculation job. + + Args: + args: ensemble run config. + prev_stage_result: ensemble predictions from previous step. + + Returns: + Generator[MetricsCalculationJob, None, None]: MetricsCalculationJob generator + """ + del args # args not used here + + image_metrics_config = self.metrics.get("image", None) + pixel_metrics_config = self.metrics.get("pixel", None) + + image_threshold, pixel_threshold = get_threshold_values(self.normalization_stage, self.root_dir) + + image_metrics, pixel_metrics = self.configure_ensemble_metrics( + image_metrics=image_metrics_config, + pixel_metrics=pixel_metrics_config, + ) + + # set thresholds for metrics that need it + image_metrics.set_threshold(image_threshold) + pixel_metrics.set_threshold(pixel_threshold) + + yield MetricsCalculationJob( + accelerator=self.accelerator, + predictions=prev_stage_result, + root_dir=self.root_dir, + image_metrics=image_metrics, + pixel_metrics=pixel_metrics, + ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/model_training.py b/src/anomalib/pipelines/tiled_ensemble/components/model_training.py new file mode 100644 index 0000000000..6bc81c793b --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/model_training.py @@ -0,0 +1,192 @@ +"""Tiled ensemble - ensemble training job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Generator +from itertools import product +from pathlib import Path + +from lightning import seed_everything + +from anomalib.data import AnomalibDataModule +from anomalib.models import AnomalyModule +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.types import GATHERED_RESULTS, PREV_STAGE_RESULT + +from .utils import NormalizationStage +from .utils.ensemble_engine import TiledEnsembleEngine +from .utils.helper_functions import ( + get_ensemble_datamodule, + get_ensemble_engine, + get_ensemble_model, + get_ensemble_tiler, +) + +logger = logging.getLogger(__name__) + + +class TrainModelJob(Job): + """Job for training of individual models in the tiled ensemble. + + Args: + accelerator (str): Accelerator (device) to use. + seed (int): Random seed for reproducibility. + root_dir (Path): Root directory to save checkpoints, stats and images. + tile_index (tuple[int, int]): Index of tile that this model processes. + normalization_stage (str): Normalization stage flag. + metrics (dict): metrics dict with pixel and image metric names. + trainer_args (dict| None): Additional arguments to pass to the trainer class. + model (AnomalyModule): Model to train. + datamodule (AnomalibDataModule): Datamodule with all dataloaders. + + """ + + name = "TrainModels" + + def __init__( + self, + accelerator: str, + seed: int, + root_dir: Path, + tile_index: tuple[int, int], + normalization_stage: str, + metrics: dict, + trainer_args: dict | None, + model: AnomalyModule, + datamodule: AnomalibDataModule, + ) -> None: + super().__init__() + self.accelerator = accelerator + self.seed = seed + self.root_dir = root_dir + self.tile_index = tile_index + self.normalization_stage = normalization_stage + self.metrics = metrics + self.trainer_args = trainer_args + self.model = model + self.datamodule = datamodule + + def run( + self, + task_id: int | None = None, + ) -> TiledEnsembleEngine: + """Run train job that fits the model for given tile location. + + Args: + task_id: Passed when job is ran in parallel. + + Returns: + TiledEnsembleEngine: Engine containing trained model. + """ + devices: str | list[int] = "auto" + if task_id is not None: + devices = [task_id] + logger.info(f"Running job {self.model.__class__.__name__} with device {task_id}") + + logger.info("Start of training for tile at position %s,", self.tile_index) + seed_everything(self.seed) + + # create engine for specific tile location and fit the model + engine = get_ensemble_engine( + tile_index=self.tile_index, + accelerator=self.accelerator, + devices=devices, + root_dir=self.root_dir, + normalization_stage=self.normalization_stage, + metrics=self.metrics, + trainer_args=self.trainer_args, + ) + engine.fit(model=self.model, datamodule=self.datamodule) + # move model to cpu to avoid memory issues as the engine is returned to be used in validation phase + engine.model.cpu() + + return engine + + @staticmethod + def collect(results: list[TiledEnsembleEngine]) -> dict[tuple[int, int], TiledEnsembleEngine]: + """Collect engines from each tile location into a dict. + + Returns: + dict[tuple[int, int], TiledEnsembleEngine]: Dict has form {tile_index: TiledEnsembleEngine} + """ + return {r.tile_index: r for r in results} + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """Skip as checkpoints are already saved by callback.""" + + +class TrainModelJobGenerator(JobGenerator): + """Generator for training job that train model for each tile location. + + Args: + root_dir (Path): Root directory to save checkpoints, stats and images. + """ + + def __init__( + self, + seed: int, + accelerator: str, + root_dir: Path, + tiling_args: dict, + data_args: dict, + normalization_stage: NormalizationStage, + ) -> None: + self.seed = seed + self.accelerator = accelerator + self.root_dir = root_dir + self.tiling_args = tiling_args + self.data_args = data_args + self.normalization_stage = normalization_stage + + @property + def job_class(self) -> type: + """Return the job class.""" + return TrainModelJob + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: PREV_STAGE_RESULT = None, + ) -> Generator[TrainModelJob, None, None]: + """Generate training jobs for each tile location. + + Args: + args (dict): Dict with config passed to training. + prev_stage_result (None): Not used here. + + Returns: + Generator[TrainModelJob, None, None]: TrainModelJob generator + """ + del prev_stage_result # Not needed for this job + if args is None: + msg = "TrainModels job requires config args" + raise ValueError(msg) + + # tiler used for splitting the image and getting the tile count + tiler = get_ensemble_tiler(self.tiling_args, self.data_args) + + logger.info( + "Tiled ensemble training started. Separate models will be trained for %d tile locations.", + tiler.num_tiles, + ) + # go over all tile positions + for tile_index in product(range(tiler.num_patches_h), range(tiler.num_patches_w)): + # prepare datamodule with custom collate function that only provides specific tile of image + datamodule = get_ensemble_datamodule(self.data_args, tiler, tile_index) + model = get_ensemble_model(args["model"], tiler) + + # pass root_dir to engine so all models in ensemble have the same root dir + yield TrainModelJob( + accelerator=self.accelerator, + seed=self.seed, + root_dir=self.root_dir, + tile_index=tile_index, + normalization_stage=self.normalization_stage, + metrics=args["metrics"], + trainer_args=args.get("trainer", {}), + model=model, + datamodule=datamodule, + ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/normalization.py b/src/anomalib/pipelines/tiled_ensemble/components/normalization.py new file mode 100644 index 0000000000..8c7a563506 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/normalization.py @@ -0,0 +1,120 @@ +"""Tiled ensemble - normalization job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import json +import logging +from collections.abc import Generator +from pathlib import Path +from typing import Any + +from tqdm import tqdm + +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS +from anomalib.utils.normalization.min_max import normalize + +logger = logging.getLogger(__name__) + + +class NormalizationJob(Job): + """Job for normalization of predictions. + + Args: + predictions (list[Any]): List of predictions. + root_dir (Path): Root directory containing statistics needed for normalization. + """ + + name = "Normalize" + + def __init__(self, predictions: list[Any] | None, root_dir: Path) -> None: + super().__init__() + self.predictions = predictions + self.root_dir = root_dir + + def run(self, task_id: int | None = None) -> list[Any] | None: + """Run normalization job which normalizes image, pixel and box scores. + + Args: + task_id: Not used in this case. + + Returns: + list[Any]: List of normalized predictions. + """ + del task_id # not needed here + + # load all statistics needed for normalization + stats_path = self.root_dir / "weights" / "lightning" / "stats.json" + with stats_path.open("r") as f: + stats = json.load(f) + minmax = stats["minmax"] + image_threshold = stats["image_threshold"] + pixel_threshold = stats["pixel_threshold"] + + logger.info("Starting normalization.") + + for data in tqdm(self.predictions, desc="Normalizing"): + data["pred_scores"] = normalize( + data["pred_scores"], + image_threshold, + minmax["pred_scores"]["min"], + minmax["pred_scores"]["max"], + ) + if "anomaly_maps" in data: + data["anomaly_maps"] = normalize( + data["anomaly_maps"], + pixel_threshold, + minmax["anomaly_maps"]["min"], + minmax["anomaly_maps"]["max"], + ) + + return self.predictions + + @staticmethod + def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: + """Nothing to collect in this job. + + Returns: + list[Any]: List of predictions. + """ + # take the first element as result is list of lists here + return results[0] + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """Nothing is saved in this job.""" + + +class NormalizationJobGenerator(JobGenerator): + """Generate NormalizationJob. + + Args: + root_dir (Path): Root directory where statistics are saved. + """ + + def __init__(self, root_dir: Path) -> None: + self.root_dir = root_dir + + @property + def job_class(self) -> type: + """Return the job class.""" + return NormalizationJob + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: list[Any] | None = None, + ) -> Generator[NormalizationJob, None, None]: + """Return a generator producing a single normalization job. + + Args: + args: not used here. + prev_stage_result (list[Any]): Ensemble predictions from previous step. + + Returns: + Generator[NormalizationJob, None, None]: NormalizationJob generator. + """ + del args # not needed here + + yield NormalizationJob(prev_stage_result, self.root_dir) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/prediction.py b/src/anomalib/pipelines/tiled_ensemble/components/prediction.py new file mode 100644 index 0000000000..792d86a497 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/prediction.py @@ -0,0 +1,228 @@ +"""Tiled ensemble - ensemble prediction job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Generator +from itertools import product +from pathlib import Path +from typing import Any + +from lightning import seed_everything +from torch.utils.data import DataLoader + +from anomalib.models import AnomalyModule +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.types import GATHERED_RESULTS, PREV_STAGE_RESULT + +from .utils import NormalizationStage, PredictData +from .utils.ensemble_engine import TiledEnsembleEngine +from .utils.helper_functions import ( + get_ensemble_datamodule, + get_ensemble_engine, + get_ensemble_model, + get_ensemble_tiler, +) +from .utils.prediction_data import EnsemblePredictions + +logger = logging.getLogger(__name__) + + +class PredictJob(Job): + """Job for generating predictions with individual models in the tiled ensemble. + + Args: + accelerator (str): Accelerator (device) to use. + seed (int): Random seed for reproducibility. + root_dir (Path): Root directory to save checkpoints, stats and images. + tile_index (tuple[int, int]): Index of tile that this model processes. + normalization_stage (str): Normalization stage flag. + dataloader (DataLoader): Dataloader to use for training (either val or test). + model (AnomalyModule): Model to train. + engine (TiledEnsembleEngine | None): + engine from train job. If job is used standalone, instantiate engine and model from checkpoint. + ckpt_path (Path | None): Path to checkpoint to be loaded if engine doesn't contain correct weights. + + """ + + name = "Predict" + + def __init__( + self, + accelerator: str, + seed: int, + root_dir: Path, + tile_index: tuple[int, int], + normalization_stage: str, + dataloader: DataLoader, + model: AnomalyModule | None, + engine: TiledEnsembleEngine | None, + ckpt_path: Path | None, + ) -> None: + super().__init__() + if engine is None and ckpt_path is None: + msg = "Either engine or checkpoint must be provided to predict job." + raise ValueError(msg) + + self.accelerator = accelerator + self.seed = seed + self.root_dir = root_dir + self.tile_index = tile_index + self.normalization_stage = normalization_stage + self.dataloader = dataloader + self.model = model + self.engine = engine + self.ckpt_path = ckpt_path + + def run( + self, + task_id: int | None = None, + ) -> tuple[tuple[int, int], Any | None]: + """Predict job that predicts the data with specific model for given tile location. + + Args: + task_id: Passed when job is ran in parallel. + + Returns: + tuple[tuple[int, int], list[Any]]: Tile index, List of predictions. + """ + devices: str | list[int] = "auto" + if task_id is not None: + devices = [task_id] + logger.info(f"Running job {self.model.__class__.__name__} with device {task_id}") + + logger.info("Start of predicting for tile at position %s,", self.tile_index) + seed_everything(self.seed) + + if self.engine is None: + # in case predict is invoked separately from train job, make new engine instance + self.engine = get_ensemble_engine( + tile_index=self.tile_index, + accelerator=self.accelerator, + devices=devices, + root_dir=self.root_dir, + normalization_stage=self.normalization_stage, + ) + + predictions = self.engine.predict(model=self.model, dataloaders=self.dataloader, ckpt_path=self.ckpt_path) + + # also return tile index as it's needed in collect method + return self.tile_index, predictions + + @staticmethod + def collect(results: list[tuple[tuple[int, int], list[Any]]]) -> EnsemblePredictions: + """Collect predictions from each tile location into the predictions class. + + Returns: + EnsemblePredictions: Object containing all predictions in form ready for merging. + """ + storage = EnsemblePredictions() + + for tile_index, predictions in results: + storage.add_tile_prediction(tile_index, predictions) + + return storage + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """This stage doesn't save anything.""" + + +class PredictJobGenerator(JobGenerator): + """Generator for predict job that uses individual models to predict for each tile location. + + Args: + root_dir (Path): Root directory to save checkpoints, stats and images. + data_source (PredictData): Whether to predict on validation set. If false use test set. + """ + + def __init__( + self, + data_source: PredictData, + seed: int, + accelerator: str, + root_dir: Path, + tiling_args: dict, + data_args: dict, + model_args: dict, + normalization_stage: NormalizationStage, + ) -> None: + self.data_source = data_source + self.seed = seed + self.accelerator = accelerator + self.root_dir = root_dir + self.tiling_args = tiling_args + self.data_args = data_args + self.model_args = model_args + self.normalization_stage = normalization_stage + + @property + def job_class(self) -> type: + """Return the job class.""" + return PredictJob + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: PREV_STAGE_RESULT = None, + ) -> Generator[PredictJob, None, None]: + """Generate predict jobs for each tile location. + + Args: + args (dict): Dict with config passed to training. + prev_stage_result (dict[tuple[int, int], TiledEnsembleEngine] | None): + if called after train job this contains engines with individual models, otherwise load from checkpoints. + + Returns: + Generator[PredictJob, None, None]: PredictJob generator. + """ + del args # args not used here + + # tiler used for splitting the image and getting the tile count + tiler = get_ensemble_tiler(self.tiling_args, self.data_args) + + logger.info( + "Tiled ensemble predicting started using %s data.", + self.data_source.value, + ) + # go over all tile positions + for tile_index in product(range(tiler.num_patches_h), range(tiler.num_patches_w)): + # prepare datamodule with custom collate function that only provides specific tile of image + datamodule = get_ensemble_datamodule(self.data_args, tiler, tile_index) + + # check if predict step is positioned after training + if prev_stage_result and tile_index in prev_stage_result: + engine = prev_stage_result[tile_index] + # model is inside engine in this case + model = engine.model + ckpt_path = None + else: + # any other case - predict is called standalone + engine = None + # we need to make new model instance as it's not inside engine + model = get_ensemble_model(self.model_args, tiler) + tile_i, tile_j = tile_index + # prepare checkpoint path for model on current tile location + ckpt_path = self.root_dir / "weights" / "lightning" / f"model{tile_i}_{tile_j}.ckpt" + + # pick the dataloader based on predict data + dataloader = datamodule.test_dataloader() + if self.data_source == PredictData.VAL: + dataloader = datamodule.val_dataloader() + # TODO(blaz-r): - this is tweak to avoid problem in engine:388 + # 2254 + dataloader.dataset.transform = None + + # pass root_dir to engine so all models in ensemble have the same root dir + yield PredictJob( + accelerator=self.accelerator, + seed=self.seed, + root_dir=self.root_dir, + tile_index=tile_index, + normalization_stage=self.normalization_stage, + model=model, + dataloader=dataloader, + engine=engine, + ckpt_path=ckpt_path, + ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/smoothing.py b/src/anomalib/pipelines/tiled_ensemble/components/smoothing.py new file mode 100644 index 0000000000..b3d5a51000 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/smoothing.py @@ -0,0 +1,167 @@ +"""Tiled ensemble - seam smoothing job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Generator +from typing import Any + +import torch +from tqdm import tqdm + +from anomalib.models.components import GaussianBlur2d +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS + +from .utils.ensemble_tiling import EnsembleTiler +from .utils.helper_functions import get_ensemble_tiler + +logger = logging.getLogger(__name__) + + +class SmoothingJob(Job): + """Job for smoothing the area around the tile seam. + + Args: + accelerator (str): Accelerator used for processing. + predictions (list[Any]): List of image-level predictions. + width_factor (float): Factor multiplied by tile dimension to get the region around seam which will be smoothed. + filter_sigma (float): Sigma of filter used for smoothing the seams. + tiler (EnsembleTiler): Tiler object used to get tile dimension data. + """ + + name = "SeamSmoothing" + + def __init__( + self, + accelerator: str, + predictions: list[Any], + width_factor: float, + filter_sigma: float, + tiler: EnsembleTiler, + ) -> None: + super().__init__() + self.accelerator = accelerator + self.predictions = predictions + + # offset in pixels of region around tile seam that will be smoothed + self.height_offset = int(tiler.tile_size_h * width_factor) + self.width_offset = int(tiler.tile_size_w * width_factor) + self.tiler = tiler + + self.seam_mask = self.prepare_seam_mask() + + self.blur = GaussianBlur2d(sigma=filter_sigma) + + def prepare_seam_mask(self) -> torch.Tensor: + """Prepare boolean mask of regions around the part where tiles seam in ensemble. + + Returns: + torch.Tensor: Representation of boolean mask where filtered seams should be used. + """ + img_h, img_w = self.tiler.image_size + stride_h, stride_w = self.tiler.stride_h, self.tiler.stride_w + + mask = torch.zeros(img_h, img_w, dtype=torch.bool) + + # prepare mask strip on vertical seams + curr_w = stride_w + while curr_w < img_w: + start_i = curr_w - self.width_offset + end_i = curr_w + self.width_offset + mask[:, start_i:end_i] = 1 + curr_w += stride_w + + # prepare mask strip on horizontal seams + curr_h = stride_h + while curr_h < img_h: + start_i = curr_h - self.height_offset + end_i = curr_h + self.height_offset + mask[start_i:end_i, :] = True + curr_h += stride_h + + return mask + + def run(self, task_id: int | None = None) -> list[Any]: + """Run smoothing job. + + Args: + task_id: Not used in this case. + + Returns: + list[Any]: List of predictions. + """ + del task_id # not needed here + + logger.info("Starting seam smoothing.") + + for data in tqdm(self.predictions, desc="Seam smoothing"): + # move to specified accelerator for faster execution + data["anomaly_maps"] = data["anomaly_maps"].to(self.accelerator) + # smooth the anomaly map and take only region around seams delimited by seam_mask + smoothed = self.blur(data["anomaly_maps"]) + data["anomaly_maps"][:, :, self.seam_mask] = smoothed[:, :, self.seam_mask] + data["anomaly_maps"] = data["anomaly_maps"].cpu() + + return self.predictions + + @staticmethod + def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: + """Nothing to collect in this job. + + Returns: + list[Any]: List of predictions. + """ + # take the first element as result is list of lists here + return results[0] + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """Nothing to save in this job.""" + + +class SmoothingJobGenerator(JobGenerator): + """Generate SmoothingJob.""" + + def __init__(self, accelerator: str, tiling_args: dict, data_args: dict) -> None: + super().__init__() + self.accelerator = accelerator + self.tiling_args = tiling_args + self.data_args = data_args + + @property + def job_class(self) -> type: + """Return the job class.""" + return SmoothingJob + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: list[Any] | None = None, + ) -> Generator[SmoothingJob, None, None]: + """Return a generator producing a single seam smoothing job. + + Args: + args: Tiled ensemble pipeline args. + prev_stage_result (list[Any]): Ensemble predictions from previous step. + + Returns: + Generator[SmoothingJob, None, None]: SmoothingJob generator + """ + if args is None: + msg = "SeamSmoothing job requires config args" + raise ValueError(msg) + # tiler is used to determine where seams appear + tiler = get_ensemble_tiler(self.tiling_args, self.data_args) + if prev_stage_result is not None: + yield SmoothingJob( + accelerator=self.accelerator, + predictions=prev_stage_result, + width_factor=args["width"], + filter_sigma=args["sigma"], + tiler=tiler, + ) + else: + msg = "Join smoothing job requires tile level predictions from previous step." + raise ValueError(msg) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/stats_calculation.py b/src/anomalib/pipelines/tiled_ensemble/components/stats_calculation.py new file mode 100644 index 0000000000..6c48b639f7 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/stats_calculation.py @@ -0,0 +1,180 @@ +"""Tiled ensemble - post-processing statistics calculation job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import json +import logging +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import torch +from omegaconf import DictConfig, ListConfig +from torchmetrics import MetricCollection +from tqdm import tqdm + +from anomalib.callbacks.thresholding import _ThresholdCallback +from anomalib.metrics import MinMax +from anomalib.metrics.threshold import Threshold +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS + +logger = logging.getLogger(__name__) + + +class StatisticsJob(Job): + """Job for calculating min, max and threshold statistics for post-processing. + + Args: + predictions (list[Any]): List of image-level predictions. + root_dir (Path): Root directory to save checkpoints, stats and images. + """ + + name = "Stats" + + def __init__( + self, + predictions: list[Any] | None, + root_dir: Path, + image_threshold: Threshold, + pixel_threshold: Threshold, + ) -> None: + super().__init__() + self.predictions = predictions + self.root_dir = root_dir + self.image_threshold = image_threshold + self.pixel_threshold = pixel_threshold + + def run(self, task_id: int | None = None) -> dict: + """Run job that calculates statistics needed in post-processing steps. + + Args: + task_id: Not used in this case + + Returns: + dict: Statistics dict with min, max and threshold values. + """ + del task_id # not needed here + + minmax = MetricCollection( + { + "anomaly_maps": MinMax().cpu(), + "pred_scores": MinMax().cpu(), + }, + ) + pixel_update_called = False + + logger.info("Starting post-processing statistics calculation.") + + for data in tqdm(self.predictions, desc="Stats calculation"): + # update minmax + if "anomaly_maps" in data: + minmax["anomaly_maps"](data["anomaly_maps"]) + if "pred_scores" in data: + minmax["pred_scores"](data["pred_scores"]) + + # update thresholds + self.image_threshold.update(data["pred_scores"], data["label"].int()) + if "mask" in data and "anomaly_maps" in data: + self.pixel_threshold.update(torch.squeeze(data["anomaly_maps"]), torch.squeeze(data["mask"].int())) + pixel_update_called = True + + self.image_threshold.compute() + if pixel_update_called: + self.pixel_threshold.compute() + else: + self.pixel_threshold.value = self.image_threshold.value + + min_max_vals = {} + for pred_name, pred_metric in minmax.items(): + min_max_vals[pred_name] = { + "min": pred_metric.min.item(), + "max": pred_metric.max.item(), + } + + # return stats with save path that is later used to save statistics. + return { + "minmax": min_max_vals, + "image_threshold": self.image_threshold.value.item(), + "pixel_threshold": self.pixel_threshold.value.item(), + "save_path": (self.root_dir / "weights" / "lightning" / "stats.json"), + } + + @staticmethod + def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: + """Nothing to collect in this job. + + Returns: + dict: statistics dictionary. + """ + # take the first element as result is list of lists here + return results[0] + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """Save statistics to file system.""" + # get and remove path from stats dict + stats_path: Path = results.pop("save_path") + stats_path.parent.mkdir(parents=True, exist_ok=True) + + # save statistics next to weights + with stats_path.open("w", encoding="utf-8") as stats_file: + json.dump(results, stats_file, ensure_ascii=False, indent=4) + + +class StatisticsJobGenerator(JobGenerator): + """Generate StatisticsJob. + + Args: + root_dir (Path): Root directory where statistics file will be saved (in weights folder). + """ + + def __init__( + self, + root_dir: Path, + thresholding_method: DictConfig | str | ListConfig | list[dict[str, str | float]], + ) -> None: + self.root_dir = root_dir + self.threshold = thresholding_method + + @property + def job_class(self) -> type: + """Return the job class.""" + return StatisticsJob + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: list[Any] | None = None, + ) -> Generator[StatisticsJob, None, None]: + """Return a generator producing a single stats calculating job. + + Args: + args: Not used here. + prev_stage_result (list[Any]): Ensemble predictions from previous step. + + Returns: + Generator[StatisticsJob, None, None]: StatisticsJob generator. + """ + del args # not needed here + + # get threshold class based config + if isinstance(self.threshold, str | DictConfig): + # single method provided + image_threshold = _ThresholdCallback._get_threshold_from_config(self.threshold) # noqa: SLF001 + pixel_threshold = image_threshold.clone() + elif isinstance(self.threshold, ListConfig | list): + # image and pixel method specified separately + image_threshold = _ThresholdCallback._get_threshold_from_config(self.threshold[0]) # noqa: SLF001 + pixel_threshold = _ThresholdCallback._get_threshold_from_config(self.threshold[1]) # noqa: SLF001 + else: + msg = f"Invalid threshold config {self.threshold}" + raise TypeError(msg) + + yield StatisticsJob( + predictions=prev_stage_result, + root_dir=self.root_dir, + image_threshold=image_threshold, + pixel_threshold=pixel_threshold, + ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/thresholding.py b/src/anomalib/pipelines/tiled_ensemble/components/thresholding.py new file mode 100644 index 0000000000..733c3d99db --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/thresholding.py @@ -0,0 +1,114 @@ +"""Tiled ensemble - thresholding job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Generator +from pathlib import Path +from typing import Any + +from tqdm import tqdm + +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS + +from .utils import NormalizationStage +from .utils.helper_functions import get_threshold_values + +logger = logging.getLogger(__name__) + + +class ThresholdingJob(Job): + """Job used to threshold predictions, producing labels from scores. + + Args: + predictions (list[Any]): List of predictions. + image_threshold (float): Threshold used for image-level thresholding. + pixel_threshold (float): Threshold used for pixel-level thresholding. + """ + + name = "Threshold" + + def __init__(self, predictions: list[Any] | None, image_threshold: float, pixel_threshold: float) -> None: + super().__init__() + self.predictions = predictions + self.image_threshold = image_threshold + self.pixel_threshold = pixel_threshold + + def run(self, task_id: int | None = None) -> list[Any] | None: + """Run job that produces prediction labels from scores. + + Args: + task_id: Not used in this case. + + Returns: + list[Any]: List of thresholded predictions. + """ + del task_id # not needed here + + logger.info("Starting thresholding.") + + for data in tqdm(self.predictions, desc="Thresholding"): + if "pred_scores" in data: + data["pred_labels"] = data["pred_scores"] >= self.image_threshold + if "anomaly_maps" in data: + data["pred_masks"] = data["anomaly_maps"] >= self.pixel_threshold + + return self.predictions + + @staticmethod + def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: + """Nothing to collect in this job. + + Returns: + list[Any]: List of predictions. + """ + # take the first element as result is list of lists here + return results[0] + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """Nothing is saved in this job.""" + + +class ThresholdingJobGenerator(JobGenerator): + """Generate ThresholdingJob. + + Args: + root_dir (Path): Root directory containing post-processing stats. + """ + + def __init__(self, root_dir: Path, normalization_stage: NormalizationStage) -> None: + self.root_dir = root_dir + self.normalization_stage = normalization_stage + + @property + def job_class(self) -> type: + """Return the job class.""" + return ThresholdingJob + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: list[Any] | None = None, + ) -> Generator[ThresholdingJob, None, None]: + """Return a generator producing a single thresholding job. + + Args: + args: ensemble run args. + prev_stage_result (list[Any]): Ensemble predictions from previous step. + + Returns: + Generator[ThresholdingJob, None, None]: ThresholdingJob generator. + """ + del args # args not used here + + # get threshold values base on normalization + image_threshold, pixel_threshold = get_threshold_values(self.normalization_stage, self.root_dir) + + yield ThresholdingJob( + predictions=prev_stage_result, + image_threshold=image_threshold, + pixel_threshold=pixel_threshold, + ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/__init__.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/__init__.py new file mode 100644 index 0000000000..a010208908 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/utils/__init__.py @@ -0,0 +1,44 @@ +"""Tiled ensemble utils and helper functions.""" + +from enum import Enum + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + + +class NormalizationStage(str, Enum): + """Enum signaling at which stage the normalization is done. + + In case of tile, tiles are normalized for each tile position separately. + In case of image, normalization is done at the end when images are joined back together. + In case of none, output is not normalized. + """ + + TILE = "tile" + IMAGE = "image" + NONE = "none" + + +class ThresholdStage(str, Enum): + """Enum signaling at which stage the thresholding is applied. + + In case of tile, thresholding is applied for each tile location separately. + In case of image, thresholding is applied at the end when images are joined back together. + """ + + TILE = "tile" + IMAGE = "image" + + +class PredictData(Enum): + """Enum indicating which data to use in prediction job.""" + + VAL = "val" + TEST = "test" + + +__all__ = [ + "NormalizationStage", + "ThresholdStage", + "PredictData", +] diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_engine.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_engine.py new file mode 100644 index 0000000000..449109ed3f --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_engine.py @@ -0,0 +1,92 @@ +"""Implements custom Anomalib engine for tiled ensemble training.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from pathlib import Path + +from lightning.pytorch.callbacks import Callback, RichModelSummary + +from anomalib.callbacks import ModelCheckpoint, TimerCallback +from anomalib.callbacks.metrics import _MetricsCallback +from anomalib.callbacks.normalization import get_normalization_callback +from anomalib.callbacks.post_processor import _PostProcessorCallback +from anomalib.callbacks.thresholding import _ThresholdCallback +from anomalib.engine import Engine +from anomalib.models import AnomalyModule +from anomalib.utils.path import create_versioned_dir + +logger = logging.getLogger(__name__) + + +class TiledEnsembleEngine(Engine): + """Engine used for training and evaluating tiled ensemble. + + Most of the logic stays the same, but workspace creation and callbacks are adjusted for ensemble. + + Args: + tile_index (tuple[int, int]): index of tile that this engine instance processes. + **kwargs: Engine arguments. + """ + + def __init__(self, tile_index: tuple[int, int], **kwargs) -> None: + self.tile_index = tile_index + super().__init__(**kwargs) + + def _setup_workspace(self, *args, **kwargs) -> None: + """Skip since in case of tiled ensemble, workspace is only setup once at the beginning of training.""" + + @staticmethod + def setup_ensemble_workspace(args: dict, versioned_dir: bool = True) -> Path: + """Set up the workspace at the beginning of tiled ensemble training. + + Args: + args (dict): Tiled ensemble config dict. + versioned_dir (bool, optional): Whether to create a versioned directory. + Defaults to ``True``. + + Returns: + Path: path to new workspace root dir + """ + model_name = args["TrainModels"]["model"]["class_path"].split(".")[-1] + dataset_name = args["data"]["class_path"].split(".")[-1] + category = args["data"]["init_args"]["category"] + root_dir = Path(args["default_root_dir"]) / model_name / dataset_name / category + return create_versioned_dir(root_dir) if versioned_dir else root_dir / "latest" + + def _setup_anomalib_callbacks(self, model: AnomalyModule) -> None: + """Modified method to enable individual model training. It's called when Trainer is being set up.""" + del model # not used here + + _callbacks: list[Callback] = [RichModelSummary()] + + # Add ModelCheckpoint if it is not in the callbacks list. + has_checkpoint_callback = any(isinstance(c, ModelCheckpoint) for c in self._cache.args["callbacks"]) + if not has_checkpoint_callback: + tile_i, tile_j = self.tile_index + _callbacks.append( + ModelCheckpoint( + dirpath=self._cache.args["default_root_dir"] / "weights" / "lightning", + filename=f"model{tile_i}_{tile_j}", + auto_insert_metric_name=False, + ), + ) + + # Add the post-processor callbacks. Used for thresholding and label calculation. + _callbacks.append(_PostProcessorCallback()) + + # Add the normalization callback if tile level normalization was specified (is not none). + normalization_callback = get_normalization_callback(self.normalization) + if normalization_callback is not None: + _callbacks.append(normalization_callback) + + # Add the thresholding and metrics callbacks in all cases, + # because individual model might still need this for early stop. + _callbacks.append(_ThresholdCallback(self.threshold)) + _callbacks.append(_MetricsCallback(self.task, self.image_metric_names, self.pixel_metric_names)) + + _callbacks.append(TimerCallback()) + + # Combine the callbacks, and update the trainer callbacks. + self._cache.args["callbacks"] = _callbacks + self._cache.args["callbacks"] diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_tiling.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_tiling.py new file mode 100644 index 0000000000..db56f88b47 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_tiling.py @@ -0,0 +1,147 @@ +"""Tiler used with ensemble of models.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Sequence +from typing import Any + +from torch import Tensor + +from anomalib.data.base.datamodule import collate_fn +from anomalib.data.utils.tiler import Tiler, compute_new_image_size + + +class EnsembleTiler(Tiler): + """Tile Image into (non)overlapping Patches which are then used for ensemble training. + + Args: + tile_size (int | Sequence): Tile dimension for each patch. + stride (int | Sequence): Stride length between patches. + image_size (int | Sequence): Size of input image that will be tiled. + + Examples: + >>> import torch + >>> tiler = EnsembleTiler(tile_size=256, stride=128, image_size=512) + >>> + >>> # random images, shape: [B, C, H, W] + >>> images = torch.rand(32, 5, 512, 512) + >>> # once tiled, the shape is [tile_count_H, tile_count_W, B, C, tile_H, tile_W] + >>> tiled = tiler.tile(images) + >>> tiled.shape + torch.Size([3, 3, 32, 5, 256, 256]) + + >>> # assemble the tiles back together + >>> untiled = tiler.untile(tiled) + >>> untiled.shape + torch.Size([32, 5, 512, 512]) + """ + + def __init__(self, tile_size: int | Sequence, stride: int | Sequence, image_size: int | Sequence) -> None: + super().__init__( + tile_size=tile_size, + stride=stride, + ) + + # calculate final image size + self.image_size = self.validate_size_type(image_size) + self.input_h, self.input_w = self.image_size + self.resized_h, self.resized_w = compute_new_image_size( + image_size=(self.input_h, self.input_w), + tile_size=(self.tile_size_h, self.tile_size_w), + stride=(self.stride_h, self.stride_w), + ) + + # get number of patches in both dimensions + self.num_patches_h = int((self.resized_h - self.tile_size_h) / self.stride_h) + 1 + self.num_patches_w = int((self.resized_w - self.tile_size_w) / self.stride_w) + 1 + self.num_tiles = self.num_patches_h * self.num_patches_w + + def tile(self, image: Tensor, use_random_tiling: bool = False) -> Tensor: + """Tiles an input image to either overlapping or non-overlapping patches. + + Args: + image (Tensor): Input images. + use_random_tiling (bool): Random tiling, which is part of original tiler but is unused here. + + Returns: + Tensor: Tiles generated from images. + Returned shape: [num_h, num_w, batch, channel, tile_height, tile_width]. + """ + # tiles are returned in order [tile_count * batch, channels, tile_height, tile_width] + combined_tiles = super().tile(image, use_random_tiling) + + # rearrange to [num_h, num_w, batch, channel, tile_height, tile_width] + tiles = combined_tiles.contiguous().view( + self.batch_size, + self.num_patches_h, + self.num_patches_w, + self.num_channels, + self.tile_size_h, + self.tile_size_w, + ) + tiles = tiles.permute(1, 2, 0, 3, 4, 5) + + return tiles # noqa: RET504 + + def untile(self, tiles: Tensor) -> Tensor: + """Reassemble the tiled tensor into image level representation. + + Args: + tiles (Tensor): Tiles in shape: [num_h, num_w, batch, channel, tile_height, tile_width]. + + Returns: + Tensor: Image constructed from input tiles. Shape: [B, C, H, W]. + """ + # tiles have shape [num_h, num_w, batch, channel, tile_height, tile_width] + _, _, batch, channels, tile_size_h, tile_size_w = tiles.shape + + # set tilers batch size as it might have been changed by previous tiling + self.batch_size = batch + + # rearrange the tiles in order [tile_count * batch, channels, tile_height, tile_width] + # the required shape for untiling + tiles = tiles.permute(2, 0, 1, 3, 4, 5) + tiles = tiles.contiguous().view(-1, channels, tile_size_h, tile_size_w) + + untiled = super().untile(tiles) + + return untiled # noqa: RET504 + + +class TileCollater: + """Class serving as collate function to perform tiling on batch of images from Dataloader. + + Args: + tiler (EnsembleTiler): Tiler used to split the images to tiles. + tile_index (tuple[int, int]): Index of tile we want to return. + """ + + def __init__(self, tiler: EnsembleTiler, tile_index: tuple[int, int]) -> None: + self.tiler = tiler + self.tile_index = tile_index + + def __call__(self, batch: list) -> dict[str, Any]: + """Collate batch and tile images + masks from batch. + + Args: + batch (list): Batch of elements from data, also including images. + + Returns: + dict[str, Any]: Collated batch dictionary with tiled images. + """ + # use default collate + coll_batch = collate_fn(batch) + + tiled_images = self.tiler.tile(coll_batch["image"]) + # return only tiles at given index + coll_batch["image"] = tiled_images[self.tile_index] + + if "mask" in coll_batch: + # insert channel (as mask has just one) + tiled_masks = self.tiler.tile(coll_batch["mask"].unsqueeze(1)) + + # return only tiled at given index, squeeze to remove previously added channel + coll_batch["mask"] = tiled_masks[self.tile_index].squeeze(1) + + return coll_batch diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/helper_functions.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/helper_functions.py new file mode 100644 index 0000000000..bc1e5f4f55 --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/utils/helper_functions.py @@ -0,0 +1,179 @@ +"""Helper functions for the tiled ensemble training.""" + +import json + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +from pathlib import Path + +from jsonargparse import ArgumentParser, Namespace +from lightning import Trainer + +from anomalib.data import AnomalibDataModule, get_datamodule +from anomalib.models import AnomalyModule, get_model +from anomalib.utils.normalization import NormalizationMethod + +from . import NormalizationStage +from .ensemble_engine import TiledEnsembleEngine +from .ensemble_tiling import EnsembleTiler, TileCollater + + +def get_ensemble_datamodule(data_args: dict, tiler: EnsembleTiler, tile_index: tuple[int, int]) -> AnomalibDataModule: + """Get Anomaly Datamodule adjusted for use in ensemble. + + Datamodule collate function gets replaced by TileCollater in order to tile all images before they are passed on. + + Args: + data_args: tiled ensemble data configuration. + tiler (EnsembleTiler): Tiler used to split the images to tiles for use in ensemble. + tile_index (tuple[int, int]): Index of the tile in the split image. + + Returns: + AnomalibDataModule: Anomalib Lightning DataModule + """ + datamodule = get_datamodule(data_args) + # set custom collate function that does the tiling + datamodule.collate_fn = TileCollater(tiler, tile_index) + datamodule.setup() + + return datamodule + + +def get_ensemble_model(model_args: dict, tiler: EnsembleTiler) -> AnomalyModule: + """Get model prepared for ensemble training. + + Args: + model_args: tiled ensemble model configuration. + tiler (EnsembleTiler): tiler used to get tile dimensions. + + Returns: + AnomalyModule: model with input_size setup + """ + model = get_model(model_args) + # set model input size match tile size + model.set_input_size((tiler.tile_size_h, tiler.tile_size_w)) + + return model + + +def get_ensemble_tiler(tiling_args: dict, data_args: dict) -> EnsembleTiler: + """Get tiler used for image tiling and to obtain tile dimensions. + + Args: + tiling_args: tiled ensemble tiling configuration. + data_args: tiled ensemble data configuration. + + Returns: + EnsembleTiler: tiler object. + """ + tiler = EnsembleTiler( + tile_size=tiling_args["tile_size"], + stride=tiling_args["stride"], + image_size=data_args["init_args"]["image_size"], + ) + + return tiler # noqa: RET504 + + +def parse_trainer_kwargs(trainer_args: dict | None) -> Namespace | dict: + """Parse trainer args and instantiate all needed elements. + + Transforms config into kwargs ready for Trainer, including instantiation of callback etc. + + Args: + trainer_args (dict): Trainer args dictionary. + + Returns: + dict: parsed kwargs with instantiated elements. + """ + if not trainer_args: + return {} + + # try to get trainer args, if not present return empty + parser = ArgumentParser() + + parser.add_class_arguments(Trainer, fail_untyped=False, instantiate=False, sub_configs=True) + config = parser.parse_object(trainer_args) + objects = parser.instantiate_classes(config) + + return objects # noqa: RET504 + + +def get_ensemble_engine( + tile_index: tuple[int, int], + accelerator: str, + devices: list[int] | str | int, + root_dir: Path, + normalization_stage: str, + metrics: dict | None = None, + trainer_args: dict | None = None, +) -> TiledEnsembleEngine: + """Prepare engine for ensemble training or prediction. + + This method makes sure correct normalization is used, prepares metrics and additional trainer kwargs.. + + Args: + tile_index (tuple[int, int]): Index of tile that this model processes. + accelerator (str): Accelerator (device) to use. + devices (list[int] | str | int): device IDs used for training. + root_dir (Path): Root directory to save checkpoints, stats and images. + normalization_stage (str): Config dictionary for ensemble post-processing. + metrics (dict): Dict containing pixel and image metrics names. + trainer_args (dict): Trainer args dictionary. Empty dict if not present. + + Returns: + TiledEnsembleEngine: set up engine for ensemble training/prediction. + """ + # if we want tile level normalization we set it here, otherwise it's done later on joined images + if normalization_stage == NormalizationStage.TILE: + normalization = NormalizationMethod.MIN_MAX + else: + normalization = NormalizationMethod.NONE + + # parse additional trainer args and callbacks if present in config + trainer_kwargs = parse_trainer_kwargs(trainer_args) + # remove keys that we already have + trainer_kwargs.pop("accelerator", None) + trainer_kwargs.pop("default_root_dir", None) + trainer_kwargs.pop("devices", None) + + # create engine for specific tile location + engine = TiledEnsembleEngine( + tile_index=tile_index, + normalization=normalization, + accelerator=accelerator, + devices=devices, + default_root_dir=root_dir, + image_metrics=metrics.get("image", None) if metrics else None, + pixel_metrics=metrics.get("pixel", None) if metrics else None, + **trainer_kwargs, + ) + + return engine # noqa: RET504 + + +def get_threshold_values(normalization_stage: NormalizationStage, root_dir: Path) -> tuple[float, float]: + """Get threshold values for image and pixel level predictions. + + If normalization is not used, get values based on statistics obtained from validation set. + If normalization is used, both image and pixel threshold are 0.5 + + Args: + normalization_stage (NormalizationStage): ensemble run args, used to get normalization stage. + root_dir (Path): path to run root where stats file is saved. + + Returns: + tuple[float, float]: image and pixel threshold. + """ + if normalization_stage == NormalizationStage.NONE: + stats_path = root_dir / "weights" / "lightning" / "stats.json" + with stats_path.open("r") as f: + stats = json.load(f) + image_threshold = stats["image_threshold"] + pixel_threshold = stats["pixel_threshold"] + else: + # normalization transforms the scores so that threshold is at 0.5 + image_threshold = 0.5 + pixel_threshold = 0.5 + + return image_threshold, pixel_threshold diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_data.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_data.py new file mode 100644 index 0000000000..4fe45e9c4a --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_data.py @@ -0,0 +1,45 @@ +"""Classes used to store ensemble predictions.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from torch import Tensor + + +class EnsemblePredictions: + """Basic implementation of EnsemblePredictionData that keeps all predictions in main memory.""" + + def __init__(self) -> None: + super().__init__() + self.all_data: dict[tuple[int, int], list] = {} + + def add_tile_prediction(self, tile_index: tuple[int, int], tile_prediction: list[dict[str, Tensor | list]]) -> None: + """Add tile prediction data at provided index to class dictionary in main memory. + + Args: + tile_index (tuple[int, int]): Index of tile that we are adding in form (row, column). + tile_prediction (list[dict[str, Tensor | list]]): + List of batches containing all predicted data for current tile position. + + """ + self.num_batches = len(tile_prediction) + + self.all_data[tile_index] = tile_prediction + + def get_batch_tiles(self, batch_index: int) -> dict[tuple[int, int], dict]: + """Get all tiles of current batch from class dictionary. + + Called by merging mechanism. + + Args: + batch_index (int): Index of current batch of tiles to be returned. + + Returns: + dict[tuple[int, int], dict]: Dictionary mapping tile index to predicted data, for provided batch index. + """ + batch_data = {} + + for index, batches in self.all_data.items(): + batch_data[index] = batches[batch_index] + + return batch_data diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_merging.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_merging.py new file mode 100644 index 0000000000..7337cc4ffe --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_merging.py @@ -0,0 +1,167 @@ +"""Class used as mechanism to merge ensemble predictions from each tile into complete whole-image representation.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import torch +from torch import Tensor + +from .ensemble_tiling import EnsembleTiler +from .prediction_data import EnsemblePredictions + + +class PredictionMergingMechanism: + """Class used for merging the data predicted by each separate model of tiled ensemble. + + Tiles are stacked in one tensor and untiled using Ensemble Tiler. + Boxes from tiles are either stacked or generated anew from anomaly map. + Labels are combined with OR operator, meaning one anomalous tile -> anomalous image. + Scores are averaged across all tiles. + + Args: + ensemble_predictions (EnsemblePredictions): Object containing predictions on tile level. + tiler (EnsembleTiler): Tiler used to transform tiles back to image level representation. + + Example: + >>> from anomalib.pipelines.tiled_ensemble.components.utils.ensemble_tiling import EnsembleTiler + >>> from anomalib.pipelines.tiled_ensemble.components.utils.prediction_data import EnsemblePredictions + >>> + >>> tiler = EnsembleTiler(tile_size=256, stride=128, image_size=512) + >>> data = EnsemblePredictions() + >>> merger = PredictionMergingMechanism(data, tiler) + >>> + >>> # we can then start merging procedure for each batch + >>> merger.merge_tile_predictions(0) + """ + + def __init__(self, ensemble_predictions: EnsemblePredictions, tiler: EnsembleTiler) -> None: + assert ensemble_predictions.num_batches > 0, "There should be at least one batch for each tile prediction." + assert (0, 0) in ensemble_predictions.get_batch_tiles( + 0, + ), "Tile prediction dictionary should always have at least one tile" + + self.ensemble_predictions = ensemble_predictions + self.num_batches = self.ensemble_predictions.num_batches + + self.tiler = tiler + + def merge_tiles(self, batch_data: dict, tile_key: str) -> Tensor: + """Merge tiles back into one tensor and perform untiling with tiler. + + Args: + batch_data (dict): Dictionary containing all tile predictions of current batch. + tile_key (str): Key used in prediction dictionary for tiles that we want to merge. + + Returns: + Tensor: Tensor of tiles in original (stitched) shape. + """ + # batch of tiles with index (0, 0) always exists, so we use it to get some basic information + first_tiles = batch_data[0, 0][tile_key] + batch_size = first_tiles.shape[0] + device = first_tiles.device + + if tile_key == "mask": + # in case of ground truth masks, we don't have channels + merged_size = [ + self.tiler.num_patches_h, + self.tiler.num_patches_w, + batch_size, + self.tiler.tile_size_h, + self.tiler.tile_size_w, + ] + else: + # all tiles beside masks also have channels + num_channels = first_tiles.shape[1] + merged_size = [ + self.tiler.num_patches_h, + self.tiler.num_patches_w, + batch_size, + int(num_channels), + self.tiler.tile_size_h, + self.tiler.tile_size_w, + ] + + # create new empty tensor for merged tiles + merged_masks = torch.zeros(size=merged_size, device=device) + + # insert tile into merged tensor at right locations + for (tile_i, tile_j), tile_data in batch_data.items(): + merged_masks[tile_i, tile_j, ...] = tile_data[tile_key] + + if tile_key == "mask": + # add channel as tiler needs it + merged_masks = merged_masks.unsqueeze(3) + + # stitch tiles back into whole, output is [B, C, H, W] + merged_output = self.tiler.untile(merged_masks) + + if tile_key == "mask": + # remove previously added channels + merged_output = merged_output.squeeze(1) + + return merged_output + + def merge_labels_and_scores(self, batch_data: dict) -> dict[str, Tensor]: + """Join scores and their corresponding label predictions from all tiles for each image. + + Label merging is done by rule where one anomalous tile in image results in whole image being anomalous. + Scores are averaged over tiles. + + Args: + batch_data (dict): Dictionary containing all tile predictions of current batch. + + Returns: + dict[str, Tensor]: Dictionary with "pred_labels" and "pred_scores" + """ + # create accumulator with same shape as original + labels = torch.zeros(batch_data[0, 0]["pred_labels"].shape, dtype=torch.bool) + scores = torch.zeros(batch_data[0, 0]["pred_scores"].shape) + + for curr_tile_data in batch_data.values(): + curr_labels = curr_tile_data["pred_labels"] + curr_scores = curr_tile_data["pred_scores"] + + labels = labels.logical_or(curr_labels) + scores += curr_scores + + scores /= self.tiler.num_tiles + + return {"pred_labels": labels, "pred_scores": scores} + + def merge_tile_predictions(self, batch_index: int) -> dict[str, Tensor | list]: + """Join predictions from ensemble into whole image level representation for batch at index batch_index. + + Args: + batch_index (int): Index of current batch. + + Returns: + dict[str, Tensor | list]: List of merged predictions for specified batch. + """ + current_batch_data = self.ensemble_predictions.get_batch_tiles(batch_index) + + # take first tile as base prediction, keep items that are the same over all tiles: + # image_path, label, mask_path + merged_predictions = { + "image_path": current_batch_data[0, 0]["image_path"], + "label": current_batch_data[0, 0]["label"], + } + if "mask_path" in current_batch_data[0, 0]: + merged_predictions["mask_path"] = current_batch_data[0, 0]["mask_path"] + if "boxes" in current_batch_data[0, 0]: + merged_predictions["boxes"] = current_batch_data[0, 0]["boxes"] + + tiled_data = ["image", "mask"] + if "anomaly_maps" in current_batch_data[0, 0]: + tiled_data += ["anomaly_maps", "pred_masks"] + + # merge all tiled data + for t_key in tiled_data: + if t_key in current_batch_data[0, 0]: + merged_predictions[t_key] = self.merge_tiles(current_batch_data, t_key) + + # label and score merging + merged_scores_and_labels = self.merge_labels_and_scores(current_batch_data) + merged_predictions["pred_labels"] = merged_scores_and_labels["pred_labels"] + merged_predictions["pred_scores"] = merged_scores_and_labels["pred_scores"] + + return merged_predictions diff --git a/src/anomalib/pipelines/tiled_ensemble/components/visualization.py b/src/anomalib/pipelines/tiled_ensemble/components/visualization.py new file mode 100644 index 0000000000..1298ece89f --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/components/visualization.py @@ -0,0 +1,125 @@ +"""Tiled ensemble - visualization job.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Generator +from pathlib import Path +from typing import Any + +from tqdm import tqdm + +from anomalib import TaskType +from anomalib.data.utils.image import save_image +from anomalib.pipelines.components import Job, JobGenerator +from anomalib.pipelines.tiled_ensemble.components.utils import NormalizationStage +from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS +from anomalib.utils.visualization import ImageVisualizer + +logger = logging.getLogger(__name__) + + +class VisualizationJob(Job): + """Job for visualization of predictions. + + Args: + predictions (list[Any]): list of image-level predictions. + root_dir (Path): Root directory to save checkpoints, stats and images. + task (TaskType): type of task the predictions represent. + normalize (bool): if predictions need to be normalized + """ + + name = "Visualize" + + def __init__(self, predictions: list[Any], root_dir: Path, task: TaskType, normalize: bool) -> None: + super().__init__() + self.predictions = predictions + self.root_dir = root_dir / "images" + self.task = task + self.normalize = normalize + + def run(self, task_id: int | None = None) -> list[Any]: + """Run job that visualizes all prediction data. + + Args: + task_id: Not used in this case. + + Returns: + list[Any]: Unchanged predictions. + """ + del task_id # not needed here + + visualizer = ImageVisualizer(task=self.task, normalize=self.normalize) + + logger.info("Starting visualization.") + + for data in tqdm(self.predictions, desc="Visualizing"): + for result in visualizer(outputs=data): + # Finally image path is root/defect_type/image_name + if result.file_name is not None: + file_path = Path(result.file_name) + else: + msg = "file_path should exist in returned Visualizer." + raise ValueError(msg) + + root = self.root_dir / file_path.parent.name + filename = file_path.name + + save_image(image=result.image, root=root, filename=filename) + + return self.predictions + + @staticmethod + def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: + """Nothing to collect in this job. + + Returns: + list[Any]: Unchanged list of predictions. + """ + # take the first element as result is list of lists here + return results[0] + + @staticmethod + def save(results: GATHERED_RESULTS) -> None: + """This job doesn't save anything.""" + + +class VisualizationJobGenerator(JobGenerator): + """Generate VisualizationJob. + + Args: + root_dir (Path): Root directory where images will be saved (root/images). + """ + + def __init__(self, root_dir: Path, task: TaskType, normalization_stage: NormalizationStage) -> None: + self.root_dir = root_dir + self.task = task + self.normalize = normalization_stage == NormalizationStage.NONE + + @property + def job_class(self) -> type: + """Return the job class.""" + return VisualizationJob + + def generate_jobs( + self, + args: dict | None = None, + prev_stage_result: list[Any] | None = None, + ) -> Generator[VisualizationJob, None, None]: + """Return a generator producing a single visualization job. + + Args: + args: Ensemble run args. + prev_stage_result (list[Any]): Ensemble predictions from previous step. + + Returns: + Generator[VisualizationJob, None, None]: VisualizationJob generator + """ + del args # args not used here + + if prev_stage_result is not None: + yield VisualizationJob(prev_stage_result, self.root_dir, self.task, self.normalize) + else: + msg = "Visualization job requires tile level predictions from previous step." + raise ValueError(msg) diff --git a/src/anomalib/pipelines/tiled_ensemble/test_pipeline.py b/src/anomalib/pipelines/tiled_ensemble/test_pipeline.py new file mode 100644 index 0000000000..7fdd61e9ff --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/test_pipeline.py @@ -0,0 +1,124 @@ +"""Tiled ensemble test pipeline.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import logging +from pathlib import Path + +import torch + +from anomalib.data.utils import TestSplitMode +from anomalib.pipelines.components.base import Pipeline, Runner +from anomalib.pipelines.components.runners import ParallelRunner, SerialRunner +from anomalib.pipelines.tiled_ensemble.components import ( + MergeJobGenerator, + MetricsCalculationJobGenerator, + NormalizationJobGenerator, + PredictJobGenerator, + SmoothingJobGenerator, + ThresholdingJobGenerator, + VisualizationJobGenerator, +) +from anomalib.pipelines.tiled_ensemble.components.utils import NormalizationStage, PredictData, ThresholdStage + +logger = logging.getLogger(__name__) + + +class EvalTiledEnsemble(Pipeline): + """Tiled ensemble evaluation pipeline. + + Args: + root_dir (Path): Path to root dir of run that contains checkpoints. + """ + + def __init__(self, root_dir: Path) -> None: + self.root_dir = Path(root_dir) + + def _setup_runners(self, args: dict) -> list[Runner]: + """Set up the runners for the pipeline. + + This pipeline consists of jobs used to test/evaluate tiled ensemble: + Prediction on test data > merging of predictions > (optional) seam smoothing + > (optional) Normalization > (optional) Thresholding + > Visualisation of predictions > Metrics calculation. + + Returns: + list[Runner]: List of runners executing tiled ensemble testing jobs. + """ + runners: list[Runner] = [] + + if args["data"]["init_args"]["test_split_mode"] == TestSplitMode.NONE: + logger.info("Test split mode set to `none`, skipping test phase.") + return runners + + seed = args["seed"] + accelerator = args["accelerator"] + tiling_args = args["tiling"] + data_args = args["data"] + normalization_stage = NormalizationStage(args["normalization_stage"]) + threshold_stage = ThresholdStage(args["thresholding"]["stage"]) + model_args = args["TrainModels"]["model"] + task = args["data"]["init_args"]["task"] + metrics = args["TrainModels"]["metrics"] + + predict_job_generator = PredictJobGenerator( + PredictData.TEST, + seed=seed, + accelerator=accelerator, + root_dir=self.root_dir, + tiling_args=tiling_args, + data_args=data_args, + model_args=model_args, + normalization_stage=normalization_stage, + ) + # 1. predict using test data + if accelerator == "cuda": + runners.append( + ParallelRunner( + predict_job_generator, + n_jobs=torch.cuda.device_count(), + ), + ) + else: + runners.append( + SerialRunner( + predict_job_generator, + ), + ) + # 2. merge predictions + runners.append(SerialRunner(MergeJobGenerator(tiling_args=tiling_args, data_args=data_args))) + + # 3. (optional) smooth seams + if args["SeamSmoothing"]["apply"]: + runners.append( + SerialRunner( + SmoothingJobGenerator(accelerator=accelerator, tiling_args=tiling_args, data_args=data_args), + ), + ) + + # 4. (optional) normalize + if normalization_stage == NormalizationStage.IMAGE: + runners.append(SerialRunner(NormalizationJobGenerator(self.root_dir))) + # 5. (optional) threshold to get labels from scores + if threshold_stage == ThresholdStage.IMAGE: + runners.append(SerialRunner(ThresholdingJobGenerator(self.root_dir, normalization_stage))) + + # 6. visualize predictions + runners.append( + SerialRunner(VisualizationJobGenerator(self.root_dir, task=task, normalization_stage=normalization_stage)), + ) + # calculate metrics + runners.append( + SerialRunner( + MetricsCalculationJobGenerator( + accelerator=accelerator, + root_dir=self.root_dir, + task=task, + metrics=metrics, + normalization_stage=normalization_stage, + ), + ), + ) + + return runners diff --git a/src/anomalib/pipelines/tiled_ensemble/train_pipeline.py b/src/anomalib/pipelines/tiled_ensemble/train_pipeline.py new file mode 100644 index 0000000000..38e4e34e4b --- /dev/null +++ b/src/anomalib/pipelines/tiled_ensemble/train_pipeline.py @@ -0,0 +1,123 @@ +"""Tiled ensemble training pipeline.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from typing import TYPE_CHECKING + +from anomalib.data.utils import ValSplitMode + +if TYPE_CHECKING: + from pathlib import Path + +import logging + +import torch + +from anomalib.pipelines.components.base import Pipeline, Runner +from anomalib.pipelines.components.runners import ParallelRunner, SerialRunner + +from .components import ( + MergeJobGenerator, + PredictJobGenerator, + SmoothingJobGenerator, + StatisticsJobGenerator, + TrainModelJobGenerator, +) +from .components.utils import NormalizationStage, PredictData +from .components.utils.ensemble_engine import TiledEnsembleEngine + +logger = logging.getLogger(__name__) + + +class TrainTiledEnsemble(Pipeline): + """Tiled ensemble training pipeline.""" + + def __init__(self) -> None: + self.root_dir: Path + + def _setup_runners(self, args: dict) -> list[Runner]: + """Setup the runners for the pipeline. + + This pipeline consists of training and validation steps: + Training models > prediction on val data > merging val data > + > (optionally) smoothing seams > calculation of post-processing statistics + + Returns: + list[Runner]: List of runners executing tiled ensemble train + val jobs. + """ + runners: list[Runner] = [] + self.root_dir = TiledEnsembleEngine.setup_ensemble_workspace(args) + + seed = args["seed"] + accelerator = args["accelerator"] + tiling_args = args["tiling"] + data_args = args["data"] + normalization_stage = NormalizationStage(args["normalization_stage"]) + thresholding_method = args["thresholding"]["method"] + model_args = args["TrainModels"]["model"] + + train_job_generator = TrainModelJobGenerator( + seed=seed, + accelerator=accelerator, + root_dir=self.root_dir, + tiling_args=tiling_args, + data_args=data_args, + normalization_stage=normalization_stage, + ) + + predict_job_generator = PredictJobGenerator( + data_source=PredictData.VAL, + seed=seed, + accelerator=accelerator, + root_dir=self.root_dir, + tiling_args=tiling_args, + data_args=data_args, + model_args=model_args, + normalization_stage=normalization_stage, + ) + + # 1. train + if accelerator == "cuda": + runners.append( + ParallelRunner( + train_job_generator, + n_jobs=torch.cuda.device_count(), + ), + ) + else: + runners.append( + SerialRunner( + train_job_generator, + ), + ) + + if data_args["init_args"]["val_split_mode"] == ValSplitMode.NONE: + logger.warning("No validation set provided, skipping statistics calculation.") + return runners + + # 2. predict using validation data + if accelerator == "cuda": + runners.append( + ParallelRunner(predict_job_generator, n_jobs=torch.cuda.device_count()), + ) + else: + runners.append( + SerialRunner(predict_job_generator), + ) + + # 3. merge predictions + runners.append(SerialRunner(MergeJobGenerator(tiling_args=tiling_args, data_args=data_args))) + + # 4. (optional) smooth seams + if args["SeamSmoothing"]["apply"]: + runners.append( + SerialRunner( + SmoothingJobGenerator(accelerator=accelerator, tiling_args=tiling_args, data_args=data_args), + ), + ) + + # 5. calculate statistics used for inference + runners.append(SerialRunner(StatisticsJobGenerator(self.root_dir, thresholding_method))) + + return runners diff --git a/tests/integration/pipelines/test_tiled_ensemble.py b/tests/integration/pipelines/test_tiled_ensemble.py new file mode 100644 index 0000000000..2909311276 --- /dev/null +++ b/tests/integration/pipelines/test_tiled_ensemble.py @@ -0,0 +1,62 @@ +"""Test tiled ensemble training and prediction.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + +import pytest +import yaml + +from anomalib.pipelines.tiled_ensemble import EvalTiledEnsemble, TrainTiledEnsemble + + +@pytest.fixture(scope="session") +def get_mock_environment(dataset_path: Path, project_path: Path) -> Path: + """Return mock directory for testing with datapath setup to dummy data.""" + ens_temp_dir = project_path / "ens_tmp" + ens_temp_dir.mkdir(exist_ok=True) + + with Path("tests/integration/pipelines/tiled_ensemble.yaml").open(encoding="utf-8") as file: + config = yaml.safe_load(file) + + # use separate project temp dir to avoid messing with other tests + config["default_root_dir"] = str(ens_temp_dir) + config["data"]["init_args"]["root"] = str(dataset_path / "mvtec") + + with (Path(ens_temp_dir) / "tiled_ensemble.yaml").open("w", encoding="utf-8") as file: + yaml.safe_dump(config, file) + + return Path(ens_temp_dir) + + +def test_train(get_mock_environment: Path, capsys: pytest.CaptureFixture) -> None: + """Test training of the tiled ensemble.""" + train_pipeline = TrainTiledEnsemble() + train_parser = train_pipeline.get_parser() + args = train_parser.parse_args(["--config", str(get_mock_environment / "tiled_ensemble.yaml")]) + train_pipeline.run(args) + # check that no errors were printed -> all stages were successful + out = capsys.readouterr().out + assert not any(line.startswith("There were some errors") for line in out.split("\n")) + + +def test_predict(get_mock_environment: Path, capsys: pytest.CaptureFixture) -> None: + """Test prediction with the tiled ensemble.""" + predict_pipeline = EvalTiledEnsemble(root_dir=get_mock_environment / "Padim" / "MVTec" / "dummy" / "v0") + predict_parser = predict_pipeline.get_parser() + args = predict_parser.parse_args(["--config", str(get_mock_environment / "tiled_ensemble.yaml")]) + predict_pipeline.run(args) + # check that no errors were printed -> all stages were successful + out = capsys.readouterr().out + assert not any(line.startswith("There were some errors") for line in out.split("\n")) + + +def test_visualisation(get_mock_environment: Path) -> None: + """Test that images were produced.""" + assert (get_mock_environment / "Padim/MVTec/dummy/v0/images/bad/000.png").exists() + + +def test_metric_results(get_mock_environment: Path) -> None: + """Test that metrics were saved.""" + assert (get_mock_environment / "Padim/MVTec/dummy/v0/metric_results.csv").exists() diff --git a/tests/integration/pipelines/tiled_ensemble.yaml b/tests/integration/pipelines/tiled_ensemble.yaml new file mode 100644 index 0000000000..8d35be8297 --- /dev/null +++ b/tests/integration/pipelines/tiled_ensemble.yaml @@ -0,0 +1,43 @@ +seed: 42 +accelerator: "cpu" +default_root_dir: "results" + +tiling: + tile_size: [50, 50] + stride: 50 + +normalization_stage: image # on what level we normalize, options: [tile, image, none] +thresholding: + method: F1AdaptiveThreshold # refer to documentation for thresholding methods + stage: image # stage at which we apply threshold, options: [tile, image] + +data: + class_path: anomalib.data.MVTec + init_args: + root: toBeSetup + category: dummy + train_batch_size: 32 + eval_batch_size: 32 + num_workers: 0 + task: segmentation + transform: null + train_transform: null + eval_transform: null + test_split_mode: from_dir + test_split_ratio: 0.2 + val_split_mode: same_as_test + val_split_ratio: 0.5 + image_size: [50, 100] + +SeamSmoothing: + apply: True # if this is applied, area around tile seams are is smoothed + sigma: 2 # sigma of gaussian filter used to smooth this area + width: 0.1 # width factor, multiplied by tile dimension gives the region width around seam which will be smoothed + +TrainModels: + model: + class_path: Padim + + metrics: + pixel: AUROC + image: AUROC diff --git a/tests/unit/pipelines/__init__.py b/tests/unit/pipelines/__init__.py new file mode 100644 index 0000000000..46de40af76 --- /dev/null +++ b/tests/unit/pipelines/__init__.py @@ -0,0 +1,4 @@ +"""Pipeline unit tests.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/pipelines/tiled_ensemble/__init__.py b/tests/unit/pipelines/tiled_ensemble/__init__.py new file mode 100644 index 0000000000..a78a1ad659 --- /dev/null +++ b/tests/unit/pipelines/tiled_ensemble/__init__.py @@ -0,0 +1,4 @@ +"""Tiled ensemble unit tests.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/pipelines/tiled_ensemble/conftest.py b/tests/unit/pipelines/tiled_ensemble/conftest.py new file mode 100644 index 0000000000..b4fad61ebb --- /dev/null +++ b/tests/unit/pipelines/tiled_ensemble/conftest.py @@ -0,0 +1,151 @@ +"""Fixtures that are used in tiled ensemble testing.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +import torch +import yaml + +from anomalib.data import AnomalibDataModule +from anomalib.models import AnomalyModule +from anomalib.pipelines.tiled_ensemble.components.utils.ensemble_tiling import EnsembleTiler +from anomalib.pipelines.tiled_ensemble.components.utils.helper_functions import ( + get_ensemble_datamodule, + get_ensemble_model, + get_ensemble_tiler, +) +from anomalib.pipelines.tiled_ensemble.components.utils.prediction_data import EnsemblePredictions +from anomalib.pipelines.tiled_ensemble.components.utils.prediction_merging import PredictionMergingMechanism + + +@pytest.fixture(scope="module") +def get_ensemble_config(dataset_path: Path) -> dict: + """Return ensemble dummy config dict with corrected dataset path to dummy temp dir.""" + with Path("tests/unit/pipelines/tiled_ensemble/dummy_config.yaml").open(encoding="utf-8") as file: + config = yaml.safe_load(file) + # dummy dataset + config["data"]["init_args"]["root"] = dataset_path / "mvtec" + + return config + + +@pytest.fixture(scope="module") +def get_tiler(get_ensemble_config: dict) -> EnsembleTiler: + """Return EnsembleTiler object based on test dummy config.""" + config = get_ensemble_config + + return get_ensemble_tiler(config["tiling"], config["data"]) + + +@pytest.fixture(scope="module") +def get_model(get_ensemble_config: dict, get_tiler: EnsembleTiler) -> AnomalyModule: + """Return model prepared for tiled ensemble training.""" + config = get_ensemble_config + tiler = get_tiler + + return get_ensemble_model(config["TrainModels"]["model"], tiler) + + +@pytest.fixture(scope="module") +def get_datamodule(get_ensemble_config: dict, get_tiler: EnsembleTiler) -> AnomalibDataModule: + """Return ensemble datamodule.""" + config = get_ensemble_config + tiler = get_tiler + datamodule = get_ensemble_datamodule(config, tiler, (0, 0)) + datamodule.setup() + + return datamodule + + +@pytest.fixture(scope="module") +def get_tile_predictions(get_datamodule: AnomalibDataModule) -> EnsemblePredictions: + """Return tile predictions inside EnsemblePredictions object.""" + datamodule = get_datamodule + + data = EnsemblePredictions() + + for tile_index in [(0, 0), (0, 1), (1, 0), (1, 1)]: + datamodule.collate_fn.tile_index = tile_index + + tile_prediction = [] + batch = next(iter(datamodule.test_dataloader())) + + # make mock labels and scores + batch["pred_scores"] = torch.rand(batch["label"].shape) + batch["pred_labels"] = batch["pred_scores"] > 0.5 + + # set mock maps to just one channel of image + batch["anomaly_maps"] = batch["image"].clone()[:, 0, :, :].unsqueeze(1) + # set mock pred mask to mask but add channel + batch["pred_masks"] = batch["mask"].clone().unsqueeze(1) + + tile_prediction.append(batch) + + # store to prediction storage object + data.add_tile_prediction(tile_index, tile_prediction) + + return data + + +@pytest.fixture(scope="module") +def get_batch_predictions() -> list[dict]: + """Return mock batched predictions.""" + mock_data = { + "image": torch.rand((5, 3, 100, 100)), + "mask": (torch.rand((5, 100, 100)) > 0.5).type(torch.float32), + "anomaly_maps": torch.rand((5, 1, 100, 100)), + "label": torch.Tensor([0, 1, 1, 0, 1]), + "pred_scores": torch.rand(5), + "pred_labels": torch.ones(5), + "pred_masks": torch.zeros((5, 100, 100)), + } + + return [mock_data, mock_data] + + +@pytest.fixture(scope="module") +def get_merging_mechanism( + get_tile_predictions: EnsemblePredictions, + get_tiler: EnsembleTiler, +) -> PredictionMergingMechanism: + """Return ensemble prediction merging mechanism object.""" + tiler = get_tiler + predictions = get_tile_predictions + return PredictionMergingMechanism(predictions, tiler) + + +@pytest.fixture(scope="module") +def get_mock_stats_dir() -> Path: + """Get temp dir containing statistics.""" + with TemporaryDirectory() as temp_dir: + stats = { + "minmax": { + "anomaly_maps": { + "min": 1.9403648376464844, + "max": 209.91940307617188, + }, + "box_scores": { + "min": 0.5, + "max": 0.45, + }, + "pred_scores": { + "min": 9.390382766723633, + "max": 209.91940307617188, + }, + }, + "image_threshold": 0.1111, + "pixel_threshold": 0.1111, + } + stats_path = Path(temp_dir) / "weights" / "lightning" / "stats.json" + stats_path.parent.mkdir(parents=True) + + # save mock statistics + with stats_path.open("w", encoding="utf-8") as stats_file: + json.dump(stats, stats_file, ensure_ascii=False, indent=4) + + yield Path(temp_dir) diff --git a/tests/unit/pipelines/tiled_ensemble/dummy_config.yaml b/tests/unit/pipelines/tiled_ensemble/dummy_config.yaml new file mode 100644 index 0000000000..fcd4b7c716 --- /dev/null +++ b/tests/unit/pipelines/tiled_ensemble/dummy_config.yaml @@ -0,0 +1,52 @@ +seed: 42 +accelerator: "cpu" +default_root_dir: "results" + +tiling: + tile_size: [50, 50] + stride: 50 + +normalization_stage: image # on what level we normalize, options: [tile, image, none] +thresholding: + method: F1AdaptiveThreshold # refer to documentation for thresholding methods + stage: image # stage at which we apply threshold, options: [tile, image] + +data: + class_path: anomalib.data.MVTec + init_args: + root: toBeSetup + category: dummy + train_batch_size: 32 + eval_batch_size: 32 + num_workers: 0 + task: segmentation + transform: null + train_transform: null + eval_transform: null + test_split_mode: from_dir + test_split_ratio: 0.2 + val_split_mode: same_as_test + val_split_ratio: 0.5 + image_size: [100, 100] + +SeamSmoothing: + apply: True # if this is applied, area around tile seams are is smoothed + sigma: 2 # sigma of gaussian filter used to smooth this area + width: 0.1 # width factor, multiplied by tile dimension gives the region width around seam which will be smoothed + +TrainModels: + model: + class_path: Fastflow + + metrics: + pixel: AUROC + image: AUROC + + trainer: + max_epochs: 1 + callbacks: + - class_path: lightning.pytorch.callbacks.EarlyStopping + init_args: + patience: 1 + monitor: pixel_AUROC + mode: max diff --git a/tests/unit/pipelines/tiled_ensemble/test_components.py b/tests/unit/pipelines/tiled_ensemble/test_components.py new file mode 100644 index 0000000000..0e3c0dcdd4 --- /dev/null +++ b/tests/unit/pipelines/tiled_ensemble/test_components.py @@ -0,0 +1,387 @@ +"""Test working of tiled ensemble pipeline components.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import copy +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +import torch + +from anomalib.data import get_datamodule +from anomalib.metrics import F1AdaptiveThreshold, ManualThreshold +from anomalib.pipelines.tiled_ensemble.components import ( + MergeJobGenerator, + MetricsCalculationJobGenerator, + NormalizationJobGenerator, + SmoothingJobGenerator, + StatisticsJobGenerator, + ThresholdingJobGenerator, +) +from anomalib.pipelines.tiled_ensemble.components.metrics_calculation import MetricsCalculationJob +from anomalib.pipelines.tiled_ensemble.components.smoothing import SmoothingJob +from anomalib.pipelines.tiled_ensemble.components.utils import NormalizationStage +from anomalib.pipelines.tiled_ensemble.components.utils.prediction_data import EnsemblePredictions +from anomalib.pipelines.tiled_ensemble.components.utils.prediction_merging import PredictionMergingMechanism + + +class TestMerging: + """Test merging mechanism and merging job.""" + + @staticmethod + def test_tile_merging(get_ensemble_config: dict, get_merging_mechanism: PredictionMergingMechanism) -> None: + """Test tiled data merging.""" + config = get_ensemble_config + merger = get_merging_mechanism + + # prepared original data + datamodule = get_datamodule(config) + datamodule.prepare_data() + datamodule.setup() + original_data = next(iter(datamodule.test_dataloader())) + + batch = merger.ensemble_predictions.get_batch_tiles(0) + + merged_image = merger.merge_tiles(batch, "image") + assert merged_image.equal(original_data["image"]) + + merged_mask = merger.merge_tiles(batch, "mask") + assert merged_mask.equal(original_data["mask"]) + + @staticmethod + def test_label_and_score_merging(get_merging_mechanism: PredictionMergingMechanism) -> None: + """Test label and score merging.""" + merger = get_merging_mechanism + scores = torch.rand(4, 10) + labels = scores > 0.5 + + mock_data = {(0, 0): {}, (0, 1): {}, (1, 0): {}, (1, 1): {}} + + for i, data in enumerate(mock_data.values()): + data["pred_scores"] = scores[i] + data["pred_labels"] = labels[i] + + merged = merger.merge_labels_and_scores(mock_data) + + assert merged["pred_scores"].equal(scores.mean(dim=0)) + + assert merged["pred_labels"].equal(labels.any(dim=0)) + + @staticmethod + def test_merge_job( + get_tile_predictions: EnsemblePredictions, + get_ensemble_config: dict, + get_merging_mechanism: PredictionMergingMechanism, + ) -> None: + """Test merging job execution.""" + config = get_ensemble_config + predictions = copy.deepcopy(get_tile_predictions) + merging_mechanism = get_merging_mechanism + + merging_job_generator = MergeJobGenerator(tiling_args=config["tiling"], data_args=config["data"]) + merging_job = next(merging_job_generator.generate_jobs(prev_stage_result=predictions)) + + merged_direct = merging_mechanism.merge_tile_predictions(0) + merged_with_job = merging_job.run()[0] + + # check that merging by job is same as with the mechanism directly + for key, value in merged_direct.items(): + if isinstance(value, torch.Tensor): + assert merged_with_job[key].equal(value) + elif isinstance(value, list) and isinstance(value[0], torch.Tensor): + # boxes + assert all(j.equal(d) for j, d in zip(merged_with_job[key], value, strict=False)) + else: + assert merged_with_job[key] == value + + +class TestStatsCalculation: + """Test post-processing statistics calculations.""" + + @staticmethod + @pytest.mark.parametrize( + ("threshold_str", "threshold_cls"), + [("F1AdaptiveThreshold", F1AdaptiveThreshold), ("ManualThreshold", ManualThreshold)], + ) + def test_threshold_method(threshold_str: str, threshold_cls: type, get_ensemble_config: dict) -> None: + """Test that correct thresholding method is used.""" + config = copy.deepcopy(get_ensemble_config) + config["thresholding"]["method"] = threshold_str + + stats_job_generator = StatisticsJobGenerator(Path("mock"), threshold_str) + stats_job = next(stats_job_generator.generate_jobs(None, None)) + + assert isinstance(stats_job.image_threshold, threshold_cls) + + @staticmethod + def test_stats_run(project_path: Path) -> None: + """Test execution of statistics calc. job.""" + mock_preds = [ + { + "pred_scores": torch.rand(4), + "label": torch.ones(4), + "anomaly_maps": torch.rand(4, 1, 50, 50), + "mask": torch.ones(4, 1, 50, 50), + }, + ] + + stats_job_generator = StatisticsJobGenerator(project_path, "F1AdaptiveThreshold") + stats_job = next(stats_job_generator.generate_jobs(None, mock_preds)) + + results = stats_job.run() + + assert "minmax" in results + assert "image_threshold" in results + assert "pixel_threshold" in results + + # save as it's removed from results + save_path = results["save_path"] + stats_job.save(results) + assert Path(save_path).exists() + + @staticmethod + @pytest.mark.parametrize( + ("key", "values"), + [ + ("anomaly_maps", [torch.rand(5, 1, 50, 50), torch.rand(5, 1, 50, 50)]), + ("pred_scores", [torch.rand(5), torch.rand(5)]), + ], + ) + def test_minmax(key: str, values: list) -> None: + """Test minmax stats calculation.""" + # add given keys to test all possible sources of minmax + data = [ + {"pred_scores": torch.rand(5), "label": torch.ones(5), key: values[0]}, + {"pred_scores": torch.rand(5), "label": torch.ones(5), key: values[1]}, + ] + + stats_job_generator = StatisticsJobGenerator(Path("mock"), "F1AdaptiveThreshold") + stats_job = next(stats_job_generator.generate_jobs(None, data)) + results = stats_job.run() + + if isinstance(values[0], list): + values[0] = torch.cat(values[0]) + values[1] = torch.cat(values[1]) + values = torch.stack(values) + + assert results["minmax"][key]["min"] == torch.min(values) + assert results["minmax"][key]["max"] == torch.max(values) + + @staticmethod + @pytest.mark.parametrize( + ("labels", "preds", "target_threshold"), + [ + (torch.Tensor([0, 0, 0, 1, 1]), torch.Tensor([2.3, 1.6, 2.6, 7.9, 3.3]), 3.3), # standard case + (torch.Tensor([1, 0, 0, 0]), torch.Tensor([4, 3, 2, 1]), 4), # 100% recall for all thresholds + ], + ) + def test_threshold(labels: torch.Tensor, preds: torch.Tensor, target_threshold: float) -> None: + """Test threshold calculation job.""" + data = [ + { + "label": labels, + "mask": labels, + "pred_scores": preds, + "anomaly_maps": preds, + }, + ] + + stats_job_generator = StatisticsJobGenerator(Path("mock"), "F1AdaptiveThreshold") + stats_job = next(stats_job_generator.generate_jobs(None, data)) + results = stats_job.run() + + assert round(results["image_threshold"], 5) == target_threshold + assert round(results["pixel_threshold"], 5) == target_threshold + + +class TestMetrics: + """Test ensemble metrics.""" + + @pytest.fixture(scope="class") + @staticmethod + def get_ensemble_metrics_job( + get_ensemble_config: dict, + get_batch_predictions: list[dict], + ) -> tuple[MetricsCalculationJob, str]: + """Return Metrics calculation job and path to directory where metrics csv will be saved.""" + config = get_ensemble_config + with TemporaryDirectory() as tmp_dir: + metrics = MetricsCalculationJobGenerator( + config["accelerator"], + root_dir=Path(tmp_dir), + task=config["data"]["init_args"]["task"], + metrics=config["TrainModels"]["metrics"], + normalization_stage=NormalizationStage(config["normalization_stage"]), + ) + + mock_predictions = get_batch_predictions + + return next(metrics.generate_jobs(prev_stage_result=copy.deepcopy(mock_predictions))), tmp_dir + + @staticmethod + def test_metrics_result(get_ensemble_metrics_job: tuple[MetricsCalculationJob, str]) -> None: + """Test metrics result.""" + metrics_job, _ = get_ensemble_metrics_job + + result = metrics_job.run() + + assert "pixel_AUROC" in result + assert "image_AUROC" in result + + @staticmethod + def test_metrics_saving(get_ensemble_metrics_job: tuple[MetricsCalculationJob, str]) -> None: + """Test metrics saving to csv.""" + metrics_job, tmp_dir = get_ensemble_metrics_job + + result = metrics_job.run() + metrics_job.save(result) + assert (Path(tmp_dir) / "metric_results.csv").exists() + + +class TestJoinSmoothing: + """Test JoinSmoothing job responsible for smoothing area at tile seams.""" + + @pytest.fixture(scope="class") + @staticmethod + def get_join_smoothing_job(get_ensemble_config: dict, get_batch_predictions: list[dict]) -> SmoothingJob: + """Make and return SmoothingJob instance.""" + config = get_ensemble_config + job_gen = SmoothingJobGenerator( + accelerator=config["accelerator"], + tiling_args=config["tiling"], + data_args=config["data"], + ) + # copy since smoothing changes data + mock_predictions = copy.deepcopy(get_batch_predictions) + return next(job_gen.generate_jobs(config["SeamSmoothing"], mock_predictions)) + + @staticmethod + def test_mask(get_join_smoothing_job: SmoothingJob) -> None: + """Test seam mask in case where tiles don't overlap.""" + smooth = get_join_smoothing_job + + join_index = smooth.tiler.tile_size_h, smooth.tiler.tile_size_w + + # seam should be covered by True + assert smooth.seam_mask[join_index] + + # non-seam region should be false + assert not smooth.seam_mask[0, 0] + assert not smooth.seam_mask[-1, -1] + + @staticmethod + def test_mask_overlapping(get_ensemble_config: dict, get_batch_predictions: list[dict]) -> None: + """Test seam mask in case where tiles overlap.""" + config = copy.deepcopy(get_ensemble_config) + # tile size = 50, stride = 25 -> overlapping + config["tiling"]["stride"] = 25 + job_gen = SmoothingJobGenerator( + accelerator=config["accelerator"], + tiling_args=config["tiling"], + data_args=config["data"], + ) + mock_predictions = copy.deepcopy(get_batch_predictions) + smooth = next(job_gen.generate_jobs(config["SeamSmoothing"], mock_predictions)) + + join_index = smooth.tiler.stride_h, smooth.tiler.stride_w + + # overlap seam should be covered by True + assert smooth.seam_mask[join_index] + assert smooth.seam_mask[-join_index[0], -join_index[1]] + + # non-seam region should be false + assert not smooth.seam_mask[0, 0] + assert not smooth.seam_mask[-1, -1] + + @staticmethod + def test_smoothing(get_join_smoothing_job: SmoothingJob, get_batch_predictions: list[dict]) -> None: + """Test smoothing job run.""" + original_data = get_batch_predictions + # fixture makes a copy of data + smooth = get_join_smoothing_job + + # take first batch + smoothed = smooth.run()[0] + join_index = smooth.tiler.tile_size_h, smooth.tiler.tile_size_w + + # join sections should be processed + assert not smoothed["anomaly_maps"][:, :, join_index].equal(original_data[0]["anomaly_maps"][:, :, join_index]) + + # non-join section shouldn't be changed + assert smoothed["anomaly_maps"][:, :, 0, 0].equal(original_data[0]["anomaly_maps"][:, :, 0, 0]) + + +def test_normalization(get_batch_predictions: list[dict], project_path: Path) -> None: + """Test normalization step.""" + original_predictions = copy.deepcopy(get_batch_predictions) + + for batch in original_predictions: + batch["anomaly_maps"] *= 100 + batch["pred_scores"] *= 100 + + # # get and save stats using stats job on predictions + stats_job_generator = StatisticsJobGenerator(project_path, "F1AdaptiveThreshold") + stats_job = next(stats_job_generator.generate_jobs(prev_stage_result=original_predictions)) + stats = stats_job.run() + stats_job.save(stats) + + # normalize predictions based on obtained stats + norm_job_generator = NormalizationJobGenerator(root_dir=project_path) + # copy as this changes preds + norm_job = next(norm_job_generator.generate_jobs(prev_stage_result=original_predictions)) + normalized_predictions = norm_job.run() + + for batch in normalized_predictions: + assert (batch["anomaly_maps"] >= 0).all() + assert (batch["anomaly_maps"] <= 1).all() + + assert (batch["pred_scores"] >= 0).all() + assert (batch["pred_scores"] <= 1).all() + + +class TestThresholding: + """Test tiled ensemble thresholding stage.""" + + @pytest.fixture(scope="class") + @staticmethod + def get_threshold_job(get_mock_stats_dir: Path) -> callable: + """Return a function that takes prediction data and runs threshold job.""" + thresh_job_generator = ThresholdingJobGenerator( + root_dir=get_mock_stats_dir, + normalization_stage=NormalizationStage.IMAGE, + ) + + def thresh_helper(preds: dict) -> list | None: + thresh_job = next(thresh_job_generator.generate_jobs(prev_stage_result=preds)) + return thresh_job.run() + + return thresh_helper + + @staticmethod + def test_score_threshold(get_threshold_job: callable) -> None: + """Test anomaly score thresholding.""" + thresholding = get_threshold_job + + data = [{"pred_scores": torch.tensor([0.7, 0.8, 0.1, 0.33, 0.5])}] + + thresholded = thresholding(data)[0] + + assert thresholded["pred_labels"].equal(torch.tensor([True, True, False, False, True])) + + @staticmethod + def test_anomap_threshold(get_threshold_job: callable) -> None: + """Test anomaly map thresholding.""" + thresholding = get_threshold_job + + data = [ + { + "pred_scores": torch.tensor([0.7, 0.8, 0.1, 0.33, 0.5]), + "anomaly_maps": torch.tensor([[0.7, 0.8, 0.1], [0.33, 0.5, 0.1]]), + }, + ] + + thresholded = thresholding(data)[0] + + assert thresholded["pred_masks"].equal(torch.tensor([[True, True, False], [False, True, False]])) diff --git a/tests/unit/pipelines/tiled_ensemble/test_helper_functions.py b/tests/unit/pipelines/tiled_ensemble/test_helper_functions.py new file mode 100644 index 0000000000..06e5864cef --- /dev/null +++ b/tests/unit/pipelines/tiled_ensemble/test_helper_functions.py @@ -0,0 +1,113 @@ +"""Test ensemble helper functions.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + +import pytest +from jsonargparse import Namespace +from lightning.pytorch.callbacks import EarlyStopping + +from anomalib.callbacks.normalization import _MinMaxNormalizationCallback +from anomalib.models import AnomalyModule +from anomalib.pipelines.tiled_ensemble.components.utils import NormalizationStage +from anomalib.pipelines.tiled_ensemble.components.utils.ensemble_tiling import EnsembleTiler, TileCollater +from anomalib.pipelines.tiled_ensemble.components.utils.helper_functions import ( + get_ensemble_datamodule, + get_ensemble_engine, + get_ensemble_model, + get_ensemble_tiler, + get_threshold_values, + parse_trainer_kwargs, +) + + +class TestHelperFunctions: + """Test ensemble helper functions.""" + + @staticmethod + def test_ensemble_datamodule(get_ensemble_config: dict, get_tiler: EnsembleTiler) -> None: + """Test that datamodule is created and has correct collate function.""" + config = get_ensemble_config + tiler = get_tiler + datamodule = get_ensemble_datamodule(config, tiler, (0, 0)) + + assert isinstance(datamodule.collate_fn, TileCollater) + + @staticmethod + def test_ensemble_model(get_ensemble_config: dict, get_tiler: EnsembleTiler) -> None: + """Test that model is successfully created with correct input shape.""" + config = get_ensemble_config + tiler = get_tiler + model = get_ensemble_model(config["TrainModels"]["model"], tiler) + + assert model.input_size == tuple(config["tiling"]["tile_size"]) + + @staticmethod + def test_tiler(get_ensemble_config: dict) -> None: + """Test that tiler is successfully instantiated.""" + config = get_ensemble_config + + tiler = get_ensemble_tiler(config["tiling"], config["data"]) + assert isinstance(tiler, EnsembleTiler) + + @staticmethod + def test_trainer_kwargs(get_ensemble_config: dict) -> None: + """Test that objects are correctly constructed from kwargs.""" + config = get_ensemble_config + + objects = parse_trainer_kwargs(config["TrainModels"]["trainer"]) + assert isinstance(objects, Namespace) + # verify that early stopping is parsed and added to callbacks + assert isinstance(objects.callbacks[0], EarlyStopping) + + @staticmethod + @pytest.mark.parametrize( + "normalization_stage", + [NormalizationStage.NONE, NormalizationStage.IMAGE, NormalizationStage.TILE], + ) + def test_threshold_values(normalization_stage: NormalizationStage, get_mock_stats_dir: Path) -> None: + """Test that threshold values are correctly set based on normalization stage.""" + stats_dir = get_mock_stats_dir + + i_thresh, p_thresh = get_threshold_values(normalization_stage, stats_dir) + + if normalization_stage != NormalizationStage.NONE: + # minmax normalization sets thresholds to 0.5 + assert i_thresh == p_thresh == 0.5 + else: + assert i_thresh == p_thresh == 0.1111 + + +class TestEnsembleEngine: + """Test ensemble engine configuration.""" + + @staticmethod + @pytest.mark.parametrize( + "normalization_stage", + [NormalizationStage.NONE, NormalizationStage.IMAGE, NormalizationStage.TILE], + ) + def test_normalisation(normalization_stage: NormalizationStage, get_model: AnomalyModule) -> None: + """Test that normalization callback is correctly initialized.""" + engine = get_ensemble_engine( + tile_index=(0, 0), + accelerator="cpu", + devices="1", + root_dir=Path("mock"), + normalization_stage=normalization_stage, + ) + + engine._setup_anomalib_callbacks(get_model) # noqa: SLF001 + + # verify that only in case of tile level normalization the callback is present + if normalization_stage == NormalizationStage.TILE: + assert any( + isinstance(x, _MinMaxNormalizationCallback) + for x in engine._cache.args["callbacks"] # noqa: SLF001 + ) + else: + assert not any( + isinstance(x, _MinMaxNormalizationCallback) + for x in engine._cache.args["callbacks"] # noqa: SLF001 + ) diff --git a/tests/unit/pipelines/tiled_ensemble/test_prediction_data.py b/tests/unit/pipelines/tiled_ensemble/test_prediction_data.py new file mode 100644 index 0000000000..7185f1e2ca --- /dev/null +++ b/tests/unit/pipelines/tiled_ensemble/test_prediction_data.py @@ -0,0 +1,69 @@ +"""Test tiled prediction storage class.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import copy +from collections.abc import Callable + +import torch +from torch import Tensor + +from anomalib.data import AnomalibDataModule +from anomalib.pipelines.tiled_ensemble.components.utils.prediction_data import EnsemblePredictions + + +class TestPredictionData: + """Test EnsemblePredictions class, used for tiled prediction storage.""" + + @staticmethod + def store_all(data: EnsemblePredictions, datamodule: AnomalibDataModule) -> dict: + """Store the tiled predictions in the EnsemblePredictions object.""" + tile_dict = {} + for tile_index in [(0, 0), (0, 1), (1, 0), (1, 1)]: + datamodule.collate_fn.tile_index = tile_index + + tile_prediction = [] + for batch in iter(datamodule.train_dataloader()): + # set mock maps to just one channel of image + batch["anomaly_maps"] = batch["image"].clone()[:, 0, :, :].unsqueeze(1) + # set mock pred mask to mask but add channel + batch["pred_masks"] = batch["mask"].clone().unsqueeze(1) + tile_prediction.append(batch) + # save original + tile_dict[tile_index] = copy.deepcopy(tile_prediction) + # store to prediction storage object + data.add_tile_prediction(tile_index, tile_prediction) + + return tile_dict + + @staticmethod + def verify_equal(name: str, tile_dict: dict, storage: EnsemblePredictions, eq_funct: Callable) -> bool: + """Verify that all data at same tile index and same batch index matches.""" + batch_num = len(tile_dict[0, 0]) + + for batch_i in range(batch_num): + # batch is dict where key: tile index and val is batched data of that tile + curr_batch = storage.get_batch_tiles(batch_i) + + # go over all indices of current batch of stored data + for tile_index, stored_data_batch in curr_batch.items(): + stored_data = stored_data_batch[name] + # get original data dict at current tile index and batch index + original_data = tile_dict[tile_index][batch_i][name] + if isinstance(original_data, Tensor): + if not eq_funct(original_data, stored_data): + return False + elif original_data != stored_data: + return False + + return True + + def test_prediction_object(self, get_datamodule: AnomalibDataModule) -> None: + """Test prediction storage class.""" + datamodule = get_datamodule + storage = EnsemblePredictions() + original = self.store_all(storage, datamodule) + + for name in original[0, 0][0]: + assert self.verify_equal(name, original, storage, torch.equal), f"{name} doesn't match" diff --git a/tests/unit/pipelines/tiled_ensemble/test_tiler.py b/tests/unit/pipelines/tiled_ensemble/test_tiler.py new file mode 100644 index 0000000000..96b6c0e7bc --- /dev/null +++ b/tests/unit/pipelines/tiled_ensemble/test_tiler.py @@ -0,0 +1,119 @@ +"""Tiling related tests for tiled ensemble.""" + +# Copyright (C) 2023-2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +import copy + +import pytest +import torch + +from anomalib.data import AnomalibDataModule +from anomalib.pipelines.tiled_ensemble.components.utils.helper_functions import get_ensemble_tiler + +tiler_config = { + "tiling": { + "tile_size": 256, + "stride": 256, + }, + "data": {"init_args": {"image_size": 512}}, +} + +tiler_config_overlap = { + "tiling": { + "tile_size": 256, + "stride": 128, + }, + "data": {"init_args": {"image_size": 512}}, +} + + +class TestTiler: + """EnsembleTiler tests.""" + + @staticmethod + @pytest.mark.parametrize( + ("input_shape", "config", "expected_shape"), + [ + (torch.Size([5, 3, 512, 512]), tiler_config, torch.Size([2, 2, 5, 3, 256, 256])), + (torch.Size([5, 3, 512, 512]), tiler_config_overlap, torch.Size([3, 3, 5, 3, 256, 256])), + (torch.Size([5, 3, 500, 500]), tiler_config, torch.Size([2, 2, 5, 3, 256, 256])), + (torch.Size([5, 3, 500, 500]), tiler_config_overlap, torch.Size([3, 3, 5, 3, 256, 256])), + ], + ) + def test_basic_tile_for_ensemble(input_shape: torch.Size, config: dict, expected_shape: torch.Size) -> None: + """Test basic tiling of data.""" + config = copy.deepcopy(config) + config["data"]["init_args"]["image_size"] = input_shape[-1] + tiler = get_ensemble_tiler(config["tiling"], config["data"]) + + images = torch.rand(size=input_shape) + tiled = tiler.tile(images) + + assert tiled.shape == expected_shape + + @staticmethod + @pytest.mark.parametrize( + ("input_shape", "config"), + [ + (torch.Size([5, 3, 512, 512]), tiler_config), + (torch.Size([5, 3, 512, 512]), tiler_config_overlap), + (torch.Size([5, 3, 500, 500]), tiler_config), + (torch.Size([5, 3, 500, 500]), tiler_config_overlap), + ], + ) + def test_basic_tile_reconstruction(input_shape: torch.Size, config: dict) -> None: + """Test basic reconstruction of tiled data.""" + config = copy.deepcopy(config) + config["data"]["init_args"]["image_size"] = input_shape[-1] + + tiler = get_ensemble_tiler(config["tiling"], config["data"]) + + images = torch.rand(size=input_shape) + tiled = tiler.tile(images.clone()) + untiled = tiler.untile(tiled) + + assert images.shape == untiled.shape + assert images.equal(untiled) + + @staticmethod + @pytest.mark.parametrize( + ("input_shape", "config"), + [ + (torch.Size([5, 3, 512, 512]), tiler_config), + (torch.Size([5, 3, 500, 500]), tiler_config), + ], + ) + def test_untile_different_instance(input_shape: torch.Size, config: dict) -> None: + """Test untiling with different Tiler instance.""" + config = copy.deepcopy(config) + config["data"]["init_args"]["image_size"] = input_shape[-1] + tiler_1 = get_ensemble_tiler(config["tiling"], config["data"]) + + tiler_2 = get_ensemble_tiler(config["tiling"], config["data"]) + + images = torch.rand(size=input_shape) + tiled = tiler_1.tile(images.clone()) + + untiled = tiler_2.untile(tiled) + + # untiling should work even with different instance of tiler + assert images.shape == untiled.shape + assert images.equal(untiled) + + +class TestTileCollater: + """Test tile collater.""" + + @staticmethod + def test_collate_tile_shape(get_ensemble_config: dict, get_datamodule: AnomalibDataModule) -> None: + """Test that collate function successfully tiles the image.""" + config = get_ensemble_config + # datamodule with tile collater + datamodule = get_datamodule + + tile_w, tile_h = config["tiling"]["tile_size"] + + batch = next(iter(datamodule.train_dataloader())) + assert batch["image"].shape[1:] == (3, tile_w, tile_h) + assert batch["mask"].shape[1:] == (tile_w, tile_h) diff --git a/tools/tiled_ensemble/ens_config.yaml b/tools/tiled_ensemble/ens_config.yaml new file mode 100644 index 0000000000..2490b22e9a --- /dev/null +++ b/tools/tiled_ensemble/ens_config.yaml @@ -0,0 +1,43 @@ +seed: 42 +accelerator: "gpu" +default_root_dir: "results" + +tiling: + tile_size: [128, 128] + stride: 128 + +normalization_stage: image # on what level we normalize, options: [tile, image, none] +thresholding: + method: F1AdaptiveThreshold # refer to documentation for thresholding methods + stage: image # stage at which we apply threshold, options: [tile, image] + +data: + class_path: anomalib.data.MVTec + init_args: + root: ./datasets/MVTec + category: bottle + train_batch_size: 32 + eval_batch_size: 32 + num_workers: 8 + task: segmentation + transform: null + train_transform: null + eval_transform: null + test_split_mode: from_dir + test_split_ratio: 0.2 + val_split_mode: same_as_test + val_split_ratio: 0.5 + image_size: [256, 256] + +SeamSmoothing: + apply: True # if this is applied, area around tile seams are is smoothed + sigma: 2 # sigma of gaussian filter used to smooth this area + width: 0.1 # width factor, multiplied by tile dimension gives the region width around seam which will be smoothed + +TrainModels: + model: + class_path: Padim + + metrics: + pixel: AUROC + image: AUROC diff --git a/tools/tiled_ensemble/eval.py b/tools/tiled_ensemble/eval.py new file mode 100644 index 0000000000..58be27c25c --- /dev/null +++ b/tools/tiled_ensemble/eval.py @@ -0,0 +1,28 @@ +"""Run tiled ensemble prediction.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + +from jsonargparse import ArgumentParser + +from anomalib.pipelines.tiled_ensemble import EvalTiledEnsemble + + +def get_parser() -> ArgumentParser: + """Create a new parser if none is provided.""" + parser = ArgumentParser() + parser.add_argument("--config", type=str | Path, help="Configuration file path.", required=True) + parser.add_argument("--root", type=str | Path, help="Weights file path.", required=True) + + return parser + + +if __name__ == "__main__": + args = get_parser().parse_args() + + print("Running tiled ensemble test pipeline.") + # pass the path to root dir with checkpoints + test_pipeline = EvalTiledEnsemble(args.root) + test_pipeline.run(args) diff --git a/tools/tiled_ensemble/train.py b/tools/tiled_ensemble/train.py new file mode 100644 index 0000000000..8aed47ea0d --- /dev/null +++ b/tools/tiled_ensemble/train.py @@ -0,0 +1,17 @@ +"""Run tiled ensemble training.""" + +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from anomalib.pipelines.tiled_ensemble import EvalTiledEnsemble, TrainTiledEnsemble + +if __name__ == "__main__": + print("Running tiled ensemble train pipeline") + train_pipeline = TrainTiledEnsemble() + # run training + train_pipeline.run() + + print("Running tiled ensemble test pipeline.") + # pass the root dir from train run to load checkpoints + test_pipeline = EvalTiledEnsemble(train_pipeline.root_dir) + test_pipeline.run() From 0301d591061674008dbab11110ba9f310c9bed07 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Thu, 24 Oct 2024 12:19:37 +0100 Subject: [PATCH 22/32] =?UTF-8?q?=F0=9F=93=9A=20Add=20training=20from=20a?= =?UTF-8?q?=20checkpoint=20example=20(#2389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add training from a checkpoint example Signed-off-by: Samet Akcay * Replace patchcore example with efficient-ad Signed-off-by: Samet Akcay --------- Signed-off-by: Samet Akcay --- docs/source/markdown/get_started/anomalib.md | 30 +++++++++++--------- docs/source/snippets/train/api/default.txt | 11 ++++--- docs/source/snippets/train/cli/default.txt | 7 +++-- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/source/markdown/get_started/anomalib.md b/docs/source/markdown/get_started/anomalib.md index 37af563b3e..4580c7fae5 100644 --- a/docs/source/markdown/get_started/anomalib.md +++ b/docs/source/markdown/get_started/anomalib.md @@ -17,7 +17,7 @@ The installer can be installed using the following commands: :::{tab-item} API :sync: label-1 -```{literalinclude} ../../snippets/install/pypi.txt +```{literalinclude} /snippets/install/pypi.txt :language: bash ``` @@ -26,7 +26,7 @@ The installer can be installed using the following commands: :::{tab-item} Source :sync: label-2 -```{literalinclude} ../../snippets/install/source.txt +```{literalinclude} /snippets/install/source.txt :language: bash ``` @@ -42,7 +42,7 @@ The next section demonstrates how to install the full package using the CLI inst :::::{dropdown} Installing the Full Package After installing anomalib, you can install the full package using the following commands: -```{literalinclude} ../../snippets/install/anomalib_help.txt +```{literalinclude} /snippets/install/anomalib_help.txt :language: bash ``` @@ -50,14 +50,14 @@ As can be seen above, the only available sub-command is `install` at the moment. The `install` sub-command has options to install either the full package or the specific components of the package. -```{literalinclude} ../../snippets/install/anomalib_install_help.txt +```{literalinclude} /snippets/install/anomalib_install_help.txt :language: bash ``` By default the `install` sub-command installs the full package. If you want to install only the specific components of the package, you can use the `--option` flag. -```{literalinclude} ../../snippets/install/anomalib_install.txt +```{literalinclude} /snippets/install/anomalib_install.txt :language: bash ``` @@ -66,13 +66,15 @@ After following these steps, your environment will be ready to use anomalib! ## {octicon}`mortar-board` Training -Anomalib supports both API and CLI-based training. The API is more flexible and allows for more customization, while the CLI training utilizes command line interfaces, and might be easier for those who would like to use anomalib off-the-shelf. +Anomalib supports both API and CLI-based training. The API is more flexible +and allows for more customization, while the CLI training utilizes command line +interfaces, and might be easier for those who would like to use anomalib off-the-shelf. ::::{tab-set} :::{tab-item} API -```{literalinclude} ../../snippets/train/api/default.txt +```{literalinclude} /snippets/train/api/default.txt :language: python ``` @@ -80,7 +82,7 @@ Anomalib supports both API and CLI-based training. The API is more flexible and :::{tab-item} CLI -```{literalinclude} ../../snippets/train/cli/default.txt +```{literalinclude} /snippets/train/cli/default.txt :language: bash ``` @@ -100,7 +102,7 @@ Anomalib includes multiple inferencing scripts, including Torch, Lightning, Grad :::{tab-item} API :sync: label-1 -```{literalinclude} ../../snippets/inference/api/lightning.txt +```{literalinclude} /snippets/inference/api/lightning.txt :language: python ``` @@ -109,7 +111,7 @@ Anomalib includes multiple inferencing scripts, including Torch, Lightning, Grad :::{tab-item} CLI :sync: label-2 -```{literalinclude} ../../snippets/inference/cli/lightning.txt +```{literalinclude} /snippets/inference/cli/lightning.txt :language: bash ``` @@ -201,7 +203,7 @@ Anomalib supports hyper-parameter optimization using [wandb](https://wandb.ai/) :::{tab-item} CLI -```{literalinclude} ../../snippets/pipelines/hpo/cli.txt +```{literalinclude} /snippets/pipelines/hpo/cli.txt :language: bash ``` @@ -209,7 +211,7 @@ Anomalib supports hyper-parameter optimization using [wandb](https://wandb.ai/) :::{tab-item} API -```{literalinclude} ../../snippets/pipelines/hpo/api.txt +```{literalinclude} /snippets/pipelines/hpo/api.txt :language: bash ``` @@ -233,7 +235,7 @@ To run a training experiment with experiment tracking, you will need the followi By using the configuration file above, you can run the experiment with the following command: -```{literalinclude} ../../snippets/logging/cli.txt +```{literalinclude} /snippets/logging/cli.txt :language: bash ``` @@ -241,7 +243,7 @@ By using the configuration file above, you can run the experiment with the follo :::{tab-item} API -```{literalinclude} ../../snippets/logging/api.txt +```{literalinclude} /snippets/logging/api.txt :language: bash ``` diff --git a/docs/source/snippets/train/api/default.txt b/docs/source/snippets/train/api/default.txt index 30293cf501..1fe6cb895c 100644 --- a/docs/source/snippets/train/api/default.txt +++ b/docs/source/snippets/train/api/default.txt @@ -1,12 +1,15 @@ # Import the required modules from anomalib.data import MVTec -from anomalib.models import Patchcore from anomalib.engine import Engine +from anomalib.models import EfficientAd # Initialize the datamodule, model and engine -datamodule = MVTec() -model = Patchcore() -engine = Engine() +datamodule = MVTec(train_batch_size=1) +model = EfficientAd() +engine = Engine(max_epochs=5) # Train the model engine.fit(datamodule=datamodule, model=model) + +# Continue from a checkpoint +engine.fit(datamodule=datamodule, model=model, ckpt_path="path/to/checkpoint.ckpt") diff --git a/docs/source/snippets/train/cli/default.txt b/docs/source/snippets/train/cli/default.txt index 3f64f687ad..1990dbf97e 100644 --- a/docs/source/snippets/train/cli/default.txt +++ b/docs/source/snippets/train/cli/default.txt @@ -2,10 +2,13 @@ anomalib train -h # Train by using the default values. -anomalib train --model Patchcore --data anomalib.data.MVTec +anomalib train --model EfficientAd --data anomalib.data.MVTec --data.train_batch_size 1 # Train by overriding arguments. -anomalib train --model Patchcore --data anomalib.data.MVTec --data.category transistor +anomalib train --model EfficientAd --data anomalib.data.MVTec --data.train_batch_size 1 --data.category transistor # Train by using a config file. anomalib train --config + +# Continue training from a checkpoint +anomalib train --config --ckpt_path From c00e101f3351b622e407dc097135fe3348860a9a Mon Sep 17 00:00:00 2001 From: Weilin Xu Date: Thu, 24 Oct 2024 07:12:15 -0700 Subject: [PATCH 23/32] Export experiment duration in seconds in CSV. (#2392) * Export experiment duration in seconds in CSV. Signed-off-by: Weilin Xu * Update CHANGELOG Signed-off-by: Weilin Xu * Log fit and test durations separately. Signed-off-by: Weilin Xu --------- Signed-off-by: Weilin Xu Co-authored-by: Samet Akcay --- CHANGELOG.md | 2 ++ src/anomalib/pipelines/benchmark/job.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b50bf09ecb..7760befc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed +- Add duration of experiments in seconds in the benchmark CSV result by [mzweilin](https://github.com/mzweilin) in https://github.com/openvinotoolkit/anomalib/pull/2392 + ### Deprecated ### Fixed diff --git a/src/anomalib/pipelines/benchmark/job.py b/src/anomalib/pipelines/benchmark/job.py index ab443cfa8a..01822d5f29 100644 --- a/src/anomalib/pipelines/benchmark/job.py +++ b/src/anomalib/pipelines/benchmark/job.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 import logging +import time from datetime import datetime from pathlib import Path from tempfile import TemporaryDirectory @@ -48,6 +49,7 @@ def run( task_id: int | None = None, ) -> dict[str, Any]: """Run the benchmark.""" + job_start_time = time.time() devices: str | list[int] = "auto" if task_id is not None: devices = [task_id] @@ -59,8 +61,16 @@ def run( devices=devices, default_root_dir=temp_dir, ) + fit_start_time = time.time() engine.fit(self.model, self.datamodule) + test_start_time = time.time() test_results = engine.test(self.model, self.datamodule) + job_end_time = time.time() + durations = { + "job_duration": job_end_time - job_start_time, + "fit_duration": test_start_time - fit_start_time, + "test_duration": job_end_time - test_start_time, + } # TODO(ashwinvaidya17): Restore throughput # https://github.com/openvinotoolkit/anomalib/issues/2054 output = { @@ -69,6 +79,7 @@ def run( "model": self.model.__class__.__name__, "data": self.datamodule.__class__.__name__, "category": self.datamodule.category, + **durations, **test_results[0], } logger.info(f"Completed with result {output}") From 31952db16eded32017adf2910207e40148272ea1 Mon Sep 17 00:00:00 2001 From: Weilin Xu Date: Thu, 24 Oct 2024 09:37:39 -0700 Subject: [PATCH 24/32] Make single GPU benchmarking 5x more efficient (#2390) * Use SerialRunner if only one CUDA device is available. Signed-off-by: Weilin Xu * Resolve PLR6201. Signed-off-by: Weilin Xu * Update CHANGELOG. Signed-off-by: Weilin Xu * Keep the same logging level in benchmarking. Signed-off-by: Weilin Xu --------- Signed-off-by: Weilin Xu Co-authored-by: Samet Akcay --- CHANGELOG.md | 2 ++ src/anomalib/pipelines/benchmark/pipeline.py | 11 ++++++----- src/anomalib/utils/logging.py | 4 +--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7760befc7a..1c6f07555a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +- Make single GPU benchmarking 5x more efficient by [mzweilin](https://github.com/mzweilin) in https://github.com/openvinotoolkit/anomalib/pull/2390 + ### New Contributors **Full Changelog**: diff --git a/src/anomalib/pipelines/benchmark/pipeline.py b/src/anomalib/pipelines/benchmark/pipeline.py index 730b3ecccc..f68ee5e2a1 100644 --- a/src/anomalib/pipelines/benchmark/pipeline.py +++ b/src/anomalib/pipelines/benchmark/pipeline.py @@ -20,11 +20,12 @@ def _setup_runners(args: dict) -> list[Runner]: accelerators = args["accelerator"] if isinstance(args["accelerator"], list) else [args["accelerator"]] runners: list[Runner] = [] for accelerator in accelerators: - if accelerator == "cpu": - runners.append(SerialRunner(BenchmarkJobGenerator("cpu"))) - elif accelerator == "cuda": - runners.append(ParallelRunner(BenchmarkJobGenerator("cuda"), n_jobs=torch.cuda.device_count())) - else: + if accelerator not in {"cpu", "cuda"}: msg = f"Unsupported accelerator: {accelerator}" raise ValueError(msg) + device_count = torch.cuda.device_count() + if device_count <= 1: + runners.append(SerialRunner(BenchmarkJobGenerator(accelerator))) + else: + runners.append(ParallelRunner(BenchmarkJobGenerator(accelerator), n_jobs=device_count)) return runners diff --git a/src/anomalib/utils/logging.py b/src/anomalib/utils/logging.py index 21f7994fbf..d73ef440c4 100644 --- a/src/anomalib/utils/logging.py +++ b/src/anomalib/utils/logging.py @@ -74,10 +74,8 @@ def redirect_logs(log_file: str) -> None: """ Path(log_file).parent.mkdir(exist_ok=True, parents=True) logger_file_handler = logging.FileHandler(log_file) - root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) format_string = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - logging.basicConfig(format=format_string, level=logging.DEBUG, handlers=[logger_file_handler]) + logging.basicConfig(format=format_string, handlers=[logger_file_handler]) logging.captureWarnings(capture=True) # remove other handlers from all loggers loggers = [logging.getLogger(name) for name in logging.root.manager.loggerDict] From f4f9b9a28036c5d6a12294c380f7897d1ef1755b Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Thu, 24 Oct 2024 21:20:25 +0100 Subject: [PATCH 25/32] =?UTF-8?q?=F0=9F=90=9E=20Fix=20installation=20packa?= =?UTF-8?q?ge=20issues=20(#2395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update the coverage settings Signed-off-by: Samet Akcay * Remove VlmAd's relative import Signed-off-by: Samet Akcay * Revert relative imports Signed-off-by: Samet Akcay * Add type checking Signed-off-by: Samet Akcay --------- Signed-off-by: Samet Akcay --- pyproject.toml | 11 ++++++++--- src/anomalib/models/__init__.py | 2 +- .../models/image/vlm_ad/backends/huggingface.py | 12 +++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 268544ad2e..e47f7e55d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "jsonargparse[signatures]>=4.27.7", "docstring_parser", # CLI help-formatter "rich_argparse", # CLI help-formatter + "lightning-utilities", ] [project.optional-dependencies] @@ -293,11 +294,15 @@ pythonpath = "src" # COVERAGE CONFIGURATION # [tool.coverage.report] exclude_lines = [ - "except ImportError", + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@abstractmethod", + "pass", "raise ImportError", - "except ApiException", - "raise ApiException", "raise ValueError", + "except ImportError:", ] [tool.coverage.paths] diff --git a/src/anomalib/models/__init__.py b/src/anomalib/models/__init__.py index ea091d1640..3b32c83367 100644 --- a/src/anomalib/models/__init__.py +++ b/src/anomalib/models/__init__.py @@ -58,9 +58,9 @@ class UnknownModelError(ModuleNotFoundError): "Rkde", "Stfpm", "Uflow", - "AiVad", "VlmAd", "WinClip", + "AiVad", ] logger = logging.getLogger(__name__) diff --git a/src/anomalib/models/image/vlm_ad/backends/huggingface.py b/src/anomalib/models/image/vlm_ad/backends/huggingface.py index c234ecfbc5..e25e9dccb3 100644 --- a/src/anomalib/models/image/vlm_ad/backends/huggingface.py +++ b/src/anomalib/models/image/vlm_ad/backends/huggingface.py @@ -5,19 +5,21 @@ import logging from pathlib import Path +from typing import TYPE_CHECKING from lightning_utilities.core.imports import package_available from PIL import Image -from transformers.modeling_utils import PreTrainedModel from anomalib.models.image.vlm_ad.utils import Prompt from .base import Backend -if package_available("transformers"): - import transformers +if TYPE_CHECKING: from transformers.modeling_utils import PreTrainedModel from transformers.processing_utils import ProcessorMixin + +if package_available("transformers"): + import transformers else: transformers = None @@ -39,7 +41,7 @@ def __init__( self._model: PreTrainedModel | None = None @property - def processor(self) -> ProcessorMixin: + def processor(self) -> "ProcessorMixin": """Get the Huggingface processor.""" if self._processor is None: if transformers is None: @@ -49,7 +51,7 @@ def processor(self) -> ProcessorMixin: return self._processor @property - def model(self) -> PreTrainedModel: + def model(self) -> "PreTrainedModel": """Get the Huggingface model.""" if self._model is None: if transformers is None: From 42b3ad5287f8e0833e21fbf43e0972f2439aa1ca Mon Sep 17 00:00:00 2001 From: Weilin Xu Date: Thu, 24 Oct 2024 22:12:48 -0700 Subject: [PATCH 26/32] Export the flattened config in benchmark CSV. (#2391) * Export the flattened config in benchmark CSV. Signed-off-by: Weilin Xu * Update CHANGELOG Signed-off-by: Weilin Xu * Reuse the existing flatten_dict(). Signed-off-by: Weilin Xu --------- Signed-off-by: Weilin Xu Co-authored-by: Samet Akcay --- CHANGELOG.md | 1 + src/anomalib/pipelines/benchmark/generator.py | 4 ++++ src/anomalib/pipelines/benchmark/job.py | 16 +++++++++++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6f07555a..8152e202b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Add duration of experiments in seconds in the benchmark CSV result by [mzweilin](https://github.com/mzweilin) in https://github.com/openvinotoolkit/anomalib/pull/2392 +- Export flat configurations in benchmark CSV results by [mzweilin](https://github.com/mzweilin) in https://github.com/openvinotoolkit/anomalib/pull/2391 ### Deprecated diff --git a/src/anomalib/pipelines/benchmark/generator.py b/src/anomalib/pipelines/benchmark/generator.py index 922dfa06cb..988e0111b7 100644 --- a/src/anomalib/pipelines/benchmark/generator.py +++ b/src/anomalib/pipelines/benchmark/generator.py @@ -10,6 +10,7 @@ from anomalib.pipelines.components import JobGenerator from anomalib.pipelines.components.utils import get_iterator_from_grid_dict from anomalib.pipelines.types import PREV_STAGE_RESULT +from anomalib.utils.config import flatten_dict from anomalib.utils.logging import hide_output from .job import BenchmarkJob @@ -39,9 +40,12 @@ def generate_jobs( """Return iterator based on the arguments.""" del previous_stage_result # Not needed for this job for _container in get_iterator_from_grid_dict(args): + # Pass experimental configs as a flatten dictionary to the job runner. + flat_cfg = flatten_dict(_container) yield BenchmarkJob( accelerator=self.accelerator, seed=_container["seed"], model=get_model(_container["model"]), datamodule=get_datamodule(_container["data"]), + flat_cfg=flat_cfg, ) diff --git a/src/anomalib/pipelines/benchmark/job.py b/src/anomalib/pipelines/benchmark/job.py index 01822d5f29..f56899ac5d 100644 --- a/src/anomalib/pipelines/benchmark/job.py +++ b/src/anomalib/pipelines/benchmark/job.py @@ -32,16 +32,25 @@ class BenchmarkJob(Job): model (AnomalyModule): The model to use. datamodule (AnomalibDataModule): The data module to use. seed (int): The seed to use. + flat_cfg (dict): The flat dictionary of configs with dotted keys. """ name = "benchmark" - def __init__(self, accelerator: str, model: AnomalyModule, datamodule: AnomalibDataModule, seed: int) -> None: + def __init__( + self, + accelerator: str, + model: AnomalyModule, + datamodule: AnomalibDataModule, + seed: int, + flat_cfg: dict, + ) -> None: super().__init__() self.accelerator = accelerator self.model = model self.datamodule = datamodule self.seed = seed + self.flat_cfg = flat_cfg @hide_output def run( @@ -74,12 +83,9 @@ def run( # TODO(ashwinvaidya17): Restore throughput # https://github.com/openvinotoolkit/anomalib/issues/2054 output = { - "seed": self.seed, "accelerator": self.accelerator, - "model": self.model.__class__.__name__, - "data": self.datamodule.__class__.__name__, - "category": self.datamodule.category, **durations, + **self.flat_cfg, **test_results[0], } logger.info(f"Completed with result {output}") From 7d6b89c173e98611dfbcfd0256647598c42dc002 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Thu, 31 Oct 2024 08:58:05 +0000 Subject: [PATCH 27/32] `v1.2.0` Release (#2397) Prepare v1.2.0 release (#2396) * Update changelog * Update the version in __init__ --------- Signed-off-by: Samet Akcay --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++++++++----- src/anomalib/__init__.py | 2 +- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8152e202b9..dedec2f441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,21 +8,67 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- Add `VlmAd` metric by [Bepitic](https://github.com/Bepitic) and refactored by [ashwinvaidya17](https://github.com/ashwinvaidya17) in https://github.com/openvinotoolkit/anomalib/pull/2344 -- Add `Datumaro` annotation format support by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/2377 -- Add `AUPIMO` tutorials notebooks in https://github.com/openvinotoolkit/anomalib/pull/2330 and https://github.com/openvinotoolkit/anomalib/pull/2336 -- Add `AUPIMO` metric by [jpcbertoldo](https://github.com/jpcbertoldo) in https://github.com/openvinotoolkit/anomalib/pull/1726 and refactored by [ashwinvaidya17](https://github.com/ashwinvaidya17) in https://github.com/openvinotoolkit/anomalib/pull/2329 +### Changed + +### Deprecated + +### Fixed + +### New Contributors + +## [v1.2.0] + +### Added + +- πŸš€ Add ensembling methods for tiling to Anomalib by @blaz-r in https://github.com/openvinotoolkit/anomalib/pull/1226 +- πŸ“š optimization/quantization added into 500 series by @paularamo in https://github.com/openvinotoolkit/anomalib/pull/2197 +- πŸš€ Add PIMO by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/2329 +- πŸ“š Add PIMO tutorial advanced i (fixed) by @jpcbertoldo in https://github.com/openvinotoolkit/anomalib/pull/2336 +- πŸš€ Add VLM based Anomaly Model by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/2344 +- πŸ“š Add PIMO tutorials/02 advanced ii by @jpcbertoldo in https://github.com/openvinotoolkit/anomalib/pull/2347 +- πŸ“š Add PIMO tutorials/03 advanced iii by @jpcbertoldo in https://github.com/openvinotoolkit/anomalib/pull/2348 +- πŸ“š Add PIMO tutorials/04 advanced iv by @jpcbertoldo in https://github.com/openvinotoolkit/anomalib/pull/2352 +- πŸš€ Add datumaro annotation dataloader by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/2377 +- πŸ“š Add training from a checkpoint example by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2389 ### Changed -- Add duration of experiments in seconds in the benchmark CSV result by [mzweilin](https://github.com/mzweilin) in https://github.com/openvinotoolkit/anomalib/pull/2392 -- Export flat configurations in benchmark CSV results by [mzweilin](https://github.com/mzweilin) in https://github.com/openvinotoolkit/anomalib/pull/2391 +- πŸ”¨ Refactor folder3d to avoid complex-structure (C901) issue by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2185 +- Update open-clip-torch requirement from <2.26.1,>=2.23.0 to >=2.23.0,<2.26.2 by @dependabot in https://github.com/openvinotoolkit/anomalib/pull/2189 +- Update sphinx requirement by @dependabot in https://github.com/openvinotoolkit/anomalib/pull/2235 +- Refactor Lightning's `trainer.model` to `trainer.lightning_module` by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2255 +- Revert "Update open-clip-torch requirement from <2.26.1,>=2.23.0 to >=2.23.0,<2.26.2" by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2270 +- Update ruff configuration by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2269 +- Update timm requirement by @dependabot in https://github.com/openvinotoolkit/anomalib/pull/2274 +- Refactor BaseThreshold to Threshold by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2278 +- πŸ”¨ Lint: Update Ruff Config - Add Missing Copyright Headers by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2281 +- Reduce rich methods by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/2283 +- Enable Ruff Rules: PLW1514 and PLR6201 by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2284 +- Update nncf export by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/2286 +- Linting: Enable `PLR6301`, # could be a function, class method or static method by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2288 +- 🐞 Update `setuptools` requirement for PEP 660 support by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2320 +- πŸ”¨ Update the issue templates by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2363 +- 🐞 Defer OpenVINO import to avoid unnecessary warnings by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2385 +- πŸ”¨ Make single GPU benchmarking 5x more efficient by @mzweilin in https://github.com/openvinotoolkit/anomalib/pull/2390 +- 🐞 Export the flattened config in benchmark CSV. by @mzweilin in https://github.com/openvinotoolkit/anomalib/pull/2391 +- πŸ”¨ Export experiment duration in seconds in CSV. by @mzweilin in https://github.com/openvinotoolkit/anomalib/pull/2392 +- 🐞 Fix installation package issues by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2395 ### Deprecated +- πŸ”¨ Deprecate try import and replace it with Lightning's package_available by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/2373 + ### Fixed -- Make single GPU benchmarking 5x more efficient by [mzweilin](https://github.com/mzweilin) in https://github.com/openvinotoolkit/anomalib/pull/2390 +- Add check before loading metrics data from checkpoint by @blaz-r in https://github.com/openvinotoolkit/anomalib/pull/2323 +- Fix transforms for draem, dsr and rkde by @blaz-r in https://github.com/openvinotoolkit/anomalib/pull/2324 +- Makes batch size dynamic by @Marcus1506 in https://github.com/openvinotoolkit/anomalib/pull/2339 + +## New Contributors + +- @Marcus1506 made their first contribution in https://github.com/openvinotoolkit/anomalib/pull/2339 + +**Full Changelog**: https://github.com/openvinotoolkit/anomalib/compare/v1.1.1...v1.2.0 ### New Contributors diff --git a/src/anomalib/__init__.py b/src/anomalib/__init__.py index 1b7a30497c..09edd93c22 100644 --- a/src/anomalib/__init__.py +++ b/src/anomalib/__init__.py @@ -5,7 +5,7 @@ from enum import Enum -__version__ = "1.2.0dev" +__version__ = "1.2.0" class LearningType(str, Enum): From 6ed0067813a5b4969e7ec69d22ab617f4e921142 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Thu, 31 Oct 2024 11:47:10 +0000 Subject: [PATCH 28/32] Bump Anomalib version to `2.0.0dev` in `main` (#2402) Update __init__.py --- src/anomalib/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anomalib/__init__.py b/src/anomalib/__init__.py index 09edd93c22..206e9531a9 100644 --- a/src/anomalib/__init__.py +++ b/src/anomalib/__init__.py @@ -5,7 +5,7 @@ from enum import Enum -__version__ = "1.2.0" +__version__ = "2.0.0dev" class LearningType(str, Enum): From bcc0b439f616b13a8629cb64d8bf0f88fc9083a8 Mon Sep 17 00:00:00 2001 From: Harim Kang Date: Tue, 5 Nov 2024 19:29:23 +0900 Subject: [PATCH 29/32] =?UTF-8?q?=F0=9F=90=9EReplace=20package=5Favailable?= =?UTF-8?q?=20with=20module=5Favailable=20(#2407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/anomalib/cli/pipelines.py | 4 ++-- src/anomalib/cli/utils/openvino.py | 4 ++-- src/anomalib/deploy/inferencers/openvino_inferencer.py | 4 ++-- src/anomalib/loggers/wandb.py | 4 ++-- src/anomalib/models/components/base/export_mixin.py | 6 +++--- src/anomalib/models/image/vlm_ad/backends/chat_gpt.py | 4 ++-- src/anomalib/models/image/vlm_ad/backends/huggingface.py | 4 ++-- src/anomalib/models/image/vlm_ad/backends/ollama.py | 4 ++-- src/anomalib/utils/exceptions/imports.py | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/anomalib/cli/pipelines.py b/src/anomalib/cli/pipelines.py index 8cfb04fd2e..ba6030491b 100644 --- a/src/anomalib/cli/pipelines.py +++ b/src/anomalib/cli/pipelines.py @@ -6,13 +6,13 @@ import logging from jsonargparse import Namespace -from lightning_utilities.core.imports import package_available +from lightning_utilities.core.imports import module_available from anomalib.cli.utils.help_formatter import get_short_docstring logger = logging.getLogger(__name__) -if package_available("anomalib.pipelines"): +if module_available("anomalib.pipelines"): from anomalib.pipelines import Benchmark from anomalib.pipelines.components.base import Pipeline diff --git a/src/anomalib/cli/utils/openvino.py b/src/anomalib/cli/utils/openvino.py index ee54bf09b2..50a894c304 100644 --- a/src/anomalib/cli/utils/openvino.py +++ b/src/anomalib/cli/utils/openvino.py @@ -6,12 +6,12 @@ import logging from jsonargparse import ArgumentParser -from lightning_utilities.core.imports import package_available +from lightning_utilities.core.imports import module_available logger = logging.getLogger(__name__) -if package_available("openvino"): +if module_available("openvino"): from openvino.tools.ovc.cli_parser import get_common_cli_parser else: get_common_cli_parser = None diff --git a/src/anomalib/deploy/inferencers/openvino_inferencer.py b/src/anomalib/deploy/inferencers/openvino_inferencer.py index 8dea77b92e..b85df0536c 100644 --- a/src/anomalib/deploy/inferencers/openvino_inferencer.py +++ b/src/anomalib/deploy/inferencers/openvino_inferencer.py @@ -9,7 +9,7 @@ import cv2 import numpy as np -from lightning_utilities.core.imports import package_available +from lightning_utilities.core.imports import module_available from omegaconf import DictConfig from PIL import Image @@ -94,7 +94,7 @@ def __init__( task: str | None = None, config: dict | None = None, ) -> None: - if not package_available("openvino"): + if not module_available("openvino"): msg = "OpenVINO is not installed. Please install OpenVINO to use OpenVINOInferencer." raise ImportError(msg) diff --git a/src/anomalib/loggers/wandb.py b/src/anomalib/loggers/wandb.py index 55e65e6d54..ff41a0949e 100644 --- a/src/anomalib/loggers/wandb.py +++ b/src/anomalib/loggers/wandb.py @@ -9,12 +9,12 @@ from lightning.fabric.utilities.types import _PATH from lightning.pytorch.loggers.wandb import WandbLogger from lightning.pytorch.utilities import rank_zero_only -from lightning_utilities.core.imports import package_available +from lightning_utilities.core.imports import module_available from matplotlib.figure import Figure from .base import ImageLoggerBase -if package_available("wandb"): +if module_available("wandb"): import wandb if TYPE_CHECKING: diff --git a/src/anomalib/models/components/base/export_mixin.py b/src/anomalib/models/components/base/export_mixin.py index d11b50ff99..327cb87e02 100644 --- a/src/anomalib/models/components/base/export_mixin.py +++ b/src/anomalib/models/components/base/export_mixin.py @@ -12,7 +12,7 @@ import numpy as np import torch -from lightning_utilities.core.imports import package_available +from lightning_utilities.core.imports import module_available from torch import nn from torchmetrics import Metric from torchvision.transforms.v2 import Transform @@ -245,7 +245,7 @@ def to_openvino( ... task="segmentation", ... ) """ - if not package_available("openvino"): + if not module_available("openvino"): logger.exception("Could not find OpenVINO. Please check OpenVINO installation.") raise ModuleNotFoundError @@ -294,7 +294,7 @@ def _compress_ov_model( Returns: model (CompiledModel): Model in the OpenVINO format compressed with NNCF quantization. """ - if not package_available("nncf"): + if not module_available("nncf"): logger.exception("Could not find NCCF. Please check NNCF installation.") raise ModuleNotFoundError diff --git a/src/anomalib/models/image/vlm_ad/backends/chat_gpt.py b/src/anomalib/models/image/vlm_ad/backends/chat_gpt.py index 741288354f..53648e688a 100644 --- a/src/anomalib/models/image/vlm_ad/backends/chat_gpt.py +++ b/src/anomalib/models/image/vlm_ad/backends/chat_gpt.py @@ -10,13 +10,13 @@ from typing import TYPE_CHECKING from dotenv import load_dotenv -from lightning_utilities.core.imports import package_available +from lightning_utilities.core.imports import module_available from anomalib.models.image.vlm_ad.utils import Prompt from .base import Backend -if package_available("openai"): +if module_available("openai"): from openai import OpenAI else: OpenAI = None diff --git a/src/anomalib/models/image/vlm_ad/backends/huggingface.py b/src/anomalib/models/image/vlm_ad/backends/huggingface.py index e25e9dccb3..e8d3c1e84b 100644 --- a/src/anomalib/models/image/vlm_ad/backends/huggingface.py +++ b/src/anomalib/models/image/vlm_ad/backends/huggingface.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from lightning_utilities.core.imports import package_available +from lightning_utilities.core.imports import module_available from PIL import Image from anomalib.models.image.vlm_ad.utils import Prompt @@ -18,7 +18,7 @@ from transformers.modeling_utils import PreTrainedModel from transformers.processing_utils import ProcessorMixin -if package_available("transformers"): +if module_available("transformers"): import transformers else: transformers = None diff --git a/src/anomalib/models/image/vlm_ad/backends/ollama.py b/src/anomalib/models/image/vlm_ad/backends/ollama.py index db5a215bb3..ff680bee3b 100644 --- a/src/anomalib/models/image/vlm_ad/backends/ollama.py +++ b/src/anomalib/models/image/vlm_ad/backends/ollama.py @@ -12,13 +12,13 @@ import logging from pathlib import Path -from lightning_utilities.core.imports import package_available +from lightning_utilities.core.imports import module_available from anomalib.models.image.vlm_ad.utils import Prompt from .base import Backend -if package_available("ollama"): +if module_available("ollama"): from ollama import chat from ollama._client import _encode_image else: diff --git a/src/anomalib/utils/exceptions/imports.py b/src/anomalib/utils/exceptions/imports.py index dac22ba056..6ef8dbd89d 100644 --- a/src/anomalib/utils/exceptions/imports.py +++ b/src/anomalib/utils/exceptions/imports.py @@ -22,7 +22,7 @@ def try_import(import_path: str) -> bool: warnings.warn( "The 'try_import' function is deprecated and will be removed in v2.0.0. " - "Use 'package_available' from lightning-utilities instead.", + "Use 'module_available' from lightning-utilities instead.", DeprecationWarning, stacklevel=2, ) From 0e6aa9025e346db89c7769dcc05ec345fee8f570 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Wed, 11 Dec 2024 14:26:32 +0000 Subject: [PATCH 30/32] ignore tiled ensemble for now Signed-off-by: Samet Akcay --- .../markdown/guides/how_to/pipelines/index.md | 7 - .../guides/how_to/pipelines/tiled_ensemble.md | 157 ------------ .../pipelines/tiled_ensemble/__init__.py | 12 - .../tiled_ensemble/components/__init__.py | 30 --- .../tiled_ensemble/components/merging.py | 110 --------- .../components/metrics_calculation.py | 217 ----------------- .../components/model_training.py | 192 --------------- .../components/normalization.py | 120 --------- .../tiled_ensemble/components/prediction.py | 228 ------------------ .../tiled_ensemble/components/smoothing.py | 167 ------------- .../components/stats_calculation.py | 180 -------------- .../tiled_ensemble/components/thresholding.py | 114 --------- .../components/utils/__init__.py | 44 ---- .../components/utils/ensemble_engine.py | 92 ------- .../components/utils/ensemble_tiling.py | 147 ----------- .../components/utils/helper_functions.py | 179 -------------- .../components/utils/prediction_data.py | 45 ---- .../components/utils/prediction_merging.py | 167 ------------- .../components/visualization.py | 125 ---------- .../pipelines/tiled_ensemble/test_pipeline.py | 124 ---------- .../tiled_ensemble/train_pipeline.py | 123 ---------- .../pipelines/test_tiled_ensemble.py | 62 ----- .../integration/pipelines/tiled_ensemble.yaml | 43 ---- tools/tiled_ensemble/ens_config.yaml | 43 ---- tools/tiled_ensemble/eval.py | 28 --- tools/tiled_ensemble/train.py | 17 -- 26 files changed, 2773 deletions(-) delete mode 100644 docs/source/markdown/guides/how_to/pipelines/tiled_ensemble.md delete mode 100644 src/anomalib/pipelines/tiled_ensemble/__init__.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/__init__.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/merging.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/metrics_calculation.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/model_training.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/normalization.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/prediction.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/smoothing.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/stats_calculation.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/thresholding.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/__init__.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_engine.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_tiling.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/helper_functions.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_data.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_merging.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/components/visualization.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/test_pipeline.py delete mode 100644 src/anomalib/pipelines/tiled_ensemble/train_pipeline.py delete mode 100644 tests/integration/pipelines/test_tiled_ensemble.py delete mode 100644 tests/integration/pipelines/tiled_ensemble.yaml delete mode 100644 tools/tiled_ensemble/ens_config.yaml delete mode 100644 tools/tiled_ensemble/eval.py delete mode 100644 tools/tiled_ensemble/train.py diff --git a/docs/source/markdown/guides/how_to/pipelines/index.md b/docs/source/markdown/guides/how_to/pipelines/index.md index c7f2c44706..d70e6be757 100644 --- a/docs/source/markdown/guides/how_to/pipelines/index.md +++ b/docs/source/markdown/guides/how_to/pipelines/index.md @@ -6,13 +6,6 @@ This section contains tutorials on how to use different pipelines of Anomalib an :margin: 1 1 0 0 :gutter: 1 -:::{grid-item-card} {octicon}`stack` Tiled Ensemble -:link: ./tiled_ensemble -:link-type: doc - -Learn more about how to use the tiled ensemble pipelines. -::: - :::{grid-item-card} {octicon}`gear` Custom Pipeline :link: ./custom_pipeline :link-type: doc diff --git a/docs/source/markdown/guides/how_to/pipelines/tiled_ensemble.md b/docs/source/markdown/guides/how_to/pipelines/tiled_ensemble.md deleted file mode 100644 index 3550efb5fd..0000000000 --- a/docs/source/markdown/guides/how_to/pipelines/tiled_ensemble.md +++ /dev/null @@ -1,157 +0,0 @@ -# Tiled ensemble - -This guide will show you how to use **The Tiled Ensemble** method for anomaly detection. For more details, refer to the official [Paper](https://openaccess.thecvf.com/content/CVPR2024W/VAND/html/Rolih_Divide_and_Conquer_High-Resolution_Industrial_Anomaly_Detection_via_Memory_Efficient_CVPRW_2024_paper.html). - -The tiled ensemble approach reduces memory consumption by dividing input images into a grid of tiles and training a dedicated model for each tile location. -It is compatible with any existing image anomaly detection model without the need for any modification of the underlying architecture. - -![Tiled ensemble flow](../../../../images/tiled_ensemble/ensemble_flow.png) - -```{note} -This feature is experimental and may not work as expected. -For any problems refer to [Issues](https://github.com/openvinotoolkit/anomalib/issues) and feel free to ask any question in [Discussions](https://github.com/openvinotoolkit/anomalib/discussions). -``` - -## Training - -You can train a tiled ensemble using the training script located inside `tools/tiled_ensemble` directory: - -```{code-block} bash - -python tools/tiled_ensemble/train_ensemble.py \ - --config tools/tiled_ensemble/ens_config.yaml -``` - -By default, the Padim model is trained on **MVTec AD bottle** category using image size of 256x256, divided into non-overlapping 128x128 tiles. -You can modify these parameters in the [config file](#ensemble-configuration). - -## Evaluation - -After training, you can evaluate the tiled ensemble on test data using: - -```{code-block} bash - -python tools/tiled_ensemble/eval.py \ - --config tools/tiled_ensemble/ens_config.yaml \ - --root path_to_results_dir - -``` - -Ensure that `root` points to the directory containing the training results, typically `results/padim/mvtec/bottle/runX`. - -## Ensemble configuration - -Tiled ensemble is configured using `ens_config.yaml` file in the `tools/tiled_ensemble` directory. -It contains general settings and tiled ensemble specific settings. - -### General - -General settings at the top of the config file are used to set up the random `seed`, `accelerator` (device) and the path to where results will be saved `default_root_dir`. - -```{code-block} yaml -seed: 42 -accelerator: "gpu" -default_root_dir: "results" -``` - -### Tiling - -This section contains the following settings, used for image tiling: - -```{code-block} yaml - -tiling: - tile_size: 256 - stride: 256 -``` - -These settings determine the tile size and stride. Another important parameter is image_size from `data` section later in the config. It determines the original size of the image. - -Input image is split into tiles, where each tile is of shape set by `tile_size` and tiles are taken with step set by `stride`. -For example: having image_size: 512, tile_size: 256, and stride: 256, results in 4 non-overlapping tile locations. - -### Normalization and thresholding - -Next up are the normalization and thresholding settings: - -```{code-block} yaml -normalization_stage: image -thresholding: - method: F1AdaptiveThreshold - stage: image -``` - -- **Normalization**: Can be applied per each tile location separately (`tile` option), after combining prediction (`image` option), or skipped (`none` option). - -- **Thresholding**: Can also be applied at different stages, but it is limited to `tile` and `image`. Another setting for thresholding is the method used. It can be specified as a string or by the class path. - -### Data - -The `data` section is used to configure the input `image_size` and other parameters for the dataset used. - -```{code-block} yaml -data: - class_path: anomalib.data.MVTec - init_args: - root: ./datasets/MVTec - category: bottle - train_batch_size: 32 - eval_batch_size: 32 - num_workers: 8 - task: segmentation - transform: null - train_transform: null - eval_transform: null - test_split_mode: from_dir - test_split_ratio: 0.2 - val_split_mode: same_as_test - val_split_ratio: 0.5 - image_size: [256, 256] -``` - -Refer to [Data](../../reference/data/image/index.md) for more details on parameters. - -### SeamSmoothing - -This section contains settings for `SeamSmoothing` block of pipeline: - -```{code-block} yaml -SeamSmoothing: - apply: True - sigma: 2 - width: 0.1 - -``` - -SeamSmoothing job is responsible for smoothing of regions where tiles meet - called tile seams. - -- **apply**: If True, smoothing will be applied. -- **sigma**: Controls the sigma of Gaussian filter used for smoothing. -- **width**: Sets the percentage of the region around the seam to be smoothed. - -### TrainModels - -The last section `TrainModels` contains the setup for model training: - -```{code-block} yaml -TrainModels: - model: - class_path: Fastflow - - metrics: - pixel: AUROC - image: AUROC - - trainer: - max_epochs: 500 - callbacks: - - class_path: lightning.pytorch.callbacks.EarlyStopping - init_args: - patience: 42 - monitor: pixel_AUROC - mode: max -``` - -- **Model**: Specifies the model used. Refer to [Models](../../reference/models/image/index.md) for more details on the model parameters. -- **Metrics**: Defines evaluation metrics for pixel and image level. -- **Trainer**: _optional_ parameters, used to control the training process. Refer to [Engine](../../reference/engine/index.md) for more details. diff --git a/src/anomalib/pipelines/tiled_ensemble/__init__.py b/src/anomalib/pipelines/tiled_ensemble/__init__.py deleted file mode 100644 index 1a068562b7..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Tiled ensemble pipelines.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from .test_pipeline import EvalTiledEnsemble -from .train_pipeline import TrainTiledEnsemble - -__all__ = [ - "TrainTiledEnsemble", - "EvalTiledEnsemble", -] diff --git a/src/anomalib/pipelines/tiled_ensemble/components/__init__.py b/src/anomalib/pipelines/tiled_ensemble/components/__init__.py deleted file mode 100644 index 619dc2e673..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Tiled ensemble pipeline components.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from .merging import MergeJobGenerator -from .metrics_calculation import MetricsCalculationJobGenerator -from .model_training import TrainModelJobGenerator -from .normalization import NormalizationJobGenerator -from .prediction import PredictJobGenerator -from .smoothing import SmoothingJobGenerator -from .stats_calculation import StatisticsJobGenerator -from .thresholding import ThresholdingJobGenerator -from .utils import NormalizationStage, PredictData, ThresholdStage -from .visualization import VisualizationJobGenerator - -__all__ = [ - "NormalizationStage", - "ThresholdStage", - "PredictData", - "TrainModelJobGenerator", - "PredictJobGenerator", - "MergeJobGenerator", - "SmoothingJobGenerator", - "StatisticsJobGenerator", - "NormalizationJobGenerator", - "ThresholdingJobGenerator", - "VisualizationJobGenerator", - "MetricsCalculationJobGenerator", -] diff --git a/src/anomalib/pipelines/tiled_ensemble/components/merging.py b/src/anomalib/pipelines/tiled_ensemble/components/merging.py deleted file mode 100644 index 6e8d5fc84c..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/merging.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Tiled ensemble - prediction merging job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from collections.abc import Generator -from typing import Any - -from tqdm import tqdm - -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS - -from .utils.ensemble_tiling import EnsembleTiler -from .utils.helper_functions import get_ensemble_tiler -from .utils.prediction_data import EnsemblePredictions -from .utils.prediction_merging import PredictionMergingMechanism - -logger = logging.getLogger(__name__) - - -class MergeJob(Job): - """Job for merging tile-level predictions into image-level predictions. - - Args: - predictions (EnsemblePredictions): Object containing ensemble predictions. - tiler (EnsembleTiler): Ensemble tiler used for untiling. - """ - - name = "Merge" - - def __init__(self, predictions: EnsemblePredictions, tiler: EnsembleTiler) -> None: - super().__init__() - self.predictions = predictions - self.tiler = tiler - - def run(self, task_id: int | None = None) -> list[Any]: - """Run merging job that merges all batches of tile-level predictions into image-level predictions. - - Args: - task_id: Not used in this case. - - Returns: - list[Any]: List of merged predictions. - """ - del task_id # not needed here - - merger = PredictionMergingMechanism(self.predictions, self.tiler) - - logger.info("Merging predictions.") - - # merge all batches - merged_predictions = [ - merger.merge_tile_predictions(batch_idx) - for batch_idx in tqdm(range(merger.num_batches), desc="Prediction merging") - ] - - return merged_predictions # noqa: RET504 - - @staticmethod - def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: - """Nothing to collect in this job. - - Returns: - list[Any]: List of predictions. - """ - # take the first element as result is list of lists here - return results[0] - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """Nothing to save in this job.""" - - -class MergeJobGenerator(JobGenerator): - """Generate MergeJob.""" - - def __init__(self, tiling_args: dict, data_args: dict) -> None: - super().__init__() - self.tiling_args = tiling_args - self.data_args = data_args - - @property - def job_class(self) -> type: - """Return the job class.""" - return MergeJob - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: EnsemblePredictions | None = None, - ) -> Generator[MergeJob, None, None]: - """Return a generator producing a single merging job. - - Args: - args (dict): Tiled ensemble pipeline args. - prev_stage_result (EnsemblePredictions): Ensemble predictions from predict step. - - Returns: - Generator[MergeJob, None, None]: MergeJob generator - """ - del args # args not used here - - tiler = get_ensemble_tiler(self.tiling_args, self.data_args) - if prev_stage_result is not None: - yield MergeJob(prev_stage_result, tiler) - else: - msg = "Merging job requires tile level predictions from previous step." - raise ValueError(msg) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/metrics_calculation.py b/src/anomalib/pipelines/tiled_ensemble/components/metrics_calculation.py deleted file mode 100644 index 530662b1d3..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/metrics_calculation.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Tiled ensemble - metrics calculation job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from collections.abc import Generator -from pathlib import Path -from typing import Any - -import pandas as pd -from tqdm import tqdm - -from anomalib import TaskType -from anomalib.metrics import AnomalibMetricCollection, create_metric_collection -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.types import GATHERED_RESULTS, PREV_STAGE_RESULT, RUN_RESULTS - -from .utils import NormalizationStage -from .utils.helper_functions import get_threshold_values - -logger = logging.getLogger(__name__) - - -class MetricsCalculationJob(Job): - """Job for image and pixel metrics calculation. - - Args: - accelerator (str): Accelerator (device) to use. - predictions (list[Any]): List of batch predictions. - root_dir (Path): Root directory to save checkpoints, stats and images. - image_metrics (AnomalibMetricCollection): Collection of all image-level metrics. - pixel_metrics (AnomalibMetricCollection): Collection of all pixel-level metrics. - """ - - name = "Metrics" - - def __init__( - self, - accelerator: str, - predictions: list[Any] | None, - root_dir: Path, - image_metrics: AnomalibMetricCollection, - pixel_metrics: AnomalibMetricCollection, - ) -> None: - super().__init__() - self.accelerator = accelerator - self.predictions = predictions - self.root_dir = root_dir - self.image_metrics = image_metrics - self.pixel_metrics = pixel_metrics - - def run(self, task_id: int | None = None) -> dict: - """Run a job that calculates image and pixel level metrics. - - Args: - task_id: Not used in this case. - - Returns: - dict[str, float]: Dictionary containing calculated metric values. - """ - del task_id # not needed here - - logger.info("Starting metrics calculation.") - - # add predicted data to metrics - for data in tqdm(self.predictions, desc="Calculating metrics"): - self.image_metrics.update(data["pred_scores"], data["label"].int()) - if "mask" in data and "anomaly_maps" in data: - self.pixel_metrics.update(data["anomaly_maps"], data["mask"].int()) - - # compute all metrics on specified accelerator - metrics_dict = {} - for name, metric in self.image_metrics.items(): - metric.to(self.accelerator) - metrics_dict[name] = metric.compute().item() - metric.cpu() - - if self.pixel_metrics.update_called: - for name, metric in self.pixel_metrics.items(): - metric.to(self.accelerator) - metrics_dict[name] = metric.compute().item() - metric.cpu() - - for name, value in metrics_dict.items(): - print(f"{name}: {value:.4f}") - - # save path used in `save` method - metrics_dict["save_path"] = self.root_dir / "metric_results.csv" - - return metrics_dict - - @staticmethod - def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: - """Nothing to collect in this job. - - Returns: - list[Any]: list of predictions. - """ - # take the first element as result is list of dict here - return results[0] - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """Save metrics values to csv.""" - logger.info("Saving metrics to csv.") - - # get and remove path from stats dict - results_path: Path = results.pop("save_path") - results_path.parent.mkdir(parents=True, exist_ok=True) - - df_dict = {k: [v] for k, v in results.items()} - metrics_df = pd.DataFrame(df_dict) - metrics_df.to_csv(results_path, index=False) - - -class MetricsCalculationJobGenerator(JobGenerator): - """Generate MetricsCalculationJob. - - Args: - root_dir (Path): Root directory to save checkpoints, stats and images. - """ - - def __init__( - self, - accelerator: str, - root_dir: Path, - task: TaskType, - metrics: dict, - normalization_stage: NormalizationStage, - ) -> None: - self.accelerator = accelerator - self.root_dir = root_dir - self.task = task - self.metrics = metrics - self.normalization_stage = normalization_stage - - @property - def job_class(self) -> type: - """Return the job class.""" - return MetricsCalculationJob - - def configure_ensemble_metrics( - self, - image_metrics: list[str] | dict[str, dict[str, Any]] | None = None, - pixel_metrics: list[str] | dict[str, dict[str, Any]] | None = None, - ) -> tuple[AnomalibMetricCollection, AnomalibMetricCollection]: - """Configure image and pixel metrics and put them into a collection. - - Args: - image_metrics (list[str] | None): List of image-level metric names. - pixel_metrics (list[str] | None): List of pixel-level metric names. - - Returns: - tuple[AnomalibMetricCollection, AnomalibMetricCollection]: - Image-metrics collection and pixel-metrics collection - """ - image_metrics = [] if image_metrics is None else image_metrics - - if pixel_metrics is None: - pixel_metrics = [] - elif self.task == TaskType.CLASSIFICATION: - pixel_metrics = [] - logger.warning( - "Cannot perform pixel-level evaluation when task type is classification. " - "Ignoring the following pixel-level metrics: %s", - pixel_metrics, - ) - - # if a single metric is passed, transform to list to fit the creation function - if isinstance(image_metrics, str): - image_metrics = [image_metrics] - if isinstance(pixel_metrics, str): - pixel_metrics = [pixel_metrics] - - image_metrics_collection = create_metric_collection(image_metrics, "image_") - pixel_metrics_collection = create_metric_collection(pixel_metrics, "pixel_") - - return image_metrics_collection, pixel_metrics_collection - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: PREV_STAGE_RESULT = None, - ) -> Generator[MetricsCalculationJob, None, None]: - """Make a generator that yields a single metrics calculation job. - - Args: - args: ensemble run config. - prev_stage_result: ensemble predictions from previous step. - - Returns: - Generator[MetricsCalculationJob, None, None]: MetricsCalculationJob generator - """ - del args # args not used here - - image_metrics_config = self.metrics.get("image", None) - pixel_metrics_config = self.metrics.get("pixel", None) - - image_threshold, pixel_threshold = get_threshold_values(self.normalization_stage, self.root_dir) - - image_metrics, pixel_metrics = self.configure_ensemble_metrics( - image_metrics=image_metrics_config, - pixel_metrics=pixel_metrics_config, - ) - - # set thresholds for metrics that need it - image_metrics.set_threshold(image_threshold) - pixel_metrics.set_threshold(pixel_threshold) - - yield MetricsCalculationJob( - accelerator=self.accelerator, - predictions=prev_stage_result, - root_dir=self.root_dir, - image_metrics=image_metrics, - pixel_metrics=pixel_metrics, - ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/model_training.py b/src/anomalib/pipelines/tiled_ensemble/components/model_training.py deleted file mode 100644 index 6bc81c793b..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/model_training.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Tiled ensemble - ensemble training job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from collections.abc import Generator -from itertools import product -from pathlib import Path - -from lightning import seed_everything - -from anomalib.data import AnomalibDataModule -from anomalib.models import AnomalyModule -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.types import GATHERED_RESULTS, PREV_STAGE_RESULT - -from .utils import NormalizationStage -from .utils.ensemble_engine import TiledEnsembleEngine -from .utils.helper_functions import ( - get_ensemble_datamodule, - get_ensemble_engine, - get_ensemble_model, - get_ensemble_tiler, -) - -logger = logging.getLogger(__name__) - - -class TrainModelJob(Job): - """Job for training of individual models in the tiled ensemble. - - Args: - accelerator (str): Accelerator (device) to use. - seed (int): Random seed for reproducibility. - root_dir (Path): Root directory to save checkpoints, stats and images. - tile_index (tuple[int, int]): Index of tile that this model processes. - normalization_stage (str): Normalization stage flag. - metrics (dict): metrics dict with pixel and image metric names. - trainer_args (dict| None): Additional arguments to pass to the trainer class. - model (AnomalyModule): Model to train. - datamodule (AnomalibDataModule): Datamodule with all dataloaders. - - """ - - name = "TrainModels" - - def __init__( - self, - accelerator: str, - seed: int, - root_dir: Path, - tile_index: tuple[int, int], - normalization_stage: str, - metrics: dict, - trainer_args: dict | None, - model: AnomalyModule, - datamodule: AnomalibDataModule, - ) -> None: - super().__init__() - self.accelerator = accelerator - self.seed = seed - self.root_dir = root_dir - self.tile_index = tile_index - self.normalization_stage = normalization_stage - self.metrics = metrics - self.trainer_args = trainer_args - self.model = model - self.datamodule = datamodule - - def run( - self, - task_id: int | None = None, - ) -> TiledEnsembleEngine: - """Run train job that fits the model for given tile location. - - Args: - task_id: Passed when job is ran in parallel. - - Returns: - TiledEnsembleEngine: Engine containing trained model. - """ - devices: str | list[int] = "auto" - if task_id is not None: - devices = [task_id] - logger.info(f"Running job {self.model.__class__.__name__} with device {task_id}") - - logger.info("Start of training for tile at position %s,", self.tile_index) - seed_everything(self.seed) - - # create engine for specific tile location and fit the model - engine = get_ensemble_engine( - tile_index=self.tile_index, - accelerator=self.accelerator, - devices=devices, - root_dir=self.root_dir, - normalization_stage=self.normalization_stage, - metrics=self.metrics, - trainer_args=self.trainer_args, - ) - engine.fit(model=self.model, datamodule=self.datamodule) - # move model to cpu to avoid memory issues as the engine is returned to be used in validation phase - engine.model.cpu() - - return engine - - @staticmethod - def collect(results: list[TiledEnsembleEngine]) -> dict[tuple[int, int], TiledEnsembleEngine]: - """Collect engines from each tile location into a dict. - - Returns: - dict[tuple[int, int], TiledEnsembleEngine]: Dict has form {tile_index: TiledEnsembleEngine} - """ - return {r.tile_index: r for r in results} - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """Skip as checkpoints are already saved by callback.""" - - -class TrainModelJobGenerator(JobGenerator): - """Generator for training job that train model for each tile location. - - Args: - root_dir (Path): Root directory to save checkpoints, stats and images. - """ - - def __init__( - self, - seed: int, - accelerator: str, - root_dir: Path, - tiling_args: dict, - data_args: dict, - normalization_stage: NormalizationStage, - ) -> None: - self.seed = seed - self.accelerator = accelerator - self.root_dir = root_dir - self.tiling_args = tiling_args - self.data_args = data_args - self.normalization_stage = normalization_stage - - @property - def job_class(self) -> type: - """Return the job class.""" - return TrainModelJob - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: PREV_STAGE_RESULT = None, - ) -> Generator[TrainModelJob, None, None]: - """Generate training jobs for each tile location. - - Args: - args (dict): Dict with config passed to training. - prev_stage_result (None): Not used here. - - Returns: - Generator[TrainModelJob, None, None]: TrainModelJob generator - """ - del prev_stage_result # Not needed for this job - if args is None: - msg = "TrainModels job requires config args" - raise ValueError(msg) - - # tiler used for splitting the image and getting the tile count - tiler = get_ensemble_tiler(self.tiling_args, self.data_args) - - logger.info( - "Tiled ensemble training started. Separate models will be trained for %d tile locations.", - tiler.num_tiles, - ) - # go over all tile positions - for tile_index in product(range(tiler.num_patches_h), range(tiler.num_patches_w)): - # prepare datamodule with custom collate function that only provides specific tile of image - datamodule = get_ensemble_datamodule(self.data_args, tiler, tile_index) - model = get_ensemble_model(args["model"], tiler) - - # pass root_dir to engine so all models in ensemble have the same root dir - yield TrainModelJob( - accelerator=self.accelerator, - seed=self.seed, - root_dir=self.root_dir, - tile_index=tile_index, - normalization_stage=self.normalization_stage, - metrics=args["metrics"], - trainer_args=args.get("trainer", {}), - model=model, - datamodule=datamodule, - ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/normalization.py b/src/anomalib/pipelines/tiled_ensemble/components/normalization.py deleted file mode 100644 index 8c7a563506..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/normalization.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tiled ensemble - normalization job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import json -import logging -from collections.abc import Generator -from pathlib import Path -from typing import Any - -from tqdm import tqdm - -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS -from anomalib.utils.normalization.min_max import normalize - -logger = logging.getLogger(__name__) - - -class NormalizationJob(Job): - """Job for normalization of predictions. - - Args: - predictions (list[Any]): List of predictions. - root_dir (Path): Root directory containing statistics needed for normalization. - """ - - name = "Normalize" - - def __init__(self, predictions: list[Any] | None, root_dir: Path) -> None: - super().__init__() - self.predictions = predictions - self.root_dir = root_dir - - def run(self, task_id: int | None = None) -> list[Any] | None: - """Run normalization job which normalizes image, pixel and box scores. - - Args: - task_id: Not used in this case. - - Returns: - list[Any]: List of normalized predictions. - """ - del task_id # not needed here - - # load all statistics needed for normalization - stats_path = self.root_dir / "weights" / "lightning" / "stats.json" - with stats_path.open("r") as f: - stats = json.load(f) - minmax = stats["minmax"] - image_threshold = stats["image_threshold"] - pixel_threshold = stats["pixel_threshold"] - - logger.info("Starting normalization.") - - for data in tqdm(self.predictions, desc="Normalizing"): - data["pred_scores"] = normalize( - data["pred_scores"], - image_threshold, - minmax["pred_scores"]["min"], - minmax["pred_scores"]["max"], - ) - if "anomaly_maps" in data: - data["anomaly_maps"] = normalize( - data["anomaly_maps"], - pixel_threshold, - minmax["anomaly_maps"]["min"], - minmax["anomaly_maps"]["max"], - ) - - return self.predictions - - @staticmethod - def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: - """Nothing to collect in this job. - - Returns: - list[Any]: List of predictions. - """ - # take the first element as result is list of lists here - return results[0] - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """Nothing is saved in this job.""" - - -class NormalizationJobGenerator(JobGenerator): - """Generate NormalizationJob. - - Args: - root_dir (Path): Root directory where statistics are saved. - """ - - def __init__(self, root_dir: Path) -> None: - self.root_dir = root_dir - - @property - def job_class(self) -> type: - """Return the job class.""" - return NormalizationJob - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: list[Any] | None = None, - ) -> Generator[NormalizationJob, None, None]: - """Return a generator producing a single normalization job. - - Args: - args: not used here. - prev_stage_result (list[Any]): Ensemble predictions from previous step. - - Returns: - Generator[NormalizationJob, None, None]: NormalizationJob generator. - """ - del args # not needed here - - yield NormalizationJob(prev_stage_result, self.root_dir) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/prediction.py b/src/anomalib/pipelines/tiled_ensemble/components/prediction.py deleted file mode 100644 index 792d86a497..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/prediction.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Tiled ensemble - ensemble prediction job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from collections.abc import Generator -from itertools import product -from pathlib import Path -from typing import Any - -from lightning import seed_everything -from torch.utils.data import DataLoader - -from anomalib.models import AnomalyModule -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.types import GATHERED_RESULTS, PREV_STAGE_RESULT - -from .utils import NormalizationStage, PredictData -from .utils.ensemble_engine import TiledEnsembleEngine -from .utils.helper_functions import ( - get_ensemble_datamodule, - get_ensemble_engine, - get_ensemble_model, - get_ensemble_tiler, -) -from .utils.prediction_data import EnsemblePredictions - -logger = logging.getLogger(__name__) - - -class PredictJob(Job): - """Job for generating predictions with individual models in the tiled ensemble. - - Args: - accelerator (str): Accelerator (device) to use. - seed (int): Random seed for reproducibility. - root_dir (Path): Root directory to save checkpoints, stats and images. - tile_index (tuple[int, int]): Index of tile that this model processes. - normalization_stage (str): Normalization stage flag. - dataloader (DataLoader): Dataloader to use for training (either val or test). - model (AnomalyModule): Model to train. - engine (TiledEnsembleEngine | None): - engine from train job. If job is used standalone, instantiate engine and model from checkpoint. - ckpt_path (Path | None): Path to checkpoint to be loaded if engine doesn't contain correct weights. - - """ - - name = "Predict" - - def __init__( - self, - accelerator: str, - seed: int, - root_dir: Path, - tile_index: tuple[int, int], - normalization_stage: str, - dataloader: DataLoader, - model: AnomalyModule | None, - engine: TiledEnsembleEngine | None, - ckpt_path: Path | None, - ) -> None: - super().__init__() - if engine is None and ckpt_path is None: - msg = "Either engine or checkpoint must be provided to predict job." - raise ValueError(msg) - - self.accelerator = accelerator - self.seed = seed - self.root_dir = root_dir - self.tile_index = tile_index - self.normalization_stage = normalization_stage - self.dataloader = dataloader - self.model = model - self.engine = engine - self.ckpt_path = ckpt_path - - def run( - self, - task_id: int | None = None, - ) -> tuple[tuple[int, int], Any | None]: - """Predict job that predicts the data with specific model for given tile location. - - Args: - task_id: Passed when job is ran in parallel. - - Returns: - tuple[tuple[int, int], list[Any]]: Tile index, List of predictions. - """ - devices: str | list[int] = "auto" - if task_id is not None: - devices = [task_id] - logger.info(f"Running job {self.model.__class__.__name__} with device {task_id}") - - logger.info("Start of predicting for tile at position %s,", self.tile_index) - seed_everything(self.seed) - - if self.engine is None: - # in case predict is invoked separately from train job, make new engine instance - self.engine = get_ensemble_engine( - tile_index=self.tile_index, - accelerator=self.accelerator, - devices=devices, - root_dir=self.root_dir, - normalization_stage=self.normalization_stage, - ) - - predictions = self.engine.predict(model=self.model, dataloaders=self.dataloader, ckpt_path=self.ckpt_path) - - # also return tile index as it's needed in collect method - return self.tile_index, predictions - - @staticmethod - def collect(results: list[tuple[tuple[int, int], list[Any]]]) -> EnsemblePredictions: - """Collect predictions from each tile location into the predictions class. - - Returns: - EnsemblePredictions: Object containing all predictions in form ready for merging. - """ - storage = EnsemblePredictions() - - for tile_index, predictions in results: - storage.add_tile_prediction(tile_index, predictions) - - return storage - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """This stage doesn't save anything.""" - - -class PredictJobGenerator(JobGenerator): - """Generator for predict job that uses individual models to predict for each tile location. - - Args: - root_dir (Path): Root directory to save checkpoints, stats and images. - data_source (PredictData): Whether to predict on validation set. If false use test set. - """ - - def __init__( - self, - data_source: PredictData, - seed: int, - accelerator: str, - root_dir: Path, - tiling_args: dict, - data_args: dict, - model_args: dict, - normalization_stage: NormalizationStage, - ) -> None: - self.data_source = data_source - self.seed = seed - self.accelerator = accelerator - self.root_dir = root_dir - self.tiling_args = tiling_args - self.data_args = data_args - self.model_args = model_args - self.normalization_stage = normalization_stage - - @property - def job_class(self) -> type: - """Return the job class.""" - return PredictJob - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: PREV_STAGE_RESULT = None, - ) -> Generator[PredictJob, None, None]: - """Generate predict jobs for each tile location. - - Args: - args (dict): Dict with config passed to training. - prev_stage_result (dict[tuple[int, int], TiledEnsembleEngine] | None): - if called after train job this contains engines with individual models, otherwise load from checkpoints. - - Returns: - Generator[PredictJob, None, None]: PredictJob generator. - """ - del args # args not used here - - # tiler used for splitting the image and getting the tile count - tiler = get_ensemble_tiler(self.tiling_args, self.data_args) - - logger.info( - "Tiled ensemble predicting started using %s data.", - self.data_source.value, - ) - # go over all tile positions - for tile_index in product(range(tiler.num_patches_h), range(tiler.num_patches_w)): - # prepare datamodule with custom collate function that only provides specific tile of image - datamodule = get_ensemble_datamodule(self.data_args, tiler, tile_index) - - # check if predict step is positioned after training - if prev_stage_result and tile_index in prev_stage_result: - engine = prev_stage_result[tile_index] - # model is inside engine in this case - model = engine.model - ckpt_path = None - else: - # any other case - predict is called standalone - engine = None - # we need to make new model instance as it's not inside engine - model = get_ensemble_model(self.model_args, tiler) - tile_i, tile_j = tile_index - # prepare checkpoint path for model on current tile location - ckpt_path = self.root_dir / "weights" / "lightning" / f"model{tile_i}_{tile_j}.ckpt" - - # pick the dataloader based on predict data - dataloader = datamodule.test_dataloader() - if self.data_source == PredictData.VAL: - dataloader = datamodule.val_dataloader() - # TODO(blaz-r): - this is tweak to avoid problem in engine:388 - # 2254 - dataloader.dataset.transform = None - - # pass root_dir to engine so all models in ensemble have the same root dir - yield PredictJob( - accelerator=self.accelerator, - seed=self.seed, - root_dir=self.root_dir, - tile_index=tile_index, - normalization_stage=self.normalization_stage, - model=model, - dataloader=dataloader, - engine=engine, - ckpt_path=ckpt_path, - ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/smoothing.py b/src/anomalib/pipelines/tiled_ensemble/components/smoothing.py deleted file mode 100644 index b3d5a51000..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/smoothing.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Tiled ensemble - seam smoothing job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from collections.abc import Generator -from typing import Any - -import torch -from tqdm import tqdm - -from anomalib.models.components import GaussianBlur2d -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS - -from .utils.ensemble_tiling import EnsembleTiler -from .utils.helper_functions import get_ensemble_tiler - -logger = logging.getLogger(__name__) - - -class SmoothingJob(Job): - """Job for smoothing the area around the tile seam. - - Args: - accelerator (str): Accelerator used for processing. - predictions (list[Any]): List of image-level predictions. - width_factor (float): Factor multiplied by tile dimension to get the region around seam which will be smoothed. - filter_sigma (float): Sigma of filter used for smoothing the seams. - tiler (EnsembleTiler): Tiler object used to get tile dimension data. - """ - - name = "SeamSmoothing" - - def __init__( - self, - accelerator: str, - predictions: list[Any], - width_factor: float, - filter_sigma: float, - tiler: EnsembleTiler, - ) -> None: - super().__init__() - self.accelerator = accelerator - self.predictions = predictions - - # offset in pixels of region around tile seam that will be smoothed - self.height_offset = int(tiler.tile_size_h * width_factor) - self.width_offset = int(tiler.tile_size_w * width_factor) - self.tiler = tiler - - self.seam_mask = self.prepare_seam_mask() - - self.blur = GaussianBlur2d(sigma=filter_sigma) - - def prepare_seam_mask(self) -> torch.Tensor: - """Prepare boolean mask of regions around the part where tiles seam in ensemble. - - Returns: - torch.Tensor: Representation of boolean mask where filtered seams should be used. - """ - img_h, img_w = self.tiler.image_size - stride_h, stride_w = self.tiler.stride_h, self.tiler.stride_w - - mask = torch.zeros(img_h, img_w, dtype=torch.bool) - - # prepare mask strip on vertical seams - curr_w = stride_w - while curr_w < img_w: - start_i = curr_w - self.width_offset - end_i = curr_w + self.width_offset - mask[:, start_i:end_i] = 1 - curr_w += stride_w - - # prepare mask strip on horizontal seams - curr_h = stride_h - while curr_h < img_h: - start_i = curr_h - self.height_offset - end_i = curr_h + self.height_offset - mask[start_i:end_i, :] = True - curr_h += stride_h - - return mask - - def run(self, task_id: int | None = None) -> list[Any]: - """Run smoothing job. - - Args: - task_id: Not used in this case. - - Returns: - list[Any]: List of predictions. - """ - del task_id # not needed here - - logger.info("Starting seam smoothing.") - - for data in tqdm(self.predictions, desc="Seam smoothing"): - # move to specified accelerator for faster execution - data["anomaly_maps"] = data["anomaly_maps"].to(self.accelerator) - # smooth the anomaly map and take only region around seams delimited by seam_mask - smoothed = self.blur(data["anomaly_maps"]) - data["anomaly_maps"][:, :, self.seam_mask] = smoothed[:, :, self.seam_mask] - data["anomaly_maps"] = data["anomaly_maps"].cpu() - - return self.predictions - - @staticmethod - def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: - """Nothing to collect in this job. - - Returns: - list[Any]: List of predictions. - """ - # take the first element as result is list of lists here - return results[0] - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """Nothing to save in this job.""" - - -class SmoothingJobGenerator(JobGenerator): - """Generate SmoothingJob.""" - - def __init__(self, accelerator: str, tiling_args: dict, data_args: dict) -> None: - super().__init__() - self.accelerator = accelerator - self.tiling_args = tiling_args - self.data_args = data_args - - @property - def job_class(self) -> type: - """Return the job class.""" - return SmoothingJob - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: list[Any] | None = None, - ) -> Generator[SmoothingJob, None, None]: - """Return a generator producing a single seam smoothing job. - - Args: - args: Tiled ensemble pipeline args. - prev_stage_result (list[Any]): Ensemble predictions from previous step. - - Returns: - Generator[SmoothingJob, None, None]: SmoothingJob generator - """ - if args is None: - msg = "SeamSmoothing job requires config args" - raise ValueError(msg) - # tiler is used to determine where seams appear - tiler = get_ensemble_tiler(self.tiling_args, self.data_args) - if prev_stage_result is not None: - yield SmoothingJob( - accelerator=self.accelerator, - predictions=prev_stage_result, - width_factor=args["width"], - filter_sigma=args["sigma"], - tiler=tiler, - ) - else: - msg = "Join smoothing job requires tile level predictions from previous step." - raise ValueError(msg) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/stats_calculation.py b/src/anomalib/pipelines/tiled_ensemble/components/stats_calculation.py deleted file mode 100644 index 6c48b639f7..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/stats_calculation.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Tiled ensemble - post-processing statistics calculation job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import json -import logging -from collections.abc import Generator -from pathlib import Path -from typing import Any - -import torch -from omegaconf import DictConfig, ListConfig -from torchmetrics import MetricCollection -from tqdm import tqdm - -from anomalib.callbacks.thresholding import _ThresholdCallback -from anomalib.metrics import MinMax -from anomalib.metrics.threshold import Threshold -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS - -logger = logging.getLogger(__name__) - - -class StatisticsJob(Job): - """Job for calculating min, max and threshold statistics for post-processing. - - Args: - predictions (list[Any]): List of image-level predictions. - root_dir (Path): Root directory to save checkpoints, stats and images. - """ - - name = "Stats" - - def __init__( - self, - predictions: list[Any] | None, - root_dir: Path, - image_threshold: Threshold, - pixel_threshold: Threshold, - ) -> None: - super().__init__() - self.predictions = predictions - self.root_dir = root_dir - self.image_threshold = image_threshold - self.pixel_threshold = pixel_threshold - - def run(self, task_id: int | None = None) -> dict: - """Run job that calculates statistics needed in post-processing steps. - - Args: - task_id: Not used in this case - - Returns: - dict: Statistics dict with min, max and threshold values. - """ - del task_id # not needed here - - minmax = MetricCollection( - { - "anomaly_maps": MinMax().cpu(), - "pred_scores": MinMax().cpu(), - }, - ) - pixel_update_called = False - - logger.info("Starting post-processing statistics calculation.") - - for data in tqdm(self.predictions, desc="Stats calculation"): - # update minmax - if "anomaly_maps" in data: - minmax["anomaly_maps"](data["anomaly_maps"]) - if "pred_scores" in data: - minmax["pred_scores"](data["pred_scores"]) - - # update thresholds - self.image_threshold.update(data["pred_scores"], data["label"].int()) - if "mask" in data and "anomaly_maps" in data: - self.pixel_threshold.update(torch.squeeze(data["anomaly_maps"]), torch.squeeze(data["mask"].int())) - pixel_update_called = True - - self.image_threshold.compute() - if pixel_update_called: - self.pixel_threshold.compute() - else: - self.pixel_threshold.value = self.image_threshold.value - - min_max_vals = {} - for pred_name, pred_metric in minmax.items(): - min_max_vals[pred_name] = { - "min": pred_metric.min.item(), - "max": pred_metric.max.item(), - } - - # return stats with save path that is later used to save statistics. - return { - "minmax": min_max_vals, - "image_threshold": self.image_threshold.value.item(), - "pixel_threshold": self.pixel_threshold.value.item(), - "save_path": (self.root_dir / "weights" / "lightning" / "stats.json"), - } - - @staticmethod - def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: - """Nothing to collect in this job. - - Returns: - dict: statistics dictionary. - """ - # take the first element as result is list of lists here - return results[0] - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """Save statistics to file system.""" - # get and remove path from stats dict - stats_path: Path = results.pop("save_path") - stats_path.parent.mkdir(parents=True, exist_ok=True) - - # save statistics next to weights - with stats_path.open("w", encoding="utf-8") as stats_file: - json.dump(results, stats_file, ensure_ascii=False, indent=4) - - -class StatisticsJobGenerator(JobGenerator): - """Generate StatisticsJob. - - Args: - root_dir (Path): Root directory where statistics file will be saved (in weights folder). - """ - - def __init__( - self, - root_dir: Path, - thresholding_method: DictConfig | str | ListConfig | list[dict[str, str | float]], - ) -> None: - self.root_dir = root_dir - self.threshold = thresholding_method - - @property - def job_class(self) -> type: - """Return the job class.""" - return StatisticsJob - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: list[Any] | None = None, - ) -> Generator[StatisticsJob, None, None]: - """Return a generator producing a single stats calculating job. - - Args: - args: Not used here. - prev_stage_result (list[Any]): Ensemble predictions from previous step. - - Returns: - Generator[StatisticsJob, None, None]: StatisticsJob generator. - """ - del args # not needed here - - # get threshold class based config - if isinstance(self.threshold, str | DictConfig): - # single method provided - image_threshold = _ThresholdCallback._get_threshold_from_config(self.threshold) # noqa: SLF001 - pixel_threshold = image_threshold.clone() - elif isinstance(self.threshold, ListConfig | list): - # image and pixel method specified separately - image_threshold = _ThresholdCallback._get_threshold_from_config(self.threshold[0]) # noqa: SLF001 - pixel_threshold = _ThresholdCallback._get_threshold_from_config(self.threshold[1]) # noqa: SLF001 - else: - msg = f"Invalid threshold config {self.threshold}" - raise TypeError(msg) - - yield StatisticsJob( - predictions=prev_stage_result, - root_dir=self.root_dir, - image_threshold=image_threshold, - pixel_threshold=pixel_threshold, - ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/thresholding.py b/src/anomalib/pipelines/tiled_ensemble/components/thresholding.py deleted file mode 100644 index 733c3d99db..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/thresholding.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tiled ensemble - thresholding job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from collections.abc import Generator -from pathlib import Path -from typing import Any - -from tqdm import tqdm - -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS - -from .utils import NormalizationStage -from .utils.helper_functions import get_threshold_values - -logger = logging.getLogger(__name__) - - -class ThresholdingJob(Job): - """Job used to threshold predictions, producing labels from scores. - - Args: - predictions (list[Any]): List of predictions. - image_threshold (float): Threshold used for image-level thresholding. - pixel_threshold (float): Threshold used for pixel-level thresholding. - """ - - name = "Threshold" - - def __init__(self, predictions: list[Any] | None, image_threshold: float, pixel_threshold: float) -> None: - super().__init__() - self.predictions = predictions - self.image_threshold = image_threshold - self.pixel_threshold = pixel_threshold - - def run(self, task_id: int | None = None) -> list[Any] | None: - """Run job that produces prediction labels from scores. - - Args: - task_id: Not used in this case. - - Returns: - list[Any]: List of thresholded predictions. - """ - del task_id # not needed here - - logger.info("Starting thresholding.") - - for data in tqdm(self.predictions, desc="Thresholding"): - if "pred_scores" in data: - data["pred_labels"] = data["pred_scores"] >= self.image_threshold - if "anomaly_maps" in data: - data["pred_masks"] = data["anomaly_maps"] >= self.pixel_threshold - - return self.predictions - - @staticmethod - def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: - """Nothing to collect in this job. - - Returns: - list[Any]: List of predictions. - """ - # take the first element as result is list of lists here - return results[0] - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """Nothing is saved in this job.""" - - -class ThresholdingJobGenerator(JobGenerator): - """Generate ThresholdingJob. - - Args: - root_dir (Path): Root directory containing post-processing stats. - """ - - def __init__(self, root_dir: Path, normalization_stage: NormalizationStage) -> None: - self.root_dir = root_dir - self.normalization_stage = normalization_stage - - @property - def job_class(self) -> type: - """Return the job class.""" - return ThresholdingJob - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: list[Any] | None = None, - ) -> Generator[ThresholdingJob, None, None]: - """Return a generator producing a single thresholding job. - - Args: - args: ensemble run args. - prev_stage_result (list[Any]): Ensemble predictions from previous step. - - Returns: - Generator[ThresholdingJob, None, None]: ThresholdingJob generator. - """ - del args # args not used here - - # get threshold values base on normalization - image_threshold, pixel_threshold = get_threshold_values(self.normalization_stage, self.root_dir) - - yield ThresholdingJob( - predictions=prev_stage_result, - image_threshold=image_threshold, - pixel_threshold=pixel_threshold, - ) diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/__init__.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/__init__.py deleted file mode 100644 index a010208908..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/utils/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tiled ensemble utils and helper functions.""" - -from enum import Enum - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - - -class NormalizationStage(str, Enum): - """Enum signaling at which stage the normalization is done. - - In case of tile, tiles are normalized for each tile position separately. - In case of image, normalization is done at the end when images are joined back together. - In case of none, output is not normalized. - """ - - TILE = "tile" - IMAGE = "image" - NONE = "none" - - -class ThresholdStage(str, Enum): - """Enum signaling at which stage the thresholding is applied. - - In case of tile, thresholding is applied for each tile location separately. - In case of image, thresholding is applied at the end when images are joined back together. - """ - - TILE = "tile" - IMAGE = "image" - - -class PredictData(Enum): - """Enum indicating which data to use in prediction job.""" - - VAL = "val" - TEST = "test" - - -__all__ = [ - "NormalizationStage", - "ThresholdStage", - "PredictData", -] diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_engine.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_engine.py deleted file mode 100644 index 449109ed3f..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_engine.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Implements custom Anomalib engine for tiled ensemble training.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from pathlib import Path - -from lightning.pytorch.callbacks import Callback, RichModelSummary - -from anomalib.callbacks import ModelCheckpoint, TimerCallback -from anomalib.callbacks.metrics import _MetricsCallback -from anomalib.callbacks.normalization import get_normalization_callback -from anomalib.callbacks.post_processor import _PostProcessorCallback -from anomalib.callbacks.thresholding import _ThresholdCallback -from anomalib.engine import Engine -from anomalib.models import AnomalyModule -from anomalib.utils.path import create_versioned_dir - -logger = logging.getLogger(__name__) - - -class TiledEnsembleEngine(Engine): - """Engine used for training and evaluating tiled ensemble. - - Most of the logic stays the same, but workspace creation and callbacks are adjusted for ensemble. - - Args: - tile_index (tuple[int, int]): index of tile that this engine instance processes. - **kwargs: Engine arguments. - """ - - def __init__(self, tile_index: tuple[int, int], **kwargs) -> None: - self.tile_index = tile_index - super().__init__(**kwargs) - - def _setup_workspace(self, *args, **kwargs) -> None: - """Skip since in case of tiled ensemble, workspace is only setup once at the beginning of training.""" - - @staticmethod - def setup_ensemble_workspace(args: dict, versioned_dir: bool = True) -> Path: - """Set up the workspace at the beginning of tiled ensemble training. - - Args: - args (dict): Tiled ensemble config dict. - versioned_dir (bool, optional): Whether to create a versioned directory. - Defaults to ``True``. - - Returns: - Path: path to new workspace root dir - """ - model_name = args["TrainModels"]["model"]["class_path"].split(".")[-1] - dataset_name = args["data"]["class_path"].split(".")[-1] - category = args["data"]["init_args"]["category"] - root_dir = Path(args["default_root_dir"]) / model_name / dataset_name / category - return create_versioned_dir(root_dir) if versioned_dir else root_dir / "latest" - - def _setup_anomalib_callbacks(self, model: AnomalyModule) -> None: - """Modified method to enable individual model training. It's called when Trainer is being set up.""" - del model # not used here - - _callbacks: list[Callback] = [RichModelSummary()] - - # Add ModelCheckpoint if it is not in the callbacks list. - has_checkpoint_callback = any(isinstance(c, ModelCheckpoint) for c in self._cache.args["callbacks"]) - if not has_checkpoint_callback: - tile_i, tile_j = self.tile_index - _callbacks.append( - ModelCheckpoint( - dirpath=self._cache.args["default_root_dir"] / "weights" / "lightning", - filename=f"model{tile_i}_{tile_j}", - auto_insert_metric_name=False, - ), - ) - - # Add the post-processor callbacks. Used for thresholding and label calculation. - _callbacks.append(_PostProcessorCallback()) - - # Add the normalization callback if tile level normalization was specified (is not none). - normalization_callback = get_normalization_callback(self.normalization) - if normalization_callback is not None: - _callbacks.append(normalization_callback) - - # Add the thresholding and metrics callbacks in all cases, - # because individual model might still need this for early stop. - _callbacks.append(_ThresholdCallback(self.threshold)) - _callbacks.append(_MetricsCallback(self.task, self.image_metric_names, self.pixel_metric_names)) - - _callbacks.append(TimerCallback()) - - # Combine the callbacks, and update the trainer callbacks. - self._cache.args["callbacks"] = _callbacks + self._cache.args["callbacks"] diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_tiling.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_tiling.py deleted file mode 100644 index db56f88b47..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/utils/ensemble_tiling.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Tiler used with ensemble of models.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from collections.abc import Sequence -from typing import Any - -from torch import Tensor - -from anomalib.data.base.datamodule import collate_fn -from anomalib.data.utils.tiler import Tiler, compute_new_image_size - - -class EnsembleTiler(Tiler): - """Tile Image into (non)overlapping Patches which are then used for ensemble training. - - Args: - tile_size (int | Sequence): Tile dimension for each patch. - stride (int | Sequence): Stride length between patches. - image_size (int | Sequence): Size of input image that will be tiled. - - Examples: - >>> import torch - >>> tiler = EnsembleTiler(tile_size=256, stride=128, image_size=512) - >>> - >>> # random images, shape: [B, C, H, W] - >>> images = torch.rand(32, 5, 512, 512) - >>> # once tiled, the shape is [tile_count_H, tile_count_W, B, C, tile_H, tile_W] - >>> tiled = tiler.tile(images) - >>> tiled.shape - torch.Size([3, 3, 32, 5, 256, 256]) - - >>> # assemble the tiles back together - >>> untiled = tiler.untile(tiled) - >>> untiled.shape - torch.Size([32, 5, 512, 512]) - """ - - def __init__(self, tile_size: int | Sequence, stride: int | Sequence, image_size: int | Sequence) -> None: - super().__init__( - tile_size=tile_size, - stride=stride, - ) - - # calculate final image size - self.image_size = self.validate_size_type(image_size) - self.input_h, self.input_w = self.image_size - self.resized_h, self.resized_w = compute_new_image_size( - image_size=(self.input_h, self.input_w), - tile_size=(self.tile_size_h, self.tile_size_w), - stride=(self.stride_h, self.stride_w), - ) - - # get number of patches in both dimensions - self.num_patches_h = int((self.resized_h - self.tile_size_h) / self.stride_h) + 1 - self.num_patches_w = int((self.resized_w - self.tile_size_w) / self.stride_w) + 1 - self.num_tiles = self.num_patches_h * self.num_patches_w - - def tile(self, image: Tensor, use_random_tiling: bool = False) -> Tensor: - """Tiles an input image to either overlapping or non-overlapping patches. - - Args: - image (Tensor): Input images. - use_random_tiling (bool): Random tiling, which is part of original tiler but is unused here. - - Returns: - Tensor: Tiles generated from images. - Returned shape: [num_h, num_w, batch, channel, tile_height, tile_width]. - """ - # tiles are returned in order [tile_count * batch, channels, tile_height, tile_width] - combined_tiles = super().tile(image, use_random_tiling) - - # rearrange to [num_h, num_w, batch, channel, tile_height, tile_width] - tiles = combined_tiles.contiguous().view( - self.batch_size, - self.num_patches_h, - self.num_patches_w, - self.num_channels, - self.tile_size_h, - self.tile_size_w, - ) - tiles = tiles.permute(1, 2, 0, 3, 4, 5) - - return tiles # noqa: RET504 - - def untile(self, tiles: Tensor) -> Tensor: - """Reassemble the tiled tensor into image level representation. - - Args: - tiles (Tensor): Tiles in shape: [num_h, num_w, batch, channel, tile_height, tile_width]. - - Returns: - Tensor: Image constructed from input tiles. Shape: [B, C, H, W]. - """ - # tiles have shape [num_h, num_w, batch, channel, tile_height, tile_width] - _, _, batch, channels, tile_size_h, tile_size_w = tiles.shape - - # set tilers batch size as it might have been changed by previous tiling - self.batch_size = batch - - # rearrange the tiles in order [tile_count * batch, channels, tile_height, tile_width] - # the required shape for untiling - tiles = tiles.permute(2, 0, 1, 3, 4, 5) - tiles = tiles.contiguous().view(-1, channels, tile_size_h, tile_size_w) - - untiled = super().untile(tiles) - - return untiled # noqa: RET504 - - -class TileCollater: - """Class serving as collate function to perform tiling on batch of images from Dataloader. - - Args: - tiler (EnsembleTiler): Tiler used to split the images to tiles. - tile_index (tuple[int, int]): Index of tile we want to return. - """ - - def __init__(self, tiler: EnsembleTiler, tile_index: tuple[int, int]) -> None: - self.tiler = tiler - self.tile_index = tile_index - - def __call__(self, batch: list) -> dict[str, Any]: - """Collate batch and tile images + masks from batch. - - Args: - batch (list): Batch of elements from data, also including images. - - Returns: - dict[str, Any]: Collated batch dictionary with tiled images. - """ - # use default collate - coll_batch = collate_fn(batch) - - tiled_images = self.tiler.tile(coll_batch["image"]) - # return only tiles at given index - coll_batch["image"] = tiled_images[self.tile_index] - - if "mask" in coll_batch: - # insert channel (as mask has just one) - tiled_masks = self.tiler.tile(coll_batch["mask"].unsqueeze(1)) - - # return only tiled at given index, squeeze to remove previously added channel - coll_batch["mask"] = tiled_masks[self.tile_index].squeeze(1) - - return coll_batch diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/helper_functions.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/helper_functions.py deleted file mode 100644 index bc1e5f4f55..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/utils/helper_functions.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Helper functions for the tiled ensemble training.""" - -import json - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 -from pathlib import Path - -from jsonargparse import ArgumentParser, Namespace -from lightning import Trainer - -from anomalib.data import AnomalibDataModule, get_datamodule -from anomalib.models import AnomalyModule, get_model -from anomalib.utils.normalization import NormalizationMethod - -from . import NormalizationStage -from .ensemble_engine import TiledEnsembleEngine -from .ensemble_tiling import EnsembleTiler, TileCollater - - -def get_ensemble_datamodule(data_args: dict, tiler: EnsembleTiler, tile_index: tuple[int, int]) -> AnomalibDataModule: - """Get Anomaly Datamodule adjusted for use in ensemble. - - Datamodule collate function gets replaced by TileCollater in order to tile all images before they are passed on. - - Args: - data_args: tiled ensemble data configuration. - tiler (EnsembleTiler): Tiler used to split the images to tiles for use in ensemble. - tile_index (tuple[int, int]): Index of the tile in the split image. - - Returns: - AnomalibDataModule: Anomalib Lightning DataModule - """ - datamodule = get_datamodule(data_args) - # set custom collate function that does the tiling - datamodule.collate_fn = TileCollater(tiler, tile_index) - datamodule.setup() - - return datamodule - - -def get_ensemble_model(model_args: dict, tiler: EnsembleTiler) -> AnomalyModule: - """Get model prepared for ensemble training. - - Args: - model_args: tiled ensemble model configuration. - tiler (EnsembleTiler): tiler used to get tile dimensions. - - Returns: - AnomalyModule: model with input_size setup - """ - model = get_model(model_args) - # set model input size match tile size - model.set_input_size((tiler.tile_size_h, tiler.tile_size_w)) - - return model - - -def get_ensemble_tiler(tiling_args: dict, data_args: dict) -> EnsembleTiler: - """Get tiler used for image tiling and to obtain tile dimensions. - - Args: - tiling_args: tiled ensemble tiling configuration. - data_args: tiled ensemble data configuration. - - Returns: - EnsembleTiler: tiler object. - """ - tiler = EnsembleTiler( - tile_size=tiling_args["tile_size"], - stride=tiling_args["stride"], - image_size=data_args["init_args"]["image_size"], - ) - - return tiler # noqa: RET504 - - -def parse_trainer_kwargs(trainer_args: dict | None) -> Namespace | dict: - """Parse trainer args and instantiate all needed elements. - - Transforms config into kwargs ready for Trainer, including instantiation of callback etc. - - Args: - trainer_args (dict): Trainer args dictionary. - - Returns: - dict: parsed kwargs with instantiated elements. - """ - if not trainer_args: - return {} - - # try to get trainer args, if not present return empty - parser = ArgumentParser() - - parser.add_class_arguments(Trainer, fail_untyped=False, instantiate=False, sub_configs=True) - config = parser.parse_object(trainer_args) - objects = parser.instantiate_classes(config) - - return objects # noqa: RET504 - - -def get_ensemble_engine( - tile_index: tuple[int, int], - accelerator: str, - devices: list[int] | str | int, - root_dir: Path, - normalization_stage: str, - metrics: dict | None = None, - trainer_args: dict | None = None, -) -> TiledEnsembleEngine: - """Prepare engine for ensemble training or prediction. - - This method makes sure correct normalization is used, prepares metrics and additional trainer kwargs.. - - Args: - tile_index (tuple[int, int]): Index of tile that this model processes. - accelerator (str): Accelerator (device) to use. - devices (list[int] | str | int): device IDs used for training. - root_dir (Path): Root directory to save checkpoints, stats and images. - normalization_stage (str): Config dictionary for ensemble post-processing. - metrics (dict): Dict containing pixel and image metrics names. - trainer_args (dict): Trainer args dictionary. Empty dict if not present. - - Returns: - TiledEnsembleEngine: set up engine for ensemble training/prediction. - """ - # if we want tile level normalization we set it here, otherwise it's done later on joined images - if normalization_stage == NormalizationStage.TILE: - normalization = NormalizationMethod.MIN_MAX - else: - normalization = NormalizationMethod.NONE - - # parse additional trainer args and callbacks if present in config - trainer_kwargs = parse_trainer_kwargs(trainer_args) - # remove keys that we already have - trainer_kwargs.pop("accelerator", None) - trainer_kwargs.pop("default_root_dir", None) - trainer_kwargs.pop("devices", None) - - # create engine for specific tile location - engine = TiledEnsembleEngine( - tile_index=tile_index, - normalization=normalization, - accelerator=accelerator, - devices=devices, - default_root_dir=root_dir, - image_metrics=metrics.get("image", None) if metrics else None, - pixel_metrics=metrics.get("pixel", None) if metrics else None, - **trainer_kwargs, - ) - - return engine # noqa: RET504 - - -def get_threshold_values(normalization_stage: NormalizationStage, root_dir: Path) -> tuple[float, float]: - """Get threshold values for image and pixel level predictions. - - If normalization is not used, get values based on statistics obtained from validation set. - If normalization is used, both image and pixel threshold are 0.5 - - Args: - normalization_stage (NormalizationStage): ensemble run args, used to get normalization stage. - root_dir (Path): path to run root where stats file is saved. - - Returns: - tuple[float, float]: image and pixel threshold. - """ - if normalization_stage == NormalizationStage.NONE: - stats_path = root_dir / "weights" / "lightning" / "stats.json" - with stats_path.open("r") as f: - stats = json.load(f) - image_threshold = stats["image_threshold"] - pixel_threshold = stats["pixel_threshold"] - else: - # normalization transforms the scores so that threshold is at 0.5 - image_threshold = 0.5 - pixel_threshold = 0.5 - - return image_threshold, pixel_threshold diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_data.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_data.py deleted file mode 100644 index 4fe45e9c4a..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_data.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Classes used to store ensemble predictions.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from torch import Tensor - - -class EnsemblePredictions: - """Basic implementation of EnsemblePredictionData that keeps all predictions in main memory.""" - - def __init__(self) -> None: - super().__init__() - self.all_data: dict[tuple[int, int], list] = {} - - def add_tile_prediction(self, tile_index: tuple[int, int], tile_prediction: list[dict[str, Tensor | list]]) -> None: - """Add tile prediction data at provided index to class dictionary in main memory. - - Args: - tile_index (tuple[int, int]): Index of tile that we are adding in form (row, column). - tile_prediction (list[dict[str, Tensor | list]]): - List of batches containing all predicted data for current tile position. - - """ - self.num_batches = len(tile_prediction) - - self.all_data[tile_index] = tile_prediction - - def get_batch_tiles(self, batch_index: int) -> dict[tuple[int, int], dict]: - """Get all tiles of current batch from class dictionary. - - Called by merging mechanism. - - Args: - batch_index (int): Index of current batch of tiles to be returned. - - Returns: - dict[tuple[int, int], dict]: Dictionary mapping tile index to predicted data, for provided batch index. - """ - batch_data = {} - - for index, batches in self.all_data.items(): - batch_data[index] = batches[batch_index] - - return batch_data diff --git a/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_merging.py b/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_merging.py deleted file mode 100644 index 7337cc4ffe..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/utils/prediction_merging.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Class used as mechanism to merge ensemble predictions from each tile into complete whole-image representation.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import torch -from torch import Tensor - -from .ensemble_tiling import EnsembleTiler -from .prediction_data import EnsemblePredictions - - -class PredictionMergingMechanism: - """Class used for merging the data predicted by each separate model of tiled ensemble. - - Tiles are stacked in one tensor and untiled using Ensemble Tiler. - Boxes from tiles are either stacked or generated anew from anomaly map. - Labels are combined with OR operator, meaning one anomalous tile -> anomalous image. - Scores are averaged across all tiles. - - Args: - ensemble_predictions (EnsemblePredictions): Object containing predictions on tile level. - tiler (EnsembleTiler): Tiler used to transform tiles back to image level representation. - - Example: - >>> from anomalib.pipelines.tiled_ensemble.components.utils.ensemble_tiling import EnsembleTiler - >>> from anomalib.pipelines.tiled_ensemble.components.utils.prediction_data import EnsemblePredictions - >>> - >>> tiler = EnsembleTiler(tile_size=256, stride=128, image_size=512) - >>> data = EnsemblePredictions() - >>> merger = PredictionMergingMechanism(data, tiler) - >>> - >>> # we can then start merging procedure for each batch - >>> merger.merge_tile_predictions(0) - """ - - def __init__(self, ensemble_predictions: EnsemblePredictions, tiler: EnsembleTiler) -> None: - assert ensemble_predictions.num_batches > 0, "There should be at least one batch for each tile prediction." - assert (0, 0) in ensemble_predictions.get_batch_tiles( - 0, - ), "Tile prediction dictionary should always have at least one tile" - - self.ensemble_predictions = ensemble_predictions - self.num_batches = self.ensemble_predictions.num_batches - - self.tiler = tiler - - def merge_tiles(self, batch_data: dict, tile_key: str) -> Tensor: - """Merge tiles back into one tensor and perform untiling with tiler. - - Args: - batch_data (dict): Dictionary containing all tile predictions of current batch. - tile_key (str): Key used in prediction dictionary for tiles that we want to merge. - - Returns: - Tensor: Tensor of tiles in original (stitched) shape. - """ - # batch of tiles with index (0, 0) always exists, so we use it to get some basic information - first_tiles = batch_data[0, 0][tile_key] - batch_size = first_tiles.shape[0] - device = first_tiles.device - - if tile_key == "mask": - # in case of ground truth masks, we don't have channels - merged_size = [ - self.tiler.num_patches_h, - self.tiler.num_patches_w, - batch_size, - self.tiler.tile_size_h, - self.tiler.tile_size_w, - ] - else: - # all tiles beside masks also have channels - num_channels = first_tiles.shape[1] - merged_size = [ - self.tiler.num_patches_h, - self.tiler.num_patches_w, - batch_size, - int(num_channels), - self.tiler.tile_size_h, - self.tiler.tile_size_w, - ] - - # create new empty tensor for merged tiles - merged_masks = torch.zeros(size=merged_size, device=device) - - # insert tile into merged tensor at right locations - for (tile_i, tile_j), tile_data in batch_data.items(): - merged_masks[tile_i, tile_j, ...] = tile_data[tile_key] - - if tile_key == "mask": - # add channel as tiler needs it - merged_masks = merged_masks.unsqueeze(3) - - # stitch tiles back into whole, output is [B, C, H, W] - merged_output = self.tiler.untile(merged_masks) - - if tile_key == "mask": - # remove previously added channels - merged_output = merged_output.squeeze(1) - - return merged_output - - def merge_labels_and_scores(self, batch_data: dict) -> dict[str, Tensor]: - """Join scores and their corresponding label predictions from all tiles for each image. - - Label merging is done by rule where one anomalous tile in image results in whole image being anomalous. - Scores are averaged over tiles. - - Args: - batch_data (dict): Dictionary containing all tile predictions of current batch. - - Returns: - dict[str, Tensor]: Dictionary with "pred_labels" and "pred_scores" - """ - # create accumulator with same shape as original - labels = torch.zeros(batch_data[0, 0]["pred_labels"].shape, dtype=torch.bool) - scores = torch.zeros(batch_data[0, 0]["pred_scores"].shape) - - for curr_tile_data in batch_data.values(): - curr_labels = curr_tile_data["pred_labels"] - curr_scores = curr_tile_data["pred_scores"] - - labels = labels.logical_or(curr_labels) - scores += curr_scores - - scores /= self.tiler.num_tiles - - return {"pred_labels": labels, "pred_scores": scores} - - def merge_tile_predictions(self, batch_index: int) -> dict[str, Tensor | list]: - """Join predictions from ensemble into whole image level representation for batch at index batch_index. - - Args: - batch_index (int): Index of current batch. - - Returns: - dict[str, Tensor | list]: List of merged predictions for specified batch. - """ - current_batch_data = self.ensemble_predictions.get_batch_tiles(batch_index) - - # take first tile as base prediction, keep items that are the same over all tiles: - # image_path, label, mask_path - merged_predictions = { - "image_path": current_batch_data[0, 0]["image_path"], - "label": current_batch_data[0, 0]["label"], - } - if "mask_path" in current_batch_data[0, 0]: - merged_predictions["mask_path"] = current_batch_data[0, 0]["mask_path"] - if "boxes" in current_batch_data[0, 0]: - merged_predictions["boxes"] = current_batch_data[0, 0]["boxes"] - - tiled_data = ["image", "mask"] - if "anomaly_maps" in current_batch_data[0, 0]: - tiled_data += ["anomaly_maps", "pred_masks"] - - # merge all tiled data - for t_key in tiled_data: - if t_key in current_batch_data[0, 0]: - merged_predictions[t_key] = self.merge_tiles(current_batch_data, t_key) - - # label and score merging - merged_scores_and_labels = self.merge_labels_and_scores(current_batch_data) - merged_predictions["pred_labels"] = merged_scores_and_labels["pred_labels"] - merged_predictions["pred_scores"] = merged_scores_and_labels["pred_scores"] - - return merged_predictions diff --git a/src/anomalib/pipelines/tiled_ensemble/components/visualization.py b/src/anomalib/pipelines/tiled_ensemble/components/visualization.py deleted file mode 100644 index 1298ece89f..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/components/visualization.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Tiled ensemble - visualization job.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from collections.abc import Generator -from pathlib import Path -from typing import Any - -from tqdm import tqdm - -from anomalib import TaskType -from anomalib.data.utils.image import save_image -from anomalib.pipelines.components import Job, JobGenerator -from anomalib.pipelines.tiled_ensemble.components.utils import NormalizationStage -from anomalib.pipelines.types import GATHERED_RESULTS, RUN_RESULTS -from anomalib.utils.visualization import ImageVisualizer - -logger = logging.getLogger(__name__) - - -class VisualizationJob(Job): - """Job for visualization of predictions. - - Args: - predictions (list[Any]): list of image-level predictions. - root_dir (Path): Root directory to save checkpoints, stats and images. - task (TaskType): type of task the predictions represent. - normalize (bool): if predictions need to be normalized - """ - - name = "Visualize" - - def __init__(self, predictions: list[Any], root_dir: Path, task: TaskType, normalize: bool) -> None: - super().__init__() - self.predictions = predictions - self.root_dir = root_dir / "images" - self.task = task - self.normalize = normalize - - def run(self, task_id: int | None = None) -> list[Any]: - """Run job that visualizes all prediction data. - - Args: - task_id: Not used in this case. - - Returns: - list[Any]: Unchanged predictions. - """ - del task_id # not needed here - - visualizer = ImageVisualizer(task=self.task, normalize=self.normalize) - - logger.info("Starting visualization.") - - for data in tqdm(self.predictions, desc="Visualizing"): - for result in visualizer(outputs=data): - # Finally image path is root/defect_type/image_name - if result.file_name is not None: - file_path = Path(result.file_name) - else: - msg = "file_path should exist in returned Visualizer." - raise ValueError(msg) - - root = self.root_dir / file_path.parent.name - filename = file_path.name - - save_image(image=result.image, root=root, filename=filename) - - return self.predictions - - @staticmethod - def collect(results: list[RUN_RESULTS]) -> GATHERED_RESULTS: - """Nothing to collect in this job. - - Returns: - list[Any]: Unchanged list of predictions. - """ - # take the first element as result is list of lists here - return results[0] - - @staticmethod - def save(results: GATHERED_RESULTS) -> None: - """This job doesn't save anything.""" - - -class VisualizationJobGenerator(JobGenerator): - """Generate VisualizationJob. - - Args: - root_dir (Path): Root directory where images will be saved (root/images). - """ - - def __init__(self, root_dir: Path, task: TaskType, normalization_stage: NormalizationStage) -> None: - self.root_dir = root_dir - self.task = task - self.normalize = normalization_stage == NormalizationStage.NONE - - @property - def job_class(self) -> type: - """Return the job class.""" - return VisualizationJob - - def generate_jobs( - self, - args: dict | None = None, - prev_stage_result: list[Any] | None = None, - ) -> Generator[VisualizationJob, None, None]: - """Return a generator producing a single visualization job. - - Args: - args: Ensemble run args. - prev_stage_result (list[Any]): Ensemble predictions from previous step. - - Returns: - Generator[VisualizationJob, None, None]: VisualizationJob generator - """ - del args # args not used here - - if prev_stage_result is not None: - yield VisualizationJob(prev_stage_result, self.root_dir, self.task, self.normalize) - else: - msg = "Visualization job requires tile level predictions from previous step." - raise ValueError(msg) diff --git a/src/anomalib/pipelines/tiled_ensemble/test_pipeline.py b/src/anomalib/pipelines/tiled_ensemble/test_pipeline.py deleted file mode 100644 index 7fdd61e9ff..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/test_pipeline.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Tiled ensemble test pipeline.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import logging -from pathlib import Path - -import torch - -from anomalib.data.utils import TestSplitMode -from anomalib.pipelines.components.base import Pipeline, Runner -from anomalib.pipelines.components.runners import ParallelRunner, SerialRunner -from anomalib.pipelines.tiled_ensemble.components import ( - MergeJobGenerator, - MetricsCalculationJobGenerator, - NormalizationJobGenerator, - PredictJobGenerator, - SmoothingJobGenerator, - ThresholdingJobGenerator, - VisualizationJobGenerator, -) -from anomalib.pipelines.tiled_ensemble.components.utils import NormalizationStage, PredictData, ThresholdStage - -logger = logging.getLogger(__name__) - - -class EvalTiledEnsemble(Pipeline): - """Tiled ensemble evaluation pipeline. - - Args: - root_dir (Path): Path to root dir of run that contains checkpoints. - """ - - def __init__(self, root_dir: Path) -> None: - self.root_dir = Path(root_dir) - - def _setup_runners(self, args: dict) -> list[Runner]: - """Set up the runners for the pipeline. - - This pipeline consists of jobs used to test/evaluate tiled ensemble: - Prediction on test data > merging of predictions > (optional) seam smoothing - > (optional) Normalization > (optional) Thresholding - > Visualisation of predictions > Metrics calculation. - - Returns: - list[Runner]: List of runners executing tiled ensemble testing jobs. - """ - runners: list[Runner] = [] - - if args["data"]["init_args"]["test_split_mode"] == TestSplitMode.NONE: - logger.info("Test split mode set to `none`, skipping test phase.") - return runners - - seed = args["seed"] - accelerator = args["accelerator"] - tiling_args = args["tiling"] - data_args = args["data"] - normalization_stage = NormalizationStage(args["normalization_stage"]) - threshold_stage = ThresholdStage(args["thresholding"]["stage"]) - model_args = args["TrainModels"]["model"] - task = args["data"]["init_args"]["task"] - metrics = args["TrainModels"]["metrics"] - - predict_job_generator = PredictJobGenerator( - PredictData.TEST, - seed=seed, - accelerator=accelerator, - root_dir=self.root_dir, - tiling_args=tiling_args, - data_args=data_args, - model_args=model_args, - normalization_stage=normalization_stage, - ) - # 1. predict using test data - if accelerator == "cuda": - runners.append( - ParallelRunner( - predict_job_generator, - n_jobs=torch.cuda.device_count(), - ), - ) - else: - runners.append( - SerialRunner( - predict_job_generator, - ), - ) - # 2. merge predictions - runners.append(SerialRunner(MergeJobGenerator(tiling_args=tiling_args, data_args=data_args))) - - # 3. (optional) smooth seams - if args["SeamSmoothing"]["apply"]: - runners.append( - SerialRunner( - SmoothingJobGenerator(accelerator=accelerator, tiling_args=tiling_args, data_args=data_args), - ), - ) - - # 4. (optional) normalize - if normalization_stage == NormalizationStage.IMAGE: - runners.append(SerialRunner(NormalizationJobGenerator(self.root_dir))) - # 5. (optional) threshold to get labels from scores - if threshold_stage == ThresholdStage.IMAGE: - runners.append(SerialRunner(ThresholdingJobGenerator(self.root_dir, normalization_stage))) - - # 6. visualize predictions - runners.append( - SerialRunner(VisualizationJobGenerator(self.root_dir, task=task, normalization_stage=normalization_stage)), - ) - # calculate metrics - runners.append( - SerialRunner( - MetricsCalculationJobGenerator( - accelerator=accelerator, - root_dir=self.root_dir, - task=task, - metrics=metrics, - normalization_stage=normalization_stage, - ), - ), - ) - - return runners diff --git a/src/anomalib/pipelines/tiled_ensemble/train_pipeline.py b/src/anomalib/pipelines/tiled_ensemble/train_pipeline.py deleted file mode 100644 index 38e4e34e4b..0000000000 --- a/src/anomalib/pipelines/tiled_ensemble/train_pipeline.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Tiled ensemble training pipeline.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from typing import TYPE_CHECKING - -from anomalib.data.utils import ValSplitMode - -if TYPE_CHECKING: - from pathlib import Path - -import logging - -import torch - -from anomalib.pipelines.components.base import Pipeline, Runner -from anomalib.pipelines.components.runners import ParallelRunner, SerialRunner - -from .components import ( - MergeJobGenerator, - PredictJobGenerator, - SmoothingJobGenerator, - StatisticsJobGenerator, - TrainModelJobGenerator, -) -from .components.utils import NormalizationStage, PredictData -from .components.utils.ensemble_engine import TiledEnsembleEngine - -logger = logging.getLogger(__name__) - - -class TrainTiledEnsemble(Pipeline): - """Tiled ensemble training pipeline.""" - - def __init__(self) -> None: - self.root_dir: Path - - def _setup_runners(self, args: dict) -> list[Runner]: - """Setup the runners for the pipeline. - - This pipeline consists of training and validation steps: - Training models > prediction on val data > merging val data > - > (optionally) smoothing seams > calculation of post-processing statistics - - Returns: - list[Runner]: List of runners executing tiled ensemble train + val jobs. - """ - runners: list[Runner] = [] - self.root_dir = TiledEnsembleEngine.setup_ensemble_workspace(args) - - seed = args["seed"] - accelerator = args["accelerator"] - tiling_args = args["tiling"] - data_args = args["data"] - normalization_stage = NormalizationStage(args["normalization_stage"]) - thresholding_method = args["thresholding"]["method"] - model_args = args["TrainModels"]["model"] - - train_job_generator = TrainModelJobGenerator( - seed=seed, - accelerator=accelerator, - root_dir=self.root_dir, - tiling_args=tiling_args, - data_args=data_args, - normalization_stage=normalization_stage, - ) - - predict_job_generator = PredictJobGenerator( - data_source=PredictData.VAL, - seed=seed, - accelerator=accelerator, - root_dir=self.root_dir, - tiling_args=tiling_args, - data_args=data_args, - model_args=model_args, - normalization_stage=normalization_stage, - ) - - # 1. train - if accelerator == "cuda": - runners.append( - ParallelRunner( - train_job_generator, - n_jobs=torch.cuda.device_count(), - ), - ) - else: - runners.append( - SerialRunner( - train_job_generator, - ), - ) - - if data_args["init_args"]["val_split_mode"] == ValSplitMode.NONE: - logger.warning("No validation set provided, skipping statistics calculation.") - return runners - - # 2. predict using validation data - if accelerator == "cuda": - runners.append( - ParallelRunner(predict_job_generator, n_jobs=torch.cuda.device_count()), - ) - else: - runners.append( - SerialRunner(predict_job_generator), - ) - - # 3. merge predictions - runners.append(SerialRunner(MergeJobGenerator(tiling_args=tiling_args, data_args=data_args))) - - # 4. (optional) smooth seams - if args["SeamSmoothing"]["apply"]: - runners.append( - SerialRunner( - SmoothingJobGenerator(accelerator=accelerator, tiling_args=tiling_args, data_args=data_args), - ), - ) - - # 5. calculate statistics used for inference - runners.append(SerialRunner(StatisticsJobGenerator(self.root_dir, thresholding_method))) - - return runners diff --git a/tests/integration/pipelines/test_tiled_ensemble.py b/tests/integration/pipelines/test_tiled_ensemble.py deleted file mode 100644 index 2909311276..0000000000 --- a/tests/integration/pipelines/test_tiled_ensemble.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Test tiled ensemble training and prediction.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from pathlib import Path - -import pytest -import yaml - -from anomalib.pipelines.tiled_ensemble import EvalTiledEnsemble, TrainTiledEnsemble - - -@pytest.fixture(scope="session") -def get_mock_environment(dataset_path: Path, project_path: Path) -> Path: - """Return mock directory for testing with datapath setup to dummy data.""" - ens_temp_dir = project_path / "ens_tmp" - ens_temp_dir.mkdir(exist_ok=True) - - with Path("tests/integration/pipelines/tiled_ensemble.yaml").open(encoding="utf-8") as file: - config = yaml.safe_load(file) - - # use separate project temp dir to avoid messing with other tests - config["default_root_dir"] = str(ens_temp_dir) - config["data"]["init_args"]["root"] = str(dataset_path / "mvtec") - - with (Path(ens_temp_dir) / "tiled_ensemble.yaml").open("w", encoding="utf-8") as file: - yaml.safe_dump(config, file) - - return Path(ens_temp_dir) - - -def test_train(get_mock_environment: Path, capsys: pytest.CaptureFixture) -> None: - """Test training of the tiled ensemble.""" - train_pipeline = TrainTiledEnsemble() - train_parser = train_pipeline.get_parser() - args = train_parser.parse_args(["--config", str(get_mock_environment / "tiled_ensemble.yaml")]) - train_pipeline.run(args) - # check that no errors were printed -> all stages were successful - out = capsys.readouterr().out - assert not any(line.startswith("There were some errors") for line in out.split("\n")) - - -def test_predict(get_mock_environment: Path, capsys: pytest.CaptureFixture) -> None: - """Test prediction with the tiled ensemble.""" - predict_pipeline = EvalTiledEnsemble(root_dir=get_mock_environment / "Padim" / "MVTec" / "dummy" / "v0") - predict_parser = predict_pipeline.get_parser() - args = predict_parser.parse_args(["--config", str(get_mock_environment / "tiled_ensemble.yaml")]) - predict_pipeline.run(args) - # check that no errors were printed -> all stages were successful - out = capsys.readouterr().out - assert not any(line.startswith("There were some errors") for line in out.split("\n")) - - -def test_visualisation(get_mock_environment: Path) -> None: - """Test that images were produced.""" - assert (get_mock_environment / "Padim/MVTec/dummy/v0/images/bad/000.png").exists() - - -def test_metric_results(get_mock_environment: Path) -> None: - """Test that metrics were saved.""" - assert (get_mock_environment / "Padim/MVTec/dummy/v0/metric_results.csv").exists() diff --git a/tests/integration/pipelines/tiled_ensemble.yaml b/tests/integration/pipelines/tiled_ensemble.yaml deleted file mode 100644 index 8d35be8297..0000000000 --- a/tests/integration/pipelines/tiled_ensemble.yaml +++ /dev/null @@ -1,43 +0,0 @@ -seed: 42 -accelerator: "cpu" -default_root_dir: "results" - -tiling: - tile_size: [50, 50] - stride: 50 - -normalization_stage: image # on what level we normalize, options: [tile, image, none] -thresholding: - method: F1AdaptiveThreshold # refer to documentation for thresholding methods - stage: image # stage at which we apply threshold, options: [tile, image] - -data: - class_path: anomalib.data.MVTec - init_args: - root: toBeSetup - category: dummy - train_batch_size: 32 - eval_batch_size: 32 - num_workers: 0 - task: segmentation - transform: null - train_transform: null - eval_transform: null - test_split_mode: from_dir - test_split_ratio: 0.2 - val_split_mode: same_as_test - val_split_ratio: 0.5 - image_size: [50, 100] - -SeamSmoothing: - apply: True # if this is applied, area around tile seams are is smoothed - sigma: 2 # sigma of gaussian filter used to smooth this area - width: 0.1 # width factor, multiplied by tile dimension gives the region width around seam which will be smoothed - -TrainModels: - model: - class_path: Padim - - metrics: - pixel: AUROC - image: AUROC diff --git a/tools/tiled_ensemble/ens_config.yaml b/tools/tiled_ensemble/ens_config.yaml deleted file mode 100644 index 2490b22e9a..0000000000 --- a/tools/tiled_ensemble/ens_config.yaml +++ /dev/null @@ -1,43 +0,0 @@ -seed: 42 -accelerator: "gpu" -default_root_dir: "results" - -tiling: - tile_size: [128, 128] - stride: 128 - -normalization_stage: image # on what level we normalize, options: [tile, image, none] -thresholding: - method: F1AdaptiveThreshold # refer to documentation for thresholding methods - stage: image # stage at which we apply threshold, options: [tile, image] - -data: - class_path: anomalib.data.MVTec - init_args: - root: ./datasets/MVTec - category: bottle - train_batch_size: 32 - eval_batch_size: 32 - num_workers: 8 - task: segmentation - transform: null - train_transform: null - eval_transform: null - test_split_mode: from_dir - test_split_ratio: 0.2 - val_split_mode: same_as_test - val_split_ratio: 0.5 - image_size: [256, 256] - -SeamSmoothing: - apply: True # if this is applied, area around tile seams are is smoothed - sigma: 2 # sigma of gaussian filter used to smooth this area - width: 0.1 # width factor, multiplied by tile dimension gives the region width around seam which will be smoothed - -TrainModels: - model: - class_path: Padim - - metrics: - pixel: AUROC - image: AUROC diff --git a/tools/tiled_ensemble/eval.py b/tools/tiled_ensemble/eval.py deleted file mode 100644 index 58be27c25c..0000000000 --- a/tools/tiled_ensemble/eval.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Run tiled ensemble prediction.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from pathlib import Path - -from jsonargparse import ArgumentParser - -from anomalib.pipelines.tiled_ensemble import EvalTiledEnsemble - - -def get_parser() -> ArgumentParser: - """Create a new parser if none is provided.""" - parser = ArgumentParser() - parser.add_argument("--config", type=str | Path, help="Configuration file path.", required=True) - parser.add_argument("--root", type=str | Path, help="Weights file path.", required=True) - - return parser - - -if __name__ == "__main__": - args = get_parser().parse_args() - - print("Running tiled ensemble test pipeline.") - # pass the path to root dir with checkpoints - test_pipeline = EvalTiledEnsemble(args.root) - test_pipeline.run(args) diff --git a/tools/tiled_ensemble/train.py b/tools/tiled_ensemble/train.py deleted file mode 100644 index 8aed47ea0d..0000000000 --- a/tools/tiled_ensemble/train.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Run tiled ensemble training.""" - -# Copyright (C) 2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from anomalib.pipelines.tiled_ensemble import EvalTiledEnsemble, TrainTiledEnsemble - -if __name__ == "__main__": - print("Running tiled ensemble train pipeline") - train_pipeline = TrainTiledEnsemble() - # run training - train_pipeline.run() - - print("Running tiled ensemble test pipeline.") - # pass the root dir from train run to load checkpoints - test_pipeline = EvalTiledEnsemble(train_pipeline.root_dir) - test_pipeline.run() From 90eb35fbc7f23ac9871fbec657e13e738b748046 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Wed, 11 Dec 2024 14:36:33 +0000 Subject: [PATCH 31/32] ollama version Signed-off-by: Samet Akcay --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ebdbaf6b04..805795da40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ core = [ "open-clip-torch>=2.23.0,<2.26.1", ] openvino = ["openvino>=2024.0", "nncf>=2.10.0", "onnx>=1.16.0"] -vlm = ["ollama", "openai", "python-dotenv","transformers"] +vlm = ["ollama<0.4.0", "openai", "python-dotenv","transformers"] loggers = [ "comet-ml>=3.31.7", "gradio>=4", From 3e9c76de929d959a033617894572d5f37a143e56 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Wed, 11 Dec 2024 14:39:34 +0000 Subject: [PATCH 32/32] Exclude tiled ensemble unit tests Signed-off-by: Samet Akcay --- .../unit/pipelines/tiled_ensemble/__init__.py | 4 - .../unit/pipelines/tiled_ensemble/conftest.py | 151 ------- .../tiled_ensemble/dummy_config.yaml | 52 --- .../tiled_ensemble/test_components.py | 387 ------------------ .../tiled_ensemble/test_helper_functions.py | 113 ----- .../tiled_ensemble/test_prediction_data.py | 69 ---- .../pipelines/tiled_ensemble/test_tiler.py | 119 ------ 7 files changed, 895 deletions(-) delete mode 100644 tests/unit/pipelines/tiled_ensemble/__init__.py delete mode 100644 tests/unit/pipelines/tiled_ensemble/conftest.py delete mode 100644 tests/unit/pipelines/tiled_ensemble/dummy_config.yaml delete mode 100644 tests/unit/pipelines/tiled_ensemble/test_components.py delete mode 100644 tests/unit/pipelines/tiled_ensemble/test_helper_functions.py delete mode 100644 tests/unit/pipelines/tiled_ensemble/test_prediction_data.py delete mode 100644 tests/unit/pipelines/tiled_ensemble/test_tiler.py diff --git a/tests/unit/pipelines/tiled_ensemble/__init__.py b/tests/unit/pipelines/tiled_ensemble/__init__.py deleted file mode 100644 index a78a1ad659..0000000000 --- a/tests/unit/pipelines/tiled_ensemble/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Tiled ensemble unit tests.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/pipelines/tiled_ensemble/conftest.py b/tests/unit/pipelines/tiled_ensemble/conftest.py deleted file mode 100644 index b4fad61ebb..0000000000 --- a/tests/unit/pipelines/tiled_ensemble/conftest.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Fixtures that are used in tiled ensemble testing.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import json -from pathlib import Path -from tempfile import TemporaryDirectory - -import pytest -import torch -import yaml - -from anomalib.data import AnomalibDataModule -from anomalib.models import AnomalyModule -from anomalib.pipelines.tiled_ensemble.components.utils.ensemble_tiling import EnsembleTiler -from anomalib.pipelines.tiled_ensemble.components.utils.helper_functions import ( - get_ensemble_datamodule, - get_ensemble_model, - get_ensemble_tiler, -) -from anomalib.pipelines.tiled_ensemble.components.utils.prediction_data import EnsemblePredictions -from anomalib.pipelines.tiled_ensemble.components.utils.prediction_merging import PredictionMergingMechanism - - -@pytest.fixture(scope="module") -def get_ensemble_config(dataset_path: Path) -> dict: - """Return ensemble dummy config dict with corrected dataset path to dummy temp dir.""" - with Path("tests/unit/pipelines/tiled_ensemble/dummy_config.yaml").open(encoding="utf-8") as file: - config = yaml.safe_load(file) - # dummy dataset - config["data"]["init_args"]["root"] = dataset_path / "mvtec" - - return config - - -@pytest.fixture(scope="module") -def get_tiler(get_ensemble_config: dict) -> EnsembleTiler: - """Return EnsembleTiler object based on test dummy config.""" - config = get_ensemble_config - - return get_ensemble_tiler(config["tiling"], config["data"]) - - -@pytest.fixture(scope="module") -def get_model(get_ensemble_config: dict, get_tiler: EnsembleTiler) -> AnomalyModule: - """Return model prepared for tiled ensemble training.""" - config = get_ensemble_config - tiler = get_tiler - - return get_ensemble_model(config["TrainModels"]["model"], tiler) - - -@pytest.fixture(scope="module") -def get_datamodule(get_ensemble_config: dict, get_tiler: EnsembleTiler) -> AnomalibDataModule: - """Return ensemble datamodule.""" - config = get_ensemble_config - tiler = get_tiler - datamodule = get_ensemble_datamodule(config, tiler, (0, 0)) - datamodule.setup() - - return datamodule - - -@pytest.fixture(scope="module") -def get_tile_predictions(get_datamodule: AnomalibDataModule) -> EnsemblePredictions: - """Return tile predictions inside EnsemblePredictions object.""" - datamodule = get_datamodule - - data = EnsemblePredictions() - - for tile_index in [(0, 0), (0, 1), (1, 0), (1, 1)]: - datamodule.collate_fn.tile_index = tile_index - - tile_prediction = [] - batch = next(iter(datamodule.test_dataloader())) - - # make mock labels and scores - batch["pred_scores"] = torch.rand(batch["label"].shape) - batch["pred_labels"] = batch["pred_scores"] > 0.5 - - # set mock maps to just one channel of image - batch["anomaly_maps"] = batch["image"].clone()[:, 0, :, :].unsqueeze(1) - # set mock pred mask to mask but add channel - batch["pred_masks"] = batch["mask"].clone().unsqueeze(1) - - tile_prediction.append(batch) - - # store to prediction storage object - data.add_tile_prediction(tile_index, tile_prediction) - - return data - - -@pytest.fixture(scope="module") -def get_batch_predictions() -> list[dict]: - """Return mock batched predictions.""" - mock_data = { - "image": torch.rand((5, 3, 100, 100)), - "mask": (torch.rand((5, 100, 100)) > 0.5).type(torch.float32), - "anomaly_maps": torch.rand((5, 1, 100, 100)), - "label": torch.Tensor([0, 1, 1, 0, 1]), - "pred_scores": torch.rand(5), - "pred_labels": torch.ones(5), - "pred_masks": torch.zeros((5, 100, 100)), - } - - return [mock_data, mock_data] - - -@pytest.fixture(scope="module") -def get_merging_mechanism( - get_tile_predictions: EnsemblePredictions, - get_tiler: EnsembleTiler, -) -> PredictionMergingMechanism: - """Return ensemble prediction merging mechanism object.""" - tiler = get_tiler - predictions = get_tile_predictions - return PredictionMergingMechanism(predictions, tiler) - - -@pytest.fixture(scope="module") -def get_mock_stats_dir() -> Path: - """Get temp dir containing statistics.""" - with TemporaryDirectory() as temp_dir: - stats = { - "minmax": { - "anomaly_maps": { - "min": 1.9403648376464844, - "max": 209.91940307617188, - }, - "box_scores": { - "min": 0.5, - "max": 0.45, - }, - "pred_scores": { - "min": 9.390382766723633, - "max": 209.91940307617188, - }, - }, - "image_threshold": 0.1111, - "pixel_threshold": 0.1111, - } - stats_path = Path(temp_dir) / "weights" / "lightning" / "stats.json" - stats_path.parent.mkdir(parents=True) - - # save mock statistics - with stats_path.open("w", encoding="utf-8") as stats_file: - json.dump(stats, stats_file, ensure_ascii=False, indent=4) - - yield Path(temp_dir) diff --git a/tests/unit/pipelines/tiled_ensemble/dummy_config.yaml b/tests/unit/pipelines/tiled_ensemble/dummy_config.yaml deleted file mode 100644 index fcd4b7c716..0000000000 --- a/tests/unit/pipelines/tiled_ensemble/dummy_config.yaml +++ /dev/null @@ -1,52 +0,0 @@ -seed: 42 -accelerator: "cpu" -default_root_dir: "results" - -tiling: - tile_size: [50, 50] - stride: 50 - -normalization_stage: image # on what level we normalize, options: [tile, image, none] -thresholding: - method: F1AdaptiveThreshold # refer to documentation for thresholding methods - stage: image # stage at which we apply threshold, options: [tile, image] - -data: - class_path: anomalib.data.MVTec - init_args: - root: toBeSetup - category: dummy - train_batch_size: 32 - eval_batch_size: 32 - num_workers: 0 - task: segmentation - transform: null - train_transform: null - eval_transform: null - test_split_mode: from_dir - test_split_ratio: 0.2 - val_split_mode: same_as_test - val_split_ratio: 0.5 - image_size: [100, 100] - -SeamSmoothing: - apply: True # if this is applied, area around tile seams are is smoothed - sigma: 2 # sigma of gaussian filter used to smooth this area - width: 0.1 # width factor, multiplied by tile dimension gives the region width around seam which will be smoothed - -TrainModels: - model: - class_path: Fastflow - - metrics: - pixel: AUROC - image: AUROC - - trainer: - max_epochs: 1 - callbacks: - - class_path: lightning.pytorch.callbacks.EarlyStopping - init_args: - patience: 1 - monitor: pixel_AUROC - mode: max diff --git a/tests/unit/pipelines/tiled_ensemble/test_components.py b/tests/unit/pipelines/tiled_ensemble/test_components.py deleted file mode 100644 index 0e3c0dcdd4..0000000000 --- a/tests/unit/pipelines/tiled_ensemble/test_components.py +++ /dev/null @@ -1,387 +0,0 @@ -"""Test working of tiled ensemble pipeline components.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import copy -from pathlib import Path -from tempfile import TemporaryDirectory - -import pytest -import torch - -from anomalib.data import get_datamodule -from anomalib.metrics import F1AdaptiveThreshold, ManualThreshold -from anomalib.pipelines.tiled_ensemble.components import ( - MergeJobGenerator, - MetricsCalculationJobGenerator, - NormalizationJobGenerator, - SmoothingJobGenerator, - StatisticsJobGenerator, - ThresholdingJobGenerator, -) -from anomalib.pipelines.tiled_ensemble.components.metrics_calculation import MetricsCalculationJob -from anomalib.pipelines.tiled_ensemble.components.smoothing import SmoothingJob -from anomalib.pipelines.tiled_ensemble.components.utils import NormalizationStage -from anomalib.pipelines.tiled_ensemble.components.utils.prediction_data import EnsemblePredictions -from anomalib.pipelines.tiled_ensemble.components.utils.prediction_merging import PredictionMergingMechanism - - -class TestMerging: - """Test merging mechanism and merging job.""" - - @staticmethod - def test_tile_merging(get_ensemble_config: dict, get_merging_mechanism: PredictionMergingMechanism) -> None: - """Test tiled data merging.""" - config = get_ensemble_config - merger = get_merging_mechanism - - # prepared original data - datamodule = get_datamodule(config) - datamodule.prepare_data() - datamodule.setup() - original_data = next(iter(datamodule.test_dataloader())) - - batch = merger.ensemble_predictions.get_batch_tiles(0) - - merged_image = merger.merge_tiles(batch, "image") - assert merged_image.equal(original_data["image"]) - - merged_mask = merger.merge_tiles(batch, "mask") - assert merged_mask.equal(original_data["mask"]) - - @staticmethod - def test_label_and_score_merging(get_merging_mechanism: PredictionMergingMechanism) -> None: - """Test label and score merging.""" - merger = get_merging_mechanism - scores = torch.rand(4, 10) - labels = scores > 0.5 - - mock_data = {(0, 0): {}, (0, 1): {}, (1, 0): {}, (1, 1): {}} - - for i, data in enumerate(mock_data.values()): - data["pred_scores"] = scores[i] - data["pred_labels"] = labels[i] - - merged = merger.merge_labels_and_scores(mock_data) - - assert merged["pred_scores"].equal(scores.mean(dim=0)) - - assert merged["pred_labels"].equal(labels.any(dim=0)) - - @staticmethod - def test_merge_job( - get_tile_predictions: EnsemblePredictions, - get_ensemble_config: dict, - get_merging_mechanism: PredictionMergingMechanism, - ) -> None: - """Test merging job execution.""" - config = get_ensemble_config - predictions = copy.deepcopy(get_tile_predictions) - merging_mechanism = get_merging_mechanism - - merging_job_generator = MergeJobGenerator(tiling_args=config["tiling"], data_args=config["data"]) - merging_job = next(merging_job_generator.generate_jobs(prev_stage_result=predictions)) - - merged_direct = merging_mechanism.merge_tile_predictions(0) - merged_with_job = merging_job.run()[0] - - # check that merging by job is same as with the mechanism directly - for key, value in merged_direct.items(): - if isinstance(value, torch.Tensor): - assert merged_with_job[key].equal(value) - elif isinstance(value, list) and isinstance(value[0], torch.Tensor): - # boxes - assert all(j.equal(d) for j, d in zip(merged_with_job[key], value, strict=False)) - else: - assert merged_with_job[key] == value - - -class TestStatsCalculation: - """Test post-processing statistics calculations.""" - - @staticmethod - @pytest.mark.parametrize( - ("threshold_str", "threshold_cls"), - [("F1AdaptiveThreshold", F1AdaptiveThreshold), ("ManualThreshold", ManualThreshold)], - ) - def test_threshold_method(threshold_str: str, threshold_cls: type, get_ensemble_config: dict) -> None: - """Test that correct thresholding method is used.""" - config = copy.deepcopy(get_ensemble_config) - config["thresholding"]["method"] = threshold_str - - stats_job_generator = StatisticsJobGenerator(Path("mock"), threshold_str) - stats_job = next(stats_job_generator.generate_jobs(None, None)) - - assert isinstance(stats_job.image_threshold, threshold_cls) - - @staticmethod - def test_stats_run(project_path: Path) -> None: - """Test execution of statistics calc. job.""" - mock_preds = [ - { - "pred_scores": torch.rand(4), - "label": torch.ones(4), - "anomaly_maps": torch.rand(4, 1, 50, 50), - "mask": torch.ones(4, 1, 50, 50), - }, - ] - - stats_job_generator = StatisticsJobGenerator(project_path, "F1AdaptiveThreshold") - stats_job = next(stats_job_generator.generate_jobs(None, mock_preds)) - - results = stats_job.run() - - assert "minmax" in results - assert "image_threshold" in results - assert "pixel_threshold" in results - - # save as it's removed from results - save_path = results["save_path"] - stats_job.save(results) - assert Path(save_path).exists() - - @staticmethod - @pytest.mark.parametrize( - ("key", "values"), - [ - ("anomaly_maps", [torch.rand(5, 1, 50, 50), torch.rand(5, 1, 50, 50)]), - ("pred_scores", [torch.rand(5), torch.rand(5)]), - ], - ) - def test_minmax(key: str, values: list) -> None: - """Test minmax stats calculation.""" - # add given keys to test all possible sources of minmax - data = [ - {"pred_scores": torch.rand(5), "label": torch.ones(5), key: values[0]}, - {"pred_scores": torch.rand(5), "label": torch.ones(5), key: values[1]}, - ] - - stats_job_generator = StatisticsJobGenerator(Path("mock"), "F1AdaptiveThreshold") - stats_job = next(stats_job_generator.generate_jobs(None, data)) - results = stats_job.run() - - if isinstance(values[0], list): - values[0] = torch.cat(values[0]) - values[1] = torch.cat(values[1]) - values = torch.stack(values) - - assert results["minmax"][key]["min"] == torch.min(values) - assert results["minmax"][key]["max"] == torch.max(values) - - @staticmethod - @pytest.mark.parametrize( - ("labels", "preds", "target_threshold"), - [ - (torch.Tensor([0, 0, 0, 1, 1]), torch.Tensor([2.3, 1.6, 2.6, 7.9, 3.3]), 3.3), # standard case - (torch.Tensor([1, 0, 0, 0]), torch.Tensor([4, 3, 2, 1]), 4), # 100% recall for all thresholds - ], - ) - def test_threshold(labels: torch.Tensor, preds: torch.Tensor, target_threshold: float) -> None: - """Test threshold calculation job.""" - data = [ - { - "label": labels, - "mask": labels, - "pred_scores": preds, - "anomaly_maps": preds, - }, - ] - - stats_job_generator = StatisticsJobGenerator(Path("mock"), "F1AdaptiveThreshold") - stats_job = next(stats_job_generator.generate_jobs(None, data)) - results = stats_job.run() - - assert round(results["image_threshold"], 5) == target_threshold - assert round(results["pixel_threshold"], 5) == target_threshold - - -class TestMetrics: - """Test ensemble metrics.""" - - @pytest.fixture(scope="class") - @staticmethod - def get_ensemble_metrics_job( - get_ensemble_config: dict, - get_batch_predictions: list[dict], - ) -> tuple[MetricsCalculationJob, str]: - """Return Metrics calculation job and path to directory where metrics csv will be saved.""" - config = get_ensemble_config - with TemporaryDirectory() as tmp_dir: - metrics = MetricsCalculationJobGenerator( - config["accelerator"], - root_dir=Path(tmp_dir), - task=config["data"]["init_args"]["task"], - metrics=config["TrainModels"]["metrics"], - normalization_stage=NormalizationStage(config["normalization_stage"]), - ) - - mock_predictions = get_batch_predictions - - return next(metrics.generate_jobs(prev_stage_result=copy.deepcopy(mock_predictions))), tmp_dir - - @staticmethod - def test_metrics_result(get_ensemble_metrics_job: tuple[MetricsCalculationJob, str]) -> None: - """Test metrics result.""" - metrics_job, _ = get_ensemble_metrics_job - - result = metrics_job.run() - - assert "pixel_AUROC" in result - assert "image_AUROC" in result - - @staticmethod - def test_metrics_saving(get_ensemble_metrics_job: tuple[MetricsCalculationJob, str]) -> None: - """Test metrics saving to csv.""" - metrics_job, tmp_dir = get_ensemble_metrics_job - - result = metrics_job.run() - metrics_job.save(result) - assert (Path(tmp_dir) / "metric_results.csv").exists() - - -class TestJoinSmoothing: - """Test JoinSmoothing job responsible for smoothing area at tile seams.""" - - @pytest.fixture(scope="class") - @staticmethod - def get_join_smoothing_job(get_ensemble_config: dict, get_batch_predictions: list[dict]) -> SmoothingJob: - """Make and return SmoothingJob instance.""" - config = get_ensemble_config - job_gen = SmoothingJobGenerator( - accelerator=config["accelerator"], - tiling_args=config["tiling"], - data_args=config["data"], - ) - # copy since smoothing changes data - mock_predictions = copy.deepcopy(get_batch_predictions) - return next(job_gen.generate_jobs(config["SeamSmoothing"], mock_predictions)) - - @staticmethod - def test_mask(get_join_smoothing_job: SmoothingJob) -> None: - """Test seam mask in case where tiles don't overlap.""" - smooth = get_join_smoothing_job - - join_index = smooth.tiler.tile_size_h, smooth.tiler.tile_size_w - - # seam should be covered by True - assert smooth.seam_mask[join_index] - - # non-seam region should be false - assert not smooth.seam_mask[0, 0] - assert not smooth.seam_mask[-1, -1] - - @staticmethod - def test_mask_overlapping(get_ensemble_config: dict, get_batch_predictions: list[dict]) -> None: - """Test seam mask in case where tiles overlap.""" - config = copy.deepcopy(get_ensemble_config) - # tile size = 50, stride = 25 -> overlapping - config["tiling"]["stride"] = 25 - job_gen = SmoothingJobGenerator( - accelerator=config["accelerator"], - tiling_args=config["tiling"], - data_args=config["data"], - ) - mock_predictions = copy.deepcopy(get_batch_predictions) - smooth = next(job_gen.generate_jobs(config["SeamSmoothing"], mock_predictions)) - - join_index = smooth.tiler.stride_h, smooth.tiler.stride_w - - # overlap seam should be covered by True - assert smooth.seam_mask[join_index] - assert smooth.seam_mask[-join_index[0], -join_index[1]] - - # non-seam region should be false - assert not smooth.seam_mask[0, 0] - assert not smooth.seam_mask[-1, -1] - - @staticmethod - def test_smoothing(get_join_smoothing_job: SmoothingJob, get_batch_predictions: list[dict]) -> None: - """Test smoothing job run.""" - original_data = get_batch_predictions - # fixture makes a copy of data - smooth = get_join_smoothing_job - - # take first batch - smoothed = smooth.run()[0] - join_index = smooth.tiler.tile_size_h, smooth.tiler.tile_size_w - - # join sections should be processed - assert not smoothed["anomaly_maps"][:, :, join_index].equal(original_data[0]["anomaly_maps"][:, :, join_index]) - - # non-join section shouldn't be changed - assert smoothed["anomaly_maps"][:, :, 0, 0].equal(original_data[0]["anomaly_maps"][:, :, 0, 0]) - - -def test_normalization(get_batch_predictions: list[dict], project_path: Path) -> None: - """Test normalization step.""" - original_predictions = copy.deepcopy(get_batch_predictions) - - for batch in original_predictions: - batch["anomaly_maps"] *= 100 - batch["pred_scores"] *= 100 - - # # get and save stats using stats job on predictions - stats_job_generator = StatisticsJobGenerator(project_path, "F1AdaptiveThreshold") - stats_job = next(stats_job_generator.generate_jobs(prev_stage_result=original_predictions)) - stats = stats_job.run() - stats_job.save(stats) - - # normalize predictions based on obtained stats - norm_job_generator = NormalizationJobGenerator(root_dir=project_path) - # copy as this changes preds - norm_job = next(norm_job_generator.generate_jobs(prev_stage_result=original_predictions)) - normalized_predictions = norm_job.run() - - for batch in normalized_predictions: - assert (batch["anomaly_maps"] >= 0).all() - assert (batch["anomaly_maps"] <= 1).all() - - assert (batch["pred_scores"] >= 0).all() - assert (batch["pred_scores"] <= 1).all() - - -class TestThresholding: - """Test tiled ensemble thresholding stage.""" - - @pytest.fixture(scope="class") - @staticmethod - def get_threshold_job(get_mock_stats_dir: Path) -> callable: - """Return a function that takes prediction data and runs threshold job.""" - thresh_job_generator = ThresholdingJobGenerator( - root_dir=get_mock_stats_dir, - normalization_stage=NormalizationStage.IMAGE, - ) - - def thresh_helper(preds: dict) -> list | None: - thresh_job = next(thresh_job_generator.generate_jobs(prev_stage_result=preds)) - return thresh_job.run() - - return thresh_helper - - @staticmethod - def test_score_threshold(get_threshold_job: callable) -> None: - """Test anomaly score thresholding.""" - thresholding = get_threshold_job - - data = [{"pred_scores": torch.tensor([0.7, 0.8, 0.1, 0.33, 0.5])}] - - thresholded = thresholding(data)[0] - - assert thresholded["pred_labels"].equal(torch.tensor([True, True, False, False, True])) - - @staticmethod - def test_anomap_threshold(get_threshold_job: callable) -> None: - """Test anomaly map thresholding.""" - thresholding = get_threshold_job - - data = [ - { - "pred_scores": torch.tensor([0.7, 0.8, 0.1, 0.33, 0.5]), - "anomaly_maps": torch.tensor([[0.7, 0.8, 0.1], [0.33, 0.5, 0.1]]), - }, - ] - - thresholded = thresholding(data)[0] - - assert thresholded["pred_masks"].equal(torch.tensor([[True, True, False], [False, True, False]])) diff --git a/tests/unit/pipelines/tiled_ensemble/test_helper_functions.py b/tests/unit/pipelines/tiled_ensemble/test_helper_functions.py deleted file mode 100644 index 06e5864cef..0000000000 --- a/tests/unit/pipelines/tiled_ensemble/test_helper_functions.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Test ensemble helper functions.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -from pathlib import Path - -import pytest -from jsonargparse import Namespace -from lightning.pytorch.callbacks import EarlyStopping - -from anomalib.callbacks.normalization import _MinMaxNormalizationCallback -from anomalib.models import AnomalyModule -from anomalib.pipelines.tiled_ensemble.components.utils import NormalizationStage -from anomalib.pipelines.tiled_ensemble.components.utils.ensemble_tiling import EnsembleTiler, TileCollater -from anomalib.pipelines.tiled_ensemble.components.utils.helper_functions import ( - get_ensemble_datamodule, - get_ensemble_engine, - get_ensemble_model, - get_ensemble_tiler, - get_threshold_values, - parse_trainer_kwargs, -) - - -class TestHelperFunctions: - """Test ensemble helper functions.""" - - @staticmethod - def test_ensemble_datamodule(get_ensemble_config: dict, get_tiler: EnsembleTiler) -> None: - """Test that datamodule is created and has correct collate function.""" - config = get_ensemble_config - tiler = get_tiler - datamodule = get_ensemble_datamodule(config, tiler, (0, 0)) - - assert isinstance(datamodule.collate_fn, TileCollater) - - @staticmethod - def test_ensemble_model(get_ensemble_config: dict, get_tiler: EnsembleTiler) -> None: - """Test that model is successfully created with correct input shape.""" - config = get_ensemble_config - tiler = get_tiler - model = get_ensemble_model(config["TrainModels"]["model"], tiler) - - assert model.input_size == tuple(config["tiling"]["tile_size"]) - - @staticmethod - def test_tiler(get_ensemble_config: dict) -> None: - """Test that tiler is successfully instantiated.""" - config = get_ensemble_config - - tiler = get_ensemble_tiler(config["tiling"], config["data"]) - assert isinstance(tiler, EnsembleTiler) - - @staticmethod - def test_trainer_kwargs(get_ensemble_config: dict) -> None: - """Test that objects are correctly constructed from kwargs.""" - config = get_ensemble_config - - objects = parse_trainer_kwargs(config["TrainModels"]["trainer"]) - assert isinstance(objects, Namespace) - # verify that early stopping is parsed and added to callbacks - assert isinstance(objects.callbacks[0], EarlyStopping) - - @staticmethod - @pytest.mark.parametrize( - "normalization_stage", - [NormalizationStage.NONE, NormalizationStage.IMAGE, NormalizationStage.TILE], - ) - def test_threshold_values(normalization_stage: NormalizationStage, get_mock_stats_dir: Path) -> None: - """Test that threshold values are correctly set based on normalization stage.""" - stats_dir = get_mock_stats_dir - - i_thresh, p_thresh = get_threshold_values(normalization_stage, stats_dir) - - if normalization_stage != NormalizationStage.NONE: - # minmax normalization sets thresholds to 0.5 - assert i_thresh == p_thresh == 0.5 - else: - assert i_thresh == p_thresh == 0.1111 - - -class TestEnsembleEngine: - """Test ensemble engine configuration.""" - - @staticmethod - @pytest.mark.parametrize( - "normalization_stage", - [NormalizationStage.NONE, NormalizationStage.IMAGE, NormalizationStage.TILE], - ) - def test_normalisation(normalization_stage: NormalizationStage, get_model: AnomalyModule) -> None: - """Test that normalization callback is correctly initialized.""" - engine = get_ensemble_engine( - tile_index=(0, 0), - accelerator="cpu", - devices="1", - root_dir=Path("mock"), - normalization_stage=normalization_stage, - ) - - engine._setup_anomalib_callbacks(get_model) # noqa: SLF001 - - # verify that only in case of tile level normalization the callback is present - if normalization_stage == NormalizationStage.TILE: - assert any( - isinstance(x, _MinMaxNormalizationCallback) - for x in engine._cache.args["callbacks"] # noqa: SLF001 - ) - else: - assert not any( - isinstance(x, _MinMaxNormalizationCallback) - for x in engine._cache.args["callbacks"] # noqa: SLF001 - ) diff --git a/tests/unit/pipelines/tiled_ensemble/test_prediction_data.py b/tests/unit/pipelines/tiled_ensemble/test_prediction_data.py deleted file mode 100644 index 7185f1e2ca..0000000000 --- a/tests/unit/pipelines/tiled_ensemble/test_prediction_data.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Test tiled prediction storage class.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import copy -from collections.abc import Callable - -import torch -from torch import Tensor - -from anomalib.data import AnomalibDataModule -from anomalib.pipelines.tiled_ensemble.components.utils.prediction_data import EnsemblePredictions - - -class TestPredictionData: - """Test EnsemblePredictions class, used for tiled prediction storage.""" - - @staticmethod - def store_all(data: EnsemblePredictions, datamodule: AnomalibDataModule) -> dict: - """Store the tiled predictions in the EnsemblePredictions object.""" - tile_dict = {} - for tile_index in [(0, 0), (0, 1), (1, 0), (1, 1)]: - datamodule.collate_fn.tile_index = tile_index - - tile_prediction = [] - for batch in iter(datamodule.train_dataloader()): - # set mock maps to just one channel of image - batch["anomaly_maps"] = batch["image"].clone()[:, 0, :, :].unsqueeze(1) - # set mock pred mask to mask but add channel - batch["pred_masks"] = batch["mask"].clone().unsqueeze(1) - tile_prediction.append(batch) - # save original - tile_dict[tile_index] = copy.deepcopy(tile_prediction) - # store to prediction storage object - data.add_tile_prediction(tile_index, tile_prediction) - - return tile_dict - - @staticmethod - def verify_equal(name: str, tile_dict: dict, storage: EnsemblePredictions, eq_funct: Callable) -> bool: - """Verify that all data at same tile index and same batch index matches.""" - batch_num = len(tile_dict[0, 0]) - - for batch_i in range(batch_num): - # batch is dict where key: tile index and val is batched data of that tile - curr_batch = storage.get_batch_tiles(batch_i) - - # go over all indices of current batch of stored data - for tile_index, stored_data_batch in curr_batch.items(): - stored_data = stored_data_batch[name] - # get original data dict at current tile index and batch index - original_data = tile_dict[tile_index][batch_i][name] - if isinstance(original_data, Tensor): - if not eq_funct(original_data, stored_data): - return False - elif original_data != stored_data: - return False - - return True - - def test_prediction_object(self, get_datamodule: AnomalibDataModule) -> None: - """Test prediction storage class.""" - datamodule = get_datamodule - storage = EnsemblePredictions() - original = self.store_all(storage, datamodule) - - for name in original[0, 0][0]: - assert self.verify_equal(name, original, storage, torch.equal), f"{name} doesn't match" diff --git a/tests/unit/pipelines/tiled_ensemble/test_tiler.py b/tests/unit/pipelines/tiled_ensemble/test_tiler.py deleted file mode 100644 index 96b6c0e7bc..0000000000 --- a/tests/unit/pipelines/tiled_ensemble/test_tiler.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tiling related tests for tiled ensemble.""" - -# Copyright (C) 2023-2024 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -import copy - -import pytest -import torch - -from anomalib.data import AnomalibDataModule -from anomalib.pipelines.tiled_ensemble.components.utils.helper_functions import get_ensemble_tiler - -tiler_config = { - "tiling": { - "tile_size": 256, - "stride": 256, - }, - "data": {"init_args": {"image_size": 512}}, -} - -tiler_config_overlap = { - "tiling": { - "tile_size": 256, - "stride": 128, - }, - "data": {"init_args": {"image_size": 512}}, -} - - -class TestTiler: - """EnsembleTiler tests.""" - - @staticmethod - @pytest.mark.parametrize( - ("input_shape", "config", "expected_shape"), - [ - (torch.Size([5, 3, 512, 512]), tiler_config, torch.Size([2, 2, 5, 3, 256, 256])), - (torch.Size([5, 3, 512, 512]), tiler_config_overlap, torch.Size([3, 3, 5, 3, 256, 256])), - (torch.Size([5, 3, 500, 500]), tiler_config, torch.Size([2, 2, 5, 3, 256, 256])), - (torch.Size([5, 3, 500, 500]), tiler_config_overlap, torch.Size([3, 3, 5, 3, 256, 256])), - ], - ) - def test_basic_tile_for_ensemble(input_shape: torch.Size, config: dict, expected_shape: torch.Size) -> None: - """Test basic tiling of data.""" - config = copy.deepcopy(config) - config["data"]["init_args"]["image_size"] = input_shape[-1] - tiler = get_ensemble_tiler(config["tiling"], config["data"]) - - images = torch.rand(size=input_shape) - tiled = tiler.tile(images) - - assert tiled.shape == expected_shape - - @staticmethod - @pytest.mark.parametrize( - ("input_shape", "config"), - [ - (torch.Size([5, 3, 512, 512]), tiler_config), - (torch.Size([5, 3, 512, 512]), tiler_config_overlap), - (torch.Size([5, 3, 500, 500]), tiler_config), - (torch.Size([5, 3, 500, 500]), tiler_config_overlap), - ], - ) - def test_basic_tile_reconstruction(input_shape: torch.Size, config: dict) -> None: - """Test basic reconstruction of tiled data.""" - config = copy.deepcopy(config) - config["data"]["init_args"]["image_size"] = input_shape[-1] - - tiler = get_ensemble_tiler(config["tiling"], config["data"]) - - images = torch.rand(size=input_shape) - tiled = tiler.tile(images.clone()) - untiled = tiler.untile(tiled) - - assert images.shape == untiled.shape - assert images.equal(untiled) - - @staticmethod - @pytest.mark.parametrize( - ("input_shape", "config"), - [ - (torch.Size([5, 3, 512, 512]), tiler_config), - (torch.Size([5, 3, 500, 500]), tiler_config), - ], - ) - def test_untile_different_instance(input_shape: torch.Size, config: dict) -> None: - """Test untiling with different Tiler instance.""" - config = copy.deepcopy(config) - config["data"]["init_args"]["image_size"] = input_shape[-1] - tiler_1 = get_ensemble_tiler(config["tiling"], config["data"]) - - tiler_2 = get_ensemble_tiler(config["tiling"], config["data"]) - - images = torch.rand(size=input_shape) - tiled = tiler_1.tile(images.clone()) - - untiled = tiler_2.untile(tiled) - - # untiling should work even with different instance of tiler - assert images.shape == untiled.shape - assert images.equal(untiled) - - -class TestTileCollater: - """Test tile collater.""" - - @staticmethod - def test_collate_tile_shape(get_ensemble_config: dict, get_datamodule: AnomalibDataModule) -> None: - """Test that collate function successfully tiles the image.""" - config = get_ensemble_config - # datamodule with tile collater - datamodule = get_datamodule - - tile_w, tile_h = config["tiling"]["tile_size"] - - batch = next(iter(datamodule.train_dataloader())) - assert batch["image"].shape[1:] == (3, tile_w, tile_h) - assert batch["mask"].shape[1:] == (tile_w, tile_h)