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",
+ ""
+ ]
+ },
+ {
+ "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, ?it/s]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\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, ?it/s]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "predictions = engine.predict(dataloaders=datamodule.test_dataloader(), model=model, return_predictions=True)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Compute the AUPIMO scores."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "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",
+ "for batch in predictions:\n",
+ " anomaly_maps = batch[\"anomaly_maps\"].squeeze(dim=1)\n",
+ " masks = batch[\"mask\"]\n",
+ " aupimo.update(anomaly_maps=anomaly_maps, masks=masks)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# `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": [
+ "Check the outputs."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "metadata": {},
+ "outputs": [
+ {
+ "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"
+ ]
+ }
+ ],
+ "source": [
+ "# the `nan`s are the normal images; they do not\n",
+ "# have a score because recall is not defined for them\n",
+ "print(aupimo_result.aupimos)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Statistics\n",
+ "\n",
+ "Compute statistics of the AUPIMO scores."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "MEAN\n",
+ "aupimo_result.aupimos[~isnan].mean().item()=0.6273086756193275\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"
+ ]
+ }
+ ],
+ "source": [
+ "# ignore removing the `nan`s\n",
+ "isnan = torch.isnan(aupimo_result.aupimos)\n",
+ "\n",
+ "print(f\"MEAN\\n{aupimo_result.aupimos[~isnan].mean().item()=}\")\n",
+ "print(f\"OTHER STATISTICS\\n{stats.describe(aupimo_result.aupimos[~isnan])}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Plot\n",
+ "\n",
+ "Visualize the distribution of the AUPIMO scores."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "metadata": {},
+ "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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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "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 @@
+
+
+
+
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