diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000000000000000000000000000000000000..bd349dcec5477b34f6dff688c19dbe355bf04823
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,56 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
+// https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/docker-existing-dockerfile
+{
+ "name": "Anomalib Development Environment",
+ // Sets the run context to one level up instead of the .devcontainer folder.
+ "context": "..",
+ // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
+ "dockerFile": "../Dockerfile",
+ // Set *default* container specific settings.json values on container create.
+ "settings": {
+ "python.pythonPath": "/opt/conda/bin/python",
+ "python.formatting.provider": "black",
+ "editor.formatOnSave": true,
+ "python.formatting.blackArgs": [
+ "--line-length",
+ "120"
+ ],
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": true
+ },
+ "python.linting.enabled": true,
+ "python.linting.flake8Enabled": true,
+ "python.linting.flake8Args": [
+ "--max-line-length=120"
+ ],
+ "python.testing.cwd": ".",
+ "python.linting.lintOnSave": true,
+ "python.testing.unittestEnabled": false,
+ "python.testing.nosetestsEnabled": false,
+ "python.testing.pytestEnabled": true,
+ "python.testing.pytestArgs": [
+ "tests"
+ ]
+ },
+ // Add the IDs of extensions you want installed when the container is created.
+ "extensions": [
+ "ms-python.python",
+ "njpwerner.autodocstring",
+ "streetsidesoftware.code-spell-checker",
+ "eamodio.gitlens",
+ "littlefoxteam.vscode-python-test-adapter"
+ ],
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
+ // "forwardPorts": [],
+ // Uncomment the next line to run commands after the container is created - for example installing curl.
+ // "postCreateCommand": "apt-get update && apt-get install -y curl",
+ // Uncomment when using a ptrace-based debugger like C++, Go, and Rust
+ "runArgs": [
+ "--gpus=all",
+ "--shm-size=20g"
+ ]
+ // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker.
+ // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ],
+ // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root.
+ // "remoteUser": "vscode"
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..bbc8ad9ea91eb9ba74349d24505f57dfd7181b99
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+*.dot
+.idea
+.pytest_cache
+.tox
+.vscode
+__pycache__
+{toxworkdir}
+datasets/
+embeddings/
+lightning_logs/
+lightning_test/
+papers/
+results/
diff --git a/.gitattributes b/.gitattributes
index ac481c8eb05e4d2496fbe076a38a7b4835dd733d..62377400d5787aeb67a2f3886153cb25789370ef 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -25,3 +25,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zstandard filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+*.ckpt filter=lfs diff=lfs merge=lfs -text
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000000000000000000000000000000000000..947fd4eed3f343897f3c322ce5549ca5a30ab23a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+- A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+- A clear and concise description of what you expected to happen.
+
+**Screenshots**
+- If applicable, add screenshots to help explain your problem.
+
+**Hardware and Software Configuration**
+- OS: [Ubuntu, OD]
+- NVIDIA Driver Version [470.57.02]
+- CUDA Version [e.g. 11.4]
+- CUDNN Version [e.g. v11.4.120]
+- OpenVINO Version [Optional e.g. v2021.4.2]
+
+
+**Additional context**
+- Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000000000000000000000000000000000000..ea6f2c076ea8c514771d28b0e30a49a21e9dc8cf
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+ - A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+ - A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+ - A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+ - Add any other context or screenshots about the feature request here.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000000000000000000000000000000000000..54e6d7e3f1a78560ff70d07be4d032b5c0aeca87
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,22 @@
+# Description
+
+- Provide a summary of the modification as well as the issue that has been resolved. List any dependencies that this modification necessitates.
+
+- Fixes # (issue)
+
+## Changes
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] This change requires a documentation update
+
+## Checklist
+
+- [ ] My code follows the [pre-commit style and check guidelines](https://openvinotoolkit.github.io/anomalib/guides/using_pre_commit.html#pre-commit-hooks) of this project.
+- [ ] I have performed a self-review of my code
+- [ ] I have commented my code, particularly in hard-to-understand areas
+- [ ] I have made corresponding changes to the documentation
+- [ ] My changes generate no new warnings
+- [ ] I have added tests that prove my fix is effective or that my feature works
+- [ ] New and existing tests pass locally with my changes
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000000000000000000000000000000000000..526587e997cda680e483d95c22019786c3a352f6
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,66 @@
+name: Build Docs
+
+on:
+ push:
+ branches: [development]
+ paths-ignore:
+ - ".github/**" # Ignore changes towards the .github directory
+ workflow_dispatch: # run on request (no need for PR)
+
+jobs:
+ Build-and-Publish-Documentation:
+ runs-on: [docs]
+ steps:
+ - name: CHECKOUT REPOSITORY
+ uses: actions/checkout@v2
+ - name: Install requirements
+ run: |
+ pip install -r requirements/base.txt
+ pip install -r requirements/dev.txt
+ pip install -r requirements/openvino.txt
+ - name: Build and Commit Docs
+ run: |
+ pip install -r requirements/docs.txt
+ cd docs
+ make html
+ - name: Create gh-pages branch
+ run: |
+ echo ::set-output name=SOURCE_NAME::${GITHUB_REF#refs/*/}
+ echo ::set-output name=SOURCE_BRANCH::${GITHUB_REF#refs/heads/}
+ echo ::set-output name=SOURCE_TAG::${GITHUB_REF#refs/tags/}
+
+ existed_in_remote=$(git ls-remote --heads origin gh-pages)
+
+ if [[ -z ${existed_in_remote} ]]; then
+ echo "Creating gh-pages branch"
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git checkout --orphan gh-pages
+ git reset --hard
+ touch .nojekyll
+ git add .nojekyll
+ git commit -m "Initializing gh-pages branch"
+ git push origin gh-pages
+ git checkout ${{steps.branch_name.outputs.SOURCE_NAME}}
+ echo "Created gh-pages branch"
+ else
+ echo "Branch gh-pages already exists"
+ fi
+ - name: Commit docs to gh-pages branch
+ run: |
+ git fetch
+ git checkout gh-pages
+ mkdir -p /tmp/docs_build
+ cp -r docs/build/html/* /tmp/docs_build/
+ rm -rf ./*
+ cp -r /tmp/docs_build/* ./
+ rm -rf /tmp/docs_build
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git add .
+ git commit -m "Update documentation" -a || true
+ - name: Push changes
+ uses: ad-m/github-push-action@master
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ branch: gh-pages
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8febd0cc0c16388e7afb38e96298b4818c23b39a
--- /dev/null
+++ b/.github/workflows/nightly.yml
@@ -0,0 +1,29 @@
+name: Nightly-regression Test
+
+on:
+ workflow_dispatch: # run on request (no need for PR)
+ schedule:
+ - cron: "0 0 * * *"
+
+jobs:
+ Tox:
+ runs-on: [self-hosted, linux, x64]
+ strategy:
+ max-parallel: 1
+ if: github.ref == 'refs/heads/development'
+ steps:
+ - name: Print GPU status
+ run: nvidia-smi
+ - name: CHECKOUT REPOSITORY
+ uses: actions/checkout@v2
+ - name: Install Tox
+ run: pip install tox
+ - name: Coverage
+ run: |
+ export ANOMALIB_DATASET_PATH=/media/data1/datasets/
+ tox -e nightly
+ - name: Upload coverage result
+ uses: actions/upload-artifact@v2
+ with:
+ name: coverage
+ path: .tox/coverage.xml
diff --git a/.github/workflows/pre_merge.yml b/.github/workflows/pre_merge.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0f0e8c22237842972f046f4e4018305142917135
--- /dev/null
+++ b/.github/workflows/pre_merge.yml
@@ -0,0 +1,32 @@
+name: Pre-merge Checks
+
+on:
+ push:
+ branches: [development, master]
+ pull_request:
+ workflow_dispatch: # run on request (no need for PR)
+
+jobs:
+ Tox:
+ runs-on: [self-hosted, linux, x64]
+ strategy:
+ max-parallel: 1
+ steps:
+ - name: Print GPU status
+ run: nvidia-smi
+ - name: CHECKOUT REPOSITORY
+ uses: actions/checkout@v2
+ - name: Install Tox
+ run: pip install tox
+ - name: Code quality checks
+ run: tox -e black,isort,flake8,pylint,mypy,pydocstyle
+ - name: Coverage
+ run: |
+ export ANOMALIB_DATASET_PATH=/media/data1/datasets/
+ export CUDA_VISIBLE_DEVICES=3
+ tox -e pre_merge
+ - name: Upload coverage result
+ uses: actions/upload-artifact@v2
+ with:
+ name: coverage
+ path: .tox/coverage.xml
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..b34903cc2ee2c0ca07db417c1448693932103008
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,173 @@
+# Project related
+datasets
+!anomalib/datasets
+!tests/pre_merge/datasets
+results
+!anomalib/core/results
+
+backup.py
+train.ipynb
+
+# VENV
+.python-version
+.anomalib
+.toxbase
+
+# IDE
+.vscode
+.idea
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/build/
+docs/source/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+
+# Documentations
+docs/source/generated
+docs/source/api
+docs/source/models
+docs/build/
+docs/source/_build/
+
+# Misc
+.DS_Store
+
+# logs
+wandb/
+lightning_logs/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..3178f24da922c784cd0abda52bb4909df478d69c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,70 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v3.4.0
+ hooks:
+ # list of supported hooks: https://pre-commit.com/hooks.html
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-added-large-files
+ - id: debug-statements
+ - id: detect-private-key
+
+ # python code formatting
+ - repo: https://github.com/psf/black
+ rev: 22.3.0
+ hooks:
+ - id: black
+ args: [--line-length, "120"]
+
+ # python import sorting
+ - repo: https://github.com/PyCQA/isort
+ rev: 5.8.0
+ hooks:
+ - id: isort
+ args: ["--profile", "black"]
+
+ # yaml formatting
+ - repo: https://github.com/pre-commit/mirrors-prettier
+ rev: v2.3.0
+ hooks:
+ - id: prettier
+ types: [yaml]
+
+ # python code analysis
+ - repo: https://github.com/PyCQA/flake8
+ rev: 3.9.2
+ hooks:
+ - id: flake8
+ args: [--config=tox.ini]
+ exclude: "tests|anomalib/models/components/freia"
+
+ # python linting
+ - repo: local
+ hooks:
+ - id: pylint
+ name: pylint
+ entry: pylint --score=no --rcfile=tox.ini
+ language: system
+ types: [python]
+ exclude: "tests|docs|anomalib/models/components/freia"
+
+ # python static type checking
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: "v0.910"
+ hooks:
+ - id: mypy
+ args: [--config-file=tox.ini]
+ additional_dependencies: [types-PyYAML]
+ exclude: "tests|anomalib/models/components/freia"
+
+ - repo: https://github.com/PyCQA/pydocstyle
+ rev: 6.1.1
+ hooks:
+ - id: pydocstyle
+ name: pydocstyle
+ entry: pydocstyle
+ language: python
+ types: [python]
+ args: [--config=tox.ini]
+ exclude: "tests|docs|anomalib/models/components/freia"
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000000000000000000000000000000000..18ca5e20ed4661d82f09d4740fba49156c9f15b3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,152 @@
+# Changelog
+
+## v.0.3.0
+### What's Changed
+* 🛠 ⚠️ Fix configs to properly use pytorch-lightning==1.6 with GPU by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/234
+* 🛠 Fix `get_version` in `setup.py` to avoid hard-coding version. by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/229
+* 🐞 Fix image loggers by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/233
+* Configurable metrics by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/230
+* Make OpenVINO throughput optional in benchmarking by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/239
+* 🔨 Minor fix: Ensure docs build runs only on isea-server by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/245
+* 🏷 Rename `--model_config_path` to `config` by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/246
+* Revert "🏷 Rename `--model_config_path` to `config`" by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/247
+* ➕ Add `--model_config_path` deprecation warning to `inference.py` by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/248
+* Add console logger by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/241
+* Add segmentation mask to inference output by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/242
+* 🛠 Fix broken mvtec link, and split url to fit to 120 by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/264
+* 🛠 Fix mask filenames in folder dataset by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/249
+
+
+**Full Changelog**: https://github.com/openvinotoolkit/anomalib/compare/v0.2.6...v0.3.0
+## v.0.2.6
+### What's Changed
+* ✏️ Add `torchtext==0.9.1` to support Kaggle environments. by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/165
+* 🛠 Fix `KeyError:'label'` in classification folder dataset by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/175
+* 📝 Added MVTec license to the repo by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/177
+* load best model from checkpoint by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/195
+* Replace `SaveToCSVCallback` with PL `CSVLogger` by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/198
+* WIP Refactor test by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/197
+* 🔧 Dockerfile enhancements by @LukasBommes in https://github.com/openvinotoolkit/anomalib/pull/172
+* 🛠 Fix visualization issue for fully defected images by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/194
+* ✨ Add hpo search using `wandb` by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/82
+* Separate train and validation transformations by @alexriedel1 in https://github.com/openvinotoolkit/anomalib/pull/168
+* 🛠 Fix docs workflow by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/200
+* 🔄 CFlow: Switch soft permutation to false by default to speed up training. by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/201
+* Return only `image`, `path` and `label` for classification tasks in `Mvtec` and `Btech` datasets. by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/196
+* 🗑 Remove `freia` as dependency and include it in `anomalib/models/components` by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/174
+* Visualizer show classification and segmentation by @alexriedel1 in https://github.com/openvinotoolkit/anomalib/pull/178
+* ↗️ Bump up `pytorch-lightning` version to `1.6.0` or higher by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/193
+* 🛠 Refactor DFKDE model by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/207
+* 🛠 Minor fixes: Update callbacks to AnomalyModule by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/208
+* 🛠 Minor update: Update pre-commit docs by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/206
+* ✨ Directory streaming by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/210
+* ✏️ Updated documentation for development on Docker by @LukasBommes in https://github.com/openvinotoolkit/anomalib/pull/217
+* 🏷 Fix Mac M1 dependency conflicts by @dreaquil in https://github.com/openvinotoolkit/anomalib/pull/158
+* 🐞 Set tiling off in pathcore to correctly reproduce the stats. by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/222
+* 🐞fix support for non-square images by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/204
+* Allow specifying feature layer and pool factor in DFM by @nahuja-intel in https://github.com/openvinotoolkit/anomalib/pull/215
+* 📝 Add GANomaly metrics to readme by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/224
+* ↗️ Bump the version to 0.2.6 by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/223
+* 📝 🛠 Fix inconsistent benchmarking throughput/time by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/221
+* assign test split for folder dataset by @alexriedel1 in https://github.com/openvinotoolkit/anomalib/pull/220
+* 🛠 Refactor model implementations by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/225
+
+## New Contributors
+* @LukasBommes made their first contribution in https://github.com/openvinotoolkit/anomalib/pull/172
+* @dreaquil made their first contribution in https://github.com/openvinotoolkit/anomalib/pull/158
+* @nahuja-intel made their first contribution in https://github.com/openvinotoolkit/anomalib/pull/215
+
+**Full Changelog**: https://github.com/openvinotoolkit/anomalib/compare/v.0.2.5...v0.2.6
+## v.0.2.5
+### What's Changed
+* Bugfix: fix random val/test split issue by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/48
+* Fix Readmes by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/46
+* Updated changelog by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/49
+* add distinction between image and pixel threshold in postprocessor by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/50
+* Fix docstrings by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/22
+* Fix networkx requirement by @LeonidBeynenson in https://github.com/openvinotoolkit/anomalib/pull/52
+* Add min-max normalization by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/53
+* Change hardcoded dataset path to environ variable by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/51
+* Added cflow algorithm by @blakshma in https://github.com/openvinotoolkit/anomalib/pull/47
+* perform metric computation on cpu by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/64
+* Fix Inferencer by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/60
+* Updated readme for cflow and change default config to reflect results by @blakshma in https://github.com/openvinotoolkit/anomalib/pull/68
+* Fixed issue with model loading by @blakshma in https://github.com/openvinotoolkit/anomalib/pull/69
+* Docs/sa/fix readme by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/71
+* Updated coreset subsampling method to improve accuracy by @blakshma in https://github.com/openvinotoolkit/anomalib/pull/73
+* Revert "Updated coreset subsampling method to improve accuracy" by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/79
+* Replace `SupportIndex` with `int` by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/76
+* Added reference to official CFLOW repo by @blakshma in https://github.com/openvinotoolkit/anomalib/pull/81
+* Fixed issue with k_greedy method by @blakshma in https://github.com/openvinotoolkit/anomalib/pull/80
+* Fix Mix Data type issue on inferencer by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/77
+* Create CODE_OF_CONDUCT.md by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/86
+* ✨ Add GANomaly by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/70
+* Reorder auc only when needed by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/91
+* Bump up the pytorch lightning to master branch due to vulnurability issues by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/55
+* 🚀 CI: Nightly Build by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/66
+* Refactor by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/87
+* Benchmarking Script by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/17
+* 🐞 Fix tensor detach and gpu count issues in benchmarking script by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/100
+* Return predicted masks in predict step by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/103
+* Add Citation to the Readme by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/106
+* Nightly build by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/104
+* c_idx cast to LongTensor in random sparse projection by @alexriedel1 in https://github.com/openvinotoolkit/anomalib/pull/113
+* Update Nightly by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/126
+* Updated logos by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/131
+* Add third-party-programs.txt file and update license by @LeonidBeynenson in https://github.com/openvinotoolkit/anomalib/pull/132
+* 🔨 Increase inference + openvino support by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/122
+* Fix/da/image size bug by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/135
+* Fix/da/image size bug by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/140
+* optimize compute_anomaly_score by using torch native funcrtions by @alexriedel1 in https://github.com/openvinotoolkit/anomalib/pull/141
+* Fix IndexError in adaptive threshold computation by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/146
+* Feature/data/btad by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/120
+* Update for nncf_task by @AlexanderDokuchaev in https://github.com/openvinotoolkit/anomalib/pull/145
+* fix non-adaptive thresholding bug by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/152
+* Calculate feature map shape patchcore by @alexriedel1 in https://github.com/openvinotoolkit/anomalib/pull/148
+* Add `transform_config` to the main `config.yaml` file. by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/156
+* Add Custom Dataset Training Support by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/154
+* Added extension as an option when saving the result images. by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/162
+* Update `anomalib` version and requirements by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/163
+
+## New Contributors
+* @LeonidBeynenson made their first contribution in https://github.com/openvinotoolkit/anomalib/pull/52
+* @blakshma made their first contribution in https://github.com/openvinotoolkit/anomalib/pull/47
+* @alexriedel1 made their first contribution in https://github.com/openvinotoolkit/anomalib/pull/113
+* @AlexanderDokuchaev made their first contribution in https://github.com/openvinotoolkit/anomalib/pull/145
+
+**Full Changelog**: https://github.com/openvinotoolkit/anomalib/compare/v.0.2.4...v.0.2.5
+## v.0.2.4
+### What's Changed
+* Bump up the version to 0.2.4 by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/45
+* fix heatmap color scheme by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/44
+
+**Full Changelog**: https://github.com/openvinotoolkit/anomalib/compare/v.0.2.3...v.0.2.4
+
+## v.0.2.3
+### What's Changed
+* Address docs build failing issue by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/39
+* Fix docs pipeline 📄 by @ashwinvaidya17 in https://github.com/openvinotoolkit/anomalib/pull/41
+* Feature/dick/anomaly score normalization by @djdameln in https://github.com/openvinotoolkit/anomalib/pull/35
+* Shuffle train dataloader by @samet-akcay in https://github.com/openvinotoolkit/anomalib/pull/42
+
+
+**Full Changelog**: https://github.com/openvinotoolkit/anomalib/compare/v0.2.2...v.0.2.3
+
+## v0.2.0 Pre-release (2021-12-15)
+### What's Changed
+* Address compatibility issues with OTE, that are caused by the legacy code. by @samet-akcay in [#24](https://github.com/openvinotoolkit/anomalib/pull/24)
+* Initial docs string by @ashwinvaidya17 in [#9](https://github.com/openvinotoolkit/anomalib/pull/9)
+* Load model did not work correctly as DFMModel did not inherit by @ashwinvaidya17 in [#5](https://github.com/openvinotoolkit/anomalib/pull/5)
+* Refactor/samet/data by @samet-akcay in [#8](https://github.com/openvinotoolkit/anomalib/pull/8)
+* Delete make.bat by @samet-akcay in [#11](https://github.com/openvinotoolkit/anomalib/pull/11)
+* TorchMetrics by @djdameln in [#7](https://github.com/openvinotoolkit/anomalib/pull/7)
+* ONNX node naming by @djdameln in [#13](https://github.com/openvinotoolkit/anomalib/pull/13)
+* Add FPS counter to `TimerCallback` by @ashwinvaidya17 in [#12](https://github.com/openvinotoolkit/anomalib/pull/12)
+
+
+## Contributors
+* @ashwinvaidya17
+* @djdameln
+* @samet-akcay
+
+**Full Changelog**: https://github.com/openvinotoolkit/anomalib/commits/v0.2.0
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000000000000000000000000000000000000..7d6f9414a85c494eb088801d9c8c703f5804c5e4
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,92 @@
+# This CITATION.cff file was generated with cffinit.
+# Visit https://bit.ly/cffinit to generate yours today!
+
+cff-version: 1.2.0
+title: "Anomalib: A Deep Learning Library for Anomaly Detection"
+message: "If you use this library and love it, cite the software and the paper \U0001F917"
+authors:
+ - given-names: Samet
+ family-names: Akcay
+ email: samet.akcay@intel.com
+ affiliation: Intel
+ - given-names: Dick
+ family-names: Ameln
+ email: dick.ameln@intel.com
+ affiliation: Intel
+ - given-names: Ashwin
+ family-names: Vaidya
+ email: ashwin.vaidya@intel.com
+ affiliation: Intel
+ - given-names: Barath
+ family-names: Lakshmanan
+ email: barath.lakshmanan@intel.com
+ affiliation: Intel
+ - given-names: Nilesh
+ family-names: Ahuja
+ email: nilesh.ahuja@intel.com
+ affiliation: Intel
+ - given-names: Utku
+ family-names: Genc
+ email: utku.genc@intel.com
+ affiliation: Intel
+version: 0.2.6
+doi: https://doi.org/10.48550/arXiv.2202.08341
+date-released: 2022-02-18
+references:
+ - type: article
+ authors:
+ - given-names: Samet
+ family-names: Akcay
+ email: samet.akcay@intel.com
+ affiliation: Intel
+ - given-names: Dick
+ family-names: Ameln
+ email: dick.ameln@intel.com
+ affiliation: Intel
+ - given-names: Ashwin
+ family-names: Vaidya
+ email: ashwin.vaidya@intel.com
+ affiliation: Intel
+ - given-names: Barath
+ family-names: Lakshmanan
+ email: barath.lakshmanan@intel.com
+ affiliation: Intel
+ - given-names: Nilesh
+ family-names: Ahuja
+ email: nilesh.ahuja@intel.com
+ affiliation: Intel
+ - given-names: Utku
+ family-names: Genc
+ email: utku.genc@intel.com
+ affiliation: Intel
+ title: "Anomalib: A Deep Learning Library for Anomaly Detection"
+ year: 2022
+ journal: ArXiv
+ doi: https://doi.org/10.48550/arXiv.2202.08341
+ url: https://arxiv.org/abs/2202.08341
+
+abstract: >-
+ This paper introduces anomalib, a novel library for
+ unsupervised anomaly detection and localization.
+ With reproducibility and modularity in mind, this
+ open-source library provides algorithms from the
+ literature and a set of tools to design custom
+ anomaly detection algorithms via a plug-and-play
+ approach. Anomalib comprises state-of-the-art
+ anomaly detection algorithms that achieve top
+ performance on the benchmarks and that can be used
+ off-the-shelf. In addition, the library provides
+ components to design custom algorithms that could
+ be tailored towards specific needs. Additional
+ tools, including experiment trackers, visualizers,
+ and hyper-parameter optimizers, make it simple to
+ design and implement anomaly detection models. The
+ library also supports OpenVINO model optimization
+ and quantization for real-time deployment. Overall,
+ anomalib is an extensive library for the design,
+ implementation, and deployment of unsupervised
+ anomaly detection models from data to the edge.
+keywords:
+ - Unsupervised Anomaly detection
+ - Unsupervised Anomaly localization
+license: Apache-2.0
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000000000000000000000000000000000000..95023420646e35679ee1adab24974e5b60378d92
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+[Intel Integrity Line](https://secure.ethicspoint.com/domain/media/en/gui/31244/index.html).
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000000000000000000000000000000000..90a2cc69d06a2eb36270999bbe69cd43e71ce359
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,64 @@
+# Contributing to Anomalib
+We welcome your input! 👐
+
+We want to make it as simple and straightforward as possible to contribute to this project, whether it is a:
+
+- Bug Report
+- Discussion
+- Feature Request
+- Creating a Pull Request (PR)
+- Becoming a maintainer
+
+
+## Bug Report
+We use GitHub issues to track the bugs. Report a bug by using our Bug Report Template in [Issues](https://github.com/openvinotoolkit/anomalib/issues/new?assignees=&labels=&template=bug_report.md).
+
+
+## Discussion
+We enabled [GitHub Discussions](https://github.com/openvinotoolkit/anomalib/discussions/) in anomalib to welcome the community to ask questions and/or propose ideas/solutions. This will not only provide a medium to the community to discuss about anomalib but also help us de-clutter [Issues](https://github.com/openvinotoolkit/anomalib/issues/new?assignees=&labels=&template=bug_report.md).
+
+
+## Feature Request
+We utilize GitHub issues to track the feature requests as well. If you are certain regarding the feature you are interested and have a solid proposal, you could then create the feature request by using our [Feature Request Template](https://github.com/openvinotoolkit/anomalib/issues/new?assignees=&labels=&template=feature_request.md) in Issues. If it's still in an idea phase, you could then discuss that with the community in our [Discussion](https://github.com/openvinotoolkit/anomalib/discussions/categories/ideas).
+
+
+## Development & PRs
+We actively welcome your pull requests:
+
+ 1. Fork the repo and create your branch from [`development`](https://github.com/openvinotoolkit/anomalib/tree/development).
+ 2. If you've added code that should be tested, add tests.
+ 3. If you've changed APIs, update the documentation.
+ 4. Ensure the test suite passes.
+ 5. Make sure your code lints.
+ 6. Make sure you own the code you're submitting or that you obtain it from a source with an appropriate license.
+ 7. Issue that pull request!
+
+To setup the development environment, you will need to install development requirements.
+```
+pip install -r requirements/dev.txt
+```
+
+To enforce consistency within the repo, we use several formatters, linters, and style- and type checkers:
+
+| Tool | Function | Documentation |
+| ------ | -------------------------- | --------------------------------------- |
+| Black | Code formatting | https://black.readthedocs.io/en/stable/ |
+| isort | Organize import statements | https://pycqa.github.io/isort/ |
+| Flake8 | Code style | https://flake8.pycqa.org/en/latest/ |
+| Pylint | Linting | http://pylint.pycqa.org/en/latest/ |
+| MyPy | Type checking | https://mypy.readthedocs.io/en/stable/ |
+
+Instead of running each of these tools manually, we automatically run them before each commit and after each merge request. To achieve this we use pre-commit hooks and tox. Every developer is expected to use pre-commit hooks to make sure that their code remains free of typing and linting issues, and complies with the coding style requirements. When an MR is submitted, tox will be automatically invoked from the CI pipeline in Gitlab to check if the code quality is up to standard. Developers can also run tox locally before making an MR, though this is not strictly necessary since pre-commit hooks should be sufficient to prevent code quality issues. More detailed explanations of how to work with these tools is given in the respective guides:
+
+- Pre-commit hooks: [Pre-commit hooks guide](https://openvinotoolkit.github.io/anomalib/guides/using_pre_commit.html#pre-commit-hooks)
+- Tox: [Using Tox](https://openvinotoolkit.github.io/anomalib/guides/using_tox.html#using-tox)
+
+In rare cases it might be desired to ignore certain errors or warnings for a particular part of your code. Flake8, Pylint and MyPy allow disabling specific errors for a line or block of code. The instructions for this can be found in the the documentations of each of these tools. Please make sure to only ignore errors/warnings when absolutely necessary, and always add a comment in your code stating why you chose to ignore it.
+
+
+## License
+You accept that your contributions will be licensed under the [Apache-2.0 License](https://choosealicense.com/licenses/apache-2.0/) if you contribute to this repository. If this is a concern, please notify the maintainers.
+
+
+## References
+This document was adapted from [here](https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62).
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..ff5e07b4a19412bb1dc7be3aa7fa2f5e38c69e6c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+#########################################################
+## Python Environment with CUDA
+#########################################################
+
+FROM nvidia/cuda:11.4.0-devel-ubuntu20.04 AS python_base_cuda
+LABEL MAINTAINER="Anomalib Development Team"
+
+# Update system and install wget
+RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y wget ffmpeg libpython3.8 git sudo
+
+# Install Conda
+RUN wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda.sh --quiet && \
+ bash ~/miniconda.sh -b -p /opt/conda
+ENV PATH "/opt/conda/bin:${PATH}"
+RUN conda install python=3.8
+
+
+#########################################################
+## Anomalib Development Env
+#########################################################
+
+FROM python_base_cuda as anomalib_development_env
+
+# Install all anomalib requirements
+COPY ./requirements/base.txt /tmp/anomalib/requirements/base.txt
+RUN pip install -r /tmp/anomalib/requirements/base.txt
+
+COPY ./requirements/openvino.txt /tmp/anomalib/requirements/openvino.txt
+RUN pip install -r /tmp/anomalib/requirements/openvino.txt
+
+# Install other requirements related to development
+COPY ./requirements/dev.txt /tmp/anomalib/requirements/dev.txt
+RUN pip install -r /tmp/anomalib/requirements/dev.txt
+
+# Install anomalib
+COPY . /anomalib
+WORKDIR /anomalib
+RUN pip install -e .
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..e19c7be97ba4c107c88426da1618c8e0add76708
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright (C) 2020-2021 Intel Corporation
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000000000000000000000000000000000000..d6ecc7c5c1a56bb7e4c1372dd3314917324eaa60
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1 @@
+recursive-include requirements *
diff --git a/README copy.md b/README copy.md
new file mode 100644
index 0000000000000000000000000000000000000000..94e1d53957b478af1a7240a0df566bc13001b023
--- /dev/null
+++ b/README copy.md
@@ -0,0 +1,253 @@
+
+
+

+
+**A library for benchmarking, developing and deploying deep learning anomaly detection algorithms**
+___
+
+[Key Features](#key-features) •
+[Getting Started](#getting-started) •
+[Docs](https://openvinotoolkit.github.io/anomalib) •
+[License](https://github.com/openvinotoolkit/anomalib/blob/development/LICENSE)
+
+[]()
+[]()
+[]()
+[]()
+[](https://github.com/openvinotoolkit/anomalib/actions/workflows/nightly.yml)
+[](https://github.com/openvinotoolkit/anomalib/actions/workflows/pre_merge.yml)
+[](https://github.com/openvinotoolkit/anomalib/actions/workflows/docs.yml)
+
+
+
+___
+
+## Introduction
+
+Anomalib is a deep learning library that aims to collect state-of-the-art anomaly detection algorithms for benchmarking on both public and private datasets. Anomalib provides several ready-to-use implementations of anomaly detection algorithms described in the recent literature, as well as a set of tools that facilitate the development and implementation of custom models. The library has a strong focus on image-based anomaly detection, where the goal of the algorithm is to identify anomalous images, or anomalous pixel regions within images in a dataset. Anomalib is constantly updated with new algorithms and training/inference extensions, so keep checking!
+
+
+
+**Key features:**
+
+- The largest public collection of ready-to-use deep learning anomaly detection algorithms and benchmark datasets.
+- [**PyTorch Lightning**](https://www.pytorchlightning.ai/) based model implementations to reduce boilerplate code and limit the implementation efforts to the bare essentials.
+- All models can be exported to [**OpenVINO**](https://www.intel.com/content/www/us/en/developer/tools/openvino-toolkit/overview.html) Intermediate Representation (IR) for accelerated inference on intel hardware.
+- A set of [inference tools](#inference) for quick and easy deployment of the standard or custom anomaly detection models.
+
+___
+
+## Getting Started
+
+To get an overview of all the devices where `anomalib` as been tested thoroughly, look at the [Supported Hardware](https://openvinotoolkit.github.io/anomalib/#supported-hardware) section in the documentation.
+
+### PyPI Install
+
+You can get started with `anomalib` by just using pip.
+
+```bash
+pip install anomalib
+```
+
+### Local Install
+It is highly recommended to use virtual environment when installing anomalib. For instance, with [anaconda](https://www.anaconda.com/products/individual), `anomalib` could be installed as,
+
+```bash
+yes | conda create -n anomalib_env python=3.8
+conda activate anomalib_env
+git clone https://github.com/openvinotoolkit/anomalib.git
+cd anomalib
+pip install -e .
+```
+
+## Training
+
+By default [`python tools/train.py`](https://gitlab-icv.inn.intel.com/algo_rnd_team/anomaly/-/blob/development/train.py)
+runs [PADIM](https://arxiv.org/abs/2011.08785) model on `leather` category from the [MVTec AD](https://www.mvtec.com/company/research/datasets/mvtec-ad) [(CC BY-NC-SA 4.0)](https://creativecommons.org/licenses/by-nc-sa/4.0/) dataset.
+
+```bash
+python tools/train.py # Train PADIM on MVTec AD leather
+```
+
+Training a model on a specific dataset and category requires further configuration. Each model has its own configuration
+file, [`config.yaml`](https://gitlab-icv.inn.intel.com/algo_rnd_team/anomaly/-/blob/development/padim/anomalib/models/padim/config.yaml)
+, which contains data, model and training configurable parameters. To train a specific model on a specific dataset and
+category, the config file is to be provided:
+
+```bash
+python tools/train.py --config
+```
+
+For example, to train [PADIM](anomalib/models/padim) you can use
+
+```bash
+python tools/train.py --config anomalib/models/padim/config.yaml
+```
+
+Note that `--model_config_path` will be deprecated in `v0.2.8` and removed
+in `v0.2.9`.
+
+Alternatively, a model name could also be provided as an argument, where the scripts automatically finds the corresponding config file.
+
+```bash
+python tools/train.py --model padim
+```
+
+where the currently available models are:
+
+- [CFlow](anomalib/models/cflow)
+- [PatchCore](anomalib/models/patchcore)
+- [PADIM](anomalib/models/padim)
+- [STFPM](anomalib/models/stfpm)
+- [DFM](anomalib/models/dfm)
+- [DFKDE](anomalib/models/dfkde)
+- [GANomaly](anomalib/models/ganomaly)
+
+### Custom Dataset
+It is also possible to train on a custom folder dataset. To do so, `data` section in `config.yaml` is to be modified as follows:
+```yaml
+dataset:
+ name:
+ format: folder
+ path:
+ normal: normal # name of the folder containing normal images.
+ abnormal: abnormal # name of the folder containing abnormal images.
+ task: segmentation # classification or segmentation
+ mask: #optional
+ extensions: null
+ split_ratio: 0.2 # ratio of the normal images that will be used to create a test split
+ seed: 0
+ image_size: 256
+ train_batch_size: 32
+ test_batch_size: 32
+ num_workers: 8
+ transform_config: null
+ create_validation_set: true
+ tiling:
+ apply: false
+ tile_size: null
+ stride: null
+ remove_border_count: 0
+ use_random_tiling: False
+ random_tile_count: 16
+```
+## Inference
+
+Anomalib contains several tools that can be used to perform inference with a trained model. The script in [`tools/inference`](tools/inference.py) contains an example of how the inference tools can be used to generate a prediction for an input image.
+
+If the specified weight path points to a PyTorch Lightning checkpoint file (`.ckpt`), inference will run in PyTorch. If the path points to an ONNX graph (`.onnx`) or OpenVINO IR (`.bin` or `.xml`), inference will run in OpenVINO.
+
+The following command can be used to run inference from the command line:
+
+```bash
+python tools/inference.py \
+ --config \
+ --weight_path \
+ --image_path
+```
+
+As a quick example:
+
+```bash
+python tools/inference.py \
+ --config anomalib/models/padim/config.yaml \
+ --weight_path results/padim/mvtec/bottle/weights/model.ckpt \
+ --image_path datasets/MVTec/bottle/test/broken_large/000.png
+```
+
+If you want to run OpenVINO model, ensure that `openvino` `apply` is set to `True` in the respective model `config.yaml`.
+
+```yaml
+optimization:
+ openvino:
+ apply: true
+```
+
+Example OpenVINO Inference:
+
+```bash
+python tools/inference.py \
+ --config \
+ anomalib/models/padim/config.yaml \
+ --weight_path \
+ results/padim/mvtec/bottle/compressed/compressed_model.xml \
+ --image_path \
+ datasets/MVTec/bottle/test/broken_large/000.png \
+ --meta_data \
+ results/padim/mvtec/bottle/compressed/meta_data.json
+```
+
+> Ensure that you provide path to `meta_data.json` if you want the normalization to be applied correctly.
+
+___
+
+## Datasets
+`anomalib` supports MVTec AD [(CC BY-NC-SA 4.0)](https://creativecommons.org/licenses/by-nc-sa/4.0/) and BeanTech [(CC-BY-SA)](https://creativecommons.org/licenses/by-sa/4.0/legalcode) for benchmarking and `folder` for custom dataset training/inference.
+
+### [MVTec AD Dataset](https://www.mvtec.com/company/research/datasets/mvtec-ad)
+MVTec AD dataset is one of the main benchmarks for anomaly detection, and is released under the
+Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License [(CC BY-NC-SA 4.0)](https://creativecommons.org/licenses/by-nc-sa/4.0/).
+
+### Image-Level AUC
+
+| Model | | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| ------------- | ------------------ | :-------: | :-------: | :-------: | :-----: | :-------: | :-------: | :-----: | :-------: | :-------: | :------: | :-------: | :-------: | :-------: | :--------: | :--------: | :-------: |
+| **PatchCore** | **Wide ResNet-50** | **0.980** | 0.984 | 0.959 | 1.000 | **1.000** | 0.989 | 1.000 | **0.990** | **0.982** | 1.000 | 0.994 | 0.924 | 0.960 | 0.933 | **1.000** | 0.982 |
+| PatchCore | ResNet-18 | 0.973 | 0.970 | 0.947 | 1.000 | 0.997 | 0.997 | 1.000 | 0.986 | 0.965 | 1.000 | 0.991 | 0.916 | **0.943** | 0.931 | 0.996 | 0.953 |
+| CFlow | Wide ResNet-50 | 0.962 | 0.986 | 0.962 | **1.0** | 0.999 | **0.993** | **1.0** | 0.893 | 0.945 | **1.0** | **0.995** | 0.924 | 0.908 | 0.897 | 0.943 | **0.984** |
+| PaDiM | Wide ResNet-50 | 0.950 | **0.995** | 0.942 | 1.0 | 0.974 | **0.993** | 0.999 | 0.878 | 0.927 | 0.964 | 0.989 | **0.939** | 0.845 | 0.942 | 0.976 | 0.882 |
+| PaDiM | ResNet-18 | 0.891 | 0.945 | 0.857 | 0.982 | 0.950 | 0.976 | 0.994 | 0.844 | 0.901 | 0.750 | 0.961 | 0.863 | 0.759 | 0.889 | 0.920 | 0.780 |
+| STFPM | Wide ResNet-50 | 0.876 | 0.957 | 0.977 | 0.981 | 0.976 | 0.939 | 0.987 | 0.878 | 0.732 | 0.995 | 0.973 | 0.652 | 0.825 | 0.5 | 0.875 | 0.899 |
+| STFPM | ResNet-18 | 0.893 | 0.954 | **0.982** | 0.989 | 0.949 | 0.961 | 0.979 | 0.838 | 0.759 | 0.999 | 0.956 | 0.705 | 0.835 | **0.997** | 0.853 | 0.645 |
+| DFM | Wide ResNet-50 | 0.891 | 0.978 | 0.540 | 0.979 | 0.977 | 0.974 | 0.990 | 0.891 | 0.931 | 0.947 | 0.839 | 0.809 | 0.700 | 0.911 | 0.915 | 0.981 |
+| DFM | ResNet-18 | 0.894 | 0.864 | 0.558 | 0.945 | 0.984 | 0.946 | 0.994 | 0.913 | 0.871 | 0.979 | 0.941 | 0.838 | 0.761 | 0.95 | 0.911 | 0.949 |
+| DFKDE | Wide ResNet-50 | 0.774 | 0.708 | 0.422 | 0.905 | 0.959 | 0.903 | 0.936 | 0.746 | 0.853 | 0.736 | 0.687 | 0.749 | 0.574 | 0.697 | 0.843 | 0.892 |
+| DFKDE | ResNet-18 | 0.762 | 0.646 | 0.577 | 0.669 | 0.965 | 0.863 | 0.951 | 0.751 | 0.698 | 0.806 | 0.729 | 0.607 | 0.694 | 0.767 | 0.839 | 0.866 |
+| GANomaly | | 0.421 | 0.203 | 0.404 | 0.413 | 0.408 | 0.744 | 0.251 | 0.457 | 0.682 | 0.537 | 0.270 | 0.472 | 0.231 | 0.372 | 0.440 | 0.434 |
+
+### Pixel-Level AUC
+
+| Model | | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| ------------- | ------------------ | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :--------: | :--------: | :-------: |
+| **PatchCore** | **Wide ResNet-50** | **0.980** | 0.988 | 0.968 | 0.991 | 0.961 | 0.934 | 0.984 | **0.988** | **0.988** | 0.987 | **0.989** | 0.980 | **0.989** | 0.988 | **0.981** | 0.983 |
+| PatchCore | ResNet-18 | 0.976 | 0.986 | 0.955 | 0.990 | 0.943 | 0.933 | 0.981 | 0.984 | 0.986 | 0.986 | 0.986 | 0.974 | 0.991 | 0.988 | 0.974 | 0.983 |
+| CFlow | Wide ResNet-50 | 0.971 | 0.986 | 0.968 | 0.993 | **0.968** | 0.924 | 0.981 | 0.955 | **0.988** | **0.990** | 0.982 | **0.983** | 0.979 | 0.985 | 0.897 | 0.980 |
+| PaDiM | Wide ResNet-50 | 0.979 | **0.991** | 0.970 | 0.993 | 0.955 | **0.957** | **0.985** | 0.970 | **0.988** | 0.985 | 0.982 | 0.966 | 0.988 | **0.991** | 0.976 | **0.986** |
+| PaDiM | ResNet-18 | 0.968 | 0.984 | 0.918 | **0.994** | 0.934 | 0.947 | 0.983 | 0.965 | 0.984 | 0.978 | 0.970 | 0.957 | 0.978 | 0.988 | 0.968 | 0.979 |
+| STFPM | Wide ResNet-50 | 0.903 | 0.987 | **0.989** | 0.980 | 0.966 | 0.956 | 0.966 | 0.913 | 0.956 | 0.974 | 0.961 | 0.946 | 0.988 | 0.178 | 0.807 | 0.980 |
+| STFPM | ResNet-18 | 0.951 | 0.986 | 0.988 | 0.991 | 0.946 | 0.949 | 0.971 | 0.898 | 0.962 | 0.981 | 0.942 | 0.878 | 0.983 | 0.983 | 0.838 | 0.972 |
+
+### Image F1 Score
+
+| Model | | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| ------------- | ------------------ | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :-------: | :--------: | :--------: | :-------: |
+| **PatchCore** | **Wide ResNet-50** | **0.976** | 0.971 | 0.974 | **1.000** | **1.000** | 0.967 | **1.000** | 0.968 | **0.982** | **1.000** | 0.984 | 0.940 | 0.943 | 0.938 | **1.000** | **0.979** |
+| PatchCore | ResNet-18 | 0.970 | 0.949 | 0.946 | **1.000** | 0.98 | **0.992** | **1.000** | **0.978** | 0.969 | **1.000** | **0.989** | 0.940 | 0.932 | 0.935 | 0.974 | 0.967 |
+| CFlow | Wide ResNet-50 | 0.944 | 0.972 | 0.932 | **1.0** | 0.988 | 0.967 | **1.0** | 0.832 | 0.939 | **1.0** | 0.979 | 0.924 | **0.971** | 0.870 | 0.818 | 0.967 |
+| PaDiM | Wide ResNet-50 | 0.951 | **0.989** | 0.930 | **1.0** | 0.960 | 0.983 | 0.992 | 0.856 | **0.982** | 0.937 | 0.978 | **0.946** | 0.895 | 0.952 | 0.914 | 0.947 |
+| PaDiM | ResNet-18 | 0.916 | 0.930 | 0.893 | 0.984 | 0.934 | 0.952 | 0.976 | 0.858 | 0.960 | 0.836 | 0.974 | 0.932 | 0.879 | 0.923 | 0.796 | 0.915 |
+| STFPM | Wide ResNet-50 | 0.926 | 0.973 | 0.973 | 0.974 | 0.965 | 0.929 | 0.976 | 0.853 | 0.920 | 0.972 | 0.974 | 0.922 | 0.884 | 0.833 | 0.815 | 0.931 |
+| STFPM | ResNet-18 | 0.932 | 0.961 | **0.982** | 0.989 | 0.930 | 0.951 | 0.984 | 0.819 | 0.918 | 0.993 | 0.973 | 0.918 | 0.887 | **0.984** | 0.790 | 0.908 |
+| DFM | Wide ResNet-50 | 0.918 | 0.960 | 0.844 | 0.990 | 0.970 | 0.959 | 0.976 | 0.848 | 0.944 | 0.913 | 0.912 | 0.919 | 0.859 | 0.893 | 0.815 | 0.961 |
+| DFM | ResNet-18 | 0.919 | 0.895 | 0.844 | 0.926 | 0.971 | 0.948 | 0.977 | 0.874 | 0.935 | 0.957 | 0.958 | 0.921 | 0.874 | 0.933 | 0.833 | 0.943 |
+| DFKDE | Wide ResNet-50 | 0.875 | 0.907 | 0.844 | 0.905 | 0.945 | 0.914 | 0.946 | 0.790 | 0.914 | 0.817 | 0.894 | 0.922 | 0.855 | 0.845 | 0.722 | 0.910 |
+| DFKDE | ResNet-18 | 0.872 | 0.864 | 0.844 | 0.854 | 0.960 | 0.898 | 0.942 | 0.793 | 0.908 | 0.827 | 0.894 | 0.916 | 0.859 | 0.853 | 0.756 | 0.916 |
+| GANomaly | | 0.834 | 0.864 | 0.844 | 0.852 | 0.836 | 0.863 | 0.863 | 0.760 | 0.905 | 0.777 | 0.894 | 0.916 | 0.853 | 0.833 | 0.571 | 0.881 |
+
+## Reference
+If you use this library and love it, use this to cite it 🤗
+```
+@misc{anomalib,
+ title={Anomalib: A Deep Learning Library for Anomaly Detection},
+ author={Samet Akcay and
+ Dick Ameln and
+ Ashwin Vaidya and
+ Barath Lakshmanan and
+ Nilesh Ahuja and
+ Utku Genc},
+ year={2022},
+ eprint={2202.08341},
+ archivePrefix={arXiv},
+ primaryClass={cs.CV}
+}
+```
diff --git a/anomalib/__init__.py b/anomalib/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b3612bcea5439c5c3406ceab243e0805746b0836
--- /dev/null
+++ b/anomalib/__init__.py
@@ -0,0 +1,17 @@
+"""Anomalib library for research and benchmarking."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+__version__ = "0.3.0"
diff --git a/anomalib/config/__init__.py b/anomalib/config/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2607d88f9288a56150c471f57c3ce144d9e377b
--- /dev/null
+++ b/anomalib/config/__init__.py
@@ -0,0 +1,23 @@
+"""Utilities for parsing model configuration."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .config import (
+ get_configurable_parameters,
+ update_input_size_config,
+ update_nncf_config,
+)
+
+__all__ = ["get_configurable_parameters", "update_nncf_config", "update_input_size_config"]
diff --git a/anomalib/config/config.py b/anomalib/config/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..f35d3d312b20d4495bb53903b40a211f0844efab
--- /dev/null
+++ b/anomalib/config/config.py
@@ -0,0 +1,170 @@
+"""Get configurable parameters."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+# TODO: This would require a new design.
+# TODO: https://jira.devtools.intel.com/browse/IAAALD-149
+
+from pathlib import Path
+from typing import List, Optional, Union
+from warnings import warn
+
+from omegaconf import DictConfig, ListConfig, OmegaConf
+
+
+def update_input_size_config(config: Union[DictConfig, ListConfig]) -> Union[DictConfig, ListConfig]:
+ """Update config with image size as tuple, effective input size and tiling stride.
+
+ Convert integer image size parameters into tuples, calculate the effective input size based on image size
+ and crop size, and set tiling stride if undefined.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Configurable parameters object
+
+ Returns:
+ Union[DictConfig, ListConfig]: Configurable parameters with updated values
+ """
+ # handle image size
+ if isinstance(config.dataset.image_size, int):
+ config.dataset.image_size = (config.dataset.image_size,) * 2
+
+ config.model.input_size = config.dataset.image_size
+
+ if "tiling" in config.dataset.keys() and config.dataset.tiling.apply:
+ if isinstance(config.dataset.tiling.tile_size, int):
+ config.dataset.tiling.tile_size = (config.dataset.tiling.tile_size,) * 2
+ if config.dataset.tiling.stride is None:
+ config.dataset.tiling.stride = config.dataset.tiling.tile_size
+
+ return config
+
+
+def update_nncf_config(config: Union[DictConfig, ListConfig]) -> Union[DictConfig, ListConfig]:
+ """Set the NNCF input size based on the value of the crop_size parameter in the configurable parameters object.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Configurable parameters of the current run.
+
+ Returns:
+ Union[DictConfig, ListConfig]: Updated configurable parameters in DictConfig object.
+ """
+ crop_size = config.dataset.image_size
+ sample_size = (crop_size, crop_size) if isinstance(crop_size, int) else crop_size
+ if "optimization" in config.keys():
+ if "nncf" in config.optimization.keys():
+ config.optimization.nncf.input_info.sample_size = [1, 3, *sample_size]
+ if config.optimization.nncf.apply:
+ if "update_config" in config.optimization.nncf:
+ return OmegaConf.merge(config, config.optimization.nncf.update_config)
+ return config
+
+
+def update_multi_gpu_training_config(config: Union[DictConfig, ListConfig]) -> Union[DictConfig, ListConfig]:
+ """Updates the config to change learning rate based on number of gpus assigned.
+
+ Current behaviour is to ensure only ddp accelerator is used.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Configurable parameters for the current run
+
+ Raises:
+ ValueError: If unsupported accelerator is passed
+
+ Returns:
+ Union[DictConfig, ListConfig]: Updated config
+ """
+ # validate accelerator
+ if config.trainer.accelerator is not None:
+ if config.trainer.accelerator.lower() != "ddp":
+ if config.trainer.accelerator.lower() in ("dp", "ddp_spawn", "ddp2"):
+ warn(
+ f"Using accelerator {config.trainer.accelerator.lower()} is discouraged. "
+ f"Please use one of [null, ddp]. Setting accelerator to ddp"
+ )
+ config.trainer.accelerator = "ddp"
+ else:
+ raise ValueError(
+ f"Unsupported accelerator found: {config.trainer.accelerator}. Should be one of [null, ddp]"
+ )
+ # Increase learning rate
+ # since pytorch averages the gradient over devices, the idea is to
+ # increase the learning rate by the number of devices
+ if "lr" in config.model:
+ # Number of GPUs can either be passed as gpus: 2 or gpus: [0,1]
+ n_gpus: Union[int, List] = 1
+ if "trainer" in config and "gpus" in config.trainer:
+ n_gpus = config.trainer.gpus
+ lr_scaler = n_gpus if isinstance(n_gpus, int) else len(n_gpus)
+ config.model.lr = config.model.lr * lr_scaler
+ return config
+
+
+def get_configurable_parameters(
+ model_name: Optional[str] = None,
+ config_path: Optional[Union[Path, str]] = None,
+ weight_file: Optional[str] = None,
+ config_filename: Optional[str] = "config",
+ config_file_extension: Optional[str] = "yaml",
+) -> Union[DictConfig, ListConfig]:
+ """Get configurable parameters.
+
+ Args:
+ model_name: Optional[str]: (Default value = None)
+ config_path: Optional[Union[Path, str]]: (Default value = None)
+ weight_file: Path to the weight file
+ config_filename: Optional[str]: (Default value = "config")
+ config_file_extension: Optional[str]: (Default value = "yaml")
+
+ Returns:
+ Union[DictConfig, ListConfig]: Configurable parameters in DictConfig object.
+ """
+ if model_name is None and config_path is None:
+ raise ValueError(
+ "Both model_name and model config path cannot be None! "
+ "Please provide a model name or path to a config file!"
+ )
+
+ if config_path is None:
+ config_path = Path(f"anomalib/models/{model_name}/{config_filename}.{config_file_extension}")
+
+ config = OmegaConf.load(config_path)
+
+ # Dataset Configs
+ if "format" not in config.dataset.keys():
+ config.dataset.format = "mvtec"
+
+ config = update_input_size_config(config)
+
+ # Project Configs
+ project_path = Path(config.project.path) / config.model.name / config.dataset.name
+ if config.dataset.format.lower() in ("btech", "mvtec"):
+ project_path = project_path / config.dataset.category
+
+ (project_path / "weights").mkdir(parents=True, exist_ok=True)
+ (project_path / "images").mkdir(parents=True, exist_ok=True)
+ config.project.path = str(project_path)
+ # loggers should write to results/model/dataset/category/ folder
+ config.trainer.default_root_dir = str(project_path)
+
+ if weight_file:
+ config.model.weight_file = weight_file
+
+ config = update_nncf_config(config)
+
+ # thresholding
+ if "pixel_default" not in config.model.threshold.keys():
+ config.model.threshold.pixel_default = config.model.threshold.image_default
+
+ return config
diff --git a/anomalib/data/__init__.py b/anomalib/data/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ab65b72ee5b877a4766cc55f841b16e61c5e9a6
--- /dev/null
+++ b/anomalib/data/__init__.py
@@ -0,0 +1,104 @@
+"""Anomalib Datasets."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Union
+
+from omegaconf import DictConfig, ListConfig
+from pytorch_lightning import LightningDataModule
+
+from .btech import BTechDataModule
+from .folder import FolderDataModule
+from .inference import InferenceDataset
+from .mvtec import MVTecDataModule
+
+
+def get_datamodule(config: Union[DictConfig, ListConfig]) -> LightningDataModule:
+ """Get Anomaly Datamodule.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Configuration of the anomaly model.
+
+ Returns:
+ PyTorch Lightning DataModule
+ """
+ datamodule: LightningDataModule
+
+ if config.dataset.format.lower() == "mvtec":
+ datamodule = MVTecDataModule(
+ # TODO: Remove config values. IAAALD-211
+ root=config.dataset.path,
+ category=config.dataset.category,
+ image_size=(config.dataset.image_size[0], config.dataset.image_size[1]),
+ train_batch_size=config.dataset.train_batch_size,
+ test_batch_size=config.dataset.test_batch_size,
+ num_workers=config.dataset.num_workers,
+ seed=config.project.seed,
+ task=config.dataset.task,
+ transform_config_train=config.dataset.transform_config.train,
+ transform_config_val=config.dataset.transform_config.val,
+ create_validation_set=config.dataset.create_validation_set,
+ )
+ elif config.dataset.format.lower() == "btech":
+ datamodule = BTechDataModule(
+ # TODO: Remove config values. IAAALD-211
+ root=config.dataset.path,
+ category=config.dataset.category,
+ image_size=(config.dataset.image_size[0], config.dataset.image_size[1]),
+ train_batch_size=config.dataset.train_batch_size,
+ test_batch_size=config.dataset.test_batch_size,
+ num_workers=config.dataset.num_workers,
+ seed=config.project.seed,
+ task=config.dataset.task,
+ transform_config_train=config.dataset.transform_config.train,
+ transform_config_val=config.dataset.transform_config.val,
+ create_validation_set=config.dataset.create_validation_set,
+ )
+ elif config.dataset.format.lower() == "folder":
+ datamodule = FolderDataModule(
+ root=config.dataset.path,
+ normal_dir=config.dataset.normal_dir,
+ abnormal_dir=config.dataset.abnormal_dir,
+ task=config.dataset.task,
+ normal_test_dir=config.dataset.normal_test_dir,
+ mask_dir=config.dataset.mask,
+ extensions=config.dataset.extensions,
+ split_ratio=config.dataset.split_ratio,
+ seed=config.dataset.seed,
+ image_size=(config.dataset.image_size[0], config.dataset.image_size[1]),
+ train_batch_size=config.dataset.train_batch_size,
+ test_batch_size=config.dataset.test_batch_size,
+ num_workers=config.dataset.num_workers,
+ transform_config_train=config.dataset.transform_config.train,
+ transform_config_val=config.dataset.transform_config.val,
+ create_validation_set=config.dataset.create_validation_set,
+ )
+ else:
+ raise ValueError(
+ "Unknown dataset! \n"
+ "If you use a custom dataset make sure you initialize it in"
+ "`get_datamodule` in `anomalib.data.__init__.py"
+ )
+
+ return datamodule
+
+
+__all__ = [
+ "get_datamodule",
+ "BTechDataModule",
+ "FolderDataModule",
+ "InferenceDataset",
+ "MVTecDataModule",
+]
diff --git a/anomalib/data/btech.py b/anomalib/data/btech.py
new file mode 100644
index 0000000000000000000000000000000000000000..aef0664ca1a21b8f3dac25b32dfe992f59dfda82
--- /dev/null
+++ b/anomalib/data/btech.py
@@ -0,0 +1,453 @@
+"""BTech Dataset.
+
+This script contains PyTorch Lightning DataModule for the BTech dataset.
+
+If the dataset is not on the file system, the script downloads and
+extracts the dataset and create PyTorch data objects.
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import shutil
+import zipfile
+from pathlib import Path
+from typing import Dict, Optional, Tuple, Union
+from urllib.request import urlretrieve
+
+import albumentations as A
+import cv2
+import numpy as np
+import pandas as pd
+from pandas.core.frame import DataFrame
+from pytorch_lightning.core.datamodule import LightningDataModule
+from pytorch_lightning.utilities.types import EVAL_DATALOADERS, TRAIN_DATALOADERS
+from torch import Tensor
+from torch.utils.data import DataLoader
+from torch.utils.data.dataset import Dataset
+from torchvision.datasets.folder import VisionDataset
+from tqdm import tqdm
+
+from anomalib.data.inference import InferenceDataset
+from anomalib.data.utils import DownloadProgressBar, read_image
+from anomalib.data.utils.split import (
+ create_validation_set_from_test_set,
+ split_normal_images_in_train_set,
+)
+from anomalib.pre_processing import PreProcessor
+
+logger = logging.getLogger(__name__)
+
+
+def make_btech_dataset(
+ path: Path,
+ split: Optional[str] = None,
+ split_ratio: float = 0.1,
+ seed: int = 0,
+ create_validation_set: bool = False,
+) -> DataFrame:
+ """Create BTech samples by parsing the BTech data file structure.
+
+ The files are expected to follow the structure:
+ path/to/dataset/split/category/image_filename.png
+ path/to/dataset/ground_truth/category/mask_filename.png
+
+ Args:
+ path (Path): Path to dataset
+ split (str, optional): Dataset split (ie., either train or test). Defaults to None.
+ split_ratio (float, optional): Ratio to split normal training images and add to the
+ test set in case test set doesn't contain any normal images.
+ Defaults to 0.1.
+ seed (int, optional): Random seed to ensure reproducibility when splitting. Defaults to 0.
+ create_validation_set (bool, optional): Boolean to create a validation set from the test set.
+ BTech dataset does not contain a validation set. Those wanting to create a validation set
+ could set this flag to ``True``.
+
+ Example:
+ The following example shows how to get training samples from BTech 01 category:
+
+ >>> root = Path('./BTech')
+ >>> category = '01'
+ >>> path = root / category
+ >>> path
+ PosixPath('BTech/01')
+
+ >>> samples = make_btech_dataset(path, split='train', split_ratio=0.1, seed=0)
+ >>> samples.head()
+ path split label image_path mask_path label_index
+ 0 BTech/01 train 01 BTech/01/train/ok/105.bmp BTech/01/ground_truth/ok/105.png 0
+ 1 BTech/01 train 01 BTech/01/train/ok/017.bmp BTech/01/ground_truth/ok/017.png 0
+ ...
+
+ Returns:
+ DataFrame: an output dataframe containing samples for the requested split (ie., train or test)
+ """
+ samples_list = [
+ (str(path),) + filename.parts[-3:] for filename in path.glob("**/*") if filename.suffix in (".bmp", ".png")
+ ]
+ if len(samples_list) == 0:
+ raise RuntimeError(f"Found 0 images in {path}")
+
+ samples = pd.DataFrame(samples_list, columns=["path", "split", "label", "image_path"])
+ samples = samples[samples.split != "ground_truth"]
+
+ # Create mask_path column
+ samples["mask_path"] = (
+ samples.path
+ + "/ground_truth/"
+ + samples.label
+ + "/"
+ + samples.image_path.str.rstrip("png").str.rstrip(".")
+ + ".png"
+ )
+
+ # Modify image_path column by converting to absolute path
+ samples["image_path"] = samples.path + "/" + samples.split + "/" + samples.label + "/" + samples.image_path
+
+ # Split the normal images in training set if test set doesn't
+ # contain any normal images. This is needed because AUC score
+ # cannot be computed based on 1-class
+ if sum((samples.split == "test") & (samples.label == "ok")) == 0:
+ samples = split_normal_images_in_train_set(samples, split_ratio, seed)
+
+ # Good images don't have mask
+ samples.loc[(samples.split == "test") & (samples.label == "ok"), "mask_path"] = ""
+
+ # Create label index for normal (0) and anomalous (1) images.
+ samples.loc[(samples.label == "ok"), "label_index"] = 0
+ samples.loc[(samples.label != "ok"), "label_index"] = 1
+ samples.label_index = samples.label_index.astype(int)
+
+ if create_validation_set:
+ samples = create_validation_set_from_test_set(samples, seed=seed)
+
+ # Get the data frame for the split.
+ if split is not None and split in ["train", "val", "test"]:
+ samples = samples[samples.split == split]
+ samples = samples.reset_index(drop=True)
+
+ return samples
+
+
+class BTech(VisionDataset):
+ """BTech PyTorch Dataset."""
+
+ def __init__(
+ self,
+ root: Union[Path, str],
+ category: str,
+ pre_process: PreProcessor,
+ split: str,
+ task: str = "segmentation",
+ seed: int = 0,
+ create_validation_set: bool = False,
+ ) -> None:
+ """Btech Dataset class.
+
+ Args:
+ root: Path to the BTech dataset
+ category: Name of the BTech category.
+ pre_process: List of pre_processing object containing albumentation compose.
+ split: 'train', 'val' or 'test'
+ task: ``classification`` or ``segmentation``
+ seed: seed used for the random subset splitting
+ create_validation_set: Create a validation subset in addition to the train and test subsets
+
+ Examples:
+ >>> from anomalib.data.btech import BTech
+ >>> from anomalib.data.transforms import PreProcessor
+ >>> pre_process = PreProcessor(image_size=256)
+ >>> dataset = BTech(
+ ... root='./datasets/BTech',
+ ... category='leather',
+ ... pre_process=pre_process,
+ ... task="classification",
+ ... is_train=True,
+ ... )
+ >>> dataset[0].keys()
+ dict_keys(['image'])
+
+ >>> dataset.split = "test"
+ >>> dataset[0].keys()
+ dict_keys(['image', 'image_path', 'label'])
+
+ >>> dataset.task = "segmentation"
+ >>> dataset.split = "train"
+ >>> dataset[0].keys()
+ dict_keys(['image'])
+
+ >>> dataset.split = "test"
+ >>> dataset[0].keys()
+ dict_keys(['image_path', 'label', 'mask_path', 'image', 'mask'])
+
+ >>> dataset[0]["image"].shape, dataset[0]["mask"].shape
+ (torch.Size([3, 256, 256]), torch.Size([256, 256]))
+ """
+ super().__init__(root)
+ self.root = Path(root) if isinstance(root, str) else root
+ self.category: str = category
+ self.split = split
+ self.task = task
+
+ self.pre_process = pre_process
+
+ self.samples = make_btech_dataset(
+ path=self.root / category,
+ split=self.split,
+ seed=seed,
+ create_validation_set=create_validation_set,
+ )
+
+ def __len__(self) -> int:
+ """Get length of the dataset."""
+ return len(self.samples)
+
+ def __getitem__(self, index: int) -> Dict[str, Union[str, Tensor]]:
+ """Get dataset item for the index ``index``.
+
+ Args:
+ index (int): Index to get the item.
+
+ Returns:
+ Union[Dict[str, Tensor], Dict[str, Union[str, Tensor]]]: Dict of image tensor during training.
+ Otherwise, Dict containing image path, target path, image tensor, label and transformed bounding box.
+ """
+ item: Dict[str, Union[str, Tensor]] = {}
+
+ image_path = self.samples.image_path[index]
+ image = read_image(image_path)
+
+ pre_processed = self.pre_process(image=image)
+ item = {"image": pre_processed["image"]}
+
+ if self.split in ["val", "test"]:
+ label_index = self.samples.label_index[index]
+
+ item["image_path"] = image_path
+ item["label"] = label_index
+
+ if self.task == "segmentation":
+ mask_path = self.samples.mask_path[index]
+
+ # Only Anomalous (1) images has masks in BTech dataset.
+ # Therefore, create empty mask for Normal (0) images.
+ if label_index == 0:
+ mask = np.zeros(shape=image.shape[:2])
+ else:
+ mask = cv2.imread(mask_path, flags=0) / 255.0
+
+ pre_processed = self.pre_process(image=image, mask=mask)
+
+ item["mask_path"] = mask_path
+ item["image"] = pre_processed["image"]
+ item["mask"] = pre_processed["mask"]
+
+ return item
+
+
+class BTechDataModule(LightningDataModule):
+ """BTechDataModule Lightning Data Module."""
+
+ def __init__(
+ self,
+ root: str,
+ category: str,
+ # TODO: Remove default values. IAAALD-211
+ image_size: Optional[Union[int, Tuple[int, int]]] = None,
+ train_batch_size: int = 32,
+ test_batch_size: int = 32,
+ num_workers: int = 8,
+ task: str = "segmentation",
+ transform_config_train: Optional[Union[str, A.Compose]] = None,
+ transform_config_val: Optional[Union[str, A.Compose]] = None,
+ seed: int = 0,
+ create_validation_set: bool = False,
+ ) -> None:
+ """Instantiate BTech Lightning Data Module.
+
+ Args:
+ root: Path to the BTech dataset
+ category: Name of the BTech category.
+ image_size: Variable to which image is resized.
+ train_batch_size: Training batch size.
+ test_batch_size: Testing batch size.
+ num_workers: Number of workers.
+ task: ``classification`` or ``segmentation``
+ transform_config_train: Config for pre-processing during training.
+ transform_config_val: Config for pre-processing during validation.
+ seed: seed used for the random subset splitting
+ create_validation_set: Create a validation subset in addition to the train and test subsets
+
+ Examples
+ >>> from anomalib.data import BTechDataModule
+ >>> datamodule = BTechDataModule(
+ ... root="./datasets/BTech",
+ ... category="leather",
+ ... image_size=256,
+ ... train_batch_size=32,
+ ... test_batch_size=32,
+ ... num_workers=8,
+ ... transform_config_train=None,
+ ... transform_config_val=None,
+ ... )
+ >>> datamodule.setup()
+
+ >>> i, data = next(enumerate(datamodule.train_dataloader()))
+ >>> data.keys()
+ dict_keys(['image'])
+ >>> data["image"].shape
+ torch.Size([32, 3, 256, 256])
+
+ >>> i, data = next(enumerate(datamodule.val_dataloader()))
+ >>> data.keys()
+ dict_keys(['image_path', 'label', 'mask_path', 'image', 'mask'])
+ >>> data["image"].shape, data["mask"].shape
+ (torch.Size([32, 3, 256, 256]), torch.Size([32, 256, 256]))
+ """
+ super().__init__()
+
+ self.root = root if isinstance(root, Path) else Path(root)
+ self.category = category
+ self.dataset_path = self.root / self.category
+ self.transform_config_train = transform_config_train
+ self.transform_config_val = transform_config_val
+ self.image_size = image_size
+
+ if self.transform_config_train is not None and self.transform_config_val is None:
+ self.transform_config_val = self.transform_config_train
+
+ self.pre_process_train = PreProcessor(config=self.transform_config_train, image_size=self.image_size)
+ self.pre_process_val = PreProcessor(config=self.transform_config_val, image_size=self.image_size)
+
+ self.train_batch_size = train_batch_size
+ self.test_batch_size = test_batch_size
+ self.num_workers = num_workers
+
+ self.create_validation_set = create_validation_set
+ self.task = task
+ self.seed = seed
+
+ self.train_data: Dataset
+ self.test_data: Dataset
+ if create_validation_set:
+ self.val_data: Dataset
+ self.inference_data: Dataset
+
+ def prepare_data(self) -> None:
+ """Download the dataset if not available."""
+ if (self.root / self.category).is_dir():
+ logger.info("Found the dataset.")
+ else:
+ zip_filename = self.root.parent / "btad.zip"
+
+ logger.info("Downloading the BTech dataset.")
+ with DownloadProgressBar(unit="B", unit_scale=True, miniters=1, desc="BTech") as progress_bar:
+ urlretrieve(
+ url="https://avires.dimi.uniud.it/papers/btad/btad.zip",
+ filename=zip_filename,
+ reporthook=progress_bar.update_to,
+ ) # nosec
+
+ logger.info("Extracting the dataset.")
+ with zipfile.ZipFile(zip_filename, "r") as zip_file:
+ zip_file.extractall(self.root.parent)
+
+ logger.info("Renaming the dataset directory")
+ shutil.move(src=str(self.root.parent / "BTech_Dataset_transformed"), dst=str(self.root))
+
+ # NOTE: Each BTech category has different image extension as follows
+ # | Category | Image | Mask |
+ # |----------|-------|------|
+ # | 01 | bmp | png |
+ # | 02 | png | png |
+ # | 03 | bmp | bmp |
+ # To avoid any conflict, the following script converts all the extensions to png.
+ # This solution works fine, but it's also possible to properly ready the bmp and
+ # png filenames from categories in `make_btech_dataset` function.
+ logger.info("Convert the bmp formats to png to have consistent image extensions")
+ for filename in tqdm(self.root.glob("**/*.bmp"), desc="Converting bmp to png"):
+ image = cv2.imread(str(filename))
+ cv2.imwrite(str(filename.with_suffix(".png")), image)
+ filename.unlink()
+
+ logger.info("Cleaning the tar file")
+ zip_filename.unlink()
+
+ def setup(self, stage: Optional[str] = None) -> None:
+ """Setup train, validation and test data.
+
+ BTech dataset uses BTech dataset structure, which is the reason for
+ using `anomalib.data.btech.BTech` class to get the dataset items.
+
+ Args:
+ stage: Optional[str]: Train/Val/Test stages. (Default value = None)
+
+ """
+ logger.info("Setting up train, validation, test and prediction datasets.")
+ if stage in (None, "fit"):
+ self.train_data = BTech(
+ root=self.root,
+ category=self.category,
+ pre_process=self.pre_process_train,
+ split="train",
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ if self.create_validation_set:
+ self.val_data = BTech(
+ root=self.root,
+ category=self.category,
+ pre_process=self.pre_process_val,
+ split="val",
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ self.test_data = BTech(
+ root=self.root,
+ category=self.category,
+ pre_process=self.pre_process_val,
+ split="test",
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ if stage == "predict":
+ self.inference_data = InferenceDataset(
+ path=self.root, image_size=self.image_size, transform_config=self.transform_config_val
+ )
+
+ def train_dataloader(self) -> TRAIN_DATALOADERS:
+ """Get train dataloader."""
+ return DataLoader(self.train_data, shuffle=True, batch_size=self.train_batch_size, num_workers=self.num_workers)
+
+ def val_dataloader(self) -> EVAL_DATALOADERS:
+ """Get validation dataloader."""
+ dataset = self.val_data if self.create_validation_set else self.test_data
+ return DataLoader(dataset=dataset, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers)
+
+ def test_dataloader(self) -> EVAL_DATALOADERS:
+ """Get test dataloader."""
+ return DataLoader(self.test_data, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers)
+
+ def predict_dataloader(self) -> EVAL_DATALOADERS:
+ """Get predict dataloader."""
+ return DataLoader(
+ self.inference_data, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers
+ )
diff --git a/anomalib/data/folder.py b/anomalib/data/folder.py
new file mode 100644
index 0000000000000000000000000000000000000000..e0a98e3d09b6d3c45d9ec348253af3c5d5aa94f8
--- /dev/null
+++ b/anomalib/data/folder.py
@@ -0,0 +1,540 @@
+"""Custom Folder Dataset.
+
+This script creates a custom dataset from a folder.
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import warnings
+from pathlib import Path
+from typing import Dict, Optional, Tuple, Union
+
+import albumentations as A
+import cv2
+import numpy as np
+from pandas.core.frame import DataFrame
+from pytorch_lightning.core.datamodule import LightningDataModule
+from pytorch_lightning.utilities.types import EVAL_DATALOADERS, TRAIN_DATALOADERS
+from torch import Tensor
+from torch.utils.data import DataLoader, Dataset
+from torchvision.datasets.folder import IMG_EXTENSIONS
+
+from anomalib.data.inference import InferenceDataset
+from anomalib.data.utils import read_image
+from anomalib.data.utils.split import (
+ create_validation_set_from_test_set,
+ split_normal_images_in_train_set,
+)
+from anomalib.pre_processing import PreProcessor
+
+logger = logging.getLogger(__name__)
+
+
+def _check_and_convert_path(path: Union[str, Path]) -> Path:
+ """Check an input path, and convert to Pathlib object.
+
+ Args:
+ path (Union[str, Path]): Input path.
+
+ Returns:
+ Path: Output path converted to pathlib object.
+ """
+ if not isinstance(path, Path):
+ path = Path(path)
+ return path
+
+
+def _prepare_files_labels(
+ path: Union[str, Path], path_type: str, extensions: Optional[Tuple[str, ...]] = None
+) -> Tuple[list, list]:
+ """Return a list of filenames and list corresponding labels.
+
+ Args:
+ path (Union[str, Path]): Path to the directory containing images.
+ path_type (str): Type of images in the provided path ("normal", "abnormal", "normal_test")
+ extensions (Optional[Tuple[str, ...]], optional): Type of the image extensions to read from the
+ directory.
+
+ Returns:
+ List, List: Filenames of the images provided in the paths, labels of the images provided in the paths
+ """
+ path = _check_and_convert_path(path)
+ if extensions is None:
+ extensions = IMG_EXTENSIONS
+
+ if isinstance(extensions, str):
+ extensions = (extensions,)
+
+ filenames = [f for f in path.glob(r"**/*") if f.suffix in extensions and not f.is_dir()]
+ if len(filenames) == 0:
+ raise RuntimeError(f"Found 0 {path_type} images in {path}")
+
+ labels = [path_type] * len(filenames)
+
+ return filenames, labels
+
+
+def make_dataset(
+ normal_dir: Union[str, Path],
+ abnormal_dir: Union[str, Path],
+ normal_test_dir: Optional[Union[str, Path]] = None,
+ mask_dir: Optional[Union[str, Path]] = None,
+ split: Optional[str] = None,
+ split_ratio: float = 0.2,
+ seed: int = 0,
+ create_validation_set: bool = True,
+ extensions: Optional[Tuple[str, ...]] = None,
+):
+ """Make Folder Dataset.
+
+ Args:
+ normal_dir (Union[str, Path]): Path to the directory containing normal images.
+ abnormal_dir (Union[str, Path]): Path to the directory containing abnormal images.
+ normal_test_dir (Optional[Union[str, Path]], optional): Path to the directory containing
+ normal images for the test dataset. Normal test images will be a split of `normal_dir`
+ if `None`. Defaults to None.
+ mask_dir (Optional[Union[str, Path]], optional): Path to the directory containing
+ the mask annotations. Defaults to None.
+ split (Optional[str], optional): Dataset split (ie., either train or test). Defaults to None.
+ split_ratio (float, optional): Ratio to split normal training images and add to the
+ test set in case test set doesn't contain any normal images.
+ Defaults to 0.2.
+ seed (int, optional): Random seed to ensure reproducibility when splitting. Defaults to 0.
+ create_validation_set (bool, optional):Boolean to create a validation set from the test set.
+ Those wanting to create a validation set could set this flag to ``True``.
+ extensions (Optional[Tuple[str, ...]], optional): Type of the image extensions to read from the
+ directory.
+
+ Returns:
+ DataFrame: an output dataframe containing samples for the requested split (ie., train or test)
+ """
+
+ filenames = []
+ labels = []
+ dirs = {"normal": normal_dir, "abnormal": abnormal_dir}
+
+ if normal_test_dir:
+ dirs = {**dirs, **{"normal_test": normal_test_dir}}
+
+ for dir_type, path in dirs.items():
+ filename, label = _prepare_files_labels(path, dir_type, extensions)
+ filenames += filename
+ labels += label
+
+ samples = DataFrame({"image_path": filenames, "label": labels})
+
+ # Create label index for normal (0) and abnormal (1) images.
+ samples.loc[(samples.label == "normal") | (samples.label == "normal_test"), "label_index"] = 0
+ samples.loc[(samples.label == "abnormal"), "label_index"] = 1
+ samples.label_index = samples.label_index.astype(int)
+
+ # If a path to mask is provided, add it to the sample dataframe.
+ if mask_dir is not None:
+ mask_dir = _check_and_convert_path(mask_dir)
+ samples["mask_path"] = ""
+ for index, row in samples.iterrows():
+ if row.label_index == 1:
+ samples.loc[index, "mask_path"] = str(mask_dir / row.image_path.name)
+
+ # Ensure the pathlib objects are converted to str.
+ # This is because torch dataloader doesn't like pathlib.
+ samples = samples.astype({"image_path": "str"})
+
+ # Create train/test split.
+ # By default, all the normal samples are assigned as train.
+ # and all the abnormal samples are test.
+ samples.loc[(samples.label == "normal"), "split"] = "train"
+ samples.loc[(samples.label == "abnormal") | (samples.label == "normal_test"), "split"] = "test"
+
+ if not normal_test_dir:
+ samples = split_normal_images_in_train_set(
+ samples=samples, split_ratio=split_ratio, seed=seed, normal_label="normal"
+ )
+
+ # If `create_validation_set` is set to True, the test set is split into half.
+ if create_validation_set:
+ samples = create_validation_set_from_test_set(samples, seed=seed, normal_label="normal")
+
+ # Get the data frame for the split.
+ if split is not None and split in ["train", "val", "test"]:
+ samples = samples[samples.split == split]
+ samples = samples.reset_index(drop=True)
+
+ return samples
+
+
+class FolderDataset(Dataset):
+ """Folder Dataset."""
+
+ def __init__(
+ self,
+ normal_dir: Union[Path, str],
+ abnormal_dir: Union[Path, str],
+ split: str,
+ pre_process: PreProcessor,
+ normal_test_dir: Optional[Union[Path, str]] = None,
+ split_ratio: float = 0.2,
+ mask_dir: Optional[Union[Path, str]] = None,
+ extensions: Optional[Tuple[str, ...]] = None,
+ task: Optional[str] = None,
+ seed: int = 0,
+ create_validation_set: bool = False,
+ ) -> None:
+ """Create Folder Folder Dataset.
+
+ Args:
+ normal_dir (Union[str, Path]): Path to the directory containing normal images.
+ abnormal_dir (Union[str, Path]): Path to the directory containing abnormal images.
+ split (Optional[str], optional): Dataset split (ie., either train or test). Defaults to None.
+ pre_process (Optional[PreProcessor], optional): Image Pro-processor to apply transform.
+ Defaults to None.
+ normal_test_dir (Optional[Union[str, Path]], optional): Path to the directory containing
+ normal images for the test dataset. Defaults to None.
+ split_ratio (float, optional): Ratio to split normal training images and add to the
+ test set in case test set doesn't contain any normal images.
+ Defaults to 0.2.
+ mask_dir (Optional[Union[str, Path]], optional): Path to the directory containing
+ the mask annotations. Defaults to None.
+ extensions (Optional[Tuple[str, ...]], optional): Type of the image extensions to read from the
+ directory.
+ task (Optional[str], optional): Task type. (classification or segmentation) Defaults to None.
+ seed (int, optional): Random seed to ensure reproducibility when splitting. Defaults to 0.
+ create_validation_set (bool, optional):Boolean to create a validation set from the test set.
+ Those wanting to create a validation set could set this flag to ``True``.
+
+ Raises:
+ ValueError: When task is set to classification and `mask_dir` is provided. When `mask_dir` is
+ provided, `task` should be set to `segmentation`.
+
+ """
+ self.split = split
+
+ if task == "segmentation" and mask_dir is None:
+ warnings.warn(
+ "Segmentation task is requested, but mask directory is not provided. "
+ "Classification is to be chosen if mask directory is not provided."
+ )
+ self.task = "classification"
+
+ if task == "classification" and mask_dir:
+ warnings.warn(
+ "Classification task is requested, but mask directory is provided. "
+ "Segmentation task is to be chosen if mask directory is provided."
+ )
+ self.task = "segmentation"
+
+ if task is None or mask_dir is None:
+ self.task = "classification"
+ else:
+ self.task = task
+
+ self.pre_process = pre_process
+ self.samples = make_dataset(
+ normal_dir=normal_dir,
+ abnormal_dir=abnormal_dir,
+ normal_test_dir=normal_test_dir,
+ mask_dir=mask_dir,
+ split=split,
+ split_ratio=split_ratio,
+ seed=seed,
+ create_validation_set=create_validation_set,
+ extensions=extensions,
+ )
+
+ def __len__(self) -> int:
+ """Get length of the dataset."""
+ return len(self.samples)
+
+ def __getitem__(self, index: int) -> Dict[str, Union[str, Tensor]]:
+ """Get dataset item for the index ``index``.
+
+ Args:
+ index (int): Index to get the item.
+
+ Returns:
+ Union[Dict[str, Tensor], Dict[str, Union[str, Tensor]]]: Dict of image tensor during training.
+ Otherwise, Dict containing image path, target path, image tensor, label and transformed bounding box.
+ """
+ item: Dict[str, Union[str, Tensor]] = {}
+
+ image_path = self.samples.image_path[index]
+ image = read_image(image_path)
+
+ pre_processed = self.pre_process(image=image)
+ item = {"image": pre_processed["image"]}
+
+ if self.split in ["val", "test"]:
+ label_index = self.samples.label_index[index]
+
+ item["image_path"] = image_path
+ item["label"] = label_index
+
+ if self.task == "segmentation":
+ mask_path = self.samples.mask_path[index]
+
+ # Only Anomalous (1) images has masks in MVTec AD dataset.
+ # Therefore, create empty mask for Normal (0) images.
+ if label_index == 0:
+ mask = np.zeros(shape=image.shape[:2])
+ else:
+ mask = cv2.imread(mask_path, flags=0) / 255.0
+
+ pre_processed = self.pre_process(image=image, mask=mask)
+
+ item["mask_path"] = mask_path
+ item["image"] = pre_processed["image"]
+ item["mask"] = pre_processed["mask"]
+
+ return item
+
+
+class FolderDataModule(LightningDataModule):
+ """Folder Lightning Data Module."""
+
+ def __init__(
+ self,
+ root: Union[str, Path],
+ normal_dir: str = "normal",
+ abnormal_dir: str = "abnormal",
+ task: str = "classification",
+ normal_test_dir: Optional[Union[Path, str]] = None,
+ mask_dir: Optional[Union[Path, str]] = None,
+ extensions: Optional[Tuple[str, ...]] = None,
+ split_ratio: float = 0.2,
+ seed: int = 0,
+ image_size: Optional[Union[int, Tuple[int, int]]] = None,
+ train_batch_size: int = 32,
+ test_batch_size: int = 32,
+ num_workers: int = 8,
+ transform_config_train: Optional[Union[str, A.Compose]] = None,
+ transform_config_val: Optional[Union[str, A.Compose]] = None,
+ create_validation_set: bool = False,
+ ) -> None:
+ """Folder Dataset PL Datamodule.
+
+ Args:
+ root (Union[str, Path]): Path to the root folder containing normal and abnormal dirs.
+ normal_dir (str, optional): Name of the directory containing normal images.
+ Defaults to "normal".
+ abnormal_dir (str, optional): Name of the directory containing abnormal images.
+ Defaults to "abnormal".
+ task (str, optional): Task type. Could be either classification or segmentation.
+ Defaults to "classification".
+ normal_test_dir (Optional[Union[str, Path]], optional): Path to the directory containing
+ normal images for the test dataset. Defaults to None.
+ mask_dir (Optional[Union[str, Path]], optional): Path to the directory containing
+ the mask annotations. Defaults to None.
+ extensions (Optional[Tuple[str, ...]], optional): Type of the image extensions to read from the
+ directory. Defaults to None.
+ split_ratio (float, optional): Ratio to split normal training images and add to the
+ test set in case test set doesn't contain any normal images.
+ Defaults to 0.2.
+ seed (int, optional): Random seed to ensure reproducibility when splitting. Defaults to 0.
+ image_size (Optional[Union[int, Tuple[int, int]]], optional): Size of the input image.
+ Defaults to None.
+ train_batch_size (int, optional): Training batch size. Defaults to 32.
+ test_batch_size (int, optional): Test batch size. Defaults to 32.
+ num_workers (int, optional): Number of workers. Defaults to 8.
+ transform_config_train (Optional[Union[str, A.Compose]], optional): Config for pre-processing
+ during training.
+ Defaults to None.
+ transform_config_val (Optional[Union[str, A.Compose]], optional): Config for pre-processing
+ during validation.
+ Defaults to None.
+ create_validation_set (bool, optional):Boolean to create a validation set from the test set.
+ Those wanting to create a validation set could set this flag to ``True``.
+
+ Examples:
+ Assume that we use Folder Dataset for the MVTec/bottle/broken_large category. We would do:
+ >>> from anomalib.data import FolderDataModule
+ >>> datamodule = FolderDataModule(
+ ... root="./datasets/MVTec/bottle/test",
+ ... normal="good",
+ ... abnormal="broken_large",
+ ... image_size=256
+ ... )
+ >>> datamodule.setup()
+ >>> i, data = next(enumerate(datamodule.train_dataloader()))
+ >>> data["image"].shape
+ torch.Size([16, 3, 256, 256])
+
+ >>> i, test_data = next(enumerate(datamodule.test_dataloader()))
+ >>> test_data.keys()
+ dict_keys(['image'])
+
+ We could also create a Folder DataModule for datasets containing mask annotations.
+ The dataset expects that mask annotation filenames must be same as the original filename.
+ To this end, we modified mask filenames in MVTec AD bottle category.
+ Now we could try folder data module using the mvtec bottle broken large category
+ >>> datamodule = FolderDataModule(
+ ... root="./datasets/bottle/test",
+ ... normal="good",
+ ... abnormal="broken_large",
+ ... mask_dir="./datasets/bottle/ground_truth/broken_large",
+ ... image_size=256
+ ... )
+
+ >>> i , train_data = next(enumerate(datamodule.train_dataloader()))
+ >>> train_data.keys()
+ dict_keys(['image'])
+ >>> train_data["image"].shape
+ torch.Size([16, 3, 256, 256])
+
+ >>> i, test_data = next(enumerate(datamodule.test_dataloader()))
+ dict_keys(['image_path', 'label', 'mask_path', 'image', 'mask'])
+ >>> print(test_data["image"].shape, test_data["mask"].shape)
+ torch.Size([24, 3, 256, 256]) torch.Size([24, 256, 256])
+
+ By default, Folder Data Module does not create a validation set. If a validation set
+ is needed it could be set as follows:
+
+ >>> datamodule = FolderDataModule(
+ ... root="./datasets/bottle/test",
+ ... normal="good",
+ ... abnormal="broken_large",
+ ... mask_dir="./datasets/bottle/ground_truth/broken_large",
+ ... image_size=256,
+ ... create_validation_set=True,
+ ... )
+
+ >>> i, val_data = next(enumerate(datamodule.val_dataloader()))
+ >>> val_data.keys()
+ dict_keys(['image_path', 'label', 'mask_path', 'image', 'mask'])
+ >>> print(val_data["image"].shape, val_data["mask"].shape)
+ torch.Size([12, 3, 256, 256]) torch.Size([12, 256, 256])
+
+ >>> i, test_data = next(enumerate(datamodule.test_dataloader()))
+ >>> print(test_data["image"].shape, test_data["mask"].shape)
+ torch.Size([12, 3, 256, 256]) torch.Size([12, 256, 256])
+
+ """
+ super().__init__()
+
+ self.root = _check_and_convert_path(root)
+ self.normal_dir = self.root / normal_dir
+ self.abnormal_dir = self.root / abnormal_dir
+ self.normal_test = normal_test_dir
+ if normal_test_dir:
+ self.normal_test = self.root / normal_test_dir
+ self.mask_dir = mask_dir
+ self.extensions = extensions
+ self.split_ratio = split_ratio
+
+ if task == "classification" and mask_dir is not None:
+ raise ValueError(
+ "Classification type is set but mask_dir provided. "
+ "If mask_dir is provided task type must be segmentation. "
+ "Check your configuration."
+ )
+ self.task = task
+ self.transform_config_train = transform_config_train
+ self.transform_config_val = transform_config_val
+ self.image_size = image_size
+
+ if self.transform_config_train is not None and self.transform_config_val is None:
+ self.transform_config_val = self.transform_config_train
+
+ self.pre_process_train = PreProcessor(config=self.transform_config_train, image_size=self.image_size)
+ self.pre_process_val = PreProcessor(config=self.transform_config_val, image_size=self.image_size)
+
+ self.train_batch_size = train_batch_size
+ self.test_batch_size = test_batch_size
+ self.num_workers = num_workers
+
+ self.create_validation_set = create_validation_set
+ self.seed = seed
+
+ self.train_data: Dataset
+ self.test_data: Dataset
+ if create_validation_set:
+ self.val_data: Dataset
+ self.inference_data: Dataset
+
+ def setup(self, stage: Optional[str] = None) -> None:
+ """Setup train, validation and test data.
+
+ Args:
+ stage: Optional[str]: Train/Val/Test stages. (Default value = None)
+
+ """
+ logger.info("Setting up train, validation, test and prediction datasets.")
+ if stage in (None, "fit"):
+ self.train_data = FolderDataset(
+ normal_dir=self.normal_dir,
+ abnormal_dir=self.abnormal_dir,
+ normal_test_dir=self.normal_test,
+ split="train",
+ split_ratio=self.split_ratio,
+ mask_dir=self.mask_dir,
+ pre_process=self.pre_process_train,
+ extensions=self.extensions,
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ if self.create_validation_set:
+ self.val_data = FolderDataset(
+ normal_dir=self.normal_dir,
+ abnormal_dir=self.abnormal_dir,
+ normal_test_dir=self.normal_test,
+ split="val",
+ split_ratio=self.split_ratio,
+ mask_dir=self.mask_dir,
+ pre_process=self.pre_process_val,
+ extensions=self.extensions,
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ self.test_data = FolderDataset(
+ normal_dir=self.normal_dir,
+ abnormal_dir=self.abnormal_dir,
+ split="test",
+ normal_test_dir=self.normal_test,
+ split_ratio=self.split_ratio,
+ mask_dir=self.mask_dir,
+ pre_process=self.pre_process_val,
+ extensions=self.extensions,
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ if stage == "predict":
+ self.inference_data = InferenceDataset(
+ path=self.root, image_size=self.image_size, transform_config=self.transform_config_val
+ )
+
+ def train_dataloader(self) -> TRAIN_DATALOADERS:
+ """Get train dataloader."""
+ return DataLoader(self.train_data, shuffle=True, batch_size=self.train_batch_size, num_workers=self.num_workers)
+
+ def val_dataloader(self) -> EVAL_DATALOADERS:
+ """Get validation dataloader."""
+ dataset = self.val_data if self.create_validation_set else self.test_data
+ return DataLoader(dataset=dataset, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers)
+
+ def test_dataloader(self) -> EVAL_DATALOADERS:
+ """Get test dataloader."""
+ return DataLoader(self.test_data, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers)
+
+ def predict_dataloader(self) -> EVAL_DATALOADERS:
+ """Get predict dataloader."""
+ return DataLoader(
+ self.inference_data, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers
+ )
diff --git a/anomalib/data/inference.py b/anomalib/data/inference.py
new file mode 100644
index 0000000000000000000000000000000000000000..b775a17fffab4aa2a5202089c81007d20167b824
--- /dev/null
+++ b/anomalib/data/inference.py
@@ -0,0 +1,67 @@
+"""Inference Dataset."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from pathlib import Path
+from typing import Any, Optional, Tuple, Union
+
+import albumentations as A
+from torch.utils.data.dataset import Dataset
+
+from anomalib.data.utils import get_image_filenames, read_image
+from anomalib.pre_processing import PreProcessor
+
+
+class InferenceDataset(Dataset):
+ """Inference Dataset to perform prediction."""
+
+ def __init__(
+ self,
+ path: Union[str, Path],
+ pre_process: Optional[PreProcessor] = None,
+ image_size: Optional[Union[int, Tuple[int, int]]] = None,
+ transform_config: Optional[Union[str, A.Compose]] = None,
+ ) -> None:
+ """Inference Dataset to perform prediction.
+
+ Args:
+ path (Union[str, Path]): Path to an image or image-folder.
+ pre_process (Optional[PreProcessor], optional): Pre-Processing transforms to
+ pre-process the input dataset. Defaults to None.
+ image_size (Optional[Union[int, Tuple[int, int]]], optional): Target image size
+ to resize the original image. Defaults to None.
+ transform_config (Optional[Union[str, A.Compose]], optional): Configuration file
+ parse the albumentation transforms. Defaults to None.
+ """
+ super().__init__()
+
+ self.image_filenames = get_image_filenames(path)
+
+ if pre_process is None:
+ self.pre_process = PreProcessor(transform_config, image_size)
+ else:
+ self.pre_process = pre_process
+
+ def __len__(self) -> int:
+ """Get the number of images in the given path."""
+ return len(self.image_filenames)
+
+ def __getitem__(self, index: int) -> Any:
+ """Get the image based on the `index`."""
+ image_filename = self.image_filenames[index]
+ image = read_image(path=image_filename)
+ pre_processed = self.pre_process(image=image)
+
+ return pre_processed
diff --git a/anomalib/data/mvtec.py b/anomalib/data/mvtec.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ecb4419b76d7cdbf57d311604fab6d57193211b
--- /dev/null
+++ b/anomalib/data/mvtec.py
@@ -0,0 +1,457 @@
+"""MVTec AD Dataset (CC BY-NC-SA 4.0).
+
+Description:
+ This script contains PyTorch Dataset, Dataloader and PyTorch
+ Lightning DataModule for the MVTec AD dataset.
+
+ If the dataset is not on the file system, the script downloads and
+ extracts the dataset and create PyTorch data objects.
+
+License:
+ MVTec AD dataset is released under the Creative Commons
+ Attribution-NonCommercial-ShareAlike 4.0 International License
+ (CC BY-NC-SA 4.0)(https://creativecommons.org/licenses/by-nc-sa/4.0/).
+
+Reference:
+ - Paul Bergmann, Kilian Batzner, Michael Fauser, David Sattlegger, Carsten Steger:
+ The MVTec Anomaly Detection Dataset: A Comprehensive Real-World Dataset for
+ Unsupervised Anomaly Detection; in: International Journal of Computer Vision
+ 129(4):1038-1059, 2021, DOI: 10.1007/s11263-020-01400-4.
+
+ - Paul Bergmann, Michael Fauser, David Sattlegger, Carsten Steger: MVTec AD —
+ A Comprehensive Real-World Dataset for Unsupervised Anomaly Detection;
+ in: IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR),
+ 9584-9592, 2019, DOI: 10.1109/CVPR.2019.00982.
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import tarfile
+from pathlib import Path
+from typing import Dict, Optional, Tuple, Union
+from urllib.request import urlretrieve
+
+import albumentations as A
+import cv2
+import numpy as np
+import pandas as pd
+from pandas.core.frame import DataFrame
+from pytorch_lightning.core.datamodule import LightningDataModule
+from pytorch_lightning.utilities.types import EVAL_DATALOADERS, TRAIN_DATALOADERS
+from torch import Tensor
+from torch.utils.data import DataLoader
+from torch.utils.data.dataset import Dataset
+from torchvision.datasets.folder import VisionDataset
+
+from anomalib.data.inference import InferenceDataset
+from anomalib.data.utils import DownloadProgressBar, read_image
+from anomalib.data.utils.split import (
+ create_validation_set_from_test_set,
+ split_normal_images_in_train_set,
+)
+from anomalib.pre_processing import PreProcessor
+
+logger = logging.getLogger(__name__)
+
+
+def make_mvtec_dataset(
+ path: Path,
+ split: Optional[str] = None,
+ split_ratio: float = 0.1,
+ seed: int = 0,
+ create_validation_set: bool = False,
+) -> DataFrame:
+ """Create MVTec AD samples by parsing the MVTec AD data file structure.
+
+ The files are expected to follow the structure:
+ path/to/dataset/split/category/image_filename.png
+ path/to/dataset/ground_truth/category/mask_filename.png
+
+ This function creates a dataframe to store the parsed information based on the following format:
+ |---|---------------|-------|---------|---------------|---------------------------------------|-------------|
+ | | path | split | label | image_path | mask_path | label_index |
+ |---|---------------|-------|---------|---------------|---------------------------------------|-------------|
+ | 0 | datasets/name | test | defect | filename.png | ground_truth/defect/filename_mask.png | 1 |
+ |---|---------------|-------|---------|---------------|---------------------------------------|-------------|
+
+ Args:
+ path (Path): Path to dataset
+ split (str, optional): Dataset split (ie., either train or test). Defaults to None.
+ split_ratio (float, optional): Ratio to split normal training images and add to the
+ test set in case test set doesn't contain any normal images.
+ Defaults to 0.1.
+ seed (int, optional): Random seed to ensure reproducibility when splitting. Defaults to 0.
+ create_validation_set (bool, optional): Boolean to create a validation set from the test set.
+ MVTec AD dataset does not contain a validation set. Those wanting to create a validation set
+ could set this flag to ``True``.
+
+ Example:
+ The following example shows how to get training samples from MVTec AD bottle category:
+
+ >>> root = Path('./MVTec')
+ >>> category = 'bottle'
+ >>> path = root / category
+ >>> path
+ PosixPath('MVTec/bottle')
+
+ >>> samples = make_mvtec_dataset(path, split='train', split_ratio=0.1, seed=0)
+ >>> samples.head()
+ path split label image_path mask_path label_index
+ 0 MVTec/bottle train good MVTec/bottle/train/good/105.png MVTec/bottle/ground_truth/good/105_mask.png 0
+ 1 MVTec/bottle train good MVTec/bottle/train/good/017.png MVTec/bottle/ground_truth/good/017_mask.png 0
+ 2 MVTec/bottle train good MVTec/bottle/train/good/137.png MVTec/bottle/ground_truth/good/137_mask.png 0
+ 3 MVTec/bottle train good MVTec/bottle/train/good/152.png MVTec/bottle/ground_truth/good/152_mask.png 0
+ 4 MVTec/bottle train good MVTec/bottle/train/good/109.png MVTec/bottle/ground_truth/good/109_mask.png 0
+
+ Returns:
+ DataFrame: an output dataframe containing samples for the requested split (ie., train or test)
+ """
+ samples_list = [(str(path),) + filename.parts[-3:] for filename in path.glob("**/*.png")]
+ if len(samples_list) == 0:
+ raise RuntimeError(f"Found 0 images in {path}")
+
+ samples = pd.DataFrame(samples_list, columns=["path", "split", "label", "image_path"])
+ samples = samples[samples.split != "ground_truth"]
+
+ # Create mask_path column
+ samples["mask_path"] = (
+ samples.path
+ + "/ground_truth/"
+ + samples.label
+ + "/"
+ + samples.image_path.str.rstrip("png").str.rstrip(".")
+ + "_mask.png"
+ )
+
+ # Modify image_path column by converting to absolute path
+ samples["image_path"] = samples.path + "/" + samples.split + "/" + samples.label + "/" + samples.image_path
+
+ # Split the normal images in training set if test set doesn't
+ # contain any normal images. This is needed because AUC score
+ # cannot be computed based on 1-class
+ if sum((samples.split == "test") & (samples.label == "good")) == 0:
+ samples = split_normal_images_in_train_set(samples, split_ratio, seed)
+
+ # Good images don't have mask
+ samples.loc[(samples.split == "test") & (samples.label == "good"), "mask_path"] = ""
+
+ # Create label index for normal (0) and anomalous (1) images.
+ samples.loc[(samples.label == "good"), "label_index"] = 0
+ samples.loc[(samples.label != "good"), "label_index"] = 1
+ samples.label_index = samples.label_index.astype(int)
+
+ if create_validation_set:
+ samples = create_validation_set_from_test_set(samples, seed=seed)
+
+ # Get the data frame for the split.
+ if split is not None and split in ["train", "val", "test"]:
+ samples = samples[samples.split == split]
+ samples = samples.reset_index(drop=True)
+
+ return samples
+
+
+class MVTec(VisionDataset):
+ """MVTec AD PyTorch Dataset."""
+
+ def __init__(
+ self,
+ root: Union[Path, str],
+ category: str,
+ pre_process: PreProcessor,
+ split: str,
+ task: str = "segmentation",
+ seed: int = 0,
+ create_validation_set: bool = False,
+ ) -> None:
+ """Mvtec AD Dataset class.
+
+ Args:
+ root: Path to the MVTec AD dataset
+ category: Name of the MVTec AD category.
+ pre_process: List of pre_processing object containing albumentation compose.
+ split: 'train', 'val' or 'test'
+ task: ``classification`` or ``segmentation``
+ seed: seed used for the random subset splitting
+ create_validation_set: Create a validation subset in addition to the train and test subsets
+
+ Examples:
+ >>> from anomalib.data.mvtec import MVTec
+ >>> from anomalib.data.transforms import PreProcessor
+ >>> pre_process = PreProcessor(image_size=256)
+ >>> dataset = MVTec(
+ ... root='./datasets/MVTec',
+ ... category='leather',
+ ... pre_process=pre_process,
+ ... task="classification",
+ ... is_train=True,
+ ... )
+ >>> dataset[0].keys()
+ dict_keys(['image'])
+
+ >>> dataset.split = "test"
+ >>> dataset[0].keys()
+ dict_keys(['image', 'image_path', 'label'])
+
+ >>> dataset.task = "segmentation"
+ >>> dataset.split = "train"
+ >>> dataset[0].keys()
+ dict_keys(['image'])
+
+ >>> dataset.split = "test"
+ >>> dataset[0].keys()
+ dict_keys(['image_path', 'label', 'mask_path', 'image', 'mask'])
+
+ >>> dataset[0]["image"].shape, dataset[0]["mask"].shape
+ (torch.Size([3, 256, 256]), torch.Size([256, 256]))
+ """
+ super().__init__(root)
+ self.root = Path(root) if isinstance(root, str) else root
+ self.category: str = category
+ self.split = split
+ self.task = task
+
+ self.pre_process = pre_process
+
+ self.samples = make_mvtec_dataset(
+ path=self.root / category,
+ split=self.split,
+ seed=seed,
+ create_validation_set=create_validation_set,
+ )
+
+ def __len__(self) -> int:
+ """Get length of the dataset."""
+ return len(self.samples)
+
+ def __getitem__(self, index: int) -> Dict[str, Union[str, Tensor]]:
+ """Get dataset item for the index ``index``.
+
+ Args:
+ index (int): Index to get the item.
+
+ Returns:
+ Union[Dict[str, Tensor], Dict[str, Union[str, Tensor]]]: Dict of image tensor during training.
+ Otherwise, Dict containing image path, target path, image tensor, label and transformed bounding box.
+ """
+ item: Dict[str, Union[str, Tensor]] = {}
+
+ image_path = self.samples.image_path[index]
+ image = read_image(image_path)
+
+ pre_processed = self.pre_process(image=image)
+ item = {"image": pre_processed["image"]}
+
+ if self.split in ["val", "test"]:
+ label_index = self.samples.label_index[index]
+
+ item["image_path"] = image_path
+ item["label"] = label_index
+
+ if self.task == "segmentation":
+ mask_path = self.samples.mask_path[index]
+
+ # Only Anomalous (1) images has masks in MVTec AD dataset.
+ # Therefore, create empty mask for Normal (0) images.
+ if label_index == 0:
+ mask = np.zeros(shape=image.shape[:2])
+ else:
+ mask = cv2.imread(mask_path, flags=0) / 255.0
+
+ pre_processed = self.pre_process(image=image, mask=mask)
+
+ item["mask_path"] = mask_path
+ item["image"] = pre_processed["image"]
+ item["mask"] = pre_processed["mask"]
+
+ return item
+
+
+class MVTecDataModule(LightningDataModule):
+ """MVTec AD Lightning Data Module."""
+
+ def __init__(
+ self,
+ root: str,
+ category: str,
+ # TODO: Remove default values. IAAALD-211
+ image_size: Optional[Union[int, Tuple[int, int]]] = None,
+ train_batch_size: int = 32,
+ test_batch_size: int = 32,
+ num_workers: int = 8,
+ task: str = "segmentation",
+ transform_config_train: Optional[Union[str, A.Compose]] = None,
+ transform_config_val: Optional[Union[str, A.Compose]] = None,
+ seed: int = 0,
+ create_validation_set: bool = False,
+ ) -> None:
+ """Mvtec AD Lightning Data Module.
+
+ Args:
+ root: Path to the MVTec AD dataset
+ category: Name of the MVTec AD category.
+ image_size: Variable to which image is resized.
+ train_batch_size: Training batch size.
+ test_batch_size: Testing batch size.
+ num_workers: Number of workers.
+ task: ``classification`` or ``segmentation``
+ transform_config_train: Config for pre-processing during training.
+ transform_config_val: Config for pre-processing during validation.
+ seed: seed used for the random subset splitting
+ create_validation_set: Create a validation subset in addition to the train and test subsets
+
+ Examples
+ >>> from anomalib.data import MVTecDataModule
+ >>> datamodule = MVTecDataModule(
+ ... root="./datasets/MVTec",
+ ... category="leather",
+ ... image_size=256,
+ ... train_batch_size=32,
+ ... test_batch_size=32,
+ ... num_workers=8,
+ ... transform_config_train=None,
+ ... transform_config_val=None,
+ ... )
+ >>> datamodule.setup()
+
+ >>> i, data = next(enumerate(datamodule.train_dataloader()))
+ >>> data.keys()
+ dict_keys(['image'])
+ >>> data["image"].shape
+ torch.Size([32, 3, 256, 256])
+
+ >>> i, data = next(enumerate(datamodule.val_dataloader()))
+ >>> data.keys()
+ dict_keys(['image_path', 'label', 'mask_path', 'image', 'mask'])
+ >>> data["image"].shape, data["mask"].shape
+ (torch.Size([32, 3, 256, 256]), torch.Size([32, 256, 256]))
+ """
+ super().__init__()
+
+ self.root = root if isinstance(root, Path) else Path(root)
+ self.category = category
+ self.dataset_path = self.root / self.category
+ self.transform_config_train = transform_config_train
+ self.transform_config_val = transform_config_val
+ self.image_size = image_size
+
+ if self.transform_config_train is not None and self.transform_config_val is None:
+ self.transform_config_val = self.transform_config_train
+
+ self.pre_process_train = PreProcessor(config=self.transform_config_train, image_size=self.image_size)
+ self.pre_process_val = PreProcessor(config=self.transform_config_val, image_size=self.image_size)
+
+ self.train_batch_size = train_batch_size
+ self.test_batch_size = test_batch_size
+ self.num_workers = num_workers
+
+ self.create_validation_set = create_validation_set
+ self.task = task
+ self.seed = seed
+
+ self.train_data: Dataset
+ self.test_data: Dataset
+ if create_validation_set:
+ self.val_data: Dataset
+ self.inference_data: Dataset
+
+ def prepare_data(self) -> None:
+ """Download the dataset if not available."""
+ if (self.root / self.category).is_dir():
+ logger.info("Found the dataset.")
+ else:
+ self.root.mkdir(parents=True, exist_ok=True)
+
+ logger.info("Downloading the Mvtec AD dataset.")
+ url = "https://www.mydrive.ch/shares/38536/3830184030e49fe74747669442f0f282/download/420938113-1629952094"
+ dataset_name = "mvtec_anomaly_detection.tar.xz"
+ with DownloadProgressBar(unit="B", unit_scale=True, miniters=1, desc="MVTec AD") as progress_bar:
+ urlretrieve(
+ url=f"{url}/{dataset_name}",
+ filename=self.root / dataset_name,
+ reporthook=progress_bar.update_to,
+ )
+
+ logger.info("Extracting the dataset.")
+ with tarfile.open(self.root / dataset_name) as tar_file:
+ tar_file.extractall(self.root)
+
+ logger.info("Cleaning the tar file")
+ (self.root / dataset_name).unlink()
+
+ def setup(self, stage: Optional[str] = None) -> None:
+ """Setup train, validation and test data.
+
+ Args:
+ stage: Optional[str]: Train/Val/Test stages. (Default value = None)
+
+ """
+ logger.info("Setting up train, validation, test and prediction datasets.")
+ if stage in (None, "fit"):
+ self.train_data = MVTec(
+ root=self.root,
+ category=self.category,
+ pre_process=self.pre_process_train,
+ split="train",
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ if self.create_validation_set:
+ self.val_data = MVTec(
+ root=self.root,
+ category=self.category,
+ pre_process=self.pre_process_val,
+ split="val",
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ self.test_data = MVTec(
+ root=self.root,
+ category=self.category,
+ pre_process=self.pre_process_val,
+ split="test",
+ task=self.task,
+ seed=self.seed,
+ create_validation_set=self.create_validation_set,
+ )
+
+ if stage == "predict":
+ self.inference_data = InferenceDataset(
+ path=self.root, image_size=self.image_size, transform_config=self.transform_config_val
+ )
+
+ def train_dataloader(self) -> TRAIN_DATALOADERS:
+ """Get train dataloader."""
+ return DataLoader(self.train_data, shuffle=True, batch_size=self.train_batch_size, num_workers=self.num_workers)
+
+ def val_dataloader(self) -> EVAL_DATALOADERS:
+ """Get validation dataloader."""
+ dataset = self.val_data if self.create_validation_set else self.test_data
+ return DataLoader(dataset=dataset, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers)
+
+ def test_dataloader(self) -> EVAL_DATALOADERS:
+ """Get test dataloader."""
+ return DataLoader(self.test_data, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers)
+
+ def predict_dataloader(self) -> EVAL_DATALOADERS:
+ """Get predict dataloader."""
+ return DataLoader(
+ self.inference_data, shuffle=False, batch_size=self.test_batch_size, num_workers=self.num_workers
+ )
diff --git a/anomalib/data/utils/__init__.py b/anomalib/data/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4930580515389c4498139f0d8b3b785ddeadce9
--- /dev/null
+++ b/anomalib/data/utils/__init__.py
@@ -0,0 +1,20 @@
+"""Helper utilities for data."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .download import DownloadProgressBar
+from .image import get_image_filenames, read_image
+
+__all__ = ["get_image_filenames", "read_image", "DownloadProgressBar"]
diff --git a/anomalib/data/utils/download.py b/anomalib/data/utils/download.py
new file mode 100644
index 0000000000000000000000000000000000000000..26af24834a2a2b2429bd39925227ce5b090bcb04
--- /dev/null
+++ b/anomalib/data/utils/download.py
@@ -0,0 +1,195 @@
+"""Helper to show progress bars with `urlretrieve`.
+
+Based on https://stackoverflow.com/a/53877507
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import io
+from typing import Dict, Iterable, Optional, Union
+
+from tqdm import tqdm
+
+
+class DownloadProgressBar(tqdm):
+ """Create progress bar for urlretrieve. Subclasses `tqdm`.
+
+ For information about the parameters in constructor, refer to `tqdm`'s documentation.
+
+ Args:
+ iterable (Optional[Iterable]): Iterable to decorate with a progressbar.
+ Leave blank to manually manage the updates.
+ desc (Optional[str]): Prefix for the progressbar.
+ total (Optional[Union[int, float]]): The number of expected iterations. If unspecified,
+ len(iterable) is used if possible. If float("inf") or as a last
+ resort, only basic progress statistics are displayed
+ (no ETA, no progressbar).
+ If `gui` is True and this parameter needs subsequent updating,
+ specify an initial arbitrary large positive number,
+ e.g. 9e9.
+ leave (Optional[bool]): upon termination of iteration. If `None`, will leave only if `position` is `0`.
+ file (Optional[Union[io.TextIOWrapper, io.StringIO]]): Specifies where to output the progress messages
+ (default: sys.stderr). Uses `file.write(str)` and
+ `file.flush()` methods. For encoding, see
+ `write_bytes`.
+ ncols (Optional[int]): The width of the entire output message. If specified,
+ dynamically resizes the progressbar to stay within this bound.
+ If unspecified, attempts to use environment width. The
+ fallback is a meter width of 10 and no limit for the counter and
+ statistics. If 0, will not print any meter (only stats).
+ mininterval (Optional[float]): Minimum progress display update interval [default: 0.1] seconds.
+ maxinterval (Optional[float]): Maximum progress display update interval [default: 10] seconds.
+ Automatically adjusts `miniters` to correspond to `mininterval`
+ after long display update lag. Only works if `dynamic_miniters`
+ or monitor thread is enabled.
+ miniters (Optional[Union[int, float]]): Minimum progress display update interval, in iterations.
+ If 0 and `dynamic_miniters`, will automatically adjust to equal
+ `mininterval` (more CPU efficient, good for tight loops).
+ If > 0, will skip display of specified number of iterations.
+ Tweak this and `mininterval` to get very efficient loops.
+ If your progress is erratic with both fast and slow iterations
+ (network, skipping items, etc) you should set miniters=1.
+ use_ascii (Optional[Union[bool, str]]): If unspecified or False, use unicode (smooth blocks) to fill
+ the meter. The fallback is to use ASCII characters " 123456789#".
+ disable (Optional[bool]): Whether to disable the entire progressbar wrapper
+ [default: False]. If set to None, disable on non-TTY.
+ unit (Optional[str]): String that will be used to define the unit of each iteration
+ [default: it].
+ unit_scale (Union[bool, int, float]): If 1 or True, the number of iterations will be reduced/scaled
+ automatically and a metric prefix following the
+ International System of Units standard will be added
+ (kilo, mega, etc.) [default: False]. If any other non-zero
+ number, will scale `total` and `n`.
+ dynamic_ncols (Optional[bool]): If set, constantly alters `ncols` and `nrows` to the
+ environment (allowing for window resizes) [default: False].
+ smoothing (Optional[float]): Exponential moving average smoothing factor for speed estimates
+ (ignored in GUI mode). Ranges from 0 (average speed) to 1
+ (current/instantaneous speed) [default: 0.3].
+ bar_format (Optional[str]): Specify a custom bar string formatting. May impact performance.
+ [default: '{l_bar}{bar}{r_bar}'], where
+ l_bar='{desc}: {percentage:3.0f}%|' and
+ r_bar='| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, '
+ '{rate_fmt}{postfix}]'
+ Possible vars: l_bar, bar, r_bar, n, n_fmt, total, total_fmt,
+ percentage, elapsed, elapsed_s, ncols, nrows, desc, unit,
+ rate, rate_fmt, rate_noinv, rate_noinv_fmt,
+ rate_inv, rate_inv_fmt, postfix, unit_divisor,
+ remaining, remaining_s, eta.
+ Note that a trailing ": " is automatically removed after {desc}
+ if the latter is empty.
+ initial (Optional[Union[int, float]]): The initial counter value. Useful when restarting a progress
+ bar [default: 0]. If using float, consider specifying `{n:.3f}`
+ or similar in `bar_format`, or specifying `unit_scale`.
+ position (Optional[int]): Specify the line offset to print this bar (starting from 0)
+ Automatic if unspecified.
+ Useful to manage multiple bars at once (eg, from threads).
+ postfix (Optional[Dict]): Specify additional stats to display at the end of the bar.
+ Calls `set_postfix(**postfix)` if possible (dict).
+ unit_divisor (Optional[float]): [default: 1000], ignored unless `unit_scale` is True.
+ write_bytes (Optional[bool]): If (default: None) and `file` is unspecified,
+ bytes will be written in Python 2. If `True` will also write
+ bytes. In all other cases will default to unicode.
+ lock_args (Optional[tuple]): Passed to `refresh` for intermediate output
+ (initialisation, iterating, and updating).
+ nrows (Optional[int]): The screen height. If specified, hides nested bars
+ outside this bound. If unspecified, attempts to use environment height.
+ The fallback is 20.
+ colour (Optional[str]): Bar colour (e.g. 'green', '#00ff00').
+ delay (Optional[float]): Don't display until [default: 0] seconds have elapsed.
+ gui (Optional[bool]): WARNING: internal parameter - do not use.
+ Use tqdm.gui.tqdm(...) instead. If set, will attempt to use
+ matplotlib animations for a graphical output [default: False].
+
+
+ Example:
+ >>> with DownloadProgressBar(unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1]) as p_bar:
+ >>> urllib.request.urlretrieve(url, filename=output_path, reporthook=p_bar.update_to)
+ """
+
+ def __init__(
+ self,
+ iterable: Optional[Iterable] = None,
+ desc: Optional[str] = None,
+ total: Optional[Union[int, float]] = None,
+ leave: Optional[bool] = True,
+ file: Optional[Union[io.TextIOWrapper, io.StringIO]] = None,
+ ncols: Optional[int] = None,
+ mininterval: Optional[float] = 0.1,
+ maxinterval: Optional[float] = 10.0,
+ miniters: Optional[Union[int, float]] = None,
+ use_ascii: Optional[Union[bool, str]] = None,
+ disable: Optional[bool] = False,
+ unit: Optional[str] = "it",
+ unit_scale: Optional[Union[bool, int, float]] = False,
+ dynamic_ncols: Optional[bool] = False,
+ smoothing: Optional[float] = 0.3,
+ bar_format: Optional[str] = None,
+ initial: Optional[Union[int, float]] = 0,
+ position: Optional[int] = None,
+ postfix: Optional[Dict] = None,
+ unit_divisor: Optional[float] = 1000,
+ write_bytes: Optional[bool] = None,
+ lock_args: Optional[tuple] = None,
+ nrows: Optional[int] = None,
+ colour: Optional[str] = None,
+ delay: Optional[float] = 0,
+ gui: Optional[bool] = False,
+ **kwargs
+ ):
+ super().__init__(
+ iterable=iterable,
+ desc=desc,
+ total=total,
+ leave=leave,
+ file=file,
+ ncols=ncols,
+ mininterval=mininterval,
+ maxinterval=maxinterval,
+ miniters=miniters,
+ ascii=use_ascii,
+ disable=disable,
+ unit=unit,
+ unit_scale=unit_scale,
+ dynamic_ncols=dynamic_ncols,
+ smoothing=smoothing,
+ bar_format=bar_format,
+ initial=initial,
+ position=position,
+ postfix=postfix,
+ unit_divisor=unit_divisor,
+ write_bytes=write_bytes,
+ lock_args=lock_args,
+ nrows=nrows,
+ colour=colour,
+ delay=delay,
+ gui=gui,
+ **kwargs
+ )
+ self.total: Optional[Union[int, float]]
+
+ def update_to(self, chunk_number: int = 1, max_chunk_size: int = 1, total_size=None):
+ """Progress bar hook for tqdm.
+
+ The implementor does not have to bother about passing parameters to this as it gets them from urlretrieve.
+ However the context needs a few parameters. Refer to the example.
+
+ Args:
+ chunk_number (int, optional): The current chunk being processed. Defaults to 1.
+ max_chunk_size (int, optional): Maximum size of each chunk. Defaults to 1.
+ total_size ([type], optional): Total download size. Defaults to None.
+ """
+ if total_size is not None:
+ self.total = total_size
+ self.update(chunk_number * max_chunk_size - self.n)
diff --git a/anomalib/data/utils/image.py b/anomalib/data/utils/image.py
new file mode 100644
index 0000000000000000000000000000000000000000..c36bd2efbd51df7e6e11d3721eef96601aac214f
--- /dev/null
+++ b/anomalib/data/utils/image.py
@@ -0,0 +1,91 @@
+"""Image Utils."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import math
+from pathlib import Path
+from typing import List, Union
+
+import cv2
+import numpy as np
+import torch.nn.functional as F
+from torch import Tensor
+from torchvision.datasets.folder import IMG_EXTENSIONS
+
+
+def get_image_filenames(path: Union[str, Path]) -> List[str]:
+ """Get image filenames.
+
+ Args:
+ path (Union[str, Path]): Path to image or image-folder.
+
+ Returns:
+ List[str]: List of image filenames
+
+ """
+ image_filenames: List[str]
+
+ if isinstance(path, str):
+ path = Path(path)
+
+ if path.is_file() and path.suffix in IMG_EXTENSIONS:
+ image_filenames = [str(path)]
+
+ if path.is_dir():
+ image_filenames = [str(p) for p in path.glob("**/*") if p.suffix in IMG_EXTENSIONS]
+
+ if len(image_filenames) == 0:
+ raise ValueError(f"Found 0 images in {path}")
+
+ return image_filenames
+
+
+def read_image(path: Union[str, Path]) -> np.ndarray:
+ """Read image from disk in RGB format.
+
+ Args:
+ path (str, Path): path to the image file
+
+ Example:
+ >>> image = read_image("test_image.jpg")
+
+ Returns:
+ image as numpy array
+ """
+ path = path if isinstance(path, str) else str(path)
+ image = cv2.imread(path)
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
+
+ return image
+
+
+def pad_nextpow2(batch: Tensor) -> Tensor:
+ """Compute required padding from input size and return padded images.
+
+ Finds the largest dimension and computes a square image of dimensions that are of the power of 2.
+ In case the image dimension is odd, it returns the image with an extra padding on one side.
+
+ Args:
+ batch (Tensor): Input images
+
+ Returns:
+ batch: Padded batch
+ """
+ # find the largest dimension
+ l_dim = 2 ** math.ceil(math.log(max(*batch.shape[-2:]), 2))
+ padding_w = [math.ceil((l_dim - batch.shape[-2]) / 2), math.floor((l_dim - batch.shape[-2]) / 2)]
+ padding_h = [math.ceil((l_dim - batch.shape[-1]) / 2), math.floor((l_dim - batch.shape[-1]) / 2)]
+ padded_batch = F.pad(batch, pad=[*padding_h, *padding_w])
+ return padded_batch
diff --git a/anomalib/data/utils/split.py b/anomalib/data/utils/split.py
new file mode 100644
index 0000000000000000000000000000000000000000..8c63497a415a451eb381a032c206740ed77aef72
--- /dev/null
+++ b/anomalib/data/utils/split.py
@@ -0,0 +1,94 @@
+"""Dataset Split Utils.
+
+This module contains function in regards to splitting normal images in training set,
+and creating validation sets from test sets.
+
+These function are useful
+ - when the test set does not contain any normal images.
+ - when the dataset doesn't have a validation set.
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import random
+
+from pandas.core.frame import DataFrame
+
+
+def split_normal_images_in_train_set(
+ samples: DataFrame, split_ratio: float = 0.1, seed: int = 0, normal_label: str = "good"
+) -> DataFrame:
+ """Split normal images in train set.
+
+ This function splits the normal images in training set and assigns the
+ values to the test set. This is particularly useful especially when the
+ test set does not contain any normal images.
+
+ This is important because when the test set doesn't have any normal images,
+ AUC computation fails due to having single class.
+
+ Args:
+ samples (DataFrame): Dataframe containing dataset info such as filenames, splits etc.
+ split_ratio (float, optional): Train-Test normal image split ratio. Defaults to 0.1.
+ seed (int, optional): Random seed to ensure reproducibility. Defaults to 0.
+ normal_label (str): Name of the normal label. For MVTec AD, for instance, this is normal_label.
+
+ Returns:
+ DataFrame: Output dataframe where the part of the training set is assigned to test set.
+ """
+
+ if seed > 0:
+ random.seed(seed)
+
+ normal_train_image_indices = samples.index[(samples.split == "train") & (samples.label == normal_label)].to_list()
+ num_normal_train_images = len(normal_train_image_indices)
+ num_normal_valid_images = int(num_normal_train_images * split_ratio)
+
+ indices_to_split_from_train_set = random.sample(population=normal_train_image_indices, k=num_normal_valid_images)
+ samples.loc[indices_to_split_from_train_set, "split"] = "test"
+
+ return samples
+
+
+def create_validation_set_from_test_set(samples: DataFrame, seed: int = 0, normal_label: str = "good") -> DataFrame:
+ """Craete Validation Set from Test Set.
+
+ This function creates a validation set from test set by splitting both
+ normal and abnormal samples to two.
+
+ Args:
+ samples (DataFrame): Dataframe containing dataset info such as filenames, splits etc.
+ seed (int, optional): Random seed to ensure reproducibility. Defaults to 0.
+ normal_label (str): Name of the normal label. For MVTec AD, for instance, this is normal_label.
+ """
+
+ if seed > 0:
+ random.seed(seed)
+
+ # Split normal images.
+ normal_test_image_indices = samples.index[(samples.split == "test") & (samples.label == normal_label)].to_list()
+ num_normal_valid_images = len(normal_test_image_indices) // 2
+
+ indices_to_sample = random.sample(population=normal_test_image_indices, k=num_normal_valid_images)
+ samples.loc[indices_to_sample, "split"] = "val"
+
+ # Split abnormal images.
+ abnormal_test_image_indices = samples.index[(samples.split == "test") & (samples.label != normal_label)].to_list()
+ num_abnormal_valid_images = len(abnormal_test_image_indices) // 2
+
+ indices_to_sample = random.sample(population=abnormal_test_image_indices, k=num_abnormal_valid_images)
+ samples.loc[indices_to_sample, "split"] = "val"
+
+ return samples
diff --git a/anomalib/deploy/__init__.py b/anomalib/deploy/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..27298410e5ae19258ceecf504f43305158d189e6
--- /dev/null
+++ b/anomalib/deploy/__init__.py
@@ -0,0 +1,20 @@
+"""Functions for Inference and model deployment."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .inferencers import OpenVINOInferencer, TorchInferencer
+from .optimize import export_convert, get_model_metadata
+
+__all__ = ["OpenVINOInferencer", "TorchInferencer", "export_convert", "get_model_metadata"]
diff --git a/anomalib/deploy/inferencers/__init__.py b/anomalib/deploy/inferencers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f5391dec8c5f25e9cdd6c9bf168249eb16dc36c
--- /dev/null
+++ b/anomalib/deploy/inferencers/__init__.py
@@ -0,0 +1,21 @@
+"""Inferencers for Torch and OpenVINO."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .base import Inferencer
+from .openvino import OpenVINOInferencer
+from .torch import TorchInferencer
+
+__all__ = ["Inferencer", "TorchInferencer", "OpenVINOInferencer"]
diff --git a/anomalib/deploy/inferencers/base.py b/anomalib/deploy/inferencers/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6604e232652e155f5ac20fb88fd46c2e633176f
--- /dev/null
+++ b/anomalib/deploy/inferencers/base.py
@@ -0,0 +1,204 @@
+"""Base Inferencer for Torch and OpenVINO."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Dict, Optional, Tuple, Union, cast
+
+import cv2
+import numpy as np
+from omegaconf import DictConfig, OmegaConf
+from skimage.morphology import dilation
+from skimage.segmentation import find_boundaries
+from torch import Tensor
+
+from anomalib.data.utils import read_image
+from anomalib.post_processing import compute_mask, superimpose_anomaly_map
+from anomalib.post_processing.normalization.cdf import normalize as normalize_cdf
+from anomalib.post_processing.normalization.cdf import standardize
+from anomalib.post_processing.normalization.min_max import (
+ normalize as normalize_min_max,
+)
+
+
+class Inferencer(ABC):
+ """Abstract class for the inference.
+
+ This is used by both Torch and OpenVINO inference.
+ """
+
+ @abstractmethod
+ def load_model(self, path: Union[str, Path]):
+ """Load Model."""
+ raise NotImplementedError
+
+ @abstractmethod
+ def pre_process(self, image: np.ndarray) -> Union[np.ndarray, Tensor]:
+ """Pre-process."""
+ raise NotImplementedError
+
+ @abstractmethod
+ def forward(self, image: Union[np.ndarray, Tensor]) -> Union[np.ndarray, Tensor]:
+ """Forward-Pass input to model."""
+ raise NotImplementedError
+
+ @abstractmethod
+ def post_process(
+ self, predictions: Union[np.ndarray, Tensor], meta_data: Optional[Dict]
+ ) -> Tuple[np.ndarray, float]:
+ """Post-Process."""
+ raise NotImplementedError
+
+ def predict(
+ self,
+ image: Union[str, np.ndarray, Path],
+ superimpose: bool = True,
+ meta_data: Optional[dict] = None,
+ overlay_mask: bool = False,
+ ) -> Tuple[np.ndarray, float]:
+ """Perform a prediction for a given input image.
+
+ The main workflow is (i) pre-processing, (ii) forward-pass, (iii) post-process.
+
+ Args:
+ image (Union[str, np.ndarray]): Input image whose output is to be predicted.
+ It could be either a path to image or numpy array itself.
+
+ superimpose (bool): If this is set to True, output predictions
+ will be superimposed onto the original image. If false, `predict`
+ method will return the raw heatmap.
+
+ overlay_mask (bool): If this is set to True, output segmentation mask on top of image.
+
+ Returns:
+ np.ndarray: Output predictions to be visualized.
+ """
+ if meta_data is None:
+ if hasattr(self, "meta_data"):
+ meta_data = getattr(self, "meta_data")
+ else:
+ meta_data = {}
+ if isinstance(image, (str, Path)):
+ image_arr: np.ndarray = read_image(image)
+ else: # image is already a numpy array. Kept for mypy compatibility.
+ image_arr = image
+ meta_data["image_shape"] = image_arr.shape[:2]
+
+ processed_image = self.pre_process(image_arr)
+ predictions = self.forward(processed_image)
+ anomaly_map, pred_scores = self.post_process(predictions, meta_data=meta_data)
+
+ # Overlay segmentation mask using raw predictions
+ if overlay_mask and meta_data is not None:
+ image_arr = self._superimpose_segmentation_mask(meta_data, anomaly_map, image_arr)
+
+ if superimpose is True:
+ anomaly_map = superimpose_anomaly_map(anomaly_map, image_arr)
+
+ return anomaly_map, pred_scores
+
+ def _superimpose_segmentation_mask(self, meta_data: dict, anomaly_map: np.ndarray, image: np.ndarray):
+ """Superimpose segmentation mask on top of image.
+
+ Args:
+ meta_data (dict): Metadata of the image which contains the image size.
+ anomaly_map (np.ndarray): Anomaly map which is used to extract segmentation mask.
+ image (np.ndarray): Image on which segmentation mask is to be superimposed.
+
+ Returns:
+ np.ndarray: Image with segmentation mask superimposed.
+ """
+ pred_mask = compute_mask(anomaly_map, 0.5) # assumes predictions are normalized.
+ image_height = meta_data["image_shape"][0]
+ image_width = meta_data["image_shape"][1]
+ pred_mask = cv2.resize(pred_mask, (image_width, image_height))
+ boundaries = find_boundaries(pred_mask)
+ outlines = dilation(boundaries, np.ones((7, 7)))
+ image[outlines] = [255, 0, 0]
+ return image
+
+ def __call__(self, image: np.ndarray) -> Tuple[np.ndarray, float]:
+ """Call predict on the Image.
+
+ Args:
+ image (np.ndarray): Input Image
+
+ Returns:
+ np.ndarray: Output predictions to be visualized
+ """
+ return self.predict(image)
+
+ def _normalize(
+ self,
+ anomaly_maps: Union[Tensor, np.ndarray],
+ pred_scores: Union[Tensor, np.float32],
+ meta_data: Union[Dict, DictConfig],
+ ) -> Tuple[Union[np.ndarray, Tensor], float]:
+ """Applies normalization and resizes the image.
+
+ Args:
+ anomaly_maps (Union[Tensor, np.ndarray]): Predicted raw anomaly map.
+ pred_scores (Union[Tensor, np.float32]): Predicted anomaly score
+ meta_data (Dict): Meta data. Post-processing step sometimes requires
+ additional meta data such as image shape. This variable comprises such info.
+
+ Returns:
+ Tuple[Union[np.ndarray, Tensor], float]: Post processed predictions that are ready to be visualized and
+ predicted scores.
+
+
+ """
+
+ # min max normalization
+ if "min" in meta_data and "max" in meta_data:
+ anomaly_maps = normalize_min_max(
+ anomaly_maps, meta_data["pixel_threshold"], meta_data["min"], meta_data["max"]
+ )
+ pred_scores = normalize_min_max(
+ pred_scores, meta_data["image_threshold"], meta_data["min"], meta_data["max"]
+ )
+
+ # standardize pixel scores
+ if "pixel_mean" in meta_data.keys() and "pixel_std" in meta_data.keys():
+ anomaly_maps = standardize(
+ anomaly_maps, meta_data["pixel_mean"], meta_data["pixel_std"], center_at=meta_data["image_mean"]
+ )
+ anomaly_maps = normalize_cdf(anomaly_maps, meta_data["pixel_threshold"])
+
+ # standardize image scores
+ if "image_mean" in meta_data.keys() and "image_std" in meta_data.keys():
+ pred_scores = standardize(pred_scores, meta_data["image_mean"], meta_data["image_std"])
+ pred_scores = normalize_cdf(pred_scores, meta_data["image_threshold"])
+
+ return anomaly_maps, float(pred_scores)
+
+ def _load_meta_data(
+ self, path: Optional[Union[str, Path]] = None
+ ) -> Union[DictConfig, Dict[str, Union[float, np.ndarray, Tensor]]]:
+ """Loads the meta data from the given path.
+
+ Args:
+ path (Optional[Union[str, Path]], optional): Path to JSON file containing the metadata.
+ If no path is provided, it returns an empty dict. Defaults to None.
+
+ Returns:
+ Union[DictConfig, Dict]: Dictionary containing the metadata.
+ """
+ meta_data: Union[DictConfig, Dict[str, Union[float, np.ndarray, Tensor]]] = {}
+ if path is not None:
+ config = OmegaConf.load(path)
+ meta_data = cast(DictConfig, config)
+ return meta_data
diff --git a/anomalib/deploy/inferencers/openvino.py b/anomalib/deploy/inferencers/openvino.py
new file mode 100644
index 0000000000000000000000000000000000000000..7271eac1e5ca964b91ed716a72a62f4173f1a4d4
--- /dev/null
+++ b/anomalib/deploy/inferencers/openvino.py
@@ -0,0 +1,149 @@
+"""This module contains inference-related abstract class and its Torch and OpenVINO implementations."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from importlib.util import find_spec
+from pathlib import Path
+from typing import Dict, Optional, Tuple, Union
+
+import cv2
+import numpy as np
+from omegaconf import DictConfig, ListConfig
+
+from anomalib.pre_processing import PreProcessor
+
+from .base import Inferencer
+
+if find_spec("openvino") is not None:
+ from openvino.inference_engine import ( # type: ignore # pylint: disable=no-name-in-module
+ IECore,
+ )
+
+
+class OpenVINOInferencer(Inferencer):
+ """OpenVINO implementation for the inference.
+
+ Args:
+ config (DictConfig): Configurable parameters that are used
+ during the training stage.
+ path (Union[str, Path]): Path to the openvino onnx, xml or bin file.
+ meta_data_path (Union[str, Path], optional): Path to metadata file. Defaults to None.
+ """
+
+ def __init__(
+ self,
+ config: Union[DictConfig, ListConfig],
+ path: Union[str, Path, Tuple[bytes, bytes]],
+ meta_data_path: Union[str, Path] = None,
+ ):
+ self.config = config
+ self.input_blob, self.output_blob, self.network = self.load_model(path)
+ self.meta_data = super()._load_meta_data(meta_data_path)
+
+ def load_model(self, path: Union[str, Path, Tuple[bytes, bytes]]):
+ """Load the OpenVINO model.
+
+ Args:
+ path (Union[str, Path, Tuple[bytes, bytes]]): Path to the onnx or xml and bin files
+ or tuple of .xml and .bin data as bytes.
+
+ Returns:
+ [Tuple[str, str, ExecutableNetwork]]: Input and Output blob names
+ together with the Executable network.
+ """
+ ie_core = IECore()
+ # If tuple of bytes is passed
+
+ if isinstance(path, tuple):
+ network = ie_core.read_network(model=path[0], weights=path[1], init_from_buffer=True)
+ else:
+ path = path if isinstance(path, Path) else Path(path)
+ if path.suffix in (".bin", ".xml"):
+ if path.suffix == ".bin":
+ bin_path, xml_path = path, path.with_suffix(".xml")
+ elif path.suffix == ".xml":
+ xml_path, bin_path = path, path.with_suffix(".bin")
+ network = ie_core.read_network(xml_path, bin_path)
+ elif path.suffix == ".onnx":
+ network = ie_core.read_network(path)
+ else:
+ raise ValueError(f"Path must be .onnx, .bin or .xml file. Got {path.suffix}")
+
+ input_blob = next(iter(network.input_info))
+ output_blob = next(iter(network.outputs))
+ executable_network = ie_core.load_network(network=network, device_name="CPU")
+
+ return input_blob, output_blob, executable_network
+
+ def pre_process(self, image: np.ndarray) -> np.ndarray:
+ """Pre process the input image by applying transformations.
+
+ Args:
+ image (np.ndarray): Input image.
+
+ Returns:
+ np.ndarray: pre-processed image.
+ """
+ config = self.config.transform if "transform" in self.config.keys() else None
+ image_size = tuple(self.config.dataset.image_size)
+ pre_processor = PreProcessor(config, image_size)
+ processed_image = pre_processor(image=image)["image"]
+
+ if len(processed_image.shape) == 3:
+ processed_image = np.expand_dims(processed_image, axis=0)
+
+ if processed_image.shape[-1] == 3:
+ processed_image = processed_image.transpose(0, 3, 1, 2)
+
+ return processed_image
+
+ def forward(self, image: np.ndarray) -> np.ndarray:
+ """Forward-Pass input tensor to the model.
+
+ Args:
+ image (np.ndarray): Input tensor.
+
+ Returns:
+ np.ndarray: Output predictions.
+ """
+ return self.network.infer(inputs={self.input_blob: image})
+
+ def post_process(
+ self, predictions: np.ndarray, meta_data: Optional[Union[Dict, DictConfig]] = None
+ ) -> Tuple[np.ndarray, float]:
+ """Post process the output predictions.
+
+ Args:
+ predictions (np.ndarray): Raw output predicted by the model.
+ meta_data (Dict, optional): Meta data. Post-processing step sometimes requires
+ additional meta data such as image shape. This variable comprises such info.
+ Defaults to None.
+
+ Returns:
+ np.ndarray: Post processed predictions that are ready to be visualized.
+ """
+ if meta_data is None:
+ meta_data = self.meta_data
+
+ predictions = predictions[self.output_blob]
+ anomaly_map = predictions.squeeze()
+ pred_score = anomaly_map.reshape(-1).max()
+
+ anomaly_map, pred_score = self._normalize(anomaly_map, pred_score, meta_data)
+
+ if "image_shape" in meta_data and anomaly_map.shape != meta_data["image_shape"]:
+ anomaly_map = cv2.resize(anomaly_map, meta_data["image_shape"])
+
+ return anomaly_map, float(pred_score)
diff --git a/anomalib/deploy/inferencers/torch.py b/anomalib/deploy/inferencers/torch.py
new file mode 100644
index 0000000000000000000000000000000000000000..6223b3809fc95dadd0b2ed5b926f844111d8549d
--- /dev/null
+++ b/anomalib/deploy/inferencers/torch.py
@@ -0,0 +1,164 @@
+"""This module contains Torch inference implementations."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from pathlib import Path
+from typing import Dict, Optional, Tuple, Union
+
+import cv2
+import numpy as np
+import torch
+from omegaconf import DictConfig, ListConfig
+from torch import Tensor
+
+from anomalib.deploy.optimize import get_model_metadata
+from anomalib.models import get_model
+from anomalib.models.components import AnomalyModule
+from anomalib.pre_processing import PreProcessor
+
+from .base import Inferencer
+
+
+class TorchInferencer(Inferencer):
+ """PyTorch implementation for the inference.
+
+ Args:
+ config (DictConfig): Configurable parameters that are used
+ during the training stage.
+ model_source (Union[str, Path, AnomalyModule]): Path to the model ckpt file or the Anomaly model.
+ meta_data_path (Union[str, Path], optional): Path to metadata file. If none, it tries to load the params
+ from the model state_dict. Defaults to None.
+ """
+
+ def __init__(
+ self,
+ config: Union[DictConfig, ListConfig],
+ model_source: Union[str, Path, AnomalyModule],
+ meta_data_path: Union[str, Path] = None,
+ ):
+ self.config = config
+ if isinstance(model_source, AnomalyModule):
+ self.model = model_source
+ else:
+ self.model = self.load_model(model_source)
+
+ self.meta_data = self._load_meta_data(meta_data_path)
+
+ def _load_meta_data(self, path: Optional[Union[str, Path]] = None) -> Union[Dict, DictConfig]:
+ """Load metadata from file or from model state dict.
+
+ Args:
+ path (Optional[Union[str, Path]], optional): Path to metadata file. If none, it tries to load the params
+ from the model state_dict. Defaults to None.
+
+ Returns:
+ Dict: Dictionary containing the meta_data.
+ """
+ meta_data: Union[DictConfig, Dict[str, Union[float, Tensor, np.ndarray]]]
+ if path is None:
+ meta_data = get_model_metadata(self.model)
+ else:
+ meta_data = super()._load_meta_data(path)
+ return meta_data
+
+ def load_model(self, path: Union[str, Path]) -> AnomalyModule:
+ """Load the PyTorch model.
+
+ Args:
+ path (Union[str, Path]): Path to model ckpt file.
+
+ Returns:
+ (AnomalyModule): PyTorch Lightning model.
+ """
+ model = get_model(self.config)
+ model.load_state_dict(torch.load(path)["state_dict"])
+ model.eval()
+ return model
+
+ def pre_process(self, image: np.ndarray) -> Tensor:
+ """Pre process the input image by applying transformations.
+
+ Args:
+ image (np.ndarray): Input image
+
+ Returns:
+ Tensor: pre-processed image.
+ """
+ config = self.config.transform if "transform" in self.config.keys() else None
+ image_size = tuple(self.config.dataset.image_size)
+ pre_processor = PreProcessor(config, image_size)
+ processed_image = pre_processor(image=image)["image"]
+
+ if len(processed_image) == 3:
+ processed_image = processed_image.unsqueeze(0)
+
+ return processed_image
+
+ def forward(self, image: Tensor) -> Tensor:
+ """Forward-Pass input tensor to the model.
+
+ Args:
+ image (Tensor): Input tensor.
+
+ Returns:
+ Tensor: Output predictions.
+ """
+ return self.model(image)
+
+ def post_process(
+ self, predictions: Tensor, meta_data: Optional[Union[Dict, DictConfig]] = None
+ ) -> Tuple[np.ndarray, float]:
+ """Post process the output predictions.
+
+ Args:
+ predictions (Tensor): Raw output predicted by the model.
+ meta_data (Dict, optional): Meta data. Post-processing step sometimes requires
+ additional meta data such as image shape. This variable comprises such info.
+ Defaults to None.
+
+ Returns:
+ np.ndarray: Post processed predictions that are ready to be visualized.
+ """
+ if meta_data is None:
+ meta_data = self.meta_data
+
+ if isinstance(predictions, Tensor):
+ anomaly_map = predictions
+ pred_score = anomaly_map.reshape(-1).max()
+ else:
+ # NOTE: Patchcore `forward`` returns heatmap and score.
+ # We need to add the following check to ensure the variables
+ # are properly assigned. Without this check, the code
+ # throws an error regarding type mismatch torch vs np.
+ if isinstance(predictions[1], (Tensor)):
+ anomaly_map, pred_score = predictions
+ pred_score = pred_score.detach()
+ else:
+ anomaly_map, pred_score = predictions
+ pred_score = pred_score.detach().numpy()
+
+ anomaly_map = anomaly_map.squeeze()
+
+ anomaly_map, pred_score = self._normalize(anomaly_map, pred_score, meta_data)
+
+ if isinstance(anomaly_map, Tensor):
+ anomaly_map = anomaly_map.detach().cpu().numpy()
+
+ if "image_shape" in meta_data and anomaly_map.shape != meta_data["image_shape"]:
+ image_height = meta_data["image_shape"][0]
+ image_width = meta_data["image_shape"][1]
+ anomaly_map = cv2.resize(anomaly_map, (image_width, image_height))
+
+ return anomaly_map, float(pred_score)
diff --git a/anomalib/deploy/optimize.py b/anomalib/deploy/optimize.py
new file mode 100644
index 0000000000000000000000000000000000000000..f38c8a21b46c422e82cf97c60b672a8eebec8d88
--- /dev/null
+++ b/anomalib/deploy/optimize.py
@@ -0,0 +1,89 @@
+"""Utilities for optimization and OpenVINO conversion."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+
+import json
+import os
+from pathlib import Path
+from typing import Dict, List, Tuple, Union
+
+import numpy as np
+import torch
+from torch import Tensor
+
+from anomalib.models.components import AnomalyModule
+
+
+def get_model_metadata(model: AnomalyModule) -> Dict[str, Tensor]:
+ """Get meta data related to normalization from model.
+
+ Args:
+ model (AnomalyModule): Anomaly model which contains metadata related to normalization.
+
+ Returns:
+ Dict[str, Tensor]: metadata
+ """
+ meta_data = {}
+ cached_meta_data = {
+ "image_threshold": model.image_threshold.cpu().value,
+ "pixel_threshold": model.pixel_threshold.cpu().value,
+ "pixel_mean": model.training_distribution.pixel_mean.cpu(),
+ "image_mean": model.training_distribution.image_mean.cpu(),
+ "pixel_std": model.training_distribution.pixel_std.cpu(),
+ "image_std": model.training_distribution.image_std.cpu(),
+ "min": model.min_max.min.cpu(),
+ "max": model.min_max.max.cpu(),
+ }
+ # Remove undefined values by copying in a new dict
+ for key, val in cached_meta_data.items():
+ if not np.isinf(val).all():
+ meta_data[key] = val
+ del cached_meta_data
+ return meta_data
+
+
+def export_convert(
+ model: AnomalyModule,
+ input_size: Union[List[int], Tuple[int, int]],
+ onnx_path: Union[str, Path],
+ export_path: Union[str, Path],
+):
+ """Export the model to onnx format and convert to OpenVINO IR.
+
+ Args:
+ model (AnomalyModule): Model to convert.
+ input_size (Union[List[int], Tuple[int, int]]): Image size used as the input for onnx converter.
+ onnx_path (Union[str, Path]): Path to output onnx model.
+ export_path (Union[str, Path]): Path to exported OpenVINO IR.
+ """
+ height, width = input_size
+ torch.onnx.export(
+ model.model,
+ torch.zeros((1, 3, height, width)).to(model.device),
+ onnx_path,
+ opset_version=11,
+ input_names=["input"],
+ output_names=["output"],
+ )
+ optimize_command = "mo --input_model " + str(onnx_path) + " --output_dir " + str(export_path)
+ os.system(optimize_command)
+ with open(Path(export_path) / "meta_data.json", "w", encoding="utf-8") as metadata_file:
+ meta_data = get_model_metadata(model)
+ # Convert metadata from torch
+ for key, value in meta_data.items():
+ if isinstance(value, Tensor):
+ meta_data[key] = value.numpy().tolist()
+ json.dump(meta_data, metadata_file, ensure_ascii=False, indent=4)
diff --git a/anomalib/models/__init__.py b/anomalib/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ea11f15a1acd26849b9e39d274d8fd32a94fe3f
--- /dev/null
+++ b/anomalib/models/__init__.py
@@ -0,0 +1,75 @@
+"""Load Anomaly Model."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import os
+from importlib import import_module
+from typing import List, Union
+
+from omegaconf import DictConfig, ListConfig
+from torch import load
+
+from anomalib.models.components import AnomalyModule
+
+# TODO(AlexanderDokuchaev): Workaround of wrapping by NNCF.
+# Can't not wrap `spatial_softmax2d` if use import_module.
+from anomalib.models.padim.lightning_model import PadimLightning # noqa: F401
+
+
+def get_model(config: Union[DictConfig, ListConfig]) -> AnomalyModule:
+ """Load model from the configuration file.
+
+ Works only when the convention for model naming is followed.
+
+ The convention for writing model classes is
+ `anomalib.models..model.Lightning`
+ `anomalib.models.stfpm.model.StfpmLightning`
+
+ and for OpenVINO
+ `anomalib.models..model.OpenVINO`
+ `anomalib.models.stfpm.model.StfpmOpenVINO`
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Config.yaml loaded using OmegaConf
+
+ Raises:
+ ValueError: If unsupported model is passed
+
+ Returns:
+ AnomalyModule: Anomaly Model
+ """
+ openvino_model_list: List[str] = ["stfpm"]
+ torch_model_list: List[str] = ["padim", "stfpm", "dfkde", "dfm", "patchcore", "cflow", "ganomaly"]
+ model: AnomalyModule
+
+ if "openvino" in config.keys() and config.openvino:
+ if config.model.name in openvino_model_list:
+ module = import_module(f"anomalib.models.{config.model.name}.model")
+ model = getattr(module, f"{config.model.name.capitalize()}OpenVINO")
+ else:
+ raise ValueError(f"Unknown model {config.model.name} for OpenVINO model!")
+ else:
+ if config.model.name in torch_model_list:
+ module = import_module(f"anomalib.models.{config.model.name}")
+ model = getattr(module, f"{config.model.name.capitalize()}Lightning")
+ else:
+ raise ValueError(f"Unknown model {config.model.name}!")
+
+ model = model(config)
+
+ if "init_weights" in config.keys() and config.init_weights:
+ model.load_state_dict(load(os.path.join(config.project.path, config.init_weights))["state_dict"], strict=False)
+
+ return model
diff --git a/anomalib/models/cflow/README.md b/anomalib/models/cflow/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..e8d3cfe139331ad5ac40e6a6ad620f257dfda070
--- /dev/null
+++ b/anomalib/models/cflow/README.md
@@ -0,0 +1,49 @@
+# Real-Time Unsupervised Anomaly Detection via Conditional Normalizing Flows
+
+This is the implementation of the [CFLOW-AD](https://arxiv.org/pdf/2107.12571v1.pdf) paper. This code is modified form of the [official repository](https://github.com/gudovskiy/cflow-ad).
+
+Model Type: Segmentation
+
+## Description
+
+CFLOW model is based on a conditional normalizing flow framework adopted for anomaly detection with localization. It consists of a discriminatively pretrained encoder followed by a multi-scale generative decoders. The encoder extracts features with multi-scale pyramid pooling to capture both global and local semantic information with the growing from top to bottom receptive fields. Pooled features are processed by a set of decoders to explicitly estimate likelihood of the encoded features. The estimated multi-scale likelyhoods are upsampled to input size and added up to produce the anomaly map.
+
+## Architecture
+
+
+
+## Usage
+
+`python tools/train.py --model cflow`
+
+## Benchmark
+
+All results gathered with seed `42`.
+
+## [MVTec AD Dataset](https://www.mvtec.com/company/research/datasets/mvtec-ad)
+
+### Image-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| Wide ResNet-50 | 0.962 | 0.986 | 0.962 | 1.0 | 0.999 | 0.993 | 1.0 | 0.893 | 0.945 | 1.0 | 0.995 | 0.924 | 0.908 | 0.897 | 0.943 | 0.984 |
+
+### Pixel-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| Wide ResNet-50 | 0.971 | 0.986 | 0.968 | 0.993 | 0.968 | 0.924 | 0.981 | 0.955 | 0.988 | 0.990 | 0.982 | 0.983 | 0.979 | 0.985 | 0.897 | 0.980 |
+
+### Image F1 Score
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| Wide ResNet-50 | 0.944 | 0.972 | 0.932 | 1.000 | 0.988 | 0.967 | 1.000 | 0.832 | 0.939 | 1.000 | 0.979 | 0.924 | 0.971 | 0.870 | 0.818 | 0.967 |
+
+### Sample Results
+
+
+
+
+
+
diff --git a/anomalib/models/cflow/__init__.py b/anomalib/models/cflow/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e18704980418e6553db416732a92d9efaada6e45
--- /dev/null
+++ b/anomalib/models/cflow/__init__.py
@@ -0,0 +1,19 @@
+"""Real-Time Unsupervised Anomaly Detection via Conditional Normalizing Flows."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .lightning_model import CflowLightning
+
+__all__ = ["CflowLightning"]
diff --git a/anomalib/models/cflow/anomaly_map.py b/anomalib/models/cflow/anomaly_map.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e6ec821792ccc34a7fadcf0f5f0dcf8c28746b2
--- /dev/null
+++ b/anomalib/models/cflow/anomaly_map.py
@@ -0,0 +1,97 @@
+"""Anomaly Map Generator for CFlow model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import List, Tuple, Union, cast
+
+import torch
+import torch.nn.functional as F
+from omegaconf import ListConfig
+from torch import Tensor
+
+
+class AnomalyMapGenerator:
+ """Generate Anomaly Heatmap."""
+
+ def __init__(
+ self,
+ image_size: Union[ListConfig, Tuple],
+ pool_layers: List[str],
+ ):
+ self.distance = torch.nn.PairwiseDistance(p=2, keepdim=True)
+ self.image_size = image_size if isinstance(image_size, tuple) else tuple(image_size)
+ self.pool_layers: List[str] = pool_layers
+
+ def compute_anomaly_map(
+ self, distribution: Union[List[Tensor], List[List]], height: List[int], width: List[int]
+ ) -> Tensor:
+ """Compute the layer map based on likelihood estimation.
+
+ Args:
+ distribution: Probability distribution for each decoder block
+ height: blocks height
+ width: blocks width
+
+ Returns:
+ Final Anomaly Map
+
+ """
+
+ test_map: List[Tensor] = []
+ for layer_idx in range(len(self.pool_layers)):
+ test_norm = torch.tensor(distribution[layer_idx], dtype=torch.double) # pylint: disable=not-callable
+ test_norm -= torch.max(test_norm) # normalize likelihoods to (-Inf:0] by subtracting a constant
+ test_prob = torch.exp(test_norm) # convert to probs in range [0:1]
+ test_mask = test_prob.reshape(-1, height[layer_idx], width[layer_idx])
+ # upsample
+ test_map.append(
+ F.interpolate(
+ test_mask.unsqueeze(1), size=self.image_size, mode="bilinear", align_corners=True
+ ).squeeze()
+ )
+ # score aggregation
+ score_map = torch.zeros_like(test_map[0])
+ for layer_idx in range(len(self.pool_layers)):
+ score_map += test_map[layer_idx]
+ score_mask = score_map
+ # invert probs to anomaly scores
+ anomaly_map = score_mask.max() - score_mask
+
+ return anomaly_map
+
+ def __call__(self, **kwargs: Union[List[Tensor], List[int], List[List]]) -> Tensor:
+ """Returns anomaly_map.
+
+ Expects `distribution`, `height` and 'width' keywords to be passed explicitly
+
+ Example
+ >>> anomaly_map_generator = AnomalyMapGenerator(image_size=tuple(hparams.model.input_size),
+ >>> pool_layers=pool_layers)
+ >>> output = self.anomaly_map_generator(distribution=dist, height=height, width=width)
+
+ Raises:
+ ValueError: `distribution`, `height` and 'width' keys are not found
+
+ Returns:
+ torch.Tensor: anomaly map
+ """
+ if not ("distribution" in kwargs and "height" in kwargs and "width" in kwargs):
+ raise KeyError(f"Expected keys `distribution`, `height` and `width`. Found {kwargs.keys()}")
+
+ # placate mypy
+ distribution: List[Tensor] = cast(List[Tensor], kwargs["distribution"])
+ height: List[int] = cast(List[int], kwargs["height"])
+ width: List[int] = cast(List[int], kwargs["width"])
+ return self.compute_anomaly_map(distribution, height, width)
diff --git a/anomalib/models/cflow/config.yaml b/anomalib/models/cflow/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..0ffc21661d2893a50f15c6dc712eec5ae161242d
--- /dev/null
+++ b/anomalib/models/cflow/config.yaml
@@ -0,0 +1,101 @@
+dataset:
+ name: mvtec #options: [mvtec, btech, folder]
+ format: mvtec
+ path: ./datasets/MVTec
+ category: bottle
+ task: segmentation
+ image_size: 256
+ train_batch_size: 16
+ test_batch_size: 16
+ inference_batch_size: 16
+ fiber_batch_size: 64
+ num_workers: 8
+ transform_config:
+ train: null
+ val: null
+ create_validation_set: false
+
+model:
+ name: cflow
+ backbone: wide_resnet50_2
+ layers:
+ - layer2
+ - layer3
+ - layer4
+ decoder: freia-cflow
+ condition_vector: 128
+ coupling_blocks: 8
+ clamp_alpha: 1.9
+ soft_permutation: false
+ lr: 0.0001
+ early_stopping:
+ patience: 2
+ metric: pixel_AUROC
+ mode: max
+ normalization_method: min_max # options: [null, min_max, cdf]
+ threshold:
+ image_default: 0
+ pixel_default: 0
+ adaptive: true
+
+metrics:
+ image:
+ - F1Score
+ - AUROC
+ pixel:
+ - F1Score
+ - AUROC
+
+project:
+ seed: 0
+ path: ./results
+ log_images_to: [local]
+ logger: false # options: [tensorboard, wandb, csv] or combinations.
+
+# PL Trainer Args. Don't add extra parameter here.
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
+ accumulate_grad_batches: 1
+ amp_backend: native
+ auto_lr_find: false
+ auto_scale_batch_size: false
+ auto_select_gpus: false
+ benchmark: false
+ check_val_every_n_epoch: 1
+ default_root_dir: null
+ detect_anomaly: false
+ deterministic: false
+ enable_checkpointing: true
+ enable_model_summary: true
+ enable_progress_bar: true
+ fast_dev_run: false
+ gpus: null # Set automatically
+ gradient_clip_val: 0
+ ipus: null
+ limit_predict_batches: 1.0
+ limit_test_batches: 1.0
+ limit_train_batches: 1.0
+ limit_val_batches: 1.0
+ log_every_n_steps: 50
+ log_gpu_memory: null
+ max_epochs: 50
+ max_steps: -1
+ max_time: null
+ min_epochs: null
+ min_steps: null
+ move_metrics_to_cpu: false
+ multiple_trainloader_mode: max_size_cycle
+ num_nodes: 1
+ num_processes: 1
+ num_sanity_val_steps: 0
+ overfit_batches: 0.0
+ plugins: null
+ precision: 32
+ profiler: null
+ reload_dataloaders_every_n_epochs: 0
+ replace_sampler_ddp: true
+ strategy: null
+ sync_batchnorm: false
+ tpu_cores: null
+ track_grad_norm: -1
+ val_check_interval: 1.0
diff --git a/anomalib/models/cflow/lightning_model.py b/anomalib/models/cflow/lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..1407b96295b60ba0e7729fcb58eb9e36b740d47b
--- /dev/null
+++ b/anomalib/models/cflow/lightning_model.py
@@ -0,0 +1,161 @@
+"""CFLOW: Real-Time Unsupervised Anomaly Detection via Conditional Normalizing Flows.
+
+https://arxiv.org/pdf/2107.12571v1.pdf
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+
+import einops
+import torch
+import torch.nn.functional as F
+from pytorch_lightning.callbacks import EarlyStopping
+from torch import optim
+
+from anomalib.models.cflow.torch_model import CflowModel
+from anomalib.models.cflow.utils import get_logp, positional_encoding_2d
+from anomalib.models.components import AnomalyModule
+
+logger = logging.getLogger(__name__)
+
+__all__ = ["CflowLightning"]
+
+
+class CflowLightning(AnomalyModule):
+ """PL Lightning Module for the CFLOW algorithm."""
+
+ def __init__(self, hparams):
+ super().__init__(hparams)
+ logger.info("Initializing Cflow Lightning model.")
+
+ self.model: CflowModel = CflowModel(hparams)
+ self.loss_val = 0
+ self.automatic_optimization = False
+
+ def configure_callbacks(self):
+ """Configure model-specific callbacks."""
+ early_stopping = EarlyStopping(
+ monitor=self.hparams.model.early_stopping.metric,
+ patience=self.hparams.model.early_stopping.patience,
+ mode=self.hparams.model.early_stopping.mode,
+ )
+ return [early_stopping]
+
+ def configure_optimizers(self) -> torch.optim.Optimizer:
+ """Configures optimizers for each decoder.
+
+ Returns:
+ Optimizer: Adam optimizer for each decoder
+ """
+ decoders_parameters = []
+ for decoder_idx in range(len(self.model.pool_layers)):
+ decoders_parameters.extend(list(self.model.decoders[decoder_idx].parameters()))
+
+ optimizer = optim.Adam(
+ params=decoders_parameters,
+ lr=self.hparams.model.lr,
+ )
+ return optimizer
+
+ def training_step(self, batch, _): # pylint: disable=arguments-differ
+ """Training Step of CFLOW.
+
+ For each batch, decoder layers are trained with a dynamic fiber batch size.
+ Training step is performed manually as multiple training steps are involved
+ per batch of input images
+
+ Args:
+ batch: Input batch
+ _: Index of the batch.
+
+ Returns:
+ Loss value for the batch
+
+ """
+ opt = self.optimizers()
+ self.model.encoder.eval()
+
+ images = batch["image"]
+ activation = self.model.encoder(images)
+ avg_loss = torch.zeros([1], dtype=torch.float64).to(images.device)
+
+ height = []
+ width = []
+ for layer_idx, layer in enumerate(self.model.pool_layers):
+ encoder_activations = activation[layer].detach() # BxCxHxW
+
+ batch_size, dim_feature_vector, im_height, im_width = encoder_activations.size()
+ image_size = im_height * im_width
+ embedding_length = batch_size * image_size # number of rows in the conditional vector
+
+ height.append(im_height)
+ width.append(im_width)
+ # repeats positional encoding for the entire batch 1 C H W to B C H W
+ pos_encoding = einops.repeat(
+ positional_encoding_2d(self.model.condition_vector, im_height, im_width).unsqueeze(0),
+ "b c h w-> (tile b) c h w",
+ tile=batch_size,
+ ).to(images.device)
+ c_r = einops.rearrange(pos_encoding, "b c h w -> (b h w) c") # BHWxP
+ e_r = einops.rearrange(encoder_activations, "b c h w -> (b h w) c") # BHWxC
+ perm = torch.randperm(embedding_length) # BHW
+ decoder = self.model.decoders[layer_idx].to(images.device)
+
+ fiber_batches = embedding_length // self.model.fiber_batch_size # number of fiber batches
+ assert fiber_batches > 0, "Make sure we have enough fibers, otherwise decrease N or batch-size!"
+
+ for batch_num in range(fiber_batches): # per-fiber processing
+ opt.zero_grad()
+ if batch_num < (fiber_batches - 1):
+ idx = torch.arange(
+ batch_num * self.model.fiber_batch_size, (batch_num + 1) * self.model.fiber_batch_size
+ )
+ else: # When non-full batch is encountered batch_num * N will go out of bounds
+ idx = torch.arange(batch_num * self.model.fiber_batch_size, embedding_length)
+ # get random vectors
+ c_p = c_r[perm[idx]] # NxP
+ e_p = e_r[perm[idx]] # NxC
+ # decoder returns the transformed variable z and the log Jacobian determinant
+ p_u, log_jac_det = decoder(e_p, [c_p])
+ #
+ decoder_log_prob = get_logp(dim_feature_vector, p_u, log_jac_det)
+ log_prob = decoder_log_prob / dim_feature_vector # likelihood per dim
+ loss = -F.logsigmoid(log_prob)
+ self.manual_backward(loss.mean())
+ opt.step()
+ avg_loss += loss.sum()
+
+ return {"loss": avg_loss}
+
+ def validation_step(self, batch, _): # pylint: disable=arguments-differ
+ """Validation Step of CFLOW.
+
+ Similar to the training step, encoder features
+ are extracted from the CNN for each batch, and anomaly
+ map is computed.
+
+ Args:
+ batch: Input batch
+ _: Index of the batch.
+
+ Returns:
+ Dictionary containing images, anomaly maps, true labels and masks.
+ These are required in `validation_epoch_end` for feature concatenation.
+
+ """
+ batch["anomaly_maps"] = self.model(batch["image"])
+
+ return batch
diff --git a/anomalib/models/cflow/torch_model.py b/anomalib/models/cflow/torch_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..7206030c286093047c1408fd896f0c4efcd0c682
--- /dev/null
+++ b/anomalib/models/cflow/torch_model.py
@@ -0,0 +1,130 @@
+"""PyTorch model for CFlow model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import List, Union
+
+import einops
+import torch
+import torchvision
+from omegaconf import DictConfig, ListConfig
+from torch import nn
+
+from anomalib.models.cflow.anomaly_map import AnomalyMapGenerator
+from anomalib.models.cflow.utils import cflow_head, get_logp, positional_encoding_2d
+from anomalib.models.components import FeatureExtractor
+
+
+class CflowModel(nn.Module):
+ """CFLOW: Conditional Normalizing Flows."""
+
+ def __init__(self, hparams: Union[DictConfig, ListConfig]):
+ super().__init__()
+
+ self.backbone = getattr(torchvision.models, hparams.model.backbone)
+ self.fiber_batch_size = hparams.dataset.fiber_batch_size
+ self.condition_vector: int = hparams.model.condition_vector
+ self.dec_arch = hparams.model.decoder
+ self.pool_layers = hparams.model.layers
+
+ self.encoder = FeatureExtractor(backbone=self.backbone(pretrained=True), layers=self.pool_layers)
+ self.pool_dims = self.encoder.out_dims
+ self.decoders = nn.ModuleList(
+ [
+ cflow_head(
+ condition_vector=self.condition_vector,
+ coupling_blocks=hparams.model.coupling_blocks,
+ clamp_alpha=hparams.model.clamp_alpha,
+ n_features=pool_dim,
+ permute_soft=hparams.model.soft_permutation,
+ )
+ for pool_dim in self.pool_dims
+ ]
+ )
+
+ # encoder model is fixed
+ for parameters in self.encoder.parameters():
+ parameters.requires_grad = False
+
+ self.anomaly_map_generator = AnomalyMapGenerator(
+ image_size=tuple(hparams.model.input_size), pool_layers=self.pool_layers
+ )
+
+ def forward(self, images):
+ """Forward-pass images into the network to extract encoder features and compute probability.
+
+ Args:
+ images: Batch of images.
+
+ Returns:
+ Predicted anomaly maps.
+
+ """
+
+ self.encoder.eval()
+ self.decoders.eval()
+ with torch.no_grad():
+ activation = self.encoder(images)
+
+ distribution = [torch.Tensor(0).to(images.device) for _ in self.pool_layers]
+
+ height: List[int] = []
+ width: List[int] = []
+ for layer_idx, layer in enumerate(self.pool_layers):
+ encoder_activations = activation[layer] # BxCxHxW
+
+ batch_size, dim_feature_vector, im_height, im_width = encoder_activations.size()
+ image_size = im_height * im_width
+ embedding_length = batch_size * image_size # number of rows in the conditional vector
+
+ height.append(im_height)
+ width.append(im_width)
+ # repeats positional encoding for the entire batch 1 C H W to B C H W
+ pos_encoding = einops.repeat(
+ positional_encoding_2d(self.condition_vector, im_height, im_width).unsqueeze(0),
+ "b c h w-> (tile b) c h w",
+ tile=batch_size,
+ ).to(images.device)
+ c_r = einops.rearrange(pos_encoding, "b c h w -> (b h w) c") # BHWxP
+ e_r = einops.rearrange(encoder_activations, "b c h w -> (b h w) c") # BHWxC
+ decoder = self.decoders[layer_idx].to(images.device)
+
+ # Sometimes during validation, the last batch E / N is not a whole number. Hence we need to add 1.
+ # It is assumed that during training that E / N is a whole number as no errors were discovered during
+ # testing. In case it is observed in the future, we can use only this line and ensure that FIB is at
+ # least 1 or set `drop_last` in the dataloader to drop the last non-full batch.
+ fiber_batches = embedding_length // self.fiber_batch_size + int(
+ embedding_length % self.fiber_batch_size > 0
+ )
+
+ for batch_num in range(fiber_batches): # per-fiber processing
+ if batch_num < (fiber_batches - 1):
+ idx = torch.arange(batch_num * self.fiber_batch_size, (batch_num + 1) * self.fiber_batch_size)
+ else: # When non-full batch is encountered batch_num+1 * N will go out of bounds
+ idx = torch.arange(batch_num * self.fiber_batch_size, embedding_length)
+ c_p = c_r[idx] # NxP
+ e_p = e_r[idx] # NxC
+ # decoder returns the transformed variable z and the log Jacobian determinant
+ with torch.no_grad():
+ p_u, log_jac_det = decoder(e_p, [c_p])
+ #
+ decoder_log_prob = get_logp(dim_feature_vector, p_u, log_jac_det)
+ log_prob = decoder_log_prob / dim_feature_vector # likelihood per dim
+ distribution[layer_idx] = torch.cat((distribution[layer_idx], log_prob))
+
+ output = self.anomaly_map_generator(distribution=distribution, height=height, width=width)
+ self.decoders.train()
+
+ return output.to(images.device)
diff --git a/anomalib/models/cflow/utils.py b/anomalib/models/cflow/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..4917be164ff280d9242b764be8a68865a1bc42bc
--- /dev/null
+++ b/anomalib/models/cflow/utils.py
@@ -0,0 +1,125 @@
+"""Helper functions for CFlow implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import math
+
+import numpy as np
+import torch
+from torch import nn
+
+from anomalib.models.components.freia.framework import SequenceINN
+from anomalib.models.components.freia.modules import AllInOneBlock
+
+logger = logging.getLogger(__name__)
+
+
+def get_logp(dim_feature_vector: int, p_u: torch.Tensor, logdet_j: torch.Tensor) -> torch.Tensor:
+ """Returns the log likelihood estimation.
+
+ Args:
+ dim_feature_vector (int): Dimensions of the condition vector
+ p_u (torch.Tensor): Random variable u
+ logdet_j (torch.Tensor): log of determinant of jacobian returned from the invertable decoder
+
+ Returns:
+ torch.Tensor: Log probability
+ """
+ ln_sqrt_2pi = -np.log(np.sqrt(2 * np.pi)) # ln(sqrt(2*pi))
+ logp = dim_feature_vector * ln_sqrt_2pi - 0.5 * torch.sum(p_u**2, 1) + logdet_j
+ return logp
+
+
+def positional_encoding_2d(condition_vector: int, height: int, width: int) -> torch.Tensor:
+ """Creates embedding to store relative position of the feature vector using sine and cosine functions.
+
+ Args:
+ condition_vector (int): Length of the condition vector
+ height (int): H of the positions
+ width (int): W of the positions
+
+ Raises:
+ ValueError: Cannot generate encoding with conditional vector length not as multiple of 4
+
+ Returns:
+ torch.Tensor: condition_vector x HEIGHT x WIDTH position matrix
+ """
+ if condition_vector % 4 != 0:
+ raise ValueError(f"Cannot use sin/cos positional encoding with odd dimension (got dim={condition_vector})")
+ pos_encoding = torch.zeros(condition_vector, height, width)
+ # Each dimension use half of condition_vector
+ condition_vector = condition_vector // 2
+ div_term = torch.exp(torch.arange(0.0, condition_vector, 2) * -(math.log(1e4) / condition_vector))
+ pos_w = torch.arange(0.0, width).unsqueeze(1)
+ pos_h = torch.arange(0.0, height).unsqueeze(1)
+ pos_encoding[0:condition_vector:2, :, :] = (
+ torch.sin(pos_w * div_term).transpose(0, 1).unsqueeze(1).repeat(1, height, 1)
+ )
+ pos_encoding[1:condition_vector:2, :, :] = (
+ torch.cos(pos_w * div_term).transpose(0, 1).unsqueeze(1).repeat(1, height, 1)
+ )
+ pos_encoding[condition_vector::2, :, :] = (
+ torch.sin(pos_h * div_term).transpose(0, 1).unsqueeze(2).repeat(1, 1, width)
+ )
+ pos_encoding[condition_vector + 1 :: 2, :, :] = (
+ torch.cos(pos_h * div_term).transpose(0, 1).unsqueeze(2).repeat(1, 1, width)
+ )
+ return pos_encoding
+
+
+def subnet_fc(dims_in: int, dims_out: int):
+ """Subnetwork which predicts the affine coefficients.
+
+ Args:
+ dims_in (int): input dimensions
+ dims_out (int): output dimensions
+
+ Returns:
+ nn.Sequential: Feed-forward subnetwork
+ """
+ return nn.Sequential(nn.Linear(dims_in, 2 * dims_in), nn.ReLU(), nn.Linear(2 * dims_in, dims_out))
+
+
+def cflow_head(
+ condition_vector: int, coupling_blocks: int, clamp_alpha: float, n_features: int, permute_soft: bool = False
+) -> SequenceINN:
+ """Create invertible decoder network.
+
+ Args:
+ condition_vector (int): length of the condition vector
+ coupling_blocks (int): number of coupling blocks to build the decoder
+ clamp_alpha (float): clamping value to avoid exploding values
+ n_features (int): number of decoder features
+ permute_soft (bool): Whether to sample the permutation matrix :math:`R` from :math:`SO(N)`,
+ or to use hard permutations instead. Note, ``permute_soft=True`` is very slow
+ when working with >512 dimensions.
+
+ Returns:
+ SequenceINN: decoder network block
+ """
+ coder = SequenceINN(n_features)
+ logger.info("CNF coder: %d", n_features)
+ for _ in range(coupling_blocks):
+ coder.append(
+ AllInOneBlock,
+ cond=0,
+ cond_shape=(condition_vector,),
+ subnet_constructor=subnet_fc,
+ affine_clamping=clamp_alpha,
+ global_affine_type="SOFTPLUS",
+ permute_soft=permute_soft,
+ )
+ return coder
diff --git a/anomalib/models/components/__init__.py b/anomalib/models/components/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..65d4bc6342ec0fd614550b8570c642c316fcd91b
--- /dev/null
+++ b/anomalib/models/components/__init__.py
@@ -0,0 +1,32 @@
+"""Components used within the models."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .base import AnomalyModule, DynamicBufferModule
+from .dimensionality_reduction import PCA, SparseRandomProjection
+from .feature_extractors import FeatureExtractor
+from .sampling import KCenterGreedy
+from .stats import GaussianKDE, MultiVariateGaussian
+
+__all__ = [
+ "AnomalyModule",
+ "DynamicBufferModule",
+ "PCA",
+ "SparseRandomProjection",
+ "FeatureExtractor",
+ "KCenterGreedy",
+ "GaussianKDE",
+ "MultiVariateGaussian",
+]
diff --git a/anomalib/models/components/base/__init__.py b/anomalib/models/components/base/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fdcf2bee5ab6482ecd4630ae6b2d8e6dae658f9c
--- /dev/null
+++ b/anomalib/models/components/base/__init__.py
@@ -0,0 +1,20 @@
+"""Base classes for all anomaly components."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .anomaly_module import AnomalyModule
+from .dynamic_module import DynamicBufferModule
+
+__all__ = ["AnomalyModule", "DynamicBufferModule"]
diff --git a/anomalib/models/components/base/anomaly_module.py b/anomalib/models/components/base/anomaly_module.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcaee66fc0060fd490f2a8bb17eb9c81126b32b8
--- /dev/null
+++ b/anomalib/models/components/base/anomaly_module.py
@@ -0,0 +1,181 @@
+"""Base Anomaly Module for Training Task."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from abc import ABC
+from typing import Any, List, Optional, Union
+
+import pytorch_lightning as pl
+from omegaconf import DictConfig, ListConfig
+from pytorch_lightning.callbacks.base import Callback
+from torch import Tensor, nn
+
+from anomalib.utils.metrics import (
+ AdaptiveThreshold,
+ AnomalyScoreDistribution,
+ MinMax,
+ get_metrics,
+)
+
+
+class AnomalyModule(pl.LightningModule, ABC):
+ """AnomalyModule to train, validate, predict and test images.
+
+ Acts as a base class for all the Anomaly Modules in the library.
+
+ Args:
+ params (Union[DictConfig, ListConfig]): Configuration
+ """
+
+ def __init__(self, params: Union[DictConfig, ListConfig]):
+
+ super().__init__()
+ # Force the type for hparams so that it works with OmegaConfig style of accessing
+ self.hparams: Union[DictConfig, ListConfig] # type: ignore
+ self.save_hyperparameters(params)
+ self.loss: Tensor
+ self.callbacks: List[Callback]
+
+ self.image_threshold = AdaptiveThreshold(self.hparams.model.threshold.image_default).cpu()
+ self.pixel_threshold = AdaptiveThreshold(self.hparams.model.threshold.pixel_default).cpu()
+
+ self.training_distribution = AnomalyScoreDistribution().cpu()
+ self.min_max = MinMax().cpu()
+
+ self.model: nn.Module
+
+ # metrics
+ self.image_metrics, self.pixel_metrics = get_metrics(self.hparams)
+ self.image_metrics.set_threshold(self.hparams.model.threshold.image_default)
+ self.pixel_metrics.set_threshold(self.hparams.model.threshold.pixel_default)
+
+ def forward(self, batch): # pylint: disable=arguments-differ
+ """Forward-pass input tensor to the module.
+
+ Args:
+ batch (Tensor): Input Tensor
+
+ Returns:
+ Tensor: Output tensor from the model.
+ """
+ return self.model(batch)
+
+ def validation_step(self, batch, batch_idx) -> dict: # type: ignore # pylint: disable=arguments-differ
+ """To be implemented in the subclasses."""
+ raise NotImplementedError
+
+ def predict_step(self, batch: Any, batch_idx: int, _dataloader_idx: Optional[int] = None) -> Any:
+ """Step function called during :meth:`~pytorch_lightning.trainer.trainer.Trainer.predict`.
+
+ By default, it calls :meth:`~pytorch_lightning.core.lightning.LightningModule.forward`.
+ Override to add any processing logic.
+
+ Args:
+ batch (Tensor): Current batch
+ batch_idx (int): Index of current batch
+ _dataloader_idx (int): Index of the current dataloader
+
+ Return:
+ Predicted output
+ """
+ outputs = self.validation_step(batch, batch_idx)
+ self._post_process(outputs)
+ outputs["pred_labels"] = outputs["pred_scores"] >= self.image_threshold.value
+ if "anomaly_maps" in outputs.keys():
+ outputs["pred_masks"] = outputs["anomaly_maps"] >= self.pixel_threshold.value
+ return outputs
+
+ def test_step(self, batch, _): # pylint: disable=arguments-differ
+ """Calls validation_step for anomaly map/score calculation.
+
+ Args:
+ batch (Tensor): Input batch
+ _: Index of the batch.
+
+ Returns:
+ Dictionary containing images, features, true labels and masks.
+ These are required in `validation_epoch_end` for feature concatenation.
+ """
+ return self.validation_step(batch, _)
+
+ def validation_step_end(self, val_step_outputs): # pylint: disable=arguments-differ
+ """Called at the end of each validation step."""
+ self._outputs_to_cpu(val_step_outputs)
+ self._post_process(val_step_outputs)
+ return val_step_outputs
+
+ def test_step_end(self, test_step_outputs): # pylint: disable=arguments-differ
+ """Called at the end of each test step."""
+ self._outputs_to_cpu(test_step_outputs)
+ self._post_process(test_step_outputs)
+ return test_step_outputs
+
+ def validation_epoch_end(self, outputs):
+ """Compute threshold and performance metrics.
+
+ Args:
+ outputs: Batch of outputs from the validation step
+ """
+ if self.hparams.model.threshold.adaptive:
+ self._compute_adaptive_threshold(outputs)
+ self._collect_outputs(self.image_metrics, self.pixel_metrics, outputs)
+ self._log_metrics()
+
+ def test_epoch_end(self, outputs):
+ """Compute and save anomaly scores of the test set.
+
+ Args:
+ outputs: Batch of outputs from the validation step
+ """
+ self._collect_outputs(self.image_metrics, self.pixel_metrics, outputs)
+ self._log_metrics()
+
+ def _compute_adaptive_threshold(self, outputs):
+ self._collect_outputs(self.image_threshold, self.pixel_threshold, outputs)
+ self.image_threshold.compute()
+ if "mask" in outputs[0].keys() and "anomaly_maps" in outputs[0].keys():
+ self.pixel_threshold.compute()
+ else:
+ self.pixel_threshold.value = self.image_threshold.value
+
+ self.image_metrics.set_threshold(self.image_threshold.value.item())
+ self.pixel_metrics.set_threshold(self.pixel_threshold.value.item())
+
+ def _collect_outputs(self, image_metric, pixel_metric, outputs):
+ for output in outputs:
+ image_metric.cpu()
+ image_metric.update(output["pred_scores"], output["label"].int())
+ if "mask" in output.keys() and "anomaly_maps" in output.keys():
+ pixel_metric.cpu()
+ pixel_metric.update(output["anomaly_maps"].flatten(), output["mask"].flatten().int())
+
+ def _post_process(self, outputs):
+ """Compute labels based on model predictions."""
+ if "pred_scores" not in outputs and "anomaly_maps" in outputs:
+ outputs["pred_scores"] = (
+ outputs["anomaly_maps"].reshape(outputs["anomaly_maps"].shape[0], -1).max(dim=1).values
+ )
+
+ def _outputs_to_cpu(self, output):
+ # for output in outputs:
+ for key, value in output.items():
+ if isinstance(value, Tensor):
+ output[key] = value.cpu()
+
+ def _log_metrics(self):
+ """Log computed performance metrics."""
+ self.log_dict(self.image_metrics)
+ if self.pixel_metrics.update_called:
+ self.log_dict(self.pixel_metrics)
diff --git a/anomalib/models/components/base/dynamic_module.py b/anomalib/models/components/base/dynamic_module.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb71672ba037b29c78abbd7c94d4ac0989687dc7
--- /dev/null
+++ b/anomalib/models/components/base/dynamic_module.py
@@ -0,0 +1,63 @@
+"""Dynamic Buffer Module."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from abc import ABC
+
+from torch import Tensor, nn
+
+
+class DynamicBufferModule(ABC, nn.Module):
+ """Torch module that allows loading variables from the state dict even in the case of shape mismatch."""
+
+ def get_tensor_attribute(self, attribute_name: str) -> Tensor:
+ """Get attribute of the tensor given the name.
+
+ Args:
+ attribute_name (str): Name of the tensor
+
+ Raises:
+ ValueError: `attribute_name` is not a torch Tensor
+
+ Returns:
+ Tensor: Tensor attribute
+ """
+ attribute = self.__getattr__(attribute_name)
+ if isinstance(attribute, Tensor):
+ return attribute
+
+ raise ValueError(f"Attribute with name '{attribute_name}' is not a torch Tensor")
+
+ def _load_from_state_dict(self, state_dict: dict, prefix: str, *args):
+ """Resizes the local buffers to match those stored in the state dict.
+
+ Overrides method from parent class.
+
+ Args:
+ state_dict (dict): State dictionary containing weights
+ prefix (str): Prefix of the weight file.
+ *args:
+ """
+ persistent_buffers = {k: v for k, v in self._buffers.items() if k not in self._non_persistent_buffers_set}
+ local_buffers = {k: v for k, v in persistent_buffers.items() if v is not None}
+
+ for param in local_buffers.keys():
+ for key in state_dict.keys():
+ if key.startswith(prefix) and key[len(prefix) :].split(".")[0] == param:
+ if not local_buffers[param].shape == state_dict[key].shape:
+ attribute = self.get_tensor_attribute(param)
+ attribute.resize_(state_dict[key].shape)
+
+ super()._load_from_state_dict(state_dict, prefix, *args)
diff --git a/anomalib/models/components/dimensionality_reduction/__init__.py b/anomalib/models/components/dimensionality_reduction/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b31a5ea8252b32a60e69a2a6e2c1617f2ceb231e
--- /dev/null
+++ b/anomalib/models/components/dimensionality_reduction/__init__.py
@@ -0,0 +1,20 @@
+"""Algorithms for decomposition and dimensionality reduction."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .pca import PCA
+from .random_projection import SparseRandomProjection
+
+__all__ = ["PCA", "SparseRandomProjection"]
diff --git a/anomalib/models/components/dimensionality_reduction/pca.py b/anomalib/models/components/dimensionality_reduction/pca.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef7a22f3c06b275ba965407a81c1dfe1f1b72ee1
--- /dev/null
+++ b/anomalib/models/components/dimensionality_reduction/pca.py
@@ -0,0 +1,122 @@
+"""Principle Component Analysis (PCA) with PyTorch."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Union
+
+import torch
+from torch import Tensor
+
+from anomalib.models.components.base import DynamicBufferModule
+
+
+class PCA(DynamicBufferModule):
+ """Principle Component Analysis (PCA).
+
+ Args:
+ n_components (float): Number of components. Can be either integer number of components
+ or a ratio between 0-1.
+ """
+
+ def __init__(self, n_components: Union[float, int]):
+ super().__init__()
+ self.n_components = n_components
+
+ self.register_buffer("singular_vectors", Tensor())
+ self.register_buffer("mean", Tensor())
+ self.register_buffer("num_components", Tensor())
+
+ self.singular_vectors: Tensor
+ self.singular_values: Tensor
+ self.mean: Tensor
+ self.num_components: Tensor
+
+ def fit(self, dataset: Tensor) -> None:
+ """Fits the PCA model to the dataset.
+
+ Args:
+ dataset (Tensor): Input dataset to fit the model.
+ """
+ mean = dataset.mean(dim=0)
+ dataset -= mean
+
+ _, sig, v_h = torch.linalg.svd(dataset.double())
+ num_components: int
+ if self.n_components <= 1:
+ variance_ratios = torch.cumsum(sig * sig, dim=0) / torch.sum(sig * sig)
+ num_components = torch.nonzero(variance_ratios >= self.n_components)[0]
+ else:
+ num_components = int(self.n_components)
+
+ self.num_components = Tensor([num_components])
+
+ self.singular_vectors = v_h.transpose(-2, -1)[:, :num_components].float()
+ self.singular_values = sig[:num_components].float()
+ self.mean = mean
+
+ def fit_transform(self, dataset: Tensor) -> Tensor:
+ """Fit and transform PCA to dataset.
+
+ Args:
+ dataset (Tensor): Dataset to which the PCA if fit and transformed
+
+ Returns:
+ Transformed dataset
+ """
+ mean = dataset.mean(dim=0)
+ dataset -= mean
+ num_components = int(self.n_components)
+ self.num_components = Tensor([num_components])
+
+ v_h = torch.linalg.svd(dataset)[-1]
+ self.singular_vectors = v_h.transpose(-2, -1)[:, :num_components]
+ self.mean = mean
+
+ return torch.matmul(dataset, self.singular_vectors)
+
+ def transform(self, features: Tensor) -> Tensor:
+ """Transforms the features based on singular vectors calculated earlier.
+
+ Args:
+ features (Tensor): Input features
+
+ Returns:
+ Transformed features
+ """
+
+ features -= self.mean
+ return torch.matmul(features, self.singular_vectors)
+
+ def inverse_transform(self, features: Tensor) -> Tensor:
+ """Inverses the transformed features.
+
+ Args:
+ features (Tensor): Transformed features
+
+ Returns: Inverse features
+ """
+ inv_features = torch.matmul(features, self.singular_vectors.transpose(-2, -1))
+ return inv_features
+
+ def forward(self, features: Tensor) -> Tensor:
+ """Transforms the features.
+
+ Args:
+ features (Tensor): Input features
+
+ Returns:
+ Transformed features
+ """
+ return self.transform(features)
diff --git a/anomalib/models/components/dimensionality_reduction/random_projection.py b/anomalib/models/components/dimensionality_reduction/random_projection.py
new file mode 100644
index 0000000000000000000000000000000000000000..f06f7b0db5dace4162c4c487da26ee866addf5a0
--- /dev/null
+++ b/anomalib/models/components/dimensionality_reduction/random_projection.py
@@ -0,0 +1,144 @@
+"""This module comprises PatchCore Sampling Methods for the embedding.
+
+- Random Sparse Projector
+ Sparse Random Projection using PyTorch Operations
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Optional
+
+import numpy as np
+import torch
+from sklearn.utils.random import sample_without_replacement
+from torch import Tensor
+
+
+class NotFittedError(ValueError, AttributeError):
+ """Raise Exception if estimator is used before fitting."""
+
+
+class SparseRandomProjection:
+ """Sparse Random Projection using PyTorch operations.
+
+ Args:
+ eps (float, optional): Minimum distortion rate parameter for calculating
+ Johnson-Lindenstrauss minimum dimensions. Defaults to 0.1.
+ random_state (Optional[int], optional): Uses the seed to set the random
+ state for sample_without_replacement function. Defaults to None.
+ """
+
+ def __init__(self, eps: float = 0.1, random_state: Optional[int] = None) -> None:
+ self.n_components: int
+ self.sparse_random_matrix: Tensor
+ self.eps = eps
+ self.random_state = random_state
+
+ def _sparse_random_matrix(self, n_features: int):
+ """Random sparse matrix. Based on https://web.stanford.edu/~hastie/Papers/Ping/KDD06_rp.pdf.
+
+ Args:
+ n_features (int): Dimentionality of the original source space
+
+ Returns:
+ Tensor: Sparse matrix of shape (n_components, n_features).
+ The generated Gaussian random matrix is in CSR (compressed sparse row)
+ format.
+ """
+
+ # Density 'auto'. Factorize density
+ density = 1 / np.sqrt(n_features)
+
+ if density == 1:
+ # skip index generation if totally dense
+ binomial = torch.distributions.Binomial(total_count=1, probs=0.5)
+ components = binomial.sample((self.n_components, n_features)) * 2 - 1
+ components = 1 / np.sqrt(self.n_components) * components
+
+ else:
+ # Sparse matrix is not being generated here as it is stored as dense anyways
+ components = torch.zeros((self.n_components, n_features), dtype=torch.float64)
+ for i in range(self.n_components):
+ # find the indices of the non-zero components for row i
+ nnz_idx = torch.distributions.Binomial(total_count=n_features, probs=density).sample()
+ # get nnz_idx column indices
+ # pylint: disable=not-callable
+ c_idx = torch.tensor(
+ sample_without_replacement(
+ n_population=n_features, n_samples=nnz_idx, random_state=self.random_state
+ ),
+ dtype=torch.int64,
+ )
+ data = torch.distributions.Binomial(total_count=1, probs=0.5).sample(sample_shape=c_idx.size()) * 2 - 1
+ # assign data to only those columns
+ components[i, c_idx] = data.double()
+
+ components *= np.sqrt(1 / density) / np.sqrt(self.n_components)
+
+ return components
+
+ def johnson_lindenstrauss_min_dim(self, n_samples: int, eps: float = 0.1):
+ """Find a 'safe' number of components to randomly project to.
+
+ Ref eqn 2.1 https://cseweb.ucsd.edu/~dasgupta/papers/jl.pdf
+
+ Args:
+ n_samples (int): Number of samples used to compute safe components
+ eps (float, optional): Minimum distortion rate. Defaults to 0.1.
+ """
+
+ denominator = (eps**2 / 2) - (eps**3 / 3)
+ return (4 * np.log(n_samples) / denominator).astype(np.int64)
+
+ def fit(self, embedding: Tensor) -> "SparseRandomProjection":
+ """Generates sparse matrix from the embedding tensor.
+
+ Args:
+ embedding (Tensor): embedding tensor for generating embedding
+
+ Returns:
+ (SparseRandomProjection): Return self to be used as
+ >>> generator = SparseRandomProjection()
+ >>> generator = generator.fit()
+ """
+ n_samples, n_features = embedding.shape
+ device = embedding.device
+
+ self.n_components = self.johnson_lindenstrauss_min_dim(n_samples=n_samples, eps=self.eps)
+
+ # Generate projection matrix
+ # torch can't multiply directly on sparse matrix and moving sparse matrix to cuda throws error
+ # (Could not run 'aten::empty_strided' with arguments from the 'SparseCsrCUDA' backend)
+ # hence sparse matrix is stored as a dense matrix on the device
+ self.sparse_random_matrix = self._sparse_random_matrix(n_features=n_features).to(device)
+
+ return self
+
+ def transform(self, embedding: Tensor) -> Tensor:
+ """Project the data by using matrix product with the random matrix.
+
+ Args:
+ embedding (Tensor): Embedding of shape (n_samples, n_features)
+ The input data to project into a smaller dimensional space
+
+ Returns:
+ projected_embedding (Tensor): Sparse matrix of shape
+ (n_samples, n_components) Projected array.
+ """
+ if self.sparse_random_matrix is None:
+ raise NotFittedError("`fit()` has not been called on SparseRandomProjection yet.")
+
+ projected_embedding = embedding @ self.sparse_random_matrix.T.float()
+ return projected_embedding
diff --git a/anomalib/models/components/feature_extractors/__init__.py b/anomalib/models/components/feature_extractors/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..984b5eb3a22d3a759811ef397bb40caa9b05cbb0
--- /dev/null
+++ b/anomalib/models/components/feature_extractors/__init__.py
@@ -0,0 +1,19 @@
+"""Feature extractors."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .feature_extractor import FeatureExtractor
+
+__all__ = ["FeatureExtractor"]
diff --git a/anomalib/models/components/feature_extractors/feature_extractor.py b/anomalib/models/components/feature_extractors/feature_extractor.py
new file mode 100644
index 0000000000000000000000000000000000000000..f616d232808ad72626caaca1961c6a7b555e1b9c
--- /dev/null
+++ b/anomalib/models/components/feature_extractors/feature_extractor.py
@@ -0,0 +1,96 @@
+"""Feature Extractor.
+
+This script extracts features from a CNN network
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Callable, Dict, Iterable
+
+import torch
+from torch import Tensor, nn
+
+
+class FeatureExtractor(nn.Module):
+ """Extract features from a CNN.
+
+ Args:
+ backbone (nn.Module): The backbone to which the feature extraction hooks are attached.
+ layers (Iterable[str]): List of layer names of the backbone to which the hooks are attached.
+
+ Example:
+ >>> import torch
+ >>> import torchvision
+ >>> from anomalib.core.model.feature_extractor import FeatureExtractor
+
+ >>> model = FeatureExtractor(model=torchvision.models.resnet18(), layers=['layer1', 'layer2', 'layer3'])
+ >>> input = torch.rand((32, 3, 256, 256))
+ >>> features = model(input)
+
+ >>> [layer for layer in features.keys()]
+ ['layer1', 'layer2', 'layer3']
+ >>> [feature.shape for feature in features.values()]
+ [torch.Size([32, 64, 64, 64]), torch.Size([32, 128, 32, 32]), torch.Size([32, 256, 16, 16])]
+ """
+
+ def __init__(self, backbone: nn.Module, layers: Iterable[str]):
+ super().__init__()
+ self.backbone = backbone
+ self.layers = layers
+ self._features = {layer: torch.empty(0) for layer in self.layers}
+ self.out_dims = []
+
+ for layer_id in layers:
+ layer = dict([*self.backbone.named_modules()])[layer_id]
+ layer.register_forward_hook(self.get_features(layer_id))
+ # get output dimension of features if available
+ layer_modules = [*layer.modules()]
+ for idx in reversed(range(len(layer_modules))):
+ if hasattr(layer_modules[idx], "out_channels"):
+ self.out_dims.append(layer_modules[idx].out_channels)
+ break
+
+ def get_features(self, layer_id: str) -> Callable:
+ """Get layer features.
+
+ Args:
+ layer_id (str): Layer ID
+
+ Returns:
+ Layer features
+ """
+
+ def hook(_, __, output):
+ """Hook to extract features via a forward-pass.
+
+ Args:
+ output: Feature map collected after the forward-pass.
+ """
+ self._features[layer_id] = output
+
+ return hook
+
+ def forward(self, input_tensor: Tensor) -> Dict[str, Tensor]:
+ """Forward-pass input tensor into the CNN.
+
+ Args:
+ input_tensor (Tensor): Input tensor
+
+ Returns:
+ Feature map extracted from the CNN
+ """
+ self._features = {layer: torch.empty(0) for layer in self.layers}
+ _ = self.backbone(input_tensor)
+ return self._features
diff --git a/anomalib/models/components/freia/README.md b/anomalib/models/components/freia/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..b9ef90a20b2f61f2cc8a4594c87b04ee922ac932
--- /dev/null
+++ b/anomalib/models/components/freia/README.md
@@ -0,0 +1,5 @@
+## FrEIA
+This sub-package contains freia packages to use within flow-based algorithms such as Cflow.
+
+## Description
+[FrEIA](https://github.com/VLL-HD/FrEIA) package is currently not available in pypi to install via pip. The only way to install it is `pip install git+https://github.com/VLL-HD/FrEIA.git`. PyPI, however, does not support installing packages from git links. Due to this limitation, anomalib cannot be updated on PyPI. To avoid this, `anomalib` contains some of the [FrEIA](https://github.com/VLL-HD/FrEIA) modules to facilitate CFlow training/inference.
diff --git a/anomalib/models/components/freia/__init__.py b/anomalib/models/components/freia/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b52144c7a71243a81df226def0a7f12f9b111c65
--- /dev/null
+++ b/anomalib/models/components/freia/__init__.py
@@ -0,0 +1,11 @@
+"""Framework for Easily Invertible Architectures.
+
+Module to construct invertible networks with pytorch, based on a graph
+structure of operations.
+
+Link to the original repo: https://github.com/VLL-HD/FrEIA
+"""
+
+# Copyright (c) 2018-2022 Lynton Ardizzone, Visual Learning Lab Heidelberg.
+# SPDX-License-Identifier: MIT
+#
diff --git a/anomalib/models/components/freia/framework/__init__.py b/anomalib/models/components/freia/framework/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..226ceebd518f54822b640263c7685b0af4999adf
--- /dev/null
+++ b/anomalib/models/components/freia/framework/__init__.py
@@ -0,0 +1,9 @@
+"""Framework."""
+
+# Copyright (c) 2018-2022 Lynton Ardizzone, Visual Learning Lab Heidelberg.
+# SPDX-License-Identifier: MIT
+#
+
+from .sequence_inn import SequenceINN
+
+__all__ = ["SequenceINN"]
diff --git a/anomalib/models/components/freia/framework/sequence_inn.py b/anomalib/models/components/freia/framework/sequence_inn.py
new file mode 100644
index 0000000000000000000000000000000000000000..a5c05d8291ca96b40002500b7247323728e9c3a7
--- /dev/null
+++ b/anomalib/models/components/freia/framework/sequence_inn.py
@@ -0,0 +1,120 @@
+"""Sequence INN."""
+
+# Copyright (c) 2018-2022 Lynton Ardizzone, Visual Learning Lab Heidelberg.
+# SPDX-License-Identifier: MIT
+#
+
+# pylint: disable=invalid-name
+# flake8: noqa
+# pylint: skip-file
+# type: ignore
+# pydocstyle: noqa
+
+from typing import Iterable, List, Tuple
+
+import torch
+from torch import Tensor, nn
+
+from anomalib.models.components.freia.modules.base import InvertibleModule
+
+
+class SequenceINN(InvertibleModule):
+ """Simpler than FrEIA.framework.GraphINN.
+
+ Only supports a sequential series of modules (no splitting, merging,
+ branching off).
+ Has an append() method, to add new blocks in a more simple way than the
+ computation-graph based approach of GraphINN. For example:
+ .. code-block:: python
+ inn = SequenceINN(channels, dims_H, dims_W)
+ for i in range(n_blocks):
+ inn.append(FrEIA.modules.AllInOneBlock, clamp=2.0, permute_soft=True)
+ inn.append(FrEIA.modules.HaarDownsampling)
+ # and so on
+ """
+
+ def __init__(self, *dims: int, force_tuple_output=False):
+ super().__init__([dims])
+
+ self.shapes = [tuple(dims)]
+ self.conditions = []
+ self.module_list = nn.ModuleList()
+
+ self.force_tuple_output = force_tuple_output
+
+ def append(self, module_class, cond=None, cond_shape=None, **kwargs):
+ """Append a reversible block from FrEIA.modules to the network.
+
+ Args:
+ module_class: Class from FrEIA.modules.
+ cond (int): index of which condition to use (conditions will be passed as list to forward()).
+ Conditioning nodes are not needed for SequenceINN.
+ cond_shape (tuple[int]): the shape of the condition tensor.
+ **kwargs: Further keyword arguments that are passed to the constructor of module_class (see example).
+ """
+
+ dims_in = [self.shapes[-1]]
+ self.conditions.append(cond)
+
+ if cond is not None:
+ kwargs["dims_c"] = [cond_shape]
+
+ module = module_class(dims_in, **kwargs)
+ self.module_list.append(module)
+ ouput_dims = module.output_dims(dims_in)
+ assert len(ouput_dims) == 1, "Module has more than one output"
+ self.shapes.append(ouput_dims[0])
+
+ def __getitem__(self, item):
+ """Get item."""
+ return self.module_list.__getitem__(item)
+
+ def __len__(self):
+ """Get length."""
+ return self.module_list.__len__()
+
+ def __iter__(self):
+ """Iter."""
+ return self.module_list.__iter__()
+
+ def output_dims(self, input_dims: List[Tuple[int]]) -> List[Tuple[int]]:
+ """Output Dims."""
+ if not self.force_tuple_output:
+ raise ValueError(
+ "You can only call output_dims on a SequentialINN " "when setting force_tuple_output=True."
+ )
+ return input_dims
+
+ def forward(
+ self, x_or_z: Tensor, c: Iterable[Tensor] = None, rev: bool = False, jac: bool = True
+ ) -> Tuple[Tensor, Tensor]:
+ """Execute the sequential INN in forward or inverse (rev=True) direction.
+
+ Args:
+ x_or_z: input tensor (in contrast to GraphINN, a list of
+ tensors is not supported, as SequenceINN only has
+ one input).
+ c: list of conditions.
+ rev: whether to compute the network forward or reversed.
+ jac: whether to compute the log jacobian
+ Returns:
+ z_or_x (Tensor): network output.
+ jac (Tensor): log-jacobian-determinant.
+ """
+
+ iterator = range(len(self.module_list))
+ log_det_jac = 0
+
+ if rev:
+ iterator = reversed(iterator)
+
+ if torch.is_tensor(x_or_z):
+ x_or_z = (x_or_z,)
+ for i in iterator:
+ if self.conditions[i] is None:
+ x_or_z, j = self.module_list[i](x_or_z, jac=jac, rev=rev)
+ else:
+ x_or_z, j = self.module_list[i](x_or_z, c=[c[self.conditions[i]]], jac=jac, rev=rev)
+ log_det_jac = j + log_det_jac
+
+ return x_or_z if self.force_tuple_output else x_or_z[0], log_det_jac
diff --git a/anomalib/models/components/freia/modules/__init__.py b/anomalib/models/components/freia/modules/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4060ed6bfea4c214c8d450edde6d055d40ba70cb
--- /dev/null
+++ b/anomalib/models/components/freia/modules/__init__.py
@@ -0,0 +1,10 @@
+"""Modules."""
+
+# Copyright (c) 2018-2022 Lynton Ardizzone, Visual Learning Lab Heidelberg.
+# SPDX-License-Identifier: MIT
+#
+
+from .all_in_one_block import AllInOneBlock
+from .base import InvertibleModule
+
+__all__ = ["AllInOneBlock", "InvertibleModule"]
diff --git a/anomalib/models/components/freia/modules/all_in_one_block.py b/anomalib/models/components/freia/modules/all_in_one_block.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc35c1c3f6bc7444a3d083e479fd189aec644202
--- /dev/null
+++ b/anomalib/models/components/freia/modules/all_in_one_block.py
@@ -0,0 +1,289 @@
+"""All in One Block Module."""
+
+# Copyright (c) 2018-2022 Lynton Ardizzone, Visual Learning Lab Heidelberg.
+# SPDX-License-Identifier: MIT
+#
+
+# flake8: noqa
+# pylint: skip-file
+# type: ignore
+# pydocstyle: noqa
+
+import warnings
+from typing import Callable
+
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from scipy.stats import special_ortho_group
+
+from anomalib.models.components.freia.modules.base import InvertibleModule
+
+
+class AllInOneBlock(InvertibleModule):
+ r"""Module combining the most common operations in a normalizing flow or similar model.
+
+ It combines affine coupling, permutation, and global affine transformation
+ ('ActNorm'). It can also be used as GIN coupling block, perform learned
+ householder permutations, and use an inverted pre-permutation. The affine
+ transformation includes a soft clamping mechanism, first used in Real-NVP.
+ The block as a whole performs the following computation:
+ .. math::
+ y = V\\,R \\; \\Psi(s_\\mathrm{global}) \\odot \\mathrm{Coupling}\\Big(R^{-1} V^{-1} x\\Big)+ t_\\mathrm{global}
+ - The inverse pre-permutation of x (i.e. :math:`R^{-1} V^{-1}`) is optional (see
+ ``reverse_permutation`` below).
+ - The learned householder reflection matrix
+ :math:`V` is also optional all together (see ``learned_householder_permutation``
+ below).
+ - For the coupling, the input is split into :math:`x_1, x_2` along
+ the channel dimension. Then the output of the coupling operation is the
+ two halves :math:`u = \\mathrm{concat}(u_1, u_2)`.
+ .. math::
+ u_1 &= x_1 \\odot \\exp \\Big( \\alpha \\; \\mathrm{tanh}\\big( s(x_2) \\big)\\Big) + t(x_2) \\\\
+ u_2 &= x_2
+ Because :math:`\\mathrm{tanh}(s) \\in [-1, 1]`, this clamping mechanism prevents
+ exploding values in the exponential. The hyperparameter :math:`\\alpha` can be adjusted.
+ """
+
+ def __init__(
+ self,
+ dims_in,
+ dims_c=[],
+ subnet_constructor: Callable = None,
+ affine_clamping: float = 2.0,
+ gin_block: bool = False,
+ global_affine_init: float = 1.0,
+ global_affine_type: str = "SOFTPLUS",
+ permute_soft: bool = False,
+ learned_householder_permutation: int = 0,
+ reverse_permutation: bool = False,
+ ):
+ r"""Initialize.
+
+ Args:
+ dims_in (_type_): dims_in
+ dims_c (list, optional): dims_c. Defaults to [].
+ subnet_constructor (Callable, optional): class or callable ``f``, called as ``f(channels_in, channels_out)`` and
+ should return a torch.nn.Module. Predicts coupling coefficients :math:`s, t`. Defaults to None.
+ affine_clamping (float, optional): clamp the output of the multiplicative coefficients before
+ exponentiation to +/- ``affine_clamping`` (see :math:`\\alpha` above). Defaults to 2.0.
+ gin_block (bool, optional): Turn the block into a GIN block from Sorrenson et al, 2019.
+ Makes it so that the coupling operations as a whole is volume preserving. Defaults to False.
+ global_affine_init (float, optional): Initial value for the global affine scaling :math:`s_\mathrm{global}`.. Defaults to 1.0.
+ global_affine_type (str, optional): ``'SIGMOID'``, ``'SOFTPLUS'``, or ``'EXP'``. Defines the activation to be used
+ on the beta for the global affine scaling (:math:`\\Psi` above).. Defaults to "SOFTPLUS".
+ permute_soft (bool, optional): bool, whether to sample the permutation matrix :math:`R` from :math:`SO(N)`,
+ or to use hard permutations instead. Note, ``permute_soft=True`` is very slow
+ when working with >512 dimensions. Defaults to False.
+ learned_householder_permutation (int, optional): Int, if >0, turn on the matrix :math:`V` above, that represents
+ multiple learned householder reflections. Slow if large number.
+ Dubious whether it actually helps network performance. Defaults to 0.
+ reverse_permutation (bool, optional): Reverse the permutation before the block, as introduced by Putzky
+ et al, 2019. Turns on the :math:`R^{-1} V^{-1}` pre-multiplication above. Defaults to False.
+
+ Raises:
+ ValueError: _description_
+ ValueError: _description_
+ ValueError: _description_
+ """
+
+ super().__init__(dims_in, dims_c)
+
+ channels = dims_in[0][0]
+ # rank of the tensors means 1d, 2d, 3d tensor etc.
+ self.input_rank = len(dims_in[0]) - 1
+ # tuple containing all dims except for batch-dim (used at various points)
+ self.sum_dims = tuple(range(1, 2 + self.input_rank))
+
+ if len(dims_c) == 0:
+ self.conditional = False
+ self.condition_channels = 0
+ else:
+ assert tuple(dims_c[0][1:]) == tuple(
+ dims_in[0][1:]
+ ), f"Dimensions of input and condition don't agree: {dims_c} vs {dims_in}."
+ self.conditional = True
+ self.condition_channels = sum(dc[0] for dc in dims_c)
+
+ split_len1 = channels - channels // 2
+ split_len2 = channels // 2
+ self.splits = [split_len1, split_len2]
+
+ try:
+ self.permute_function = {0: F.linear, 1: F.conv1d, 2: F.conv2d, 3: F.conv3d}[self.input_rank]
+ except KeyError:
+ raise ValueError(f"Data is {1 + self.input_rank}D. Must be 1D-4D.")
+
+ self.in_channels = channels
+ self.clamp = affine_clamping
+ self.GIN = gin_block
+ self.reverse_pre_permute = reverse_permutation
+ self.householder = learned_householder_permutation
+
+ if permute_soft and channels > 512:
+ warnings.warn(
+ (
+ "Soft permutation will take a very long time to initialize "
+ f"with {channels} feature channels. Consider using hard permutation instead."
+ )
+ )
+
+ # global_scale is used as the initial value for the global affine scale
+ # (pre-activation). It is computed such that
+ # global_scale_activation(global_scale) = global_affine_init
+ # the 'magic numbers' (specifically for sigmoid) scale the activation to
+ # a sensible range.
+ if global_affine_type == "SIGMOID":
+ global_scale = 2.0 - np.log(10.0 / global_affine_init - 1.0)
+ self.global_scale_activation = lambda a: 10 * torch.sigmoid(a - 2.0)
+ elif global_affine_type == "SOFTPLUS":
+ global_scale = 2.0 * np.log(np.exp(0.5 * 10.0 * global_affine_init) - 1)
+ self.softplus = nn.Softplus(beta=0.5)
+ self.global_scale_activation = lambda a: 0.1 * self.softplus(a)
+ elif global_affine_type == "EXP":
+ global_scale = np.log(global_affine_init)
+ self.global_scale_activation = lambda a: torch.exp(a)
+ else:
+ raise ValueError('Global affine activation must be "SIGMOID", "SOFTPLUS" or "EXP"')
+
+ self.global_scale = nn.Parameter(
+ torch.ones(1, self.in_channels, *([1] * self.input_rank)) * float(global_scale)
+ )
+ self.global_offset = nn.Parameter(torch.zeros(1, self.in_channels, *([1] * self.input_rank)))
+
+ if permute_soft:
+ w = special_ortho_group.rvs(channels)
+ else:
+ w = np.zeros((channels, channels))
+ for i, j in enumerate(np.random.permutation(channels)):
+ w[i, j] = 1.0
+
+ if self.householder:
+ # instead of just the permutation matrix w, the learned housholder
+ # permutation keeps track of reflection vectors vk, in addition to a
+ # random initial permutation w_0.
+ self.vk_householder = nn.Parameter(0.2 * torch.randn(self.householder, channels), requires_grad=True)
+ self.w_perm = None
+ self.w_perm_inv = None
+ self.w_0 = nn.Parameter(torch.FloatTensor(w), requires_grad=False)
+ else:
+ self.w_perm = nn.Parameter(
+ torch.FloatTensor(w).view(channels, channels, *([1] * self.input_rank)), requires_grad=False
+ )
+ self.w_perm_inv = nn.Parameter(
+ torch.FloatTensor(w.T).view(channels, channels, *([1] * self.input_rank)), requires_grad=False
+ )
+
+ if subnet_constructor is None:
+ raise ValueError("Please supply a callable subnet_constructor" "function or object (see docstring)")
+ self.subnet = subnet_constructor(self.splits[0] + self.condition_channels, 2 * self.splits[1])
+ self.last_jac = None
+
+ def _construct_householder_permutation(self):
+ """Compute a permutation matrix.
+
+ Compute a permutation matrix from the reflection vectors that are
+ learned internally as nn.Parameters.
+ """
+ w = self.w_0
+ for vk in self.vk_householder:
+ w = torch.mm(w, torch.eye(self.in_channels).to(w.device) - 2 * torch.ger(vk, vk) / torch.dot(vk, vk))
+
+ for i in range(self.input_rank):
+ w = w.unsqueeze(-1)
+ return w
+
+ def _permute(self, x, rev=False):
+ """Perform permutation.
+
+ Performs the permutation and scaling after the coupling operation.
+ Returns transformed outputs and the LogJacDet of the scaling operation.
+ """
+ if self.GIN:
+ scale = 1.0
+ perm_log_jac = 0.0
+ else:
+ scale = self.global_scale_activation(self.global_scale)
+ perm_log_jac = torch.sum(torch.log(scale))
+
+ if rev:
+ return ((self.permute_function(x, self.w_perm_inv) - self.global_offset) / scale, perm_log_jac)
+ else:
+ return (self.permute_function(x * scale + self.global_offset, self.w_perm), perm_log_jac)
+
+ def _pre_permute(self, x, rev=False):
+ """Permute before the coupling block, only used if reverse_permutation is set."""
+ if rev:
+ return self.permute_function(x, self.w_perm)
+ else:
+ return self.permute_function(x, self.w_perm_inv)
+
+ def _affine(self, x, a, rev=False):
+ """Perform affine coupling operation.
+
+ Given the passive half, and the pre-activation outputs of the
+ coupling subnetwork, perform the affine coupling operation.
+ Returns both the transformed inputs and the LogJacDet.
+ """
+
+ # the entire coupling coefficient tensor is scaled down by a
+ # factor of ten for stability and easier initialization.
+ a *= 0.1
+ ch = x.shape[1]
+
+ sub_jac = self.clamp * torch.tanh(a[:, :ch])
+ if self.GIN:
+ sub_jac -= torch.mean(sub_jac, dim=self.sum_dims, keepdim=True)
+
+ if not rev:
+ return (x * torch.exp(sub_jac) + a[:, ch:], torch.sum(sub_jac, dim=self.sum_dims))
+ else:
+ return ((x - a[:, ch:]) * torch.exp(-sub_jac), -torch.sum(sub_jac, dim=self.sum_dims))
+
+ def forward(self, x, c=[], rev=False, jac=True):
+ """See base class docstring."""
+ if self.householder:
+ self.w_perm = self._construct_householder_permutation()
+ if rev or self.reverse_pre_permute:
+ self.w_perm_inv = self.w_perm.transpose(0, 1).contiguous()
+
+ if rev:
+ x, global_scaling_jac = self._permute(x[0], rev=True)
+ x = (x,)
+ elif self.reverse_pre_permute:
+ x = (self._pre_permute(x[0], rev=False),)
+
+ x1, x2 = torch.split(x[0], self.splits, dim=1)
+
+ if self.conditional:
+ x1c = torch.cat([x1, *c], 1)
+ else:
+ x1c = x1
+
+ if not rev:
+ a1 = self.subnet(x1c)
+ x2, j2 = self._affine(x2, a1)
+ else:
+ a1 = self.subnet(x1c)
+ x2, j2 = self._affine(x2, a1, rev=True)
+
+ log_jac_det = j2
+ x_out = torch.cat((x1, x2), 1)
+
+ if not rev:
+ x_out, global_scaling_jac = self._permute(x_out, rev=False)
+ elif self.reverse_pre_permute:
+ x_out = self._pre_permute(x_out, rev=True)
+
+ # add the global scaling Jacobian to the total.
+ # trick to get the total number of non-channel dimensions:
+ # number of elements of the first channel of the first batch member
+ n_pixels = x_out[0, :1].numel()
+ log_jac_det += (-1) ** rev * n_pixels * global_scaling_jac
+
+ return (x_out,), log_jac_det
+
+ def output_dims(self, input_dims):
+ """Output Dims."""
+ return input_dims
diff --git a/anomalib/models/components/freia/modules/base.py b/anomalib/models/components/freia/modules/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a67d3154161837650f763e5ebc7efba0e147227
--- /dev/null
+++ b/anomalib/models/components/freia/modules/base.py
@@ -0,0 +1,112 @@
+"""Base Module."""
+
+# Copyright (c) 2018-2022 Lynton Ardizzone, Visual Learning Lab Heidelberg.
+# SPDX-License-Identifier: MIT
+#
+
+# flake8: noqa
+# pylint: skip-file
+# type: ignore
+# pydocstyle: noqa
+
+from typing import Iterable, List, Tuple
+
+import torch.nn as nn
+from torch import Tensor
+
+
+class InvertibleModule(nn.Module):
+ r"""Base class for all invertible modules in FrEIA.
+
+ Given ``module``, an instance of some InvertibleModule.
+ This ``module`` shall be invertible in its input dimensions,
+ so that the input can be recovered by applying the module
+ in backwards mode (``rev=True``), not to be confused with
+ ``pytorch.backward()`` which computes the gradient of an operation::
+ x = torch.randn(BATCH_SIZE, DIM_COUNT)
+ c = torch.randn(BATCH_SIZE, CONDITION_DIM)
+ # Forward mode
+ z, jac = module([x], [c], jac=True)
+ # Backward mode
+ x_rev, jac_rev = module(z, [c], rev=True)
+ The ``module`` returns :math:`\\log \\det J = \\log \\left| \\det \\frac{\\partial f}{\\partial x} \\right|`
+ of the operation in forward mode, and
+ :math:`-\\log | \\det J | = \\log \\left| \\det \\frac{\\partial f^{-1}}{\\partial z} \\right| = -\\log \\left| \\det \\frac{\\partial f}{\\partial x} \\right|`
+ in backward mode (``rev=True``).
+ Then, ``torch.allclose(x, x_rev) == True`` and ``torch.allclose(jac, -jac_rev) == True``.
+ """
+
+ def __init__(self, dims_in: Iterable[Tuple[int]], dims_c: Iterable[Tuple[int]] = None):
+ """Initialize.
+
+ Args:
+ dims_in: list of tuples specifying the shape of the inputs to this
+ operator: ``dims_in = [shape_x_0, shape_x_1, ...]``
+ dims_c: list of tuples specifying the shape of the conditions to
+ this operator.
+ """
+ super().__init__()
+ if dims_c is None:
+ dims_c = []
+ self.dims_in = list(dims_in)
+ self.dims_c = list(dims_c)
+
+ def forward(
+ self, x_or_z: Iterable[Tensor], c: Iterable[Tensor] = None, rev: bool = False, jac: bool = True
+ ) -> Tuple[Tuple[Tensor], Tensor]:
+ r"""Forward/Backward Pass.
+
+ Perform a forward (default, ``rev=False``) or backward pass (``rev=True``) through this module/operator.
+
+ **Note to implementers:**
+ - Subclasses MUST return a Jacobian when ``jac=True``, but CAN return a
+ valid Jacobian when ``jac=False`` (not punished). The latter is only recommended
+ if the computation of the Jacobian is trivial.
+ - Subclasses MUST follow the convention that the returned Jacobian be
+ consistent with the evaluation direction. Let's make this more precise:
+ Let :math:`f` be the function that the subclass represents. Then:
+ .. math::
+ J &= \\log \\det \\frac{\\partial f}{\\partial x} \\\\
+ -J &= \\log \\det \\frac{\\partial f^{-1}}{\\partial z}.
+ Any subclass MUST return :math:`J` for forward evaluation (``rev=False``),
+ and :math:`-J` for backward evaluation (``rev=True``).
+
+ Args:
+ x_or_z: input data (array-like of one or more tensors)
+ c: conditioning data (array-like of none or more tensors)
+ rev: perform backward pass
+ jac: return Jacobian associated to the direction
+ """
+ raise NotImplementedError(f"{self.__class__.__name__} does not provide forward(...) method")
+
+ def log_jacobian(self, *args, **kwargs):
+ """This method is deprecated, and does nothing except raise a warning."""
+ raise DeprecationWarning(
+ "module.log_jacobian(...) is deprecated. "
+ "module.forward(..., jac=True) returns a "
+ "tuple (out, jacobian) now."
+ )
+
+ def output_dims(self, input_dims: List[Tuple[int]]) -> List[Tuple[int]]:
+ """Use for shape inference during construction of the graph.
+
+ MUST be implemented for each subclass of ``InvertibleModule``.
+
+ Args:
+ input_dims: A list with one entry for each input to the module.
+ Even if the module only has one input, must be a list with one
+ entry. Each entry is a tuple giving the shape of that input,
+ excluding the batch dimension. For example for a module with one
+ input, which receives a 32x32 pixel RGB image, ``input_dims`` would
+ be ``[(3, 32, 32)]``
+
+ Returns:
+ A list structured in the same way as ``input_dims``. Each entry
+ represents one output of the module, and the entry is a tuple giving
+ the shape of that output. For example if the module splits the image
+ into a right and a left half, the return value should be
+ ``[(3, 16, 32), (3, 16, 32)]``. It is up to the implementor of the
+ subclass to ensure that the total number of elements in all inputs
+ and all outputs is consistent.
+ """
+ raise NotImplementedError(f"{self.__class__.__name__} does not provide output_dims(...)")
diff --git a/anomalib/models/components/sampling/__init__.py b/anomalib/models/components/sampling/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..26dac37289943e940f1ebc2e19b9942272920206
--- /dev/null
+++ b/anomalib/models/components/sampling/__init__.py
@@ -0,0 +1,19 @@
+"""Sampling methods."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .k_center_greedy import KCenterGreedy
+
+__all__ = ["KCenterGreedy"]
diff --git a/anomalib/models/components/sampling/k_center_greedy.py b/anomalib/models/components/sampling/k_center_greedy.py
new file mode 100644
index 0000000000000000000000000000000000000000..1703c74149932e61e56f6158637b3a02ace71f7d
--- /dev/null
+++ b/anomalib/models/components/sampling/k_center_greedy.py
@@ -0,0 +1,134 @@
+"""This module comprises PatchCore Sampling Methods for the embedding.
+
+- k Center Greedy Method
+ Returns points that minimizes the maximum distance of any point to a center.
+ . https://arxiv.org/abs/1708.00489
+"""
+
+from typing import List, Optional
+
+import torch
+import torch.nn.functional as F
+from torch import Tensor
+
+from anomalib.models.components.dimensionality_reduction import SparseRandomProjection
+
+
+class KCenterGreedy:
+ """Implements k-center-greedy method.
+
+ Args:
+ embedding (Tensor): Embedding vector extracted from a CNN
+ sampling_ratio (float): Ratio to choose coreset size from the embedding size.
+
+ Example:
+ >>> embedding.shape
+ torch.Size([219520, 1536])
+ >>> sampler = KCenterGreedy(embedding=embedding)
+ >>> sampled_idxs = sampler.select_coreset_idxs()
+ >>> coreset = embedding[sampled_idxs]
+ >>> coreset.shape
+ torch.Size([219, 1536])
+ """
+
+ def __init__(self, embedding: Tensor, sampling_ratio: float) -> None:
+ self.embedding = embedding
+ self.coreset_size = int(embedding.shape[0] * sampling_ratio)
+ self.model = SparseRandomProjection(eps=0.9)
+
+ self.features: Tensor
+ self.min_distances: Tensor = None
+ self.n_observations = self.embedding.shape[0]
+
+ def reset_distances(self) -> None:
+ """Reset minimum distances."""
+ self.min_distances = None
+
+ def update_distances(self, cluster_centers: List[int]) -> None:
+ """Update min distances given cluster centers.
+
+ Args:
+ cluster_centers (List[int]): indices of cluster centers
+ """
+
+ if cluster_centers:
+ centers = self.features[cluster_centers]
+
+ distance = F.pairwise_distance(self.features, centers, p=2).reshape(-1, 1)
+
+ if self.min_distances is None:
+ self.min_distances = distance
+ else:
+ self.min_distances = torch.minimum(self.min_distances, distance)
+
+ def get_new_idx(self) -> int:
+ """Get index value of a sample.
+
+ Based on minimum distance of the cluster
+
+ Returns:
+ int: Sample index
+ """
+
+ if isinstance(self.min_distances, Tensor):
+ idx = int(torch.argmax(self.min_distances).item())
+ else:
+ raise ValueError(f"self.min_distances must be of type Tensor. Got {type(self.min_distances)}")
+
+ return idx
+
+ def select_coreset_idxs(self, selected_idxs: Optional[List[int]] = None) -> List[int]:
+ """Greedily form a coreset to minimize the maximum distance of a cluster.
+
+ Args:
+ selected_idxs: index of samples already selected. Defaults to an empty set.
+
+ Returns:
+ indices of samples selected to minimize distance to cluster centers
+ """
+
+ if selected_idxs is None:
+ selected_idxs = []
+
+ if self.embedding.ndim == 2:
+ self.model.fit(self.embedding)
+ self.features = self.model.transform(self.embedding)
+ self.reset_distances()
+ else:
+ self.features = self.embedding.reshape(self.embedding.shape[0], -1)
+ self.update_distances(cluster_centers=selected_idxs)
+
+ selected_coreset_idxs: List[int] = []
+ idx = int(torch.randint(high=self.n_observations, size=(1,)).item())
+ for _ in range(self.coreset_size):
+ self.update_distances(cluster_centers=[idx])
+ idx = self.get_new_idx()
+ if idx in selected_idxs:
+ raise ValueError("New indices should not be in selected indices.")
+ self.min_distances[idx] = 0
+ selected_coreset_idxs.append(idx)
+
+ return selected_coreset_idxs
+
+ def sample_coreset(self, selected_idxs: Optional[List[int]] = None) -> Tensor:
+ """Select coreset from the embedding.
+
+ Args:
+ selected_idxs: index of samples already selected. Defaults to an empty set.
+
+ Returns:
+ Tensor: Output coreset
+
+ Example:
+ >>> embedding.shape
+ torch.Size([219520, 1536])
+ >>> sampler = KCenterGreedy(...)
+ >>> coreset = sampler.sample_coreset()
+ >>> coreset.shape
+ torch.Size([219, 1536])
+ """
+
+ idxs = self.select_coreset_idxs(selected_idxs)
+ coreset = self.embedding[idxs]
+
+ return coreset
diff --git a/anomalib/models/components/stats/__init__.py b/anomalib/models/components/stats/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..829c0f75b28e47cb9637638e737af827c6c3e343
--- /dev/null
+++ b/anomalib/models/components/stats/__init__.py
@@ -0,0 +1,20 @@
+"""Statistical functions."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .kde import GaussianKDE
+from .multi_variate_gaussian import MultiVariateGaussian
+
+__all__ = ["GaussianKDE", "MultiVariateGaussian"]
diff --git a/anomalib/models/components/stats/kde.py b/anomalib/models/components/stats/kde.py
new file mode 100644
index 0000000000000000000000000000000000000000..6fb66bb6f4a50b22bc4bcca943c27f960e9cdcbf
--- /dev/null
+++ b/anomalib/models/components/stats/kde.py
@@ -0,0 +1,108 @@
+"""Gaussian Kernel Density Estimation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import math
+from typing import Optional
+
+import torch
+from torch import Tensor
+
+from anomalib.models.components.base import DynamicBufferModule
+
+
+class GaussianKDE(DynamicBufferModule):
+ """Gaussian Kernel Density Estimation.
+
+ Args:
+ dataset (Optional[Tensor], optional): Dataset on which to fit the KDE model. Defaults to None.
+ """
+
+ def __init__(self, dataset: Optional[Tensor] = None):
+ super().__init__()
+
+ if dataset is not None:
+ self.fit(dataset)
+
+ self.register_buffer("bw_transform", Tensor())
+ self.register_buffer("dataset", Tensor())
+ self.register_buffer("norm", Tensor())
+
+ self.bw_transform = Tensor()
+ self.dataset = Tensor()
+ self.norm = Tensor()
+
+ def forward(self, features: Tensor) -> Tensor:
+ """Get the KDE estimates from the feature map.
+
+ Args:
+ features (Tensor): Feature map extracted from the CNN
+
+ Returns: KDE Estimates
+ """
+ features = torch.matmul(features, self.bw_transform)
+
+ estimate = torch.zeros(features.shape[0]).to(features.device)
+ for i in range(features.shape[0]):
+ embedding = ((self.dataset - features[i]) ** 2).sum(dim=1)
+ embedding = torch.exp(-embedding / 2) * self.norm
+ estimate[i] = torch.mean(embedding)
+
+ return estimate
+
+ def fit(self, dataset: Tensor) -> None:
+ """Fit a KDE model to the input dataset.
+
+ Args:
+ dataset (Tensor): Input dataset.
+
+ Returns:
+ None
+ """
+ num_samples, dimension = dataset.shape
+
+ # compute scott's bandwidth factor
+ factor = num_samples ** (-1 / (dimension + 4))
+
+ cov_mat = self.cov(dataset.T)
+ inv_cov_mat = torch.linalg.inv(cov_mat)
+ inv_cov = inv_cov_mat / factor**2
+
+ # transform data to account for bandwidth
+ bw_transform = torch.linalg.cholesky(inv_cov)
+ dataset = torch.matmul(dataset, bw_transform)
+
+ #
+ norm = torch.prod(torch.diag(bw_transform))
+ norm *= math.pow((2 * math.pi), (-dimension / 2))
+
+ self.bw_transform = bw_transform
+ self.dataset = dataset
+ self.norm = norm
+
+ @staticmethod
+ def cov(tensor: Tensor) -> Tensor:
+ """Calculate the unbiased covariance matrix.
+
+ Args:
+ tensor (Tensor): Input tensor from which covariance matrix is computed.
+
+ Returns:
+ Output covariance matrix.
+ """
+ mean = torch.mean(tensor, dim=1)
+ tensor -= mean[:, None]
+ cov = torch.matmul(tensor, tensor.T) / (tensor.size(1) - 1)
+ return cov
diff --git a/anomalib/models/components/stats/multi_variate_gaussian.py b/anomalib/models/components/stats/multi_variate_gaussian.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9926ffe6b2923720467cda141f71d4ff786fa42
--- /dev/null
+++ b/anomalib/models/components/stats/multi_variate_gaussian.py
@@ -0,0 +1,151 @@
+"""Multi Variate Gaussian Distribution."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Any, List, Optional
+
+import torch
+from torch import Tensor, nn
+
+
+class MultiVariateGaussian(nn.Module):
+ """Multi Variate Gaussian Distribution."""
+
+ def __init__(self, n_features, n_patches):
+ super().__init__()
+
+ self.register_buffer("mean", torch.zeros(n_features, n_patches))
+ self.register_buffer("inv_covariance", torch.eye(n_features).unsqueeze(0).repeat(n_patches, 1, 1))
+
+ self.mean: Tensor
+ self.inv_covariance: Tensor
+
+ @staticmethod
+ def _cov(
+ observations: Tensor,
+ rowvar: bool = False,
+ bias: bool = False,
+ ddof: Optional[int] = None,
+ aweights: Tensor = None,
+ ) -> Tensor:
+ """Estimates covariance matrix like numpy.cov.
+
+ Args:
+ observations (Tensor): A 1-D or 2-D array containing multiple variables and observations.
+ Each row of `m` represents a variable, and each column a single
+ observation of all those variables. Also see `rowvar` below.
+ rowvar (bool): If `rowvar` is True (default), then each row represents a
+ variable, with observations in the columns. Otherwise, the relationship
+ is transposed: each column represents a variable, while the rows
+ contain observations. Defaults to False.
+ bias (bool): Default normalization (False) is by ``(N - 1)``, where ``N`` is the
+ number of observations given (unbiased estimate). If `bias` is True,
+ then normalization is by ``N``. These values can be overridden by using
+ the keyword ``ddof`` in numpy versions >= 1.5. Defaults to False
+ ddof (Optional, int): If not ``None`` the default value implied by `bias` is overridden.
+ Note that ``ddof=1`` will return the unbiased estimate, even if both
+ `fweights` and `aweights` are specified, and ``ddof=0`` will return
+ the simple average. See the notes for the details. The default value
+ is ``None``.
+ aweights (Tensor): 1-D array of observation vector weights. These relative weights are
+ typically large for observations considered "important" and smaller for
+ observations considered less "important". If ``ddof=0`` the array of
+ weights can be used to assign probabilities to observation vectors. (Default value = None)
+
+
+ Returns:
+ The covariance matrix of the variables.
+ """
+ # ensure at least 2D
+ if observations.dim() == 1:
+ observations = observations.view(-1, 1)
+
+ # treat each column as a data point, each row as a variable
+ if rowvar and observations.shape[0] != 1:
+ observations = observations.t()
+
+ if ddof is None:
+ if bias == 0:
+ ddof = 1
+ else:
+ ddof = 0
+
+ weights = aweights
+ weights_sum: Any
+
+ if weights is not None:
+ if not torch.is_tensor(weights):
+ weights = torch.tensor(weights, dtype=torch.float) # pylint: disable=not-callable
+ weights_sum = torch.sum(weights)
+ avg = torch.sum(observations * (weights / weights_sum)[:, None], 0)
+ else:
+ avg = torch.mean(observations, 0)
+
+ # Determine the normalization
+ if weights is None:
+ fact = observations.shape[0] - ddof
+ elif ddof == 0:
+ fact = weights_sum
+ elif aweights is None:
+ fact = weights_sum - ddof
+ else:
+ fact = weights_sum - ddof * torch.sum(weights * weights) / weights_sum
+
+ observations_m = observations.sub(avg.expand_as(observations))
+
+ if weights is None:
+ x_transposed = observations_m.t()
+ else:
+ x_transposed = torch.mm(torch.diag(weights), observations_m).t()
+
+ covariance = torch.mm(x_transposed, observations_m)
+ covariance = covariance / fact
+
+ return covariance.squeeze()
+
+ def forward(self, embedding: Tensor) -> List[Tensor]:
+ """Calculate multivariate Gaussian distribution.
+
+ Args:
+ embedding (Tensor): CNN features whose dimensionality is reduced via either random sampling or PCA.
+
+ Returns:
+ mean and inverse covariance of the multi-variate gaussian distribution that fits the features.
+ """
+ device = embedding.device
+
+ batch, channel, height, width = embedding.size()
+ embedding_vectors = embedding.view(batch, channel, height * width)
+ self.mean = torch.mean(embedding_vectors, dim=0)
+ covariance = torch.zeros(size=(channel, channel, height * width), device=device)
+ identity = torch.eye(channel).to(device)
+ for i in range(height * width):
+ covariance[:, :, i] = self._cov(embedding_vectors[:, :, i], rowvar=False) + 0.01 * identity
+
+ # calculate inverse covariance as we need only the inverse
+ self.inv_covariance = torch.linalg.inv(covariance.permute(2, 0, 1))
+
+ return [self.mean, self.inv_covariance]
+
+ def fit(self, embedding: Tensor) -> List[Tensor]:
+ """Fit multi-variate gaussian distribution to the input embedding.
+
+ Args:
+ embedding (Tensor): Embedding vector extracted from CNN.
+
+ Returns:
+ Mean and the covariance of the embedding.
+ """
+ return self.forward(embedding)
diff --git a/anomalib/models/dfkde/README.md b/anomalib/models/dfkde/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..17e0d5483ff31e2708c8ff2fd9435b20abbde1d6
--- /dev/null
+++ b/anomalib/models/dfkde/README.md
@@ -0,0 +1,39 @@
+# Deep Feature Kernel Density Estimation
+
+Model Type: Classification
+
+## Description
+
+Fast anomaly classification algorithm that consists of a deep feature extraction stage followed by anomaly classification stage consisting of PCA and Gaussian Kernel Density Estimation.
+
+### Feature Extraction
+
+Features are extracted by feeding the images through a ResNet50 backbone, which was pre-trained on ImageNet. The output of the penultimate layer (average pooling layer) of the network is used to obtain a semantic feature vector with a fixed length of 2048.
+
+### Anomaly Detection
+
+In the anomaly classification stage, the features are first reduced to the first 16 principal components. Gaussian Kernel Density is then used to obtain an estimate of the probability density of new examples, based on the collection of training features obtained during the training phase.
+
+## Usage
+
+`python tools/train.py --model dfkde`
+
+## Benchmark
+
+All results gathered with seed `42`.
+
+## [MVTec AD Dataset](https://www.mvtec.com/company/research/datasets/mvtec-ad)
+
+### Image-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.762 | 0.646 | 0.577 | 0.669 | 0.965 | 0.863 | 0.951 | 0.751 | 0.698 | 0.806 | 0.729 | 0.607 | 0.694 | 0.767 | 0.839 | 0.866 |
+| Wide ResNet-50 | 0.774 | 0.708 | 0.422 | 0.905 | 0.959 | 0.903 | 0.936 | 0.746 | 0.853 | 0.736 | 0.687 | 0.749 | 0.574 | 0.697 | 0.843 | 0.892 |
+
+### Image F1 Score
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.872 | 0.864 | 0.844 | 0.854 | 0.960 | 0.898 | 0.942 | 0.793 | 0.908 | 0.827 | 0.894 | 0.916 | 0.859 | 0.853 | 0.756 | 0.916 |
+| Wide ResNet-50 | 0.875 | 0.907 | 0.844 | 0.905 | 0.945 | 0.914 | 0.946 | 0.790 | 0.914 | 0.817 | 0.894 | 0.922 | 0.855 | 0.845 | 0.722 | 0.910 |
diff --git a/anomalib/models/dfkde/__init__.py b/anomalib/models/dfkde/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc077649e2e3a07b899d784bb2fd5f70eb1939e9
--- /dev/null
+++ b/anomalib/models/dfkde/__init__.py
@@ -0,0 +1,19 @@
+"""Deep Feature Kernel Density Estimation model."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .lightning_model import DfkdeLightning
+
+__all__ = ["DfkdeLightning"]
diff --git a/anomalib/models/dfkde/config.yaml b/anomalib/models/dfkde/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..256a7ac78b76ec172b91be2a0bdc8412ab832881
--- /dev/null
+++ b/anomalib/models/dfkde/config.yaml
@@ -0,0 +1,85 @@
+dataset:
+ name: mvtec #options: [mvtec, btech, folder]
+ format: mvtec
+ path: ./datasets/MVTec
+ category: bottle
+ task: classification
+ image_size: 256
+ train_batch_size: 32
+ test_batch_size: 32
+ num_workers: 36
+ transform_config:
+ train: null
+ val: null
+ create_validation_set: false
+
+model:
+ name: dfkde
+ backbone: resnet18
+ max_training_points: 40000
+ confidence_threshold: 0.5
+ pre_processing: scale
+ n_components: 16
+ normalization_method: min_max # options: [null, min_max, cdf]
+ threshold:
+ image_default: 0
+ adaptive: true
+
+metrics:
+ image:
+ - F1Score
+ - AUROC
+
+project:
+ seed: 42
+ path: ./results
+ log_images_to: []
+ logger: false # options: [tensorboard, wandb, csv] or combinations.
+
+# PL Trainer Args. Don't add extra parameter here.
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
+ accumulate_grad_batches: 1
+ amp_backend: native
+ auto_lr_find: false
+ auto_scale_batch_size: false
+ auto_select_gpus: false
+ benchmark: false
+ check_val_every_n_epoch: 1 # Don't validate before extracting features.
+ default_root_dir: null
+ detect_anomaly: false
+ deterministic: false
+ enable_checkpointing: true
+ enable_model_summary: true
+ enable_progress_bar: true
+ fast_dev_run: false
+ gpus: null # Set automatically
+ gradient_clip_val: 0
+ ipus: null
+ limit_predict_batches: 1.0
+ limit_test_batches: 1.0
+ limit_train_batches: 1.0
+ limit_val_batches: 1.0
+ log_every_n_steps: 50
+ log_gpu_memory: null
+ max_epochs: 1
+ max_steps: -1
+ max_time: null
+ min_epochs: null
+ min_steps: null
+ move_metrics_to_cpu: false
+ multiple_trainloader_mode: max_size_cycle
+ num_nodes: 1
+ num_processes: 1
+ num_sanity_val_steps: 0
+ overfit_batches: 0.0
+ plugins: null
+ precision: 32
+ profiler: null
+ reload_dataloaders_every_n_epochs: 0
+ replace_sampler_ddp: true
+ strategy: null
+ sync_batchnorm: false
+ tpu_cores: null
+ track_grad_norm: -1
+ val_check_interval: 1.0 # Don't validate before extracting features.
diff --git a/anomalib/models/dfkde/lightning_model.py b/anomalib/models/dfkde/lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..363c2974cf5912066a590d47243806b10b94efa8
--- /dev/null
+++ b/anomalib/models/dfkde/lightning_model.py
@@ -0,0 +1,98 @@
+"""DFKDE: Deep Feature Kernel Density Estimation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+from typing import List, Union
+
+from omegaconf.dictconfig import DictConfig
+from omegaconf.listconfig import ListConfig
+from torch import Tensor
+
+from anomalib.models.components import AnomalyModule
+
+from .torch_model import DfkdeModel
+
+logger = logging.getLogger(__name__)
+
+
+class DfkdeLightning(AnomalyModule):
+ """DFKDE: Deep Feature Kernel Density Estimation.
+
+ Args:
+ hparams (Union[DictConfig, ListConfig]): Model params
+ """
+
+ def __init__(self, hparams: Union[DictConfig, ListConfig]):
+ super().__init__(hparams)
+ logger.info("Initializing DFKDE Lightning model.")
+ threshold_steepness = 0.05
+ threshold_offset = 12
+
+ self.model = DfkdeModel(
+ backbone=hparams.model.backbone,
+ filter_count=hparams.model.max_training_points,
+ threshold_steepness=threshold_steepness,
+ threshold_offset=threshold_offset,
+ )
+
+ self.embeddings: List[Tensor] = []
+
+ @staticmethod
+ def configure_optimizers(): # pylint: disable=arguments-differ
+ """DFKDE doesn't require optimization, therefore returns no optimizers."""
+ return None
+
+ def training_step(self, batch, _batch_idx): # pylint: disable=arguments-differ
+ """Training Step of DFKDE. For each batch, features are extracted from the CNN.
+
+ Args:
+ batch (Dict[str, Any]): Batch containing image filename, image, label and mask
+ _batch_idx: Index of the batch.
+
+ Returns:
+ Deep CNN features.
+ """
+
+ embedding = self.model.get_features(batch["image"]).squeeze()
+
+ # NOTE: `self.embedding` appends each batch embedding to
+ # store the training set embedding. We manually append these
+ # values mainly due to the new order of hooks introduced after PL v1.4.0
+ # https://github.com/PyTorchLightning/pytorch-lightning/pull/7357
+ self.embeddings.append(embedding)
+
+ def on_validation_start(self) -> None:
+ """Fit a KDE Model to the embedding collected from the training set."""
+ # NOTE: Previous anomalib versions fit Gaussian at the end of the epoch.
+ # This is not possible anymore with PyTorch Lightning v1.4.0 since validation
+ # is run within train epoch.
+ logger.info("Fitting a KDE model to the embedding collected from the training set.")
+ self.model.fit(self.embeddings)
+
+ def validation_step(self, batch, _): # pylint: disable=arguments-differ
+ """Validation Step of DFKDE.
+
+ Similar to the training step, features are extracted from the CNN for each batch.
+
+ Args:
+ batch: Input batch
+
+ Returns:
+ Dictionary containing probability, prediction and ground truth values.
+ """
+ batch["pred_scores"] = self.model(batch["image"])
+
+ return batch
diff --git a/anomalib/models/dfkde/torch_model.py b/anomalib/models/dfkde/torch_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..b4a1d86c4c653cea5ebc04b9a70a4a6f0e478f5f
--- /dev/null
+++ b/anomalib/models/dfkde/torch_model.py
@@ -0,0 +1,200 @@
+"""Normality model of DFKDE."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import random
+from typing import List, Optional, Tuple
+
+import torch
+import torchvision
+from torch import Tensor, nn
+
+from anomalib.models.components import PCA, FeatureExtractor, GaussianKDE
+
+logger = logging.getLogger(__name__)
+
+
+class DfkdeModel(nn.Module):
+ """Normality Model for the DFKDE algorithm.
+
+ Args:
+ backbone (str): Pre-trained model backbone.
+ n_comps (int, optional): Number of PCA components. Defaults to 16.
+ pre_processing (str, optional): Preprocess features before passing to KDE.
+ Options are between `norm` and `scale`. Defaults to "scale".
+ filter_count (int, optional): Number of training points to fit the KDE model. Defaults to 40000.
+ threshold_steepness (float, optional): Controls how quickly the value saturates around zero. Defaults to 0.05.
+ threshold_offset (float, optional): Offset of the density function from 0. Defaults to 12.0.
+ """
+
+ def __init__(
+ self,
+ backbone: str,
+ n_comps: int = 16,
+ pre_processing: str = "scale",
+ filter_count: int = 40000,
+ threshold_steepness: float = 0.05,
+ threshold_offset: float = 12.0,
+ ):
+ super().__init__()
+ self.n_components = n_comps
+ self.pre_processing = pre_processing
+ self.filter_count = filter_count
+ self.threshold_steepness = threshold_steepness
+ self.threshold_offset = threshold_offset
+
+ _backbone = getattr(torchvision.models, backbone)
+ self.feature_extractor = FeatureExtractor(backbone=_backbone(pretrained=True), layers=["avgpool"]).eval()
+
+ self.pca_model = PCA(n_components=self.n_components)
+ self.kde_model = GaussianKDE()
+
+ self.register_buffer("max_length", Tensor(torch.Size([])))
+ self.max_length = Tensor(torch.Size([]))
+
+ def get_features(self, batch: Tensor) -> Tensor:
+ """Extract features from the pretrained network.
+
+ Args:
+ batch (Tensor): Image batch.
+
+ Returns:
+ Tensor: Tensor containing extracted features.
+ """
+ self.feature_extractor.eval()
+ layer_outputs = self.feature_extractor(batch)
+ layer_outputs = torch.cat(list(layer_outputs.values())).detach()
+ return layer_outputs
+
+ def fit(self, embeddings: List[Tensor]) -> bool:
+ """Fit a kde model to embeddings.
+
+ Args:
+ embeddings (Tensor): Input embeddings to fit the model.
+
+ Returns:
+ Boolean confirming whether the training is successful.
+ """
+ _embeddings = torch.vstack(embeddings)
+
+ if _embeddings.shape[0] < self.n_components:
+ logger.info("Not enough features to commit. Not making a model.")
+ return False
+
+ # if max training points is non-zero and smaller than number of staged features, select random subset
+ if self.filter_count and _embeddings.shape[0] > self.filter_count:
+ # pylint: disable=not-callable
+ selected_idx = torch.tensor(random.sample(range(_embeddings.shape[0]), self.filter_count))
+ selected_features = _embeddings[selected_idx]
+ else:
+ selected_features = _embeddings
+
+ feature_stack = self.pca_model.fit_transform(selected_features)
+ feature_stack, max_length = self.preprocess(feature_stack)
+ self.max_length = max_length
+ self.kde_model.fit(feature_stack)
+
+ return True
+
+ def preprocess(self, feature_stack: Tensor, max_length: Optional[Tensor] = None) -> Tuple[Tensor, Tensor]:
+ """Pre-process the CNN features.
+
+ Args:
+ feature_stack (Tensor): Features extracted from CNN
+ max_length (Optional[Tensor]): Used to unit normalize the feature_stack vector. If ``max_len`` is not
+ provided, the length is calculated from the ``feature_stack``. Defaults to None.
+
+ Returns:
+ (Tuple): Stacked features and length
+ """
+
+ if max_length is None:
+ max_length = torch.max(torch.linalg.norm(feature_stack, ord=2, dim=1))
+
+ if self.pre_processing == "norm":
+ feature_stack /= torch.linalg.norm(feature_stack, ord=2, dim=1)[:, None]
+ elif self.pre_processing == "scale":
+ feature_stack /= max_length
+ else:
+ raise RuntimeError("Unknown pre-processing mode. Available modes are: Normalized and Scale.")
+ return feature_stack, max_length
+
+ def evaluate(self, features: Tensor, as_log_likelihood: Optional[bool] = False) -> Tensor:
+ """Compute the KDE scores.
+
+ The scores calculated from the KDE model are converted to densities. If `as_log_likelihood` is set to true then
+ the log of the scores are calculated.
+
+ Args:
+ features (Tensor): Features to which the PCA model is fit.
+ as_log_likelihood (Optional[bool], optional): If true, gets log likelihood scores. Defaults to False.
+
+ Returns:
+ (Tensor): Score
+ """
+
+ features = self.pca_model.transform(features)
+ features, _ = self.preprocess(features, self.max_length)
+ # Scores are always assumed to be passed as a density
+ kde_scores = self.kde_model(features)
+
+ # add small constant to avoid zero division in log computation
+ kde_scores += 1e-300
+
+ if as_log_likelihood:
+ kde_scores = torch.log(kde_scores)
+
+ return kde_scores
+
+ def predict(self, features: Tensor) -> Tensor:
+ """Predicts the probability that the features belong to the anomalous class.
+
+ Args:
+ features (Tensor): Feature from which the output probabilities are detected.
+
+ Returns:
+ Detection probabilities
+ """
+
+ densities = self.evaluate(features, as_log_likelihood=True)
+ probabilities = self.to_probability(densities)
+
+ return probabilities
+
+ def to_probability(self, densities: Tensor) -> Tensor:
+ """Converts density scores to anomaly probabilities (see https://www.desmos.com/calculator/ifju7eesg7).
+
+ Args:
+ densities (Tensor): density of an image.
+
+ Returns:
+ probability that image with {density} is anomalous
+ """
+
+ return 1 / (1 + torch.exp(self.threshold_steepness * (densities - self.threshold_offset)))
+
+ def forward(self, batch: Tensor) -> Tensor:
+ """Prediction by normality model.
+
+ Args:
+ batch (Tensor): Input images.
+
+ Returns:
+ Tensor: Predictions
+ """
+
+ feature_vector = self.get_features(batch)
+ return self.predict(feature_vector.view(feature_vector.shape[:2]))
diff --git a/anomalib/models/dfm/README.md b/anomalib/models/dfm/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9c44456c3e159e7e1fab352438b6f216095d32ad
--- /dev/null
+++ b/anomalib/models/dfm/README.md
@@ -0,0 +1,41 @@
+# Probabilistic Modeling of Deep Features for Out-of-Distribution and Adversarial Detection
+
+This is the implementation of [DFM](https://arxiv.org/pdf/1909.11786.pdf) paper.
+
+Model Type: Classification
+
+## Description
+
+Fast anomaly classification algorithm that consists of a deep feature extraction stage followed by anomaly classification stage consisting of PCA and class-conditional Gaussian Density Estimation.
+
+### Feature Extraction
+
+Features are extracted by feeding the images through a ResNet18 backbone, which was pre-trained on ImageNet. The output of the penultimate layer (average pooling layer) of the network is used to obtain a semantic feature vector with a fixed length of 2048.
+
+### Anomaly Detection
+
+In the anomaly classification stage, class-conditional PCA transformations and Gaussian Density models are learned. Two types of scores are calculated (i) Feature-reconstruction scores (norm of the difference between the high-dimensional pre-image of a reduced dimension feature and the original high-dimensional feature), and (ii) Negative log-likelihood under the learnt density models. Either of these scores can be used for anomaly detection.
+
+## Usage
+
+`python tools/train.py --model dfm`
+
+## Benchmark
+
+All results gathered with seed `42`.
+
+## [MVTec AD Dataset](https://www.mvtec.com/company/research/datasets/mvtec-ad)
+
+### Image-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.894 | 0.864 | 0.558 | 0.945 | 0.984 | 0.946 | 0.994 | 0.913 | 0.871 | 0.979 | 0.941 | 0.838 | 0.761 | 0.95 | 0.911 | 0.949 |
+| Wide ResNet-50 | 0.891 | 0.978 | 0.540 | 0.979 | 0.977 | 0.974 | 0.990 | 0.891 | 0.931 | 0.947 | 0.839 | 0.809 | 0.700 | 0.911 | 0.915 | 0.981 |
+
+### Image F1 Score
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.919 | 0.895 | 0.844 | 0.926 | 0.971 | 0.948 | 0.977 | 0.874 | 0.935 | 0.957 | 0.958 | 0.921 | 0.874 | 0.933 | 0.833 | 0.943 |
+| Wide ResNet-50 | 0.951 | 0.960 | 0.844 | 0.990 | 0.970 | 0.959 | 0.976 | 0.848 | 0.944 | 0.913 | 0.912 | 0.919 | 0.859 | 0.893 | 0.815 | 0.961 |
diff --git a/anomalib/models/dfm/__init__.py b/anomalib/models/dfm/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0514d279b3209eb675213d2ae5eebe77a1608408
--- /dev/null
+++ b/anomalib/models/dfm/__init__.py
@@ -0,0 +1,19 @@
+"""Deep Feature Extraction (DFM) model."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .lightning_model import DfmLightning
+
+__all__ = ["DfmLightning"]
diff --git a/anomalib/models/dfm/config.yaml b/anomalib/models/dfm/config.yaml
new file mode 100755
index 0000000000000000000000000000000000000000..52e298e04590b3ef921d21f113ec9e22ae762d91
--- /dev/null
+++ b/anomalib/models/dfm/config.yaml
@@ -0,0 +1,89 @@
+dataset:
+ name: mvtec #options: [mvtec, btech, folder]
+ format: mvtec
+ path: ./datasets/MVTec
+ category: bottle
+ task: classification
+ image_size: 256
+ train_batch_size: 32
+ test_batch_size: 32
+ num_workers: 36
+ transform_config:
+ train: null
+ val: null
+ create_validation_set: false
+
+model:
+ name: dfm
+ backbone: resnet18
+ layer: layer3
+ pooling_kernel_size: 4
+ pca_level: 0.97
+ score_type: fre # nll: for Gaussian modeling, fre: pca feature reconstruction error
+ project_path: ./results
+ normalization_method: min_max # options: [null, min_max, cdf]
+ threshold:
+ image_default: 0
+ adaptive: true
+
+metrics:
+ image:
+ - F1Score
+ - AUROC
+ pixel:
+ - F1Score
+ - AUROC
+
+project:
+ seed: 42
+ path: ./results
+ log_images_to: []
+ logger: false # options: [tensorboard, wandb, csv] or combinations.
+
+# PL Trainer Args. Don't add extra parameter here.
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
+ accumulate_grad_batches: 1
+ amp_backend: native
+ auto_lr_find: false
+ auto_scale_batch_size: false
+ auto_select_gpus: false
+ benchmark: false
+ check_val_every_n_epoch: 1 # Don't validate before extracting features.
+ default_root_dir: null
+ detect_anomaly: false
+ deterministic: false
+ enable_checkpointing: true
+ enable_model_summary: true
+ enable_progress_bar: true
+ fast_dev_run: false
+ gpus: null # Set automatically
+ gradient_clip_val: 0
+ ipus: null
+ limit_predict_batches: 1.0
+ limit_test_batches: 1.0
+ limit_train_batches: 1.0
+ limit_val_batches: 1.0
+ log_every_n_steps: 50
+ log_gpu_memory: null
+ max_epochs: 1
+ max_steps: -1
+ max_time: null
+ min_epochs: null
+ min_steps: null
+ move_metrics_to_cpu: false
+ multiple_trainloader_mode: max_size_cycle
+ num_nodes: 1
+ num_processes: 1
+ num_sanity_val_steps: 0
+ overfit_batches: 0.0
+ plugins: null
+ precision: 32
+ profiler: null
+ reload_dataloaders_every_n_epochs: 0
+ replace_sampler_ddp: true
+ strategy: null
+ sync_batchnorm: false
+ tpu_cores: null
+ track_grad_norm: -1
+ val_check_interval: 1.0 # Don't validate before extracting features.
diff --git a/anomalib/models/dfm/lightning_model.py b/anomalib/models/dfm/lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..a66e35ff906d41e6659bf51ed8855b2d5ccfd010
--- /dev/null
+++ b/anomalib/models/dfm/lightning_model.py
@@ -0,0 +1,96 @@
+"""DFM: Deep Feature Kernel Density Estimation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+from typing import List, Union
+
+import torch
+from omegaconf import DictConfig, ListConfig
+from torch import Tensor
+
+from anomalib.models.components import AnomalyModule
+
+from .torch_model import DFMModel
+
+logger = logging.getLogger(__name__)
+
+
+class DfmLightning(AnomalyModule):
+ """DFM: Deep Featured Kernel Density Estimation."""
+
+ def __init__(self, hparams: Union[DictConfig, ListConfig]):
+ super().__init__(hparams)
+ logger.info("Initializing DFKDE Lightning model.")
+
+ self.model: DFMModel = DFMModel(
+ backbone=hparams.model.backbone,
+ layer=hparams.model.layer,
+ pooling_kernel_size=hparams.model.pooling_kernel_size,
+ n_comps=hparams.model.pca_level,
+ score_type=hparams.model.score_type,
+ )
+ self.embeddings: List[Tensor] = []
+
+ @staticmethod
+ def configure_optimizers() -> None: # pylint: disable=arguments-differ
+ """DFM doesn't require optimization, therefore returns no optimizers."""
+ return None
+
+ def training_step(self, batch, _): # pylint: disable=arguments-differ
+ """Training Step of DFM.
+
+ For each batch, features are extracted from the CNN.
+
+ Args:
+ batch (Dict[str, Tensor]): Input batch
+ _: Index of the batch.
+
+ Returns:
+ Deep CNN features.
+ """
+ embedding = self.model.get_features(batch["image"]).squeeze()
+
+ # NOTE: `self.embedding` appends each batch embedding to
+ # store the training set embedding. We manually append these
+ # values mainly due to the new order of hooks introduced after PL v1.4.0
+ # https://github.com/PyTorchLightning/pytorch-lightning/pull/7357
+ self.embeddings.append(embedding)
+
+ def on_validation_start(self) -> None:
+ """Fit a PCA transformation and a Gaussian model to dataset."""
+ # NOTE: Previous anomalib versions fit Gaussian at the end of the epoch.
+ # This is not possible anymore with PyTorch Lightning v1.4.0 since validation
+ # is run within train epoch.
+ logger.info("Aggregating the embedding extracted from the training set.")
+ embeddings = torch.vstack(self.embeddings)
+
+ logger.info("Fitting a PCA and a Gaussian model to dataset.")
+ self.model.fit(embeddings)
+
+ def validation_step(self, batch, _): # pylint: disable=arguments-differ
+ """Validation Step of DFM.
+
+ Similar to the training step, features are extracted from the CNN for each batch.
+
+ Args:
+ batch (List[Dict[str, Any]]): Input batch
+
+ Returns:
+ Dictionary containing FRE anomaly scores and ground-truth.
+ """
+ batch["pred_scores"] = self.model(batch["image"])
+
+ return batch
diff --git a/anomalib/models/dfm/torch_model.py b/anomalib/models/dfm/torch_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..7014f101e390a04d0c226bddca2838d1234d2f6f
--- /dev/null
+++ b/anomalib/models/dfm/torch_model.py
@@ -0,0 +1,171 @@
+"""PyTorch model for DFM model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import math
+
+import torch
+import torch.nn.functional as F
+import torchvision
+from torch import Tensor, nn
+
+from anomalib.models.components import PCA, DynamicBufferModule, FeatureExtractor
+
+
+class SingleClassGaussian(DynamicBufferModule):
+ """Model Gaussian distribution over a set of points."""
+
+ def __init__(self):
+ super().__init__()
+ self.register_buffer("mean_vec", Tensor())
+ self.register_buffer("u_mat", Tensor())
+ self.register_buffer("sigma_mat", Tensor())
+
+ self.mean_vec: Tensor
+ self.u_mat: Tensor
+ self.sigma_mat: Tensor
+
+ def fit(self, dataset: Tensor) -> None:
+ """Fit a Gaussian model to dataset X.
+
+ Covariance matrix is not calculated directly using:
+ ``C = X.X^T``
+ Instead, it is represented in terms of the Singular Value Decomposition of X:
+ ``X = U.S.V^T``
+ Hence,
+ ``C = U.S^2.U^T``
+ This simplifies the calculation of the log-likelihood without requiring full matrix inversion.
+
+ Args:
+ dataset (Tensor): Input dataset to fit the model.
+ """
+
+ num_samples = dataset.shape[1]
+ self.mean_vec = torch.mean(dataset, dim=1)
+ data_centered = (dataset - self.mean_vec.reshape(-1, 1)) / math.sqrt(num_samples)
+ self.u_mat, self.sigma_mat, _ = torch.linalg.svd(data_centered, full_matrices=False)
+
+ def score_samples(self, features: Tensor) -> Tensor:
+ """Compute the NLL (negative log likelihood) scores.
+
+ Args:
+ features (Tensor): semantic features on which density modeling is performed.
+
+ Returns:
+ nll (Tensor): Torch tensor of scores
+ """
+ features_transformed = torch.matmul(features - self.mean_vec, self.u_mat / self.sigma_mat)
+ nll = torch.sum(features_transformed * features_transformed, dim=1) + 2 * torch.sum(torch.log(self.sigma_mat))
+ return nll
+
+ def forward(self, dataset: Tensor) -> None:
+ """Provides the same functionality as `fit`.
+
+ Transforms the input dataset based on singular values calculated earlier.
+
+ Args:
+ dataset (Tensor): Input dataset
+ """
+ self.fit(dataset)
+
+
+class DFMModel(nn.Module):
+ """Model for the DFM algorithm.
+
+ Args:
+ backbone (str): Pre-trained model backbone.
+ layer (str): Layer from which to extract features.
+ pool (int): _description_
+ n_comps (float, optional): Ratio from which number of components for PCA are calculated. Defaults to 0.97.
+ score_type (str, optional): Scoring type. Options are `fre` and `nll`. Defaults to "fre".
+ """
+
+ def __init__(
+ self, backbone: str, layer: str, pooling_kernel_size: int, n_comps: float = 0.97, score_type: str = "fre"
+ ):
+ super().__init__()
+ self.backbone = getattr(torchvision.models, backbone)
+ self.pooling_kernel_size = pooling_kernel_size
+ self.n_components = n_comps
+ self.pca_model = PCA(n_components=self.n_components)
+ self.gaussian_model = SingleClassGaussian()
+ self.score_type = score_type
+ self.feature_extractor = FeatureExtractor(backbone=self.backbone(pretrained=True), layers=[layer]).eval()
+
+ def fit(self, dataset: Tensor) -> None:
+ """Fit a pca transformation and a Gaussian model to dataset.
+
+ Args:
+ dataset (Tensor): Input dataset to fit the model.
+ """
+
+ self.pca_model.fit(dataset)
+ features_reduced = self.pca_model.transform(dataset)
+ self.gaussian_model.fit(features_reduced.T)
+
+ def score(self, features: Tensor) -> Tensor:
+ """Compute scores.
+
+ Scores are either PCA-based feature reconstruction error (FRE) scores or
+ the Gaussian density-based NLL scores
+
+ Args:
+ features (torch.Tensor): semantic features on which PCA and density modeling is performed.
+
+ Returns:
+ score (Tensor): numpy array of scores
+ """
+ feats_projected = self.pca_model.transform(features)
+ if self.score_type == "nll":
+ score = self.gaussian_model.score_samples(feats_projected)
+ elif self.score_type == "fre":
+ feats_reconstructed = self.pca_model.inverse_transform(feats_projected)
+ score = torch.sum(torch.square(features - feats_reconstructed), dim=1)
+ else:
+ raise ValueError(f"unsupported score type: {self.score_type}")
+
+ return score
+
+ def get_features(self, batch: Tensor) -> Tensor:
+ """Extract features from the pretrained network.
+
+ Args:
+ batch (Tensor): Image batch.
+
+ Returns:
+ Tensor: Tensor containing extracted features.
+ """
+ self.feature_extractor.eval()
+ features = self.feature_extractor(batch)
+ for layer in features:
+ batch_size = len(features[layer])
+ if self.pooling_kernel_size > 1:
+ features[layer] = F.avg_pool2d(input=features[layer], kernel_size=self.pooling_kernel_size)
+ features[layer] = features[layer].view(batch_size, -1)
+
+ features = torch.cat(list(features.values())).detach()
+ return features
+
+ def forward(self, batch: Tensor) -> Tensor:
+ """Computer score from input images.
+
+ Args:
+ batch (Tensor): Input images
+
+ Returns:
+ Tensor: Scores
+ """
+ feature_vector = self.get_features(batch)
+ return self.score(feature_vector.view(feature_vector.shape[:2]))
diff --git a/anomalib/models/ganomaly/README.md b/anomalib/models/ganomaly/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..563fc502eb4120b2f13033d6ba27e35e3d19cb76
--- /dev/null
+++ b/anomalib/models/ganomaly/README.md
@@ -0,0 +1,37 @@
+# GANomaly: Semi-Supervised Anomaly Detection via Adversarial Training
+
+This is the implementation of the [GANomaly](https://arxiv.org/abs/1805.06725) paper.
+
+Model Type: Classification
+
+## Description
+
+GANomaly uses the conditional GAN approach to train a Generator to produce images of the normal data. This Generator consists of an encoder-decoder-encoder architecture to generate the normal images. The distance between the latent vector $z$ between the first encoder-decoder and the output vector $\hat{z}$ is minimized during training.
+
+The key idea here is that, during inference, when an anomalous image is passed through the first encoder the latent vector $z$ will not be able to capture the data correctly. This would leave to poor reconstruction $\hat{x}$ thus resulting in a very different $\hat{z}$. The difference between $z$ and $\hat{z}$ gives the anomaly score.
+
+## Architecture
+
+
+
+## Usage
+
+`python tools/train.py --model ganomaly`
+
+## Benchmark
+
+All results gathered with seed `42`.
+
+## [MVTec AD Dataset](https://www.mvtec.com/company/research/datasets/mvtec-ad)
+
+### Image-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| | 0.421 | 0.203 | 0.404 | 0.413 | 0.408 | 0.744 | 0.251 | 0.457 | 0.682 | 0.537 | 0.270 | 0.472 | 0.231 | 0.372 | 0.440 | 0.434 |
+
+### Image F1 Score
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| | 0.834 | 0.864 | 0.844 | 0.852 | 0.836 | 0.863 | 0.863 | 0.760 | 0.905 | 0.777 | 0.894 | 0.916 | 0.853 | 0.833 | 0.571 | 0.881 |
diff --git a/anomalib/models/ganomaly/__init__.py b/anomalib/models/ganomaly/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..08fd1ff41562de45a9ad2550dff64ca41175f0b2
--- /dev/null
+++ b/anomalib/models/ganomaly/__init__.py
@@ -0,0 +1,19 @@
+"""GANomaly Model."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .lightning_model import GanomalyLightning
+
+__all__ = ["GanomalyLightning"]
diff --git a/anomalib/models/ganomaly/config.yaml b/anomalib/models/ganomaly/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..ac5fce72aae24035f7a352325040bc56ca4da60a
--- /dev/null
+++ b/anomalib/models/ganomaly/config.yaml
@@ -0,0 +1,107 @@
+dataset:
+ name: mvtec #options: [mvtec, btech, folder]
+ format: mvtec
+ path: ./datasets/MVTec
+ category: bottle
+ task: classification
+ image_size: 256
+ train_batch_size: 32
+ test_batch_size: 32
+ inference_batch_size: 32
+ num_workers: 32
+ transform_config:
+ train: null
+ val: null
+ create_validation_set: false
+ tiling:
+ apply: true
+ tile_size: 64
+ stride: null
+ remove_border_count: 0
+ use_random_tiling: False
+ random_tile_count: 16
+
+model:
+ name: ganomaly
+ latent_vec_size: 100
+ n_features: 64
+ extra_layers: 0
+ add_final_conv: true
+ early_stopping:
+ patience: 3
+ metric: image_AUROC
+ mode: max
+ lr: 0.0002
+ beta1: 0.5
+ beta2: 0.999
+ wadv: 1
+ wcon: 50
+ wenc: 1
+ threshold:
+ image_default: 0
+ adaptive: true
+
+metrics:
+ image:
+ - F1Score
+ - AUROC
+ pixel:
+ - F1Score
+ - AUROC
+
+project:
+ seed: 0
+ path: ./results
+ log_images_to: []
+ logger: false # options: [tensorboard, wandb, csv] or combinations.
+
+optimization:
+ openvino:
+ apply: false
+
+# PL Trainer Args. Don't add extra parameter here.
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
+ accumulate_grad_batches: 1
+ amp_backend: native
+ auto_lr_find: false
+ auto_scale_batch_size: false
+ auto_select_gpus: false
+ benchmark: false
+ check_val_every_n_epoch: 2
+ default_root_dir: null
+ detect_anomaly: false
+ deterministic: false
+ enable_checkpointing: true
+ enable_model_summary: true
+ enable_progress_bar: true
+ fast_dev_run: false
+ gpus: null # Set automatically
+ gradient_clip_val: 0
+ ipus: null
+ limit_predict_batches: 1.0
+ limit_test_batches: 1.0
+ limit_train_batches: 1.0
+ limit_val_batches: 1.0
+ log_every_n_steps: 50
+ log_gpu_memory: null
+ max_epochs: 100
+ max_steps: null
+ min_epochs: null
+ min_steps: null
+ move_metrics_to_cpu: false
+ multiple_trainloader_mode: max_size_cycle
+ num_nodes: 1
+ num_processes: 1
+ num_sanity_val_steps: 0
+ overfit_batches: 0.0
+ plugins: null
+ precision: 32
+ profiler: null
+ reload_dataloaders_every_n_epochs: 0
+ replace_sampler_ddp: true
+ strategy: null
+ sync_batchnorm: false
+ tpu_cores: null
+ track_grad_norm: -1
+ val_check_interval: 1.0
diff --git a/anomalib/models/ganomaly/lightning_model.py b/anomalib/models/ganomaly/lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..0ec37358ed70c9f677a4ebb24d5cd298284328c5
--- /dev/null
+++ b/anomalib/models/ganomaly/lightning_model.py
@@ -0,0 +1,185 @@
+"""GANomaly: Semi-Supervised Anomaly Detection via Adversarial Training.
+
+https://arxiv.org/abs/1805.06725
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+from typing import Dict, List, Union
+
+import torch
+from omegaconf import DictConfig, ListConfig
+from pytorch_lightning.callbacks import EarlyStopping
+from torch import Tensor, optim
+
+from anomalib.data.utils.image import pad_nextpow2
+from anomalib.models.components import AnomalyModule
+
+from .torch_model import GanomalyModel
+
+logger = logging.getLogger(__name__)
+
+
+class GanomalyLightning(AnomalyModule):
+ """PL Lightning Module for the GANomaly Algorithm.
+
+ Args:
+ hparams (Union[DictConfig, ListConfig]): Model parameters
+ """
+
+ def __init__(self, hparams: Union[DictConfig, ListConfig]):
+ super().__init__(hparams)
+ logger.info("Initializing Ganomaly Lightning model.")
+
+ self.model: GanomalyModel = GanomalyModel(
+ input_size=hparams.model.input_size,
+ num_input_channels=3,
+ n_features=hparams.model.n_features,
+ latent_vec_size=hparams.model.latent_vec_size,
+ extra_layers=hparams.model.extra_layers,
+ add_final_conv_layer=hparams.model.add_final_conv,
+ wadv=self.hparams.model.wadv,
+ wcon=self.hparams.model.wcon,
+ wenc=self.hparams.model.wenc,
+ )
+
+ self.real_label = torch.ones(size=(self.hparams.dataset.train_batch_size,), dtype=torch.float32)
+ self.fake_label = torch.zeros(size=(self.hparams.dataset.train_batch_size,), dtype=torch.float32)
+
+ self.min_scores: Tensor = torch.tensor(float("inf"), dtype=torch.float32) # pylint: disable=not-callable
+ self.max_scores: Tensor = torch.tensor(float("-inf"), dtype=torch.float32) # pylint: disable=not-callable
+
+ def _reset_min_max(self):
+ """Resets min_max scores."""
+ self.min_scores = torch.tensor(float("inf"), dtype=torch.float32) # pylint: disable=not-callable
+ self.max_scores = torch.tensor(float("-inf"), dtype=torch.float32) # pylint: disable=not-callable
+
+ def configure_callbacks(self):
+ """Configure model-specific callbacks."""
+ early_stopping = EarlyStopping(
+ monitor=self.hparams.model.early_stopping.metric,
+ patience=self.hparams.model.early_stopping.patience,
+ mode=self.hparams.model.early_stopping.mode,
+ )
+ return [early_stopping]
+
+ def configure_optimizers(self) -> List[optim.Optimizer]:
+ """Configure optimizers for generator and discriminator.
+
+ Returns:
+ List[optim.Optimizer]: Adam optimizers for discriminator and generator.
+ """
+ optimizer_d = optim.Adam(
+ self.model.discriminator.parameters(),
+ lr=self.hparams.model.lr,
+ betas=(self.hparams.model.beta1, self.hparams.model.beta2),
+ )
+ optimizer_g = optim.Adam(
+ self.model.generator.parameters(),
+ lr=self.hparams.model.lr,
+ betas=(self.hparams.model.beta1, self.hparams.model.beta2),
+ )
+ return [optimizer_d, optimizer_g]
+
+ def training_step(self, batch, _, optimizer_idx): # pylint: disable=arguments-differ
+ """Training step.
+
+ Args:
+ batch (Dict): Input batch containing images.
+ optimizer_idx (int): Optimizer which is being called for current training step.
+
+ Returns:
+ Dict[str, Tensor]: Loss
+ """
+ images = batch["image"]
+ padded_images = pad_nextpow2(images)
+ loss: Dict[str, Tensor]
+
+ # Discriminator
+ if optimizer_idx == 0:
+ # forward pass
+ loss_discriminator = self.model.get_discriminator_loss(padded_images)
+ loss = {"loss": loss_discriminator}
+
+ # Generator
+ else:
+ # forward pass
+ loss_generator = self.model.get_generator_loss(padded_images)
+
+ loss = {"loss": loss_generator}
+
+ return loss
+
+ def on_validation_start(self) -> None:
+ """Reset min and max values for current validation epoch."""
+ self._reset_min_max()
+ return super().on_validation_start()
+
+ def validation_step(self, batch, _) -> Dict[str, Tensor]: # type: ignore # pylint: disable=arguments-differ
+ """Update min and max scores from the current step.
+
+ Args:
+ batch (Dict[str, Tensor]): Predicted difference between z and z_hat.
+
+ Returns:
+ Dict[str, Tensor]: batch
+ """
+ batch["pred_scores"] = self.model(batch["image"])
+ self.max_scores = max(self.max_scores, torch.max(batch["pred_scores"]))
+ self.min_scores = min(self.min_scores, torch.min(batch["pred_scores"]))
+ return batch
+
+ def validation_epoch_end(self, outputs):
+ """Normalize outputs based on min/max values."""
+ logger.info("Normalizing validation outputs based on min/max values.")
+ for prediction in outputs:
+ prediction["pred_scores"] = self._normalize(prediction["pred_scores"])
+ super().validation_epoch_end(outputs)
+ return outputs
+
+ def on_test_start(self) -> None:
+ """Reset min max values before test batch starts."""
+ self._reset_min_max()
+ return super().on_test_start()
+
+ def test_step(self, batch, _):
+ """Update min and max scores from the current step."""
+ super().test_step(batch, _)
+ self.max_scores = max(self.max_scores, torch.max(batch["pred_scores"]))
+ self.min_scores = min(self.min_scores, torch.min(batch["pred_scores"]))
+ return batch
+
+ def test_epoch_end(self, outputs):
+ """Normalize outputs based on min/max values."""
+ logger.info("Normalizing test outputs based on min/max values.")
+ for prediction in outputs:
+ prediction["pred_scores"] = self._normalize(prediction["pred_scores"])
+ super().test_epoch_end(outputs)
+ return outputs
+
+ def _normalize(self, scores: Tensor) -> Tensor:
+ """Normalize the scores based on min/max of entire dataset.
+
+ Args:
+ scores (Tensor): Un-normalized scores.
+
+ Returns:
+ Tensor: Normalized scores.
+ """
+ scores = (scores - self.min_scores.to(scores.device)) / (
+ self.max_scores.to(scores.device) - self.min_scores.to(scores.device)
+ )
+ return scores
diff --git a/anomalib/models/ganomaly/torch_model.py b/anomalib/models/ganomaly/torch_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..88d76d6fc035da20586bef508b18d527324ec577
--- /dev/null
+++ b/anomalib/models/ganomaly/torch_model.py
@@ -0,0 +1,401 @@
+# Copyright (c) 2018-2022 Samet Akcay, Durham University, UK
+# SPDX-License-Identifier: MIT
+#
+# Copyright (C) 2020-2022 Intel Corporation
+# SPDX-License-Identifier: Apache-2.0
+#
+
+"""Torch models defining encoder, decoder, Generator and Discriminator.
+
+Code adapted from https://github.com/samet-akcay/ganomaly.
+"""
+
+
+import math
+from typing import Tuple
+
+import torch
+from torch import Tensor, nn
+
+from anomalib.data.utils.image import pad_nextpow2
+
+
+class Encoder(nn.Module):
+ """Encoder Network.
+
+ Args:
+ input_size (Tuple[int, int]): Size of input image
+ latent_vec_size (int): Size of latent vector z
+ num_input_channels (int): Number of input channels in the image
+ n_features (int): Number of features per convolution layer
+ extra_layers (int): Number of extra layers since the network uses only a single encoder layer by default.
+ Defaults to 0.
+ """
+
+ def __init__(
+ self,
+ input_size: Tuple[int, int],
+ latent_vec_size: int,
+ num_input_channels: int,
+ n_features: int,
+ extra_layers: int = 0,
+ add_final_conv_layer: bool = True,
+ ):
+ super().__init__()
+
+ self.input_layers = nn.Sequential()
+ self.input_layers.add_module(
+ f"initial-conv-{num_input_channels}-{n_features}",
+ nn.Conv2d(num_input_channels, n_features, kernel_size=4, stride=2, padding=4, bias=False),
+ )
+ self.input_layers.add_module(f"initial-relu-{n_features}", nn.LeakyReLU(0.2, inplace=True))
+
+ # Extra Layers
+ self.extra_layers = nn.Sequential()
+
+ for layer in range(extra_layers):
+ self.extra_layers.add_module(
+ f"extra-layers-{layer}-{n_features}-conv",
+ nn.Conv2d(n_features, n_features, kernel_size=3, stride=1, padding=1, bias=False),
+ )
+ self.extra_layers.add_module(f"extra-layers-{layer}-{n_features}-batchnorm", nn.BatchNorm2d(n_features))
+ self.extra_layers.add_module(f"extra-layers-{layer}-{n_features}-relu", nn.LeakyReLU(0.2, inplace=True))
+
+ # Create pyramid features to reach latent vector
+ self.pyramid_features = nn.Sequential()
+ pyramid_dim = min(*input_size) // 2 # Use the smaller dimension to create pyramid.
+ while pyramid_dim > 4:
+ in_features = n_features
+ out_features = n_features * 2
+ self.pyramid_features.add_module(
+ f"pyramid-{in_features}-{out_features}-conv",
+ nn.Conv2d(in_features, out_features, kernel_size=4, stride=2, padding=1, bias=False),
+ )
+ self.pyramid_features.add_module(f"pyramid-{out_features}-batchnorm", nn.BatchNorm2d(out_features))
+ self.pyramid_features.add_module(f"pyramid-{out_features}-relu", nn.LeakyReLU(0.2, inplace=True))
+ n_features = out_features
+ pyramid_dim = pyramid_dim // 2
+
+ # Final conv
+ if add_final_conv_layer:
+ self.final_conv_layer = nn.Conv2d(
+ n_features,
+ latent_vec_size,
+ kernel_size=4,
+ stride=1,
+ padding=0,
+ bias=False,
+ )
+
+ def forward(self, input_tensor: Tensor):
+ """Return latent vectors."""
+
+ output = self.input_layers(input_tensor)
+ output = self.extra_layers(output)
+ output = self.pyramid_features(output)
+ if self.final_conv_layer is not None:
+ output = self.final_conv_layer(output)
+
+ return output
+
+
+class Decoder(nn.Module):
+ """Decoder Network.
+
+ Args:
+ input_size (Tuple[int, int]): Size of input image
+ latent_vec_size (int): Size of latent vector z
+ num_input_channels (int): Number of input channels in the image
+ n_features (int): Number of features per convolution layer
+ extra_layers (int): Number of extra layers since the network uses only a single encoder layer by default.
+ Defaults to 0.
+ """
+
+ def __init__(
+ self,
+ input_size: Tuple[int, int],
+ latent_vec_size: int,
+ num_input_channels: int,
+ n_features: int,
+ extra_layers: int = 0,
+ ):
+ super().__init__()
+
+ self.latent_input = nn.Sequential()
+
+ # Calculate input channel size to recreate inverse pyramid
+ exp_factor = math.ceil(math.log(min(input_size) // 2, 2)) - 2
+ n_input_features = n_features * (2**exp_factor)
+
+ # CNN layer for latent vector input
+ self.latent_input.add_module(
+ f"initial-{latent_vec_size}-{n_input_features}-convt",
+ nn.ConvTranspose2d(
+ latent_vec_size,
+ n_input_features,
+ kernel_size=4,
+ stride=1,
+ padding=0,
+ bias=False,
+ ),
+ )
+ self.latent_input.add_module(f"initial-{n_input_features}-batchnorm", nn.BatchNorm2d(n_input_features))
+ self.latent_input.add_module(f"initial-{n_input_features}-relu", nn.ReLU(True))
+
+ # Create inverse pyramid
+ self.inverse_pyramid = nn.Sequential()
+ pyramid_dim = min(*input_size) // 2 # Use the smaller dimension to create pyramid.
+ while pyramid_dim > 4:
+ in_features = n_input_features
+ out_features = n_input_features // 2
+ self.inverse_pyramid.add_module(
+ f"pyramid-{in_features}-{out_features}-convt",
+ nn.ConvTranspose2d(
+ in_features,
+ out_features,
+ kernel_size=4,
+ stride=2,
+ padding=1,
+ bias=False,
+ ),
+ )
+ self.inverse_pyramid.add_module(f"pyramid-{out_features}-batchnorm", nn.BatchNorm2d(out_features))
+ self.inverse_pyramid.add_module(f"pyramid-{out_features}-relu", nn.ReLU(True))
+ n_input_features = out_features
+ pyramid_dim = pyramid_dim // 2
+
+ # Extra Layers
+ self.extra_layers = nn.Sequential()
+ for layer in range(extra_layers):
+ self.extra_layers.add_module(
+ f"extra-layers-{layer}-{n_input_features}-conv",
+ nn.Conv2d(n_input_features, n_input_features, kernel_size=3, stride=1, padding=1, bias=False),
+ )
+ self.extra_layers.add_module(
+ f"extra-layers-{layer}-{n_input_features}-batchnorm", nn.BatchNorm2d(n_input_features)
+ )
+ self.extra_layers.add_module(
+ f"extra-layers-{layer}-{n_input_features}-relu", nn.LeakyReLU(0.2, inplace=True)
+ )
+
+ # Final layers
+ self.final_layers = nn.Sequential()
+ self.final_layers.add_module(
+ f"final-{n_input_features}-{num_input_channels}-convt",
+ nn.ConvTranspose2d(
+ n_input_features,
+ num_input_channels,
+ kernel_size=4,
+ stride=2,
+ padding=1,
+ bias=False,
+ ),
+ )
+ self.final_layers.add_module(f"final-{num_input_channels}-tanh", nn.Tanh())
+
+ def forward(self, input_tensor):
+ """Return generated image."""
+ output = self.latent_input(input_tensor)
+ output = self.inverse_pyramid(output)
+ output = self.extra_layers(output)
+ output = self.final_layers(output)
+ return output
+
+
+class Discriminator(nn.Module):
+ """Discriminator.
+
+ Made of only one encoder layer which takes x and x_hat to produce a score.
+
+ Args:
+ input_size (Tuple[int,int]): Input image size.
+ num_input_channels (int): Number of image channels.
+ n_features (int): Number of feature maps in each convolution layer.
+ extra_layers (int, optional): Add extra intermediate layers. Defaults to 0.
+ """
+
+ def __init__(self, input_size: Tuple[int, int], num_input_channels: int, n_features: int, extra_layers: int = 0):
+ super().__init__()
+ encoder = Encoder(input_size, 1, num_input_channels, n_features, extra_layers)
+ layers = []
+ for block in encoder.children():
+ if isinstance(block, nn.Sequential):
+ layers.extend(list(block.children()))
+ else:
+ layers.append(block)
+
+ self.features = nn.Sequential(*layers[:-1])
+ self.classifier = nn.Sequential(layers[-1])
+ self.classifier.add_module("Sigmoid", nn.Sigmoid())
+
+ def forward(self, input_tensor):
+ """Return class of object and features."""
+ features = self.features(input_tensor)
+ classifier = self.classifier(features)
+ classifier = classifier.view(-1, 1).squeeze(1)
+ return classifier, features
+
+
+class Generator(nn.Module):
+ """Generator model.
+
+ Made of an encoder-decoder-encoder architecture.
+
+ Args:
+ input_size (Tuple[int,int]): Size of input data.
+ latent_vec_size (int): Dimension of latent vector produced between the first encoder-decoder.
+ num_input_channels (int): Number of channels in input image.
+ n_features (int): Number of feature maps in each convolution layer.
+ extra_layers (int, optional): Extra intermediate layers in the encoder/decoder. Defaults to 0.
+ add_final_conv_layer (bool, optional): Add a final convolution layer in the decoder. Defaults to True.
+ """
+
+ def __init__(
+ self,
+ input_size: Tuple[int, int],
+ latent_vec_size: int,
+ num_input_channels: int,
+ n_features: int,
+ extra_layers: int = 0,
+ add_final_conv_layer: bool = True,
+ ):
+ super().__init__()
+ self.encoder1 = Encoder(
+ input_size, latent_vec_size, num_input_channels, n_features, extra_layers, add_final_conv_layer
+ )
+ self.decoder = Decoder(input_size, latent_vec_size, num_input_channels, n_features, extra_layers)
+ self.encoder2 = Encoder(
+ input_size, latent_vec_size, num_input_channels, n_features, extra_layers, add_final_conv_layer
+ )
+
+ def forward(self, input_tensor):
+ """Return generated image and the latent vectors."""
+ latent_i = self.encoder1(input_tensor)
+ gen_image = self.decoder(latent_i)
+ latent_o = self.encoder2(gen_image)
+ return gen_image, latent_i, latent_o
+
+
+class GanomalyModel(nn.Module):
+ """Ganomaly Model.
+
+ Args:
+ input_size (Tuple[int,int]): Input dimension.
+ num_input_channels (int): Number of input channels.
+ n_features (int): Number of features layers in the CNNs.
+ latent_vec_size (int): Size of autoencoder latent vector.
+ extra_layers (int, optional): Number of extra layers for encoder/decoder. Defaults to 0.
+ add_final_conv_layer (bool, optional): Add convolution layer at the end. Defaults to True.
+ wadv (int, optional): Weight for adversarial loss. Defaults to 1.
+ wcon (int, optional): Image regeneration weight. Defaults to 50.
+ wenc (int, optional): Latent vector encoder weight. Defaults to 1.
+ """
+
+ def __init__(
+ self,
+ input_size: Tuple[int, int],
+ num_input_channels: int,
+ n_features: int,
+ latent_vec_size: int,
+ extra_layers: int = 0,
+ add_final_conv_layer: bool = True,
+ wadv: int = 1,
+ wcon: int = 50,
+ wenc: int = 1,
+ ) -> None:
+ super().__init__()
+ self.generator: Generator = Generator(
+ input_size=input_size,
+ latent_vec_size=latent_vec_size,
+ num_input_channels=num_input_channels,
+ n_features=n_features,
+ extra_layers=extra_layers,
+ add_final_conv_layer=add_final_conv_layer,
+ )
+ self.discriminator: Discriminator = Discriminator(
+ input_size=input_size,
+ num_input_channels=num_input_channels,
+ n_features=n_features,
+ extra_layers=extra_layers,
+ )
+ self.weights_init(self.generator)
+ self.weights_init(self.discriminator)
+ self.loss_enc = nn.SmoothL1Loss()
+ self.loss_adv = nn.MSELoss()
+ self.loss_con = nn.L1Loss()
+ self.loss_bce = nn.BCELoss()
+ self.wadv = wadv
+ self.wcon = wcon
+ self.wenc = wenc
+
+ @staticmethod
+ def weights_init(module: nn.Module):
+ """Initialize DCGAN weights.
+
+ Args:
+ module (nn.Module): [description]
+ """
+ classname = module.__class__.__name__
+ if classname.find("Conv") != -1:
+ nn.init.normal_(module.weight.data, 0.0, 0.02)
+ elif classname.find("BatchNorm") != -1:
+ nn.init.normal_(module.weight.data, 1.0, 0.02)
+ nn.init.constant_(module.bias.data, 0)
+
+ def get_discriminator_loss(self, images: Tensor) -> Tensor:
+ """Calculates loss for discriminator.
+
+ Args:
+ images (Tensor): Input images.
+
+ Returns:
+ Tensor: Discriminator loss.
+ """
+ fake, _, _ = self.generator(images)
+ pred_real, _ = self.discriminator(images)
+ pred_fake, _ = self.discriminator(fake.detach())
+
+ error_discriminator_real = self.loss_bce(
+ pred_real, torch.ones(size=pred_real.shape, dtype=torch.float32, device=pred_real.device)
+ )
+ error_discriminator_fake = self.loss_bce(
+ pred_fake, torch.zeros(size=pred_fake.shape, dtype=torch.float32, device=pred_fake.device)
+ )
+ loss_discriminator = (error_discriminator_fake + error_discriminator_real) * 0.5
+ return loss_discriminator
+
+ def get_generator_loss(self, images: Tensor) -> Tensor:
+ """Calculates loss for generator.
+
+ Args:
+ images (Tensor): Input images.
+
+ Returns:
+ Tensor: Generator loss.
+ """
+ fake, latent_i, latent_o = self.generator(images)
+ pred_real, _ = self.discriminator(images)
+ pred_fake, _ = self.discriminator(fake)
+
+ error_enc = self.loss_enc(latent_i, latent_o)
+
+ error_con = self.loss_con(images, fake)
+
+ error_adv = self.loss_adv(pred_real, pred_fake)
+
+ loss_generator = error_adv * self.wadv + error_con * self.wcon + error_enc * self.wenc
+ return loss_generator
+
+ def forward(self, batch: Tensor) -> Tensor:
+ """Get scores for batch.
+
+ Args:
+ batch (Tensor): Images
+
+ Returns:
+ Tensor: Regeneration scores.
+ """
+ padded_batch = pad_nextpow2(batch)
+ self.generator.eval()
+ _, latent_i, latent_o = self.generator(padded_batch)
+ return torch.mean(torch.pow((latent_i - latent_o), 2), dim=1).view(-1) # convert nx1x1 to n
diff --git a/anomalib/models/padim/README.md b/anomalib/models/padim/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f9729b8e1cfa281788bff829eec54c7416268629
--- /dev/null
+++ b/anomalib/models/padim/README.md
@@ -0,0 +1,54 @@
+# PaDiM: A Patch Distribution Modeling Framework for Anomaly Detection and Localization
+
+This is the implementation of the [PaDiM](https://arxiv.org/pdf/2011.08785.pdf) paper.
+
+Model Type: Segmentation
+
+## Description
+
+PaDiM is a patch based algorithm. It relies on a pre-trained CNN feature extractor. The image is broken into patches and embeddings are extracted from each patch using different layers of the feature extractors. The activation vectors from different layers are concatenated to get embedding vectors carrying information from different semantic levels and resolutions. This helps encode fine grained and global contexts. However, since the generated embedding vectors may carry redundant information, dimensions are reduced using random selection. A multivariate gaussian distribution is generated for each patch embedding across the entire training batch. Thus, for each patch of the set of training images, we have a different multivariate gaussian distribution. These gaussian distributions are represented as a matrix of gaussian parameters.
+
+During inference, Mahalanobis distance is used to score each patch position of the test image. It uses the inverse of the covariance matrix calculated for the patch during training. The matrix of Mahalanobis distances forms the anomaly map with higher scores indicating anomalous regions.
+
+## Architecture
+
+
+
+## Usage
+
+`python tools/train.py --model padim`
+
+## Benchmark
+
+All results gathered with seed `42`.
+
+## [MVTec AD Dataset](https://www.mvtec.com/company/research/datasets/mvtec-ad)
+
+### Image-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.891 | 0.945 | 0.857 | 0.982 | 0.950 | 0.976 | 0.994 | 0.844 | 0.901 | 0.750 | 0.961 | 0.863 | 0.759 | 0.889 | 0.920 | 0.780 |
+| Wide ResNet-50 | 0.950 | 0.995 | 0.942 | 1.0 | 0.974 | 0.993 | 0.999 | 0.878 | 0.927 | 0.964 | 0.989 | 0.939 | 0.845 | 0.942 | 0.976 | 0.882 |
+
+### Pixel-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.968 | 0.984 | 0.918 | 0.994 | 0.934 | 0.947 | 0.983 | 0.965 | 0.984 | 0.978 | 0.970 | 0.957 | 0.978 | 0.988 | 0.968 | 0.979 |
+| Wide ResNet-50 | 0.979 | 0.991 | 0.970 | 0.993 | 0.955 | 0.957 | 0.985 | 0.970 | 0.988 | 0.985 | 0.982 | 0.966 | 0.988 | 0.991 | 0.976 | 0.986 |
+
+### Image F1 Score
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.916 | 0.930 | 0.893 | 0.984 | 0.934 | 0.952 | 0.976 | 0.858 | 0.960 | 0.836 | 0.974 | 0.932 | 0.879 | 0.923 | 0.796 | 0.915 |
+| Wide ResNet-50 | 0.951 | 0.989 | 0.930 | 1.0 | 0.960 | 0.983 | 0.992 | 0.856 | 0.982 | 0.937 | 0.978 | 0.946 | 0.895 | 0.952 | 0.914 | 0.947 |
+
+### Sample Results
+
+
+
+
+
+
diff --git a/anomalib/models/padim/__init__.py b/anomalib/models/padim/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6002f79581b038ccff2ab9a1fbc97585516503c1
--- /dev/null
+++ b/anomalib/models/padim/__init__.py
@@ -0,0 +1,19 @@
+"""PADIM model."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .lightning_model import PadimLightning
+
+__all__ = ["PadimLightning"]
diff --git a/anomalib/models/padim/anomaly_map.py b/anomalib/models/padim/anomaly_map.py
new file mode 100644
index 0000000000000000000000000000000000000000..db363290ea08a90491aa65ebb09cafb00b97fabf
--- /dev/null
+++ b/anomalib/models/padim/anomaly_map.py
@@ -0,0 +1,146 @@
+"""Anomaly Map Generator for the PaDiM model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import List, Tuple, Union
+
+import torch
+import torch.nn.functional as F
+from kornia.filters import gaussian_blur2d
+from omegaconf import ListConfig
+from torch import Tensor
+
+
+class AnomalyMapGenerator:
+ """Generate Anomaly Heatmap.
+
+ Args:
+ image_size (Union[ListConfig, Tuple]): Size of the input image. The anomaly map is upsampled to this dimension.
+ sigma (int, optional): Standard deviation for Gaussian Kernel. Defaults to 4.
+ """
+
+ def __init__(self, image_size: Union[ListConfig, Tuple], sigma: int = 4):
+ self.image_size = image_size if isinstance(image_size, tuple) else tuple(image_size)
+ self.sigma = sigma
+
+ @staticmethod
+ def compute_distance(embedding: Tensor, stats: List[Tensor]) -> Tensor:
+ """Compute anomaly score to the patch in position(i,j) of a test image.
+
+ Ref: Equation (2), Section III-C of the paper.
+
+ Args:
+ embedding (Tensor): Embedding Vector
+ stats (List[Tensor]): Mean and Covariance Matrix of the multivariate Gaussian distribution
+
+ Returns:
+ Anomaly score of a test image via mahalanobis distance.
+ """
+
+ batch, channel, height, width = embedding.shape
+ embedding = embedding.reshape(batch, channel, height * width)
+
+ # calculate mahalanobis distances
+ mean, inv_covariance = stats
+ delta = (embedding - mean).permute(2, 0, 1)
+
+ distances = (torch.matmul(delta, inv_covariance) * delta).sum(2).permute(1, 0)
+ distances = distances.reshape(batch, height, width)
+ distances = torch.sqrt(distances)
+
+ return distances
+
+ def up_sample(self, distance: Tensor) -> Tensor:
+ """Up sample anomaly score to match the input image size.
+
+ Args:
+ distance (Tensor): Anomaly score computed via the mahalanobis distance.
+
+ Returns:
+ Resized distance matrix matching the input image size
+ """
+
+ score_map = F.interpolate(
+ distance.unsqueeze(1),
+ size=self.image_size,
+ mode="bilinear",
+ align_corners=False,
+ )
+ return score_map
+
+ def smooth_anomaly_map(self, anomaly_map: Tensor) -> Tensor:
+ """Apply gaussian smoothing to the anomaly map.
+
+ Args:
+ anomaly_map (Tensor): Anomaly score for the test image(s).
+
+ Returns:
+ Filtered anomaly scores
+ """
+
+ kernel_size = 2 * int(4.0 * self.sigma + 0.5) + 1
+ sigma = torch.as_tensor(self.sigma).to(anomaly_map.device)
+ anomaly_map = gaussian_blur2d(anomaly_map, (kernel_size, kernel_size), sigma=(sigma, sigma))
+
+ return anomaly_map
+
+ def compute_anomaly_map(self, embedding: Tensor, mean: Tensor, inv_covariance: Tensor) -> Tensor:
+ """Compute anomaly score.
+
+ Scores are calculated based on embedding vector, mean and inv_covariance of the multivariate gaussian
+ distribution.
+
+ Args:
+ embedding (Tensor): Embedding vector extracted from the test set.
+ mean (Tensor): Mean of the multivariate gaussian distribution
+ inv_covariance (Tensor): Inverse Covariance matrix of the multivariate gaussian distribution.
+
+ Returns:
+ Output anomaly score.
+ """
+
+ score_map = self.compute_distance(
+ embedding=embedding,
+ stats=[mean.to(embedding.device), inv_covariance.to(embedding.device)],
+ )
+ up_sampled_score_map = self.up_sample(score_map)
+ smoothed_anomaly_map = self.smooth_anomaly_map(up_sampled_score_map)
+
+ return smoothed_anomaly_map
+
+ def __call__(self, **kwds):
+ """Returns anomaly_map.
+
+ Expects `embedding`, `mean` and `covariance` keywords to be passed explicitly.
+
+ Example:
+ >>> anomaly_map_generator = AnomalyMapGenerator(image_size=input_size)
+ >>> output = anomaly_map_generator(embedding=embedding, mean=mean, covariance=covariance)
+
+ Raises:
+ ValueError: `embedding`. `mean` or `covariance` keys are not found
+
+ Returns:
+ torch.Tensor: anomaly map
+ """
+
+ if not ("embedding" in kwds and "mean" in kwds and "inv_covariance" in kwds):
+ raise ValueError(f"Expected keys `embedding`, `mean` and `covariance`. Found {kwds.keys()}")
+
+ embedding: Tensor = kwds["embedding"]
+ mean: Tensor = kwds["mean"]
+ inv_covariance: Tensor = kwds["inv_covariance"]
+
+ return self.compute_anomaly_map(embedding, mean, inv_covariance)
diff --git a/anomalib/models/padim/config.yaml b/anomalib/models/padim/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..155661fd565bf774b6ddc2d69f2628c8f8555fa0
--- /dev/null
+++ b/anomalib/models/padim/config.yaml
@@ -0,0 +1,98 @@
+dataset:
+ name: mvtec #options: [mvtec, btech, folder]
+ format: mvtec
+ path: ./datasets/MVTec
+ category: bottle
+ task: segmentation
+ image_size: 256
+ train_batch_size: 32
+ test_batch_size: 32
+ num_workers: 36
+ transform_config:
+ train: null
+ val: null
+ create_validation_set: false
+ tiling:
+ apply: false
+ tile_size: null
+ stride: null
+ remove_border_count: 0
+ use_random_tiling: False
+ random_tile_count: 16
+
+model:
+ name: padim
+ backbone: resnet18
+ layers:
+ - layer1
+ - layer2
+ - layer3
+ normalization_method: min_max # options: [none, min_max, cdf]
+ threshold:
+ image_default: 3
+ pixel_default: 3
+ adaptive: true
+
+metrics:
+ image:
+ - F1Score
+ - AUROC
+ pixel:
+ - F1Score
+ - AUROC
+
+project:
+ seed: 42
+ path: ./results
+ log_images_to: ["local"]
+ logger: false # options: [tensorboard, wandb, csv] or combinations.
+
+optimization:
+ openvino:
+ apply: false
+
+# PL Trainer Args. Don't add extra parameter here.
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
+ accumulate_grad_batches: 1
+ amp_backend: native
+ auto_lr_find: false
+ auto_scale_batch_size: false
+ auto_select_gpus: false
+ benchmark: false
+ check_val_every_n_epoch: 1 # Don't validate before extracting features.
+ default_root_dir: null
+ detect_anomaly: false
+ deterministic: false
+ enable_checkpointing: true
+ enable_model_summary: true
+ enable_progress_bar: true
+ fast_dev_run: false
+ gpus: null # Set automatically
+ gradient_clip_val: 0
+ ipus: null
+ limit_predict_batches: 1.0
+ limit_test_batches: 1.0
+ limit_train_batches: 1.0
+ limit_val_batches: 1.0
+ log_every_n_steps: 50
+ max_epochs: 1
+ max_steps: -1
+ max_time: null
+ min_epochs: null
+ min_steps: null
+ move_metrics_to_cpu: false
+ multiple_trainloader_mode: max_size_cycle
+ num_nodes: 1
+ num_processes: 1
+ num_sanity_val_steps: 0
+ overfit_batches: 0.0
+ plugins: null
+ precision: 32
+ profiler: null
+ reload_dataloaders_every_n_epochs: 0
+ replace_sampler_ddp: true
+ sync_batchnorm: false
+ tpu_cores: null
+ track_grad_norm: -1
+ val_check_interval: 1.0 # Don't validate before extracting features.
diff --git a/anomalib/models/padim/eurosat.yaml b/anomalib/models/padim/eurosat.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f7758b96e61692a830bd3603ad020f8eefe5051b
--- /dev/null
+++ b/anomalib/models/padim/eurosat.yaml
@@ -0,0 +1,106 @@
+dataset:
+ name: eurosat
+ format: folder
+ path: ./datasets/EuroSAT
+ normal_dir: Residential
+ abnormal_dir: Industrial
+ task: classification
+ normal_test_dir: null
+ mask: null
+ extensions: .jpg
+ split_ratio: 0.2
+ seed: 0
+ image_size: 64
+ train_batch_size: 16
+ test_batch_size: 16
+ num_workers: 4
+ transform_config:
+ train: null
+ val: null
+ create_validation_set: false
+ tiling:
+ apply: false
+ tile_size: null
+ stride: null
+ remove_border_count: 0
+ use_random_tiling: False
+ random_tile_count: 16
+ inference_batch_size: 16
+ fiber_batch_size: 32 #for padim
+
+model:
+ name: padim
+ backbone: resnet18
+ layers:
+ - layer1
+ - layer2
+ - layer3
+ normalization_method: min_max # options: [none, min_max, cdf]
+ threshold:
+ image_default: 3
+ pixel_default: 3
+ adaptive: true
+
+metrics:
+ image:
+ - F1Score
+ - AUROC
+ pixel:
+ - F1Score
+ - AUROC
+
+project:
+ seed: 42
+ path: ./results
+ log_images_to: ["local"]
+ logger: false # options: [tensorboard, wandb, csv] or combinations.
+
+optimization:
+ openvino:
+ apply: false
+
+# PL Trainer Args. Don't add extra parameter here.
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
+ accumulate_grad_batches: 1
+ amp_backend: native
+ auto_lr_find: false
+ auto_scale_batch_size: false
+ auto_select_gpus: false
+ benchmark: false
+ check_val_every_n_epoch: 1 # Don't validate before extracting features.
+ default_root_dir: null
+ detect_anomaly: false
+ deterministic: false
+ enable_checkpointing: true
+ enable_model_summary: true
+ enable_progress_bar: true
+ fast_dev_run: false
+ gpus: null # Set automatically
+ gradient_clip_val: 0
+ ipus: null
+ limit_predict_batches: 1.0
+ limit_test_batches: 1.0
+ limit_train_batches: 1.0
+ limit_val_batches: 1.0
+ log_every_n_steps: 50
+ max_epochs: 5
+ max_steps: -1
+ max_time: null
+ min_epochs: null
+ min_steps: null
+ move_metrics_to_cpu: false
+ multiple_trainloader_mode: max_size_cycle
+ num_nodes: 1
+ num_processes: 1
+ num_sanity_val_steps: 0
+ overfit_batches: 0.0
+ plugins: null
+ precision: 32
+ profiler: null
+ reload_dataloaders_every_n_epochs: 0
+ replace_sampler_ddp: true
+ sync_batchnorm: false
+ tpu_cores: null
+ track_grad_norm: -1
+ val_check_interval: 1.0 # Don't validate before extracting features.
diff --git a/anomalib/models/padim/lightning_model.py b/anomalib/models/padim/lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..afdcf056c898ecb753a0b4c3376a3875df4ac234
--- /dev/null
+++ b/anomalib/models/padim/lightning_model.py
@@ -0,0 +1,109 @@
+"""PaDiM: a Patch Distribution Modeling Framework for Anomaly Detection and Localization.
+
+Paper https://arxiv.org/abs/2011.08785
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+from typing import List, Union
+
+import torch
+from omegaconf import DictConfig, ListConfig
+from torch import Tensor
+
+from anomalib.models.components import AnomalyModule
+from anomalib.models.padim.torch_model import PadimModel
+
+logger = logging.getLogger(__name__)
+
+__all__ = ["PadimLightning"]
+
+
+class PadimLightning(AnomalyModule):
+ """PaDiM: a Patch Distribution Modeling Framework for Anomaly Detection and Localization.
+
+ Args:
+ hparams (Union[DictConfig, ListConfig]): Model params
+ """
+
+ def __init__(self, hparams: Union[DictConfig, ListConfig]):
+ super().__init__(hparams)
+ logger.info("Initializing Padim Lightning model.")
+
+ self.layers = hparams.model.layers
+ self.model: PadimModel = PadimModel(
+ layers=hparams.model.layers,
+ input_size=hparams.model.input_size,
+ tile_size=hparams.dataset.tiling.tile_size,
+ tile_stride=hparams.dataset.tiling.stride,
+ apply_tiling=hparams.dataset.tiling.apply,
+ backbone=hparams.model.backbone,
+ ).eval()
+
+ self.stats: List[Tensor] = []
+ self.embeddings: List[Tensor] = []
+
+ @staticmethod
+ def configure_optimizers(): # pylint: disable=arguments-differ
+ """PADIM doesn't require optimization, therefore returns no optimizers."""
+ return None
+
+ def training_step(self, batch, _batch_idx): # pylint: disable=arguments-differ
+ """Training Step of PADIM. For each batch, hierarchical features are extracted from the CNN.
+
+ Args:
+ batch (Dict[str, Any]): Batch containing image filename, image, label and mask
+ _batch_idx: Index of the batch.
+
+ Returns:
+ Hierarchical feature map
+ """
+ self.model.feature_extractor.eval()
+ embedding = self.model(batch["image"])
+
+ # NOTE: `self.embedding` appends each batch embedding to
+ # store the training set embedding. We manually append these
+ # values mainly due to the new order of hooks introduced after PL v1.4.0
+ # https://github.com/PyTorchLightning/pytorch-lightning/pull/7357
+ self.embeddings.append(embedding.cpu())
+
+ def on_validation_start(self) -> None:
+ """Fit a Gaussian to the embedding collected from the training set."""
+ # NOTE: Previous anomalib versions fit Gaussian at the end of the epoch.
+ # This is not possible anymore with PyTorch Lightning v1.4.0 since validation
+ # is run within train epoch.
+ logger.info("Aggregating the embedding extracted from the training set.")
+ embeddings = torch.vstack(self.embeddings)
+
+ logger.info("Fitting a Gaussian to the embedding collected from the training set.")
+ self.stats = self.model.gaussian.fit(embeddings)
+
+ def validation_step(self, batch, _): # pylint: disable=arguments-differ
+ """Validation Step of PADIM.
+
+ Similar to the training step, hierarchical features are extracted from the CNN for each batch.
+
+ Args:
+ batch: Input batch
+ _: Index of the batch.
+
+ Returns:
+ Dictionary containing images, features, true labels and masks.
+ These are required in `validation_epoch_end` for feature concatenation.
+ """
+
+ batch["anomaly_maps"] = self.model(batch["image"])
+ return batch
diff --git a/anomalib/models/padim/torch_model.py b/anomalib/models/padim/torch_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a393feedee03c16b160dfe874b1ebc45f973b9e
--- /dev/null
+++ b/anomalib/models/padim/torch_model.py
@@ -0,0 +1,140 @@
+"""PyTorch model for the PaDiM model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from random import sample
+from typing import Dict, List, Optional, Tuple
+
+import torch
+import torch.nn.functional as F
+import torchvision
+from torch import Tensor, nn
+
+from anomalib.models.components import FeatureExtractor, MultiVariateGaussian
+from anomalib.models.padim.anomaly_map import AnomalyMapGenerator
+from anomalib.pre_processing import Tiler
+
+DIMS = {
+ "resnet18": {"orig_dims": 448, "reduced_dims": 100, "emb_scale": 4},
+ "wide_resnet50_2": {"orig_dims": 1792, "reduced_dims": 550, "emb_scale": 4},
+}
+
+
+class PadimModel(nn.Module):
+ """Padim Module.
+
+ Args:
+ layers (List[str]): Layers used for feature extraction
+ input_size (Tuple[int, int]): Input size for the model.
+ tile_size (Tuple[int, int]): Tile size
+ tile_stride (int): Stride for tiling
+ apply_tiling (bool, optional): Apply tiling. Defaults to False.
+ backbone (str, optional): Pre-trained model backbone. Defaults to "resnet18".
+ """
+
+ def __init__(
+ self,
+ layers: List[str],
+ input_size: Tuple[int, int],
+ backbone: str = "resnet18",
+ apply_tiling: bool = False,
+ tile_size: Optional[Tuple[int, int]] = None,
+ tile_stride: Optional[int] = None,
+ ):
+ super().__init__()
+ self.backbone = getattr(torchvision.models, backbone)
+ self.layers = layers
+ self.apply_tiling = apply_tiling
+ self.feature_extractor = FeatureExtractor(backbone=self.backbone(pretrained=True), layers=self.layers)
+ self.dims = DIMS[backbone]
+ # pylint: disable=not-callable
+ # Since idx is randomly selected, save it with model to get same results
+ self.register_buffer(
+ "idx",
+ torch.tensor(sample(range(0, DIMS[backbone]["orig_dims"]), DIMS[backbone]["reduced_dims"])),
+ )
+ self.idx: Tensor
+ self.loss = None
+ self.anomaly_map_generator = AnomalyMapGenerator(image_size=input_size)
+
+ n_features = DIMS[backbone]["reduced_dims"]
+ patches_dims = torch.tensor(input_size) / DIMS[backbone]["emb_scale"]
+ n_patches = patches_dims.ceil().prod().int().item()
+ self.gaussian = MultiVariateGaussian(n_features, n_patches)
+
+ if apply_tiling:
+ assert tile_size is not None
+ assert tile_stride is not None
+ self.tiler = Tiler(tile_size, tile_stride)
+
+ def forward(self, input_tensor: Tensor) -> Tensor:
+ """Forward-pass image-batch (N, C, H, W) into model to extract features.
+
+ Args:
+ input_tensor: Image-batch (N, C, H, W)
+ input_tensor: Tensor:
+
+ Returns:
+ Features from single/multiple layers.
+
+ Example:
+ >>> x = torch.randn(32, 3, 224, 224)
+ >>> features = self.extract_features(input_tensor)
+ >>> features.keys()
+ dict_keys(['layer1', 'layer2', 'layer3'])
+
+ >>> [v.shape for v in features.values()]
+ [torch.Size([32, 64, 56, 56]),
+ torch.Size([32, 128, 28, 28]),
+ torch.Size([32, 256, 14, 14])]
+ """
+
+ if self.apply_tiling:
+ input_tensor = self.tiler.tile(input_tensor)
+ with torch.no_grad():
+ features = self.feature_extractor(input_tensor)
+ embeddings = self.generate_embedding(features)
+ if self.apply_tiling:
+ embeddings = self.tiler.untile(embeddings)
+
+ if self.training:
+ output = embeddings
+ else:
+ output = self.anomaly_map_generator(
+ embedding=embeddings, mean=self.gaussian.mean, inv_covariance=self.gaussian.inv_covariance
+ )
+
+ return output
+
+ def generate_embedding(self, features: Dict[str, Tensor]) -> Tensor:
+ """Generate embedding from hierarchical feature map.
+
+ Args:
+ features (Dict[str, Tensor]): Hierarchical feature map from a CNN (ResNet18 or WideResnet)
+
+ Returns:
+ Embedding vector
+ """
+
+ embeddings = features[self.layers[0]]
+ for layer in self.layers[1:]:
+ layer_embedding = features[layer]
+ layer_embedding = F.interpolate(layer_embedding, size=embeddings.shape[-2:], mode="nearest")
+ embeddings = torch.cat((embeddings, layer_embedding), 1)
+
+ # subsample embeddings
+ idx = self.idx.to(embeddings.device)
+ embeddings = torch.index_select(embeddings, 1, idx)
+ return embeddings
diff --git a/anomalib/models/patchcore/README.md b/anomalib/models/patchcore/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..63d2ca314f4365e75df62aaea00c8a377d022af3
--- /dev/null
+++ b/anomalib/models/patchcore/README.md
@@ -0,0 +1,54 @@
+# PatchCore
+
+This is the implementation of the [PatchCore](https://arxiv.org/pdf/2106.08265.pdf) paper.
+
+Model Type: Segmentation
+
+## Description
+
+The PatchCore algorithm is based on the idea that an image can be classified as anomalous as soon as a single patch is anomalous. The input image is tiled. These tiles act as patches which are fed into the neural network. It consists of a single pre-trained network which is used to extract "mid" level features patches. The "mid" level here refers to the feature extraction layer of the neural network model. Lower level features are generally too broad and higher level features are specific to the dataset the model is trained on. The features extracted during training phase are stored in a memory bank of neighbourhood aware patch level features.
+
+During inference this memory bank is coreset subsampled. Coreset subsampling generates a subset which best approximates the structure of the available set and allows for approximate solution finding. This subset helps reduce the search cost associated with nearest neighbour search. The anomaly score is taken as the maximum distance between the test patch in the test patch collection to each respective nearest neighbour.
+
+## Architecture
+
+
+
+## Usage
+
+`python tools/train.py --model patchcore`
+
+## Benchmark
+
+All results gathered with seed `42`.
+
+## [MVTec AD Dataset](https://www.mvtec.com/company/research/datasets/mvtec-ad)
+
+### Image-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| Wide ResNet-50 | 0.980 | 0.984 | 0.959 | 1.000 | 1.000 | 0.989 | 1.000 | 0.990 | 0.982 | 1.000 | 0.994 | 0.924 | 0.960 | 0.933 | 1.000 | 0.982 |
+| ResNet-18 | 0.973 | 0.970 | 0.947 | 1.000 | 0.997 | 0.997 | 1.000 | 0.986 | 0.965 | 1.000 | 0.991 | 0.916 | 0.943 | 0.931 | 0.996 | 0.953 |
+
+### Pixel-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| Wide ResNet-50 | 0.980 | 0.988 | 0.968 | 0.991 | 0.961 | 0.934 | 0.984 | 0.988 | 0.988 | 0.987 | 0.989 | 0.980 | 0.989 | 0.988 | 0.981 | 0.983 |
+| ResNet-18 | 0.976 | 0.986 | 0.955 | 0.990 | 0.943 | 0.933 | 0.981 | 0.984 | 0.986 | 0.986 | 0.986 | 0.974 | 0.991 | 0.988 | 0.974 | 0.983 |
+
+### Image F1 Score
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| Wide ResNet-50 | 0.976 | 0.971 | 0.974 | 1.000 | 1.000 | 0.967 | 1.000 | 0.968 | 0.982 | 1.000 | 0.984 | 0.940 | 0.943 | 0.938 | 1.000 | 0.979 |
+| ResNet-18 | 0.970 | 0.949 | 0.946 | 1.000 | 0.982 | 0.992 | 1.000 | 0.978 | 0.969 | 1.000 | 0.989 | 0.940 | 0.932 | 0.935 | 0.974 | 0.967 |
+
+### Sample Results
+
+
+
+
+
+
diff --git a/anomalib/models/patchcore/__init__.py b/anomalib/models/patchcore/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c67dde9551f1341304ae89db5a921b25403b48e
--- /dev/null
+++ b/anomalib/models/patchcore/__init__.py
@@ -0,0 +1,19 @@
+"""PatchCore model."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .lightning_model import PatchcoreLightning
+
+__all__ = ["PatchcoreLightning"]
diff --git a/anomalib/models/patchcore/anomaly_map.py b/anomalib/models/patchcore/anomaly_map.py
new file mode 100644
index 0000000000000000000000000000000000000000..15eda34f568a49ecd52c2d1ffcbc46f226a9cdd2
--- /dev/null
+++ b/anomalib/models/patchcore/anomaly_map.py
@@ -0,0 +1,100 @@
+"""Anomaly Map Generator for the PatchCore model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Tuple, Union
+
+import torch
+import torch.nn.functional as F
+from kornia.filters import gaussian_blur2d
+from omegaconf import ListConfig
+
+
+class AnomalyMapGenerator:
+ """Generate Anomaly Heatmap."""
+
+ def __init__(
+ self,
+ input_size: Union[ListConfig, Tuple],
+ sigma: int = 4,
+ ) -> None:
+ self.input_size = input_size
+ self.sigma = sigma
+
+ def compute_anomaly_map(self, patch_scores: torch.Tensor, feature_map_shape: torch.Size) -> torch.Tensor:
+ """Pixel Level Anomaly Heatmap.
+
+ Args:
+ patch_scores (torch.Tensor): Patch-level anomaly scores
+ feature_map_shape (torch.Size): 2-D feature map shape (width, height)
+
+ Returns:
+ torch.Tensor: Map of the pixel-level anomaly scores
+ """
+ width, height = feature_map_shape
+ batch_size = len(patch_scores) // (width * height)
+
+ anomaly_map = patch_scores[:, 0].reshape((batch_size, 1, width, height))
+ anomaly_map = F.interpolate(anomaly_map, size=(self.input_size[0], self.input_size[1]))
+
+ kernel_size = 2 * int(4.0 * self.sigma + 0.5) + 1
+ anomaly_map = gaussian_blur2d(anomaly_map, (kernel_size, kernel_size), sigma=(self.sigma, self.sigma))
+
+ return anomaly_map
+
+ @staticmethod
+ def compute_anomaly_score(patch_scores: torch.Tensor) -> torch.Tensor:
+ """Compute Image-Level Anomaly Score.
+
+ Args:
+ patch_scores (torch.Tensor): Patch-level anomaly scores
+ Returns:
+ torch.Tensor: Image-level anomaly scores
+ """
+ max_scores = torch.argmax(patch_scores[:, 0])
+ confidence = torch.index_select(patch_scores, 0, max_scores)
+ weights = 1 - (torch.max(torch.exp(confidence)) / torch.sum(torch.exp(confidence)))
+ score = weights * torch.max(patch_scores[:, 0])
+ return score
+
+ def __call__(self, **kwargs: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
+ """Returns anomaly_map and anomaly_score.
+
+ Expects `patch_scores` keyword to be passed explicitly
+ Expects `feature_map_shape` keyword to be passed explicitly
+
+ Example
+ >>> anomaly_map_generator = AnomalyMapGenerator(input_size=input_size)
+ >>> map, score = anomaly_map_generator(patch_scores=numpy_array, feature_map_shape=feature_map_shape)
+
+ Raises:
+ ValueError: If `patch_scores` key is not found
+
+ Returns:
+ Tuple[torch.Tensor, torch.Tensor]: anomaly_map, anomaly_score
+ """
+
+ if "patch_scores" not in kwargs:
+ raise ValueError(f"Expected key `patch_scores`. Found {kwargs.keys()}")
+
+ if "feature_map_shape" not in kwargs:
+ raise ValueError(f"Expected key `feature_map_shape`. Found {kwargs.keys()}")
+
+ patch_scores = kwargs["patch_scores"]
+ feature_map_shape = kwargs["feature_map_shape"]
+
+ anomaly_map = self.compute_anomaly_map(patch_scores, feature_map_shape)
+ anomaly_score = self.compute_anomaly_score(patch_scores)
+ return anomaly_map, anomaly_score
diff --git a/anomalib/models/patchcore/config.yaml b/anomalib/models/patchcore/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..227db046905cf62671addb6874681631327e9c08
--- /dev/null
+++ b/anomalib/models/patchcore/config.yaml
@@ -0,0 +1,98 @@
+dataset:
+ name: mvtec #options: [mvtec, btech, folder]
+ format: mvtec
+ path: ./datasets/MVTec
+ task: segmentation
+ category: bottle
+ image_size: 224
+ train_batch_size: 32
+ test_batch_size: 1
+ num_workers: 0
+ transform_config:
+ train: null
+ val: null
+ create_validation_set: false
+ tiling:
+ apply: false
+ tile_size: null
+ stride: null
+ remove_border_count: 0
+ use_random_tiling: False
+ random_tile_count: 16
+
+model:
+ name: patchcore
+ backbone: wide_resnet50_2
+ layers:
+ - layer2
+ - layer3
+ coreset_sampling_ratio: 0.1
+ num_neighbors: 9
+ weight_file: weights/model.ckpt
+ normalization_method: min_max # options: [null, min_max, cdf]
+ threshold:
+ image_default: 0
+ pixel_default: 0
+ adaptive: true
+
+metrics:
+ image:
+ - F1Score
+ - AUROC
+ pixel:
+ - F1Score
+ - AUROC
+
+project:
+ seed: 0
+ path: ./results
+ log_images_to: [local]
+ logger: false # options: [tensorboard, wandb, csv] or combinations.
+
+# PL Trainer Args. Don't add extra parameter here.
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
+ accumulate_grad_batches: 1
+ amp_backend: native
+ auto_lr_find: false
+ auto_scale_batch_size: false
+ auto_select_gpus: false
+ benchmark: false
+ check_val_every_n_epoch: 1 # Don't validate before extracting features.
+ default_root_dir: null
+ detect_anomaly: false
+ deterministic: false
+ enable_checkpointing: true
+ enable_model_summary: true
+ enable_progress_bar: true
+ fast_dev_run: false
+ gpus: null # Set automatically
+ gradient_clip_val: 0
+ ipus: null
+ limit_predict_batches: 1.0
+ limit_test_batches: 1.0
+ limit_train_batches: 1.0
+ limit_val_batches: 1.0
+ log_every_n_steps: 50
+ log_gpu_memory: null
+ max_epochs: 1
+ max_steps: -1
+ max_time: null
+ min_epochs: null
+ min_steps: null
+ move_metrics_to_cpu: false
+ multiple_trainloader_mode: max_size_cycle
+ num_nodes: 1
+ num_processes: 1
+ num_sanity_val_steps: 0
+ overfit_batches: 0.0
+ plugins: null
+ precision: 32
+ profiler: null
+ reload_dataloaders_every_n_epochs: 0
+ replace_sampler_ddp: true
+ strategy: null
+ sync_batchnorm: false
+ tpu_cores: null
+ track_grad_norm: -1
+ val_check_interval: 1.0 # Don't validate before extracting features.
diff --git a/anomalib/models/patchcore/lightning_model.py b/anomalib/models/patchcore/lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a35ce86c3c21e93ee6dcced3267d04a71866543
--- /dev/null
+++ b/anomalib/models/patchcore/lightning_model.py
@@ -0,0 +1,113 @@
+"""Towards Total Recall in Industrial Anomaly Detection.
+
+Paper https://arxiv.org/abs/2106.08265.
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+from typing import List
+
+import torch
+from torch import Tensor
+
+from anomalib.models.components import AnomalyModule
+from anomalib.models.patchcore.torch_model import PatchcoreModel
+
+logger = logging.getLogger(__name__)
+
+
+class PatchcoreLightning(AnomalyModule):
+ """PatchcoreLightning Module to train PatchCore algorithm.
+
+ Args:
+ layers (List[str]): Layers used for feature extraction
+ input_size (Tuple[int, int]): Input size for the model.
+ tile_size (Tuple[int, int]): Tile size
+ tile_stride (int): Stride for tiling
+ backbone (str, optional): Pre-trained model backbone. Defaults to "resnet18".
+ apply_tiling (bool, optional): Apply tiling. Defaults to False.
+ """
+
+ def __init__(self, hparams) -> None:
+ super().__init__(hparams)
+ logger.info("Initializing Patchcore Lightning model.")
+
+ self.model: PatchcoreModel = PatchcoreModel(
+ layers=hparams.model.layers,
+ input_size=hparams.model.input_size,
+ tile_size=hparams.dataset.tiling.tile_size,
+ tile_stride=hparams.dataset.tiling.stride,
+ backbone=hparams.model.backbone,
+ apply_tiling=hparams.dataset.tiling.apply,
+ )
+ self.embeddings: List[Tensor] = []
+
+ def configure_optimizers(self) -> None:
+ """Configure optimizers.
+
+ Returns:
+ None: Do not set optimizers by returning None.
+ """
+ return None
+
+ def training_step(self, batch, _batch_idx): # pylint: disable=arguments-differ
+ """Generate feature embedding of the batch.
+
+ Args:
+ batch (Dict[str, Any]): Batch containing image filename, image, label and mask
+ _batch_idx (int): Batch Index
+
+ Returns:
+ Dict[str, np.ndarray]: Embedding Vector
+ """
+ self.model.feature_extractor.eval()
+ embedding = self.model(batch["image"])
+
+ # NOTE: `self.embedding` appends each batch embedding to
+ # store the training set embedding. We manually append these
+ # values mainly due to the new order of hooks introduced after PL v1.4.0
+ # https://github.com/PyTorchLightning/pytorch-lightning/pull/7357
+ self.embeddings.append(embedding)
+
+ def on_validation_start(self) -> None:
+ """Apply subsampling to the embedding collected from the training set."""
+ # NOTE: Previous anomalib versions fit subsampling at the end of the epoch.
+ # This is not possible anymore with PyTorch Lightning v1.4.0 since validation
+ # is run within train epoch.
+ logger.info("Aggregating the embedding extracted from the training set.")
+ embeddings = torch.vstack(self.embeddings)
+
+ logger.info("Applying core-set subsampling to get the embedding.")
+ sampling_ratio = self.hparams.model.coreset_sampling_ratio
+ self.model.subsample_embedding(embeddings, sampling_ratio)
+
+ def validation_step(self, batch, _): # pylint: disable=arguments-differ
+ """Get batch of anomaly maps from input image batch.
+
+ Args:
+ batch (Dict[str, Any]): Batch containing image filename,
+ image, label and mask
+ _ (int): Batch Index
+
+ Returns:
+ Dict[str, Any]: Image filenames, test images, GT and predicted label/masks
+ """
+
+ anomaly_maps, anomaly_score = self.model(batch["image"])
+ batch["anomaly_maps"] = anomaly_maps
+ batch["pred_scores"] = anomaly_score.unsqueeze(0)
+
+ return batch
diff --git a/anomalib/models/patchcore/torch_model.py b/anomalib/models/patchcore/torch_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..c415199489f3c32d9c0a9b110f18228191e50e14
--- /dev/null
+++ b/anomalib/models/patchcore/torch_model.py
@@ -0,0 +1,166 @@
+"""PyTorch model for the PatchCore model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Dict, List, Optional, Tuple, Union
+
+import torch
+import torch.nn.functional as F
+import torchvision
+from torch import Tensor, nn
+
+from anomalib.models.components import (
+ DynamicBufferModule,
+ FeatureExtractor,
+ KCenterGreedy,
+)
+from anomalib.models.patchcore.anomaly_map import AnomalyMapGenerator
+from anomalib.pre_processing import Tiler
+
+
+class PatchcoreModel(DynamicBufferModule, nn.Module):
+ """Patchcore Module."""
+
+ def __init__(
+ self,
+ layers: List[str],
+ input_size: Tuple[int, int],
+ backbone: str = "wide_resnet50_2",
+ apply_tiling: bool = False,
+ tile_size: Optional[Tuple[int, int]] = None,
+ tile_stride: Optional[int] = None,
+ ) -> None:
+ super().__init__()
+
+ self.backbone = getattr(torchvision.models, backbone)
+ self.layers = layers
+ self.input_size = input_size
+ self.apply_tiling = apply_tiling
+
+ self.feature_extractor = FeatureExtractor(backbone=self.backbone(pretrained=True), layers=self.layers)
+ self.feature_pooler = torch.nn.AvgPool2d(3, 1, 1)
+ self.anomaly_map_generator = AnomalyMapGenerator(input_size=input_size)
+
+ if apply_tiling:
+ assert tile_size is not None
+ assert tile_stride is not None
+ self.tiler = Tiler(tile_size, tile_stride)
+
+ self.register_buffer("memory_bank", torch.Tensor())
+ self.memory_bank: torch.Tensor
+
+ def forward(self, input_tensor: Tensor) -> Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
+ """Return Embedding during training, or a tuple of anomaly map and anomaly score during testing.
+
+ Steps performed:
+ 1. Get features from a CNN.
+ 2. Generate embedding based on the features.
+ 3. Compute anomaly map in test mode.
+
+ Args:
+ input_tensor (Tensor): Input tensor
+
+ Returns:
+ Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]: Embedding for training,
+ anomaly map and anomaly score for testing.
+ """
+ if self.apply_tiling:
+ input_tensor = self.tiler.tile(input_tensor)
+
+ with torch.no_grad():
+ features = self.feature_extractor(input_tensor)
+
+ features = {layer: self.feature_pooler(feature) for layer, feature in features.items()}
+ embedding = self.generate_embedding(features)
+
+ if self.apply_tiling:
+ embedding = self.tiler.untile(embedding)
+
+ feature_map_shape = embedding.shape[-2:]
+ embedding = self.reshape_embedding(embedding)
+
+ if self.training:
+ output = embedding
+ else:
+ patch_scores = self.nearest_neighbors(embedding=embedding, n_neighbors=9)
+ anomaly_map, anomaly_score = self.anomaly_map_generator(
+ patch_scores=patch_scores, feature_map_shape=feature_map_shape
+ )
+ output = (anomaly_map, anomaly_score)
+
+ return output
+
+ def generate_embedding(self, features: Dict[str, Tensor]) -> torch.Tensor:
+ """Generate embedding from hierarchical feature map.
+
+ Args:
+ features: Hierarchical feature map from a CNN (ResNet18 or WideResnet)
+ features: Dict[str:Tensor]:
+
+ Returns:
+ Embedding vector
+ """
+
+ embeddings = features[self.layers[0]]
+ for layer in self.layers[1:]:
+ layer_embedding = features[layer]
+ layer_embedding = F.interpolate(layer_embedding, size=embeddings.shape[-2:], mode="nearest")
+ embeddings = torch.cat((embeddings, layer_embedding), 1)
+
+ return embeddings
+
+ @staticmethod
+ def reshape_embedding(embedding: Tensor) -> Tensor:
+ """Reshape Embedding.
+
+ Reshapes Embedding to the following format:
+ [Batch, Embedding, Patch, Patch] to [Batch*Patch*Patch, Embedding]
+
+ Args:
+ embedding (Tensor): Embedding tensor extracted from CNN features.
+
+ Returns:
+ Tensor: Reshaped embedding tensor.
+ """
+ embedding_size = embedding.size(1)
+ embedding = embedding.permute(0, 2, 3, 1).reshape(-1, embedding_size)
+ return embedding
+
+ def subsample_embedding(self, embedding: torch.Tensor, sampling_ratio: float) -> None:
+ """Subsample embedding based on coreset sampling and store to memory.
+
+ Args:
+ embedding (np.ndarray): Embedding tensor from the CNN
+ sampling_ratio (float): Coreset sampling ratio
+ """
+
+ # Coreset Subsampling
+ sampler = KCenterGreedy(embedding=embedding, sampling_ratio=sampling_ratio)
+ coreset = sampler.sample_coreset()
+ self.memory_bank = coreset
+
+ def nearest_neighbors(self, embedding: Tensor, n_neighbors: int = 9) -> Tensor:
+ """Nearest Neighbours using brute force method and euclidean norm.
+
+ Args:
+ embedding (Tensor): Features to compare the distance with the memory bank.
+ n_neighbors (int): Number of neighbors to look at
+
+ Returns:
+ Tensor: Patch scores.
+ """
+ distances = torch.cdist(embedding, self.memory_bank, p=2.0) # euclidean norm
+ patch_scores, _ = distances.topk(k=n_neighbors, largest=False, dim=1)
+ return patch_scores
diff --git a/anomalib/models/stfpm/README.md b/anomalib/models/stfpm/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..bc11cfab05fa467878e150021e2cf6a594f9cb58
--- /dev/null
+++ b/anomalib/models/stfpm/README.md
@@ -0,0 +1,54 @@
+# Student-Teacher Feature Pyramid Matching for Unsupervised Anomaly Detection
+
+This is the implementation of the [STFPM](https://arxiv.org/pdf/2103.04257.pdf) paper.
+
+Model Type: Segmentation
+
+## Description
+
+STFPM algorithm which consists of a pre-trained teacher network and a student network with identical architecture. The student network learns the distribution of anomaly-free images by matching the features with the counterpart features in the teacher network. Multi-scale feature matching is used to enhance robustness. This hierarchical feature matching enables the student network to receive a mixture of multi-level knowledge from the feature pyramid thus allowing for anomaly detection of various sizes.
+
+During inference, the feature pyramids of teacher and student networks are compared. Larger difference indicates a higher probability of anomaly occurrence.
+
+## Architecture
+
+
+
+## Usage
+
+`python tools/train.py --model stfpm`
+
+## Benchmark
+
+All results gathered with seed `42`.
+
+## [MVTec AD Dataset](https://www.mvtec.com/company/research/datasets/mvtec-ad)
+
+### Image-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.893 | 0.954 | 0.982 | 0.989 | 0.949 | 0.961 | 0.979 | 0.838 | 0.759 | 0.999 | 0.956 | 0.705 | 0.835 | 0.997 | 0.853 | 0.645 |
+| Wide ResNet-50 | 0.876 | 0.957 | 0.977 | 0.981 | 0.976 | 0.939 | 0.987 | 0.878 | 0.732 | 0.995 | 0.973 | 0.652 | 0.825 | 0.5 | 0.875 | 0.899 |
+
+### Pixel-Level AUC
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.951 | 0.986 | 0.988 | 0.991 | 0.946 | 0.949 | 0.971 | 0.898 | 0.962 | 0.981 | 0.942 | 0.878 | 0.983 | 0.983 | 0.838 | 0.972 |
+| Wide ResNet-50 | 0.903 | 0.987 | 0.989 | 0.980 | 0.966 | 0.956 | 0.966 | 0.913 | 0.956 | 0.974 | 0.961 | 0.946 | 0.988 | 0.178 | 0.807 | 0.980 |
+
+### Image F1 Score
+
+| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
+| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
+| ResNet-18 | 0.932 | 0.961 | 0.982 | 0.989 | 0.930 | 0.951 | 0.984 | 0.819 | 0.918 | 0.993 | 0.973 | 0.918 | 0.887 | 0.984 | 0.790 | 0.908 |
+| Wide ResNet-50 | 0.926 | 0.973 | 0.973 | 0.974 | 0.965 | 0.929 | 0.976 | 0.853 | 0.920 | 0.972 | 0.974 | 0.922 | 0.884 | 0.833 | 0.815 | 0.931 |
+
+### Sample Results
+
+
+
+
+
+
diff --git a/anomalib/models/stfpm/__init__.py b/anomalib/models/stfpm/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e52c48b4983b00e8ee3f17536e196b1f6ad38cc2
--- /dev/null
+++ b/anomalib/models/stfpm/__init__.py
@@ -0,0 +1,19 @@
+"""STFPM Model."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .lightning_model import StfpmLightning
+
+__all__ = ["StfpmLightning"]
diff --git a/anomalib/models/stfpm/anomaly_map.py b/anomalib/models/stfpm/anomaly_map.py
new file mode 100644
index 0000000000000000000000000000000000000000..a087d90744dbbe3824257ad8b6d1c11cda1a80cf
--- /dev/null
+++ b/anomalib/models/stfpm/anomaly_map.py
@@ -0,0 +1,98 @@
+"""Anomaly Map Generator for the STFPM model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Dict, Tuple, Union
+
+import torch
+import torch.nn.functional as F
+from omegaconf import ListConfig
+from torch import Tensor
+
+
+class AnomalyMapGenerator:
+ """Generate Anomaly Heatmap."""
+
+ def __init__(
+ self,
+ image_size: Union[ListConfig, Tuple],
+ ):
+ self.distance = torch.nn.PairwiseDistance(p=2, keepdim=True)
+ self.image_size = image_size if isinstance(image_size, tuple) else tuple(image_size)
+
+ def compute_layer_map(self, teacher_features: Tensor, student_features: Tensor) -> Tensor:
+ """Compute the layer map based on cosine similarity.
+
+ Args:
+ teacher_features (Tensor): Teacher features
+ student_features (Tensor): Student features
+
+ Returns:
+ Anomaly score based on cosine similarity.
+ """
+ norm_teacher_features = F.normalize(teacher_features)
+ norm_student_features = F.normalize(student_features)
+
+ layer_map = 0.5 * torch.norm(norm_teacher_features - norm_student_features, p=2, dim=-3, keepdim=True) ** 2
+ layer_map = F.interpolate(layer_map, size=self.image_size, align_corners=False, mode="bilinear")
+ return layer_map
+
+ def compute_anomaly_map(
+ self, teacher_features: Dict[str, Tensor], student_features: Dict[str, Tensor]
+ ) -> torch.Tensor:
+ """Compute the overall anomaly map via element-wise production the interpolated anomaly maps.
+
+ Args:
+ teacher_features (Dict[str, Tensor]): Teacher features
+ student_features (Dict[str, Tensor]): Student features
+
+ Returns:
+ Final anomaly map
+ """
+ batch_size = list(teacher_features.values())[0].shape[0]
+ anomaly_map = torch.ones(batch_size, 1, self.image_size[0], self.image_size[1])
+ for layer in teacher_features.keys():
+ layer_map = self.compute_layer_map(teacher_features[layer], student_features[layer])
+ anomaly_map = anomaly_map.to(layer_map.device)
+ anomaly_map *= layer_map
+
+ return anomaly_map
+
+ def __call__(self, **kwds: Dict[str, Tensor]) -> torch.Tensor:
+ """Returns anomaly map.
+
+ Expects `teach_features` and `student_features` keywords to be passed explicitly.
+
+ Example:
+ >>> anomaly_map_generator = AnomalyMapGenerator(image_size=tuple(hparams.model.input_size))
+ >>> output = self.anomaly_map_generator(
+ teacher_features=teacher_features,
+ student_features=student_features
+ )
+
+ Raises:
+ ValueError: `teach_features` and `student_features` keys are not found
+
+ Returns:
+ torch.Tensor: anomaly map
+ """
+
+ if not ("teacher_features" in kwds and "student_features" in kwds):
+ raise ValueError(f"Expected keys `teacher_features` and `student_features. Found {kwds.keys()}")
+
+ teacher_features: Dict[str, Tensor] = kwds["teacher_features"]
+ student_features: Dict[str, Tensor] = kwds["student_features"]
+
+ return self.compute_anomaly_map(teacher_features, student_features)
diff --git a/anomalib/models/stfpm/config.yaml b/anomalib/models/stfpm/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..05ffa8132998c97aaed0a5a2c8e190314379cf58
--- /dev/null
+++ b/anomalib/models/stfpm/config.yaml
@@ -0,0 +1,108 @@
+dataset:
+ name: mvtec #options: [mvtec, btech, folder]
+ format: mvtec
+ path: ./datasets/MVTec
+ category: bottle
+ task: segmentation
+ image_size: 256
+ train_batch_size: 32
+ test_batch_size: 32
+ inference_batch_size: 32
+ num_workers: 36
+ transform_config:
+ train: null
+ val: null
+ create_validation_set: false
+ tiling:
+ apply: false
+ tile_size: null
+ stride: null
+ remove_border_count: 0
+ use_random_tiling: False
+ random_tile_count: 16
+
+model:
+ name: stfpm
+ backbone: resnet18
+ layers:
+ - layer1
+ - layer2
+ - layer3
+ lr: 0.4
+ momentum: 0.9
+ weight_decay: 0.0001
+ early_stopping:
+ patience: 3
+ metric: pixel_AUROC
+ mode: max
+ normalization_method: min_max # options: [null, min_max, cdf]
+ threshold:
+ image_default: 0
+ pixel_default: 0
+ adaptive: true
+
+metrics:
+ image:
+ - F1Score
+ - AUROC
+ pixel:
+ - F1Score
+ - AUROC
+
+project:
+ seed: 0
+ path: ./results
+ log_images_to: [local]
+ logger: false # options: [tensorboard, wandb, csv] or combinations.
+
+optimization:
+ openvino:
+ apply: false
+
+# PL Trainer Args. Don't add extra parameter here.
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
+ accumulate_grad_batches: 1
+ amp_backend: native
+ auto_lr_find: false
+ auto_scale_batch_size: false
+ auto_select_gpus: false
+ benchmark: false
+ check_val_every_n_epoch: 1
+ default_root_dir: null
+ detect_anomaly: false
+ deterministic: false
+ enable_checkpointing: true
+ enable_model_summary: true
+ enable_progress_bar: true
+ fast_dev_run: false
+ gpus: null # Set automatically
+ gradient_clip_val: 0
+ ipus: null
+ limit_predict_batches: 1.0
+ limit_test_batches: 1.0
+ limit_train_batches: 1.0
+ limit_val_batches: 1.0
+ log_every_n_steps: 50
+ log_gpu_memory: null
+ max_epochs: 100
+ max_steps: -1
+ max_time: null
+ min_epochs: null
+ min_steps: null
+ move_metrics_to_cpu: false
+ multiple_trainloader_mode: max_size_cycle
+ num_nodes: 1
+ num_processes: 1
+ num_sanity_val_steps: 0
+ overfit_batches: 0.0
+ plugins: null
+ precision: 32
+ profiler: null
+ reload_dataloaders_every_n_epochs: 0
+ replace_sampler_ddp: true
+ strategy: null
+ sync_batchnorm: false
+ tpu_cores: null
+ track_grad_norm: -1
+ val_check_interval: 1.0
diff --git a/anomalib/models/stfpm/lightning_model.py b/anomalib/models/stfpm/lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..0fc3cb6fba06d1ab54ff944b183c898b082fe0b2
--- /dev/null
+++ b/anomalib/models/stfpm/lightning_model.py
@@ -0,0 +1,107 @@
+"""STFPM: Student-Teacher Feature Pyramid Matching for Unsupervised Anomaly Detection.
+
+https://arxiv.org/abs/2103.04257
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+
+import torch
+from pytorch_lightning.callbacks import EarlyStopping
+from torch import optim
+
+from anomalib.models.components import AnomalyModule
+from anomalib.models.stfpm.torch_model import STFPMModel
+
+logger = logging.getLogger(__name__)
+
+__all__ = ["StfpmLightning"]
+
+
+class StfpmLightning(AnomalyModule):
+ """PL Lightning Module for the STFPM algorithm."""
+
+ def __init__(self, hparams):
+ super().__init__(hparams)
+ logger.info("Initializing Stfpm Lightning model.")
+
+ self.model = STFPMModel(
+ layers=hparams.model.layers,
+ input_size=hparams.model.input_size,
+ tile_size=hparams.dataset.tiling.tile_size,
+ tile_stride=hparams.dataset.tiling.stride,
+ backbone=hparams.model.backbone,
+ apply_tiling=hparams.dataset.tiling.apply,
+ )
+ self.loss_val = 0
+
+ def configure_callbacks(self):
+ """Configure model-specific callbacks."""
+ early_stopping = EarlyStopping(
+ monitor=self.hparams.model.early_stopping.metric,
+ patience=self.hparams.model.early_stopping.patience,
+ mode=self.hparams.model.early_stopping.mode,
+ )
+ return [early_stopping]
+
+ def configure_optimizers(self) -> torch.optim.Optimizer:
+ """Configure optimizers by creating an SGD optimizer.
+
+ Returns:
+ (Optimizer): SGD optimizer
+ """
+ return optim.SGD(
+ params=self.model.student_model.parameters(),
+ lr=self.hparams.model.lr,
+ momentum=self.hparams.model.momentum,
+ weight_decay=self.hparams.model.weight_decay,
+ )
+
+ def training_step(self, batch, _): # pylint: disable=arguments-differ
+ """Training Step of STFPM.
+
+ For each batch, teacher and student and teacher features are extracted from the CNN.
+
+ Args:
+ batch (Tensor): Input batch
+ _: Index of the batch.
+
+ Returns:
+ Hierarchical feature map
+ """
+ self.model.teacher_model.eval()
+ teacher_features, student_features = self.model.forward(batch["image"])
+ loss = self.loss_val + self.model.loss(teacher_features, student_features)
+ self.loss_val = 0
+ return {"loss": loss}
+
+ def validation_step(self, batch, _): # pylint: disable=arguments-differ
+ """Validation Step of STFPM.
+
+ Similar to the training step, student/teacher features are extracted from the CNN for each batch, and
+ anomaly map is computed.
+
+ Args:
+ batch (Tensor): Input batch
+ _: Index of the batch.
+
+ Returns:
+ Dictionary containing images, anomaly maps, true labels and masks.
+ These are required in `validation_epoch_end` for feature concatenation.
+ """
+ batch["anomaly_maps"] = self.model(batch["image"])
+
+ return batch
diff --git a/anomalib/models/stfpm/torch_model.py b/anomalib/models/stfpm/torch_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..1e1b650e7808b573947be6a58ae093c28be5f4da
--- /dev/null
+++ b/anomalib/models/stfpm/torch_model.py
@@ -0,0 +1,156 @@
+"""PyTorch model for the STFPM model implementation."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Dict, List, Optional, Tuple
+
+import torch
+import torch.nn.functional as F
+import torchvision
+from torch import Tensor, nn
+
+from anomalib.models.components import FeatureExtractor
+from anomalib.models.stfpm.anomaly_map import AnomalyMapGenerator
+from anomalib.pre_processing import Tiler
+
+
+class Loss(nn.Module):
+ """Feature Pyramid Loss This class implmenents the feature pyramid loss function proposed in STFPM paper.
+
+ Example:
+ >>> from anomalib.models.components.feature_extractors.feature_extractor import FeatureExtractor
+ >>> from anomalib.models.stfpm.torch_model import Loss
+ >>> from torchvision.models import resnet18
+
+ >>> layers = ['layer1', 'layer2', 'layer3']
+ >>> teacher_model = FeatureExtractor(model=resnet18(pretrained=True), layers=layers)
+ >>> student_model = FeatureExtractor(model=resnet18(pretrained=False), layers=layers)
+ >>> loss = Loss()
+
+ >>> inp = torch.rand((4, 3, 256, 256))
+ >>> teacher_features = teacher_model(inp)
+ >>> student_features = student_model(inp)
+ >>> loss(student_features, teacher_features)
+ tensor(51.2015, grad_fn=)
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.mse_loss = nn.MSELoss(reduction="sum")
+
+ def compute_layer_loss(self, teacher_feats: Tensor, student_feats: Tensor) -> Tensor:
+ """Compute layer loss based on Equation (1) in Section 3.2 of the paper.
+
+ Args:
+ teacher_feats (Tensor): Teacher features
+ student_feats (Tensor): Student features
+
+ Returns:
+ L2 distance between teacher and student features.
+ """
+
+ height, width = teacher_feats.shape[2:]
+
+ norm_teacher_features = F.normalize(teacher_feats)
+ norm_student_features = F.normalize(student_feats)
+ layer_loss = (0.5 / (width * height)) * self.mse_loss(norm_teacher_features, norm_student_features)
+
+ return layer_loss
+
+ def forward(self, teacher_features: Dict[str, Tensor], student_features: Dict[str, Tensor]) -> Tensor:
+ """Compute the overall loss via the weighted average of the layer losses computed by the cosine similarity.
+
+ Args:
+ teacher_features (Dict[str, Tensor]): Teacher features
+ student_features (Dict[str, Tensor]): Student features
+
+ Returns:
+ Total loss, which is the weighted average of the layer losses.
+ """
+
+ layer_losses: List[Tensor] = []
+ for layer in teacher_features.keys():
+ loss = self.compute_layer_loss(teacher_features[layer], student_features[layer])
+ layer_losses.append(loss)
+
+ total_loss = torch.stack(layer_losses).sum()
+
+ return total_loss
+
+
+class STFPMModel(nn.Module):
+ """STFPM: Student-Teacher Feature Pyramid Matching for Unsupervised Anomaly Detection.
+
+ Args:
+ layers (List[str]): Layers used for feature extraction
+ input_size (Tuple[int, int]): Input size for the model.
+ tile_size (Tuple[int, int]): Tile size
+ tile_stride (int): Stride for tiling
+ backbone (str, optional): Pre-trained model backbone. Defaults to "resnet18".
+ apply_tiling (bool, optional): Apply tiling. Defaults to False.
+ """
+
+ def __init__(
+ self,
+ layers: List[str],
+ input_size: Tuple[int, int],
+ backbone: str = "resnet18",
+ apply_tiling: bool = False,
+ tile_size: Optional[Tuple[int, int]] = None,
+ tile_stride: Optional[int] = None,
+ ):
+ super().__init__()
+ self.backbone = getattr(torchvision.models, backbone)
+ self.apply_tiling = apply_tiling
+ self.teacher_model = FeatureExtractor(backbone=self.backbone(pretrained=True), layers=layers)
+ self.student_model = FeatureExtractor(backbone=self.backbone(pretrained=False), layers=layers)
+
+ # teacher model is fixed
+ for parameters in self.teacher_model.parameters():
+ parameters.requires_grad = False
+
+ self.loss = Loss()
+ if self.apply_tiling:
+ assert tile_size is not None
+ assert tile_stride is not None
+ self.tiler = Tiler(tile_size, tile_stride)
+ self.anomaly_map_generator = AnomalyMapGenerator(image_size=tuple(tile_size))
+ else:
+ self.anomaly_map_generator = AnomalyMapGenerator(image_size=tuple(input_size))
+
+ def forward(self, images):
+ """Forward-pass images into the network.
+
+ During the training mode the model extracts the features from the teacher and student networks.
+ During the evaluation mode, it returns the predicted anomaly map.
+
+ Args:
+ images (Tensor): Batch of images.
+
+ Returns:
+ Teacher and student features when in training mode, otherwise the predicted anomaly maps.
+ """
+ if self.apply_tiling:
+ images = self.tiler.tile(images)
+ teacher_features: Dict[str, Tensor] = self.teacher_model(images)
+ student_features: Dict[str, Tensor] = self.student_model(images)
+ if self.training:
+ output = teacher_features, student_features
+ else:
+ output = self.anomaly_map_generator(teacher_features=teacher_features, student_features=student_features)
+ if self.apply_tiling:
+ output = self.tiler.untile(output)
+
+ return output
diff --git a/anomalib/post_processing/__init__.py b/anomalib/post_processing/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..11b0f0092cad829b87be1cf59f168b2d7181d1ed
--- /dev/null
+++ b/anomalib/post_processing/__init__.py
@@ -0,0 +1,24 @@
+"""Methods to help post-process raw model outputs."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .post_process import (
+ anomaly_map_to_color_map,
+ compute_mask,
+ superimpose_anomaly_map,
+)
+from .visualizer import Visualizer
+
+__all__ = ["anomaly_map_to_color_map", "superimpose_anomaly_map", "compute_mask", "Visualizer"]
diff --git a/anomalib/post_processing/normalization/__init__.py b/anomalib/post_processing/normalization/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c0b064dcd2094c02df7fcde8f0d0d72eb4f77f63
--- /dev/null
+++ b/anomalib/post_processing/normalization/__init__.py
@@ -0,0 +1,15 @@
+"""Tools for anomaly score normalization."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/anomalib/post_processing/normalization/cdf.py b/anomalib/post_processing/normalization/cdf.py
new file mode 100644
index 0000000000000000000000000000000000000000..5a36c2f15a71b8d1b0bb3838304df509fd389666
--- /dev/null
+++ b/anomalib/post_processing/normalization/cdf.py
@@ -0,0 +1,68 @@
+"""Tools for CDF normalization."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Optional, Union
+
+import numpy as np
+import torch
+from scipy.stats import norm
+from torch import Tensor
+from torch.distributions import Normal
+
+
+def standardize(
+ targets: Union[np.ndarray, Tensor],
+ mean: Union[np.ndarray, Tensor, float],
+ std: Union[np.ndarray, Tensor, float],
+ center_at: Optional[float] = None,
+) -> Union[np.ndarray, Tensor]:
+ """Standardize the targets to the z-domain."""
+ if isinstance(targets, np.ndarray):
+ targets = np.log(targets)
+ elif isinstance(targets, Tensor):
+ targets = torch.log(targets)
+ else:
+ raise ValueError(f"Targets must be either Tensor or Numpy array. Received {type(targets)}")
+ standardized = (targets - mean) / std
+ if center_at:
+ standardized -= (center_at - mean) / std
+ return standardized
+
+
+def normalize(
+ targets: Union[np.ndarray, Tensor], threshold: Union[np.ndarray, Tensor, float]
+) -> Union[np.ndarray, Tensor]:
+ """Normalize the targets by using the cumulative density function."""
+ if isinstance(targets, Tensor):
+ return normalize_torch(targets, threshold)
+ if isinstance(targets, np.ndarray):
+ return normalize_numpy(targets, threshold)
+ raise ValueError(f"Targets must be either Tensor or Numpy array. Received {type(targets)}")
+
+
+def normalize_torch(targets: Tensor, threshold: Tensor) -> Tensor:
+ """Normalize the targets by using the cumulative density function, PyTorch version."""
+ device = targets.device
+ image_threshold = threshold.cpu()
+
+ dist = Normal(torch.Tensor([0]), torch.Tensor([1]))
+ normalized = dist.cdf(targets.cpu() - image_threshold).to(device)
+ return normalized
+
+
+def normalize_numpy(targets: np.ndarray, threshold: Union[np.ndarray, float]) -> np.ndarray:
+ """Normalize the targets by using the cumulative density function, Numpy version."""
+ return norm.cdf(targets - threshold)
diff --git a/anomalib/post_processing/normalization/min_max.py b/anomalib/post_processing/normalization/min_max.py
new file mode 100644
index 0000000000000000000000000000000000000000..5b8d18505923bc817fa7da93fa61b07b16a3de07
--- /dev/null
+++ b/anomalib/post_processing/normalization/min_max.py
@@ -0,0 +1,40 @@
+"""Tools for min-max normalization."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Union
+
+import numpy as np
+import torch
+from torch import Tensor
+
+
+def normalize(
+ targets: Union[np.ndarray, Tensor, np.float32],
+ threshold: Union[np.ndarray, Tensor, float],
+ min_val: Union[np.ndarray, Tensor, float],
+ max_val: Union[np.ndarray, Tensor, float],
+) -> Union[np.ndarray, Tensor]:
+ """Apply min-max normalization and shift the values such that the threshold value is centered at 0.5."""
+ normalized = ((targets - threshold) / (max_val - min_val)) + 0.5
+ if isinstance(targets, (np.ndarray, np.float32)):
+ normalized = np.minimum(normalized, 1)
+ normalized = np.maximum(normalized, 0)
+ elif isinstance(targets, Tensor):
+ normalized = torch.minimum(normalized, torch.tensor(1)) # pylint: disable=not-callable
+ normalized = torch.maximum(normalized, torch.tensor(0)) # pylint: disable=not-callable
+ else:
+ raise ValueError(f"Targets must be either Tensor or Numpy array. Received {type(targets)}")
+ return normalized
diff --git a/anomalib/post_processing/post_process.py b/anomalib/post_processing/post_process.py
new file mode 100644
index 0000000000000000000000000000000000000000..03482ee2f3e301751efbece36017752da7098721
--- /dev/null
+++ b/anomalib/post_processing/post_process.py
@@ -0,0 +1,92 @@
+"""Post Process This module contains utils function to apply post-processing to the output predictions."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+
+import cv2
+import numpy as np
+from skimage import morphology
+
+
+def anomaly_map_to_color_map(anomaly_map: np.ndarray, normalize: bool = True) -> np.ndarray:
+ """Compute anomaly color heatmap.
+
+ Args:
+ anomaly_map (np.ndarray): Final anomaly map computed by the distance metric.
+ normalize (bool, optional): Bool to normalize the anomaly map prior to applying
+ the color map. Defaults to True.
+
+ Returns:
+ np.ndarray: [description]
+ """
+ if normalize:
+ anomaly_map = (anomaly_map - anomaly_map.min()) / np.ptp(anomaly_map)
+ anomaly_map = anomaly_map * 255
+ anomaly_map = anomaly_map.astype(np.uint8)
+
+ anomaly_map = cv2.applyColorMap(anomaly_map, cv2.COLORMAP_JET)
+ anomaly_map = cv2.cvtColor(anomaly_map, cv2.COLOR_BGR2RGB)
+ return anomaly_map
+
+
+def superimpose_anomaly_map(
+ anomaly_map: np.ndarray, image: np.ndarray, alpha: float = 0.4, gamma: int = 0, normalize: bool = False
+) -> np.ndarray:
+ """Superimpose anomaly map on top of in the input image.
+
+ Args:
+ anomaly_map (np.ndarray): Anomaly map
+ image (np.ndarray): Input image
+ alpha (float, optional): Weight to overlay anomaly map
+ on the input image. Defaults to 0.4.
+ gamma (int, optional): Value to add to the blended image
+ to smooth the processing. Defaults to 0. Overall,
+ the formula to compute the blended image is
+ I' = (alpha*I1 + (1-alpha)*I2) + gamma
+ normalize: whether or not the anomaly maps should
+ be normalized to image min-max
+
+
+ Returns:
+ np.ndarray: Image with anomaly map superimposed on top of it.
+ """
+
+ anomaly_map = anomaly_map_to_color_map(anomaly_map.squeeze(), normalize=normalize)
+ superimposed_map = cv2.addWeighted(anomaly_map, alpha, image, (1 - alpha), gamma)
+ return superimposed_map
+
+
+def compute_mask(anomaly_map: np.ndarray, threshold: float, kernel_size: int = 4) -> np.ndarray:
+ """Compute anomaly mask via thresholding the predicted anomaly map.
+
+ Args:
+ anomaly_map (np.ndarray): Anomaly map predicted via the model
+ threshold (float): Value to threshold anomaly scores into 0-1 range.
+ kernel_size (int): Value to apply morphological operations to the predicted mask. Defaults to 4.
+
+ Returns:
+ Predicted anomaly mask
+ """
+
+ anomaly_map = anomaly_map.squeeze()
+ mask: np.ndarray = np.zeros_like(anomaly_map).astype(np.uint8)
+ mask[anomaly_map > threshold] = 1
+
+ kernel = morphology.disk(kernel_size)
+ mask = morphology.opening(mask, kernel)
+
+ mask *= 255
+
+ return mask
diff --git a/anomalib/post_processing/visualizer.py b/anomalib/post_processing/visualizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..4052defe745b1298b65c0e87950bfe909fc79ac4
--- /dev/null
+++ b/anomalib/post_processing/visualizer.py
@@ -0,0 +1,104 @@
+"""Anomaly Visualization."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from pathlib import Path
+from typing import Optional, Tuple
+
+import cv2
+import matplotlib.pyplot as plt
+import numpy as np
+
+
+class Visualizer:
+ """Anomaly Visualization.
+
+ The visualizer object is responsible for collating all the images passed to it into a single image. This can then
+ either be logged by accessing the `figure` attribute or can be saved directly by calling `save()` method.
+
+ Example:
+ >>> visualizer = Visualizer(num_rows=1, num_cols=5, figure_size=(12, 3))
+ >>> visualizer.add_image(image=image, title="Image")
+ >>> visualizer.close()
+
+ Args:
+ num_rows (int): Number of rows of images in the figure.
+ num_cols (int): Number of columns/images in each row.
+ figure_size (Tuple[int, int]): Size of output figure
+ """
+
+ def __init__(self, num_rows: int, num_cols: int, figure_size: Tuple[int, int]):
+ self.figure_index: int = 0
+
+ self.figure, self.axis = plt.subplots(num_rows, num_cols, figsize=figure_size)
+ self.figure.subplots_adjust(right=0.9)
+
+ for axis in self.axis:
+ axis.axes.xaxis.set_visible(False)
+ axis.axes.yaxis.set_visible(False)
+
+ def add_image(self, image: np.ndarray, title: str, color_map: Optional[str] = None, index: Optional[int] = None):
+ """Add image to figure.
+
+ Args:
+ image (np.ndarray): Image which should be added to the figure.
+ title (str): Image title shown on the plot.
+ color_map (Optional[str]): Name of matplotlib color map used to map scalar data to colours. Defaults to None.
+ index (Optional[int]): Figure index. Defaults to None.
+ """
+ if index is None:
+ index = self.figure_index
+ self.figure_index += 1
+
+ self.axis[index].imshow(image, color_map, vmin=0, vmax=255)
+ self.axis[index].title.set_text(title)
+
+ def add_text(self, image: np.ndarray, text: str, font: int = cv2.FONT_HERSHEY_PLAIN):
+ """Puts text on an image.
+
+ Args:
+ image (np.ndarray): Input image.
+ text (str): Text to add.
+ font (Optional[int]): cv2 font type. Defaults to 0.
+
+ Returns:
+ np.ndarray: Image with text.
+ """
+ image = image.copy()
+ font_size = image.shape[1] // 256 + 1 # Text scale is calculated based on the reference size of 256
+
+ for i, line in enumerate(text.split("\n")):
+ (text_w, text_h), baseline = cv2.getTextSize(line.strip(), font, font_size, thickness=1)
+ offset = i * text_h
+ cv2.rectangle(image, (0, offset + baseline // 2), (0 + text_w, 0 + text_h + offset), (255, 255, 255), -1)
+ cv2.putText(image, line.strip(), (0, (baseline // 2 + text_h) + offset), font, font_size, (0, 0, 255))
+ return image
+
+ def show(self):
+ """Show image on a matplotlib figure."""
+ self.figure.show()
+
+ def save(self, filename: Path):
+ """Save image.
+
+ Args:
+ filename (Path): Filename to save image
+ """
+ filename.parent.mkdir(parents=True, exist_ok=True)
+ self.figure.savefig(filename, dpi=100)
+
+ def close(self):
+ """Close figure."""
+ plt.close(self.figure)
diff --git a/anomalib/pre_processing/__init__.py b/anomalib/pre_processing/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6313d91ef1219d62de15cae0803ba374fc2f568a
--- /dev/null
+++ b/anomalib/pre_processing/__init__.py
@@ -0,0 +1,20 @@
+"""Utilities for pre-processing the input before passing to the model."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .pre_process import PreProcessor
+from .tiler import Tiler
+
+__all__ = ["PreProcessor", "Tiler"]
diff --git a/anomalib/pre_processing/pre_process.py b/anomalib/pre_processing/pre_process.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd10fc10f65103d9868f4b110de9d94c96b6a085
--- /dev/null
+++ b/anomalib/pre_processing/pre_process.py
@@ -0,0 +1,138 @@
+"""Pre Process.
+
+This module contains `PreProcessor` class that applies preprocessing
+to an input image before the forward-pass stage.
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Optional, Tuple, Union
+
+import albumentations as A
+from albumentations.pytorch import ToTensorV2
+
+
+class PreProcessor:
+ """Applies pre-processing and data augmentations to the input and returns the transformed output.
+
+ Output could be either numpy ndarray or torch tensor.
+ When `PreProcessor` class is used for training, the output would be `torch.Tensor`.
+ For the inference it returns a numpy array.
+
+ Args:
+ config (Optional[Union[str, A.Compose]], optional): Transformation configurations.
+ When it is ``None``, ``PreProcessor`` only applies resizing. When it is ``str``
+ it loads the config via ``albumentations`` deserialisation methos . Defaults to None.
+ image_size (Optional[Union[int, Tuple[int, int]]], optional): When there is no config,
+ ``image_size`` resizes the image. Defaults to None.
+ to_tensor (bool, optional): Boolean to check whether the augmented image is transformed
+ into a tensor or not. Defaults to True.
+
+ Examples:
+ >>> import skimage
+ >>> image = skimage.data.astronaut()
+
+ >>> pre_processor = PreProcessor(image_size=256, to_tensor=False)
+ >>> output = pre_processor(image=image)
+ >>> output["image"].shape
+ (256, 256, 3)
+
+ >>> pre_processor = PreProcessor(image_size=256, to_tensor=True)
+ >>> output = pre_processor(image=image)
+ >>> output["image"].shape
+ torch.Size([3, 256, 256])
+
+
+ Transforms could be read from albumentations Compose object.
+ >>> import albumentations as A
+ >>> from albumentations.pytorch import ToTensorV2
+ >>> config = A.Compose([A.Resize(512, 512), ToTensorV2()])
+ >>> pre_processor = PreProcessor(config=config, to_tensor=False)
+ >>> output = pre_processor(image=image)
+ >>> output["image"].shape
+ (512, 512, 3)
+ >>> type(output["image"])
+ numpy.ndarray
+
+ Transforms could be deserialized from a yaml file.
+ >>> transforms = A.Compose([A.Resize(1024, 1024), ToTensorV2()])
+ >>> A.save(transforms, "/tmp/transforms.yaml", data_format="yaml")
+ >>> pre_processor = PreProcessor(config="/tmp/transforms.yaml")
+ >>> output = pre_processor(image=image)
+ >>> output["image"].shape
+ torch.Size([3, 1024, 1024])
+ """
+
+ def __init__(
+ self,
+ config: Optional[Union[str, A.Compose]] = None,
+ image_size: Optional[Union[int, Tuple]] = None,
+ to_tensor: bool = True,
+ ) -> None:
+ self.config = config
+ self.image_size = image_size
+ self.to_tensor = to_tensor
+
+ self.transforms = self.get_transforms()
+
+ def get_transforms(self) -> A.Compose:
+ """Get transforms from config or image size.
+
+ Returns:
+ A.Compose: List of albumentation transformations to apply to the
+ input image.
+ """
+ if self.config is None and self.image_size is None:
+ raise ValueError(
+ "Both config and image_size cannot be `None`. "
+ "Provide either config file to de-serialize transforms "
+ "or image_size to get the default transformations"
+ )
+
+ transforms: A.Compose
+
+ if self.config is None and self.image_size is not None:
+ if isinstance(self.image_size, int):
+ height, width = self.image_size, self.image_size
+ elif isinstance(self.image_size, tuple):
+ height, width = self.image_size
+ else:
+ raise ValueError("``image_size`` could be either int or Tuple[int, int]")
+
+ transforms = A.Compose(
+ [
+ A.Resize(height=height, width=width, always_apply=True),
+ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
+ ToTensorV2(),
+ ]
+ )
+
+ if self.config is not None:
+ if isinstance(self.config, str):
+ transforms = A.load(filepath=self.config, data_format="yaml")
+ elif isinstance(self.config, A.Compose):
+ transforms = self.config
+ else:
+ raise ValueError("config could be either ``str`` or ``A.Compose``")
+
+ if not self.to_tensor:
+ if isinstance(transforms[-1], ToTensorV2):
+ transforms = A.Compose(transforms[:-1])
+
+ return transforms
+
+ def __call__(self, *args, **kwargs):
+ """Return transformed arguments."""
+ return self.transforms(*args, **kwargs)
diff --git a/anomalib/pre_processing/tiler.py b/anomalib/pre_processing/tiler.py
new file mode 100644
index 0000000000000000000000000000000000000000..d061337c71670421f2fc1a73befecf4de73c318a
--- /dev/null
+++ b/anomalib/pre_processing/tiler.py
@@ -0,0 +1,415 @@
+"""Image Tiler."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from itertools import product
+from math import ceil
+from typing import Optional, Sequence, Tuple, Union
+
+import torch
+import torchvision.transforms as T
+from torch import Tensor
+from torch.nn import functional as F
+
+
+class StrideSizeError(Exception):
+ """StrideSizeError to raise exception when stride size is greater than the tile size."""
+
+
+def compute_new_image_size(image_size: Tuple, tile_size: Tuple, stride: Tuple) -> Tuple:
+ """This function checks if image size is divisible by tile size and stride.
+
+ If not divisible, it resizes the image size to make it divisible.
+
+ Args:
+ image_size (Tuple): Original image size
+ tile_size (Tuple): Tile size
+ stride (Tuple): Stride
+
+ Examples:
+ >>> compute_new_image_size(image_size=(512, 512), tile_size=(256, 256), stride=(128, 128))
+ (512, 512)
+
+ >>> compute_new_image_size(image_size=(512, 512), tile_size=(222, 222), stride=(111, 111))
+ (555, 555)
+
+ Returns:
+ Tuple: Updated image size that is divisible by tile size and stride.
+ """
+
+ def __compute_new_edge_size(edge_size: int, tile_size: int, stride: int) -> int:
+ """This function makes the resizing within the edge level."""
+ if (edge_size - tile_size) % stride != 0:
+ edge_size = (ceil((edge_size - tile_size) / stride) * stride) + tile_size
+
+ return edge_size
+
+ resized_h = __compute_new_edge_size(image_size[0], tile_size[0], stride[0])
+ resized_w = __compute_new_edge_size(image_size[1], tile_size[1], stride[1])
+
+ return resized_h, resized_w
+
+
+def upscale_image(image: Tensor, size: Tuple, mode: str = "padding") -> Tensor:
+ """Upscale image to the desired size via either padding or interpolation.
+
+ Args:
+ image (Tensor): Image
+ size (Tuple): Tuple to which image is upscaled.
+ mode (str, optional): Upscaling mode. Defaults to "padding".
+
+ Examples:
+ >>> image = torch.rand(1, 3, 512, 512)
+ >>> image = upscale_image(image, size=(555, 555), mode="padding")
+ >>> image.shape
+ torch.Size([1, 3, 555, 555])
+
+ >>> image = torch.rand(1, 3, 512, 512)
+ >>> image = upscale_image(image, size=(555, 555), mode="interpolation")
+ >>> image.shape
+ torch.Size([1, 3, 555, 555])
+
+ Returns:
+ Tensor: Upscaled image.
+ """
+
+ image_h, image_w = image.shape[2:]
+ resize_h, resize_w = size
+
+ if mode == "padding":
+ pad_h = resize_h - image_h
+ pad_w = resize_w - image_w
+
+ image = F.pad(image, [0, pad_w, 0, pad_h])
+ elif mode == "interpolation":
+ image = F.interpolate(input=image, size=(resize_h, resize_w))
+ else:
+ raise ValueError(f"Unknown mode {mode}. Only padding and interpolation is available.")
+
+ return image
+
+
+def downscale_image(image: Tensor, size: Tuple, mode: str = "padding") -> Tensor:
+ """Opposite of upscaling. This image downscales image to a desired size.
+
+ Args:
+ image (Tensor): Input image
+ size (Tuple): Size to which image is down scaled.
+ mode (str, optional): Downscaling mode. Defaults to "padding".
+
+ Examples:
+ >>> x = torch.rand(1, 3, 512, 512)
+ >>> y = upscale_image(image, upscale_size=(555, 555), mode="padding")
+ >>> y = downscale_image(y, size=(512, 512), mode='padding')
+ >>> torch.allclose(x, y)
+ True
+
+ Returns:
+ Tensor: Downscaled image
+ """
+ input_h, input_w = size
+ if mode == "padding":
+ image = image[:, :, :input_h, :input_w]
+ else:
+ image = F.interpolate(input=image, size=(input_h, input_w))
+
+ return image
+
+
+class Tiler:
+ """Tile Image into (non)overlapping Patches. Images are tiled in order to efficiently process large images.
+
+ Args:
+ tile_size: Tile dimension for each patch
+ stride: Stride length between patches
+ remove_border_count: Number of border pixels to be removed from tile before untiling
+ mode: Upscaling mode for image resize.Supported formats: padding, interpolation
+
+ Examples:
+ >>> import torch
+ >>> from torchvision import transforms
+ >>> from skimage.data import camera
+ >>> tiler = Tiler(tile_size=256,stride=128)
+ >>> image = transforms.ToTensor()(camera())
+ >>> tiles = tiler.tile(image)
+ >>> image.shape, tiles.shape
+ (torch.Size([3, 512, 512]), torch.Size([9, 3, 256, 256]))
+
+ >>> # Perform your operations on the tiles.
+
+ >>> # Untile the patches to reconstruct the image
+ >>> reconstructed_image = tiler.untile(tiles)
+ >>> reconstructed_image.shape
+ torch.Size([1, 3, 512, 512])
+ """
+
+ def __init__(
+ self,
+ tile_size: Union[int, Sequence],
+ stride: Union[int, Sequence],
+ remove_border_count: int = 0,
+ mode: str = "padding",
+ tile_count: int = 4,
+ ) -> None:
+
+ self.tile_size_h, self.tile_size_w = self.__validate_size_type(tile_size)
+ self.tile_count = tile_count
+ self.stride_h, self.stride_w = self.__validate_size_type(stride)
+ self.remove_border_count = int(remove_border_count)
+ self.overlapping = not (self.stride_h == self.tile_size_h and self.stride_w == self.tile_size_w)
+ self.mode = mode
+
+ if self.stride_h > self.tile_size_h or self.stride_w > self.tile_size_w:
+ raise StrideSizeError(
+ "Larger stride size than kernel size produces unreliable tiling results. "
+ "Please ensure stride size is less than or equal than tiling size."
+ )
+
+ if self.mode not in ["padding", "interpolation"]:
+ raise ValueError(f"Unknown tiling mode {self.mode}. Available modes are padding and interpolation")
+
+ self.batch_size: int
+ self.num_channels: int
+
+ self.input_h: int
+ self.input_w: int
+
+ self.pad_h: int
+ self.pad_w: int
+
+ self.resized_h: int
+ self.resized_w: int
+
+ self.num_patches_h: int
+ self.num_patches_w: int
+
+ @staticmethod
+ def __validate_size_type(parameter: Union[int, Sequence]) -> Tuple[int, ...]:
+ if isinstance(parameter, int):
+ output = (parameter, parameter)
+ elif isinstance(parameter, Sequence):
+ output = (parameter[0], parameter[1])
+ else:
+ raise ValueError(f"Unknown type {type(parameter)} for tile or stride size. Could be int or Sequence type.")
+
+ if len(output) != 2:
+ raise ValueError(f"Length of the size type must be 2 for height and width. Got {len(output)} instead.")
+
+ return output
+
+ def __random_tile(self, image: Tensor) -> Tensor:
+ """Randomly crop tiles from the given image.
+
+ Args:
+ image: input image to be cropped
+
+ Returns: Randomly cropped tiles from the image
+ """
+ return torch.vstack([T.RandomCrop(self.tile_size_h)(image) for i in range(self.tile_count)])
+
+ def __unfold(self, tensor: Tensor) -> Tensor:
+ """Unfolds tensor into tiles.
+
+ This is the core function to perform tiling operation.
+
+ Args:
+ tensor: Input tensor from which tiles are generated.
+
+ Returns: Generated tiles
+ """
+
+ # identify device type based on input tensor
+ device = tensor.device
+
+ # extract and calculate parameters
+ batch, channels, image_h, image_w = tensor.shape
+
+ self.num_patches_h = int((image_h - self.tile_size_h) / self.stride_h) + 1
+ self.num_patches_w = int((image_w - self.tile_size_w) / self.stride_w) + 1
+
+ # create an empty torch tensor for output
+ tiles = torch.zeros(
+ (self.num_patches_h, self.num_patches_w, batch, channels, self.tile_size_h, self.tile_size_w), device=device
+ )
+
+ # fill-in output tensor with spatial patches extracted from the image
+ for (tile_i, tile_j), (loc_i, loc_j) in zip(
+ product(range(self.num_patches_h), range(self.num_patches_w)),
+ product(
+ range(0, image_h - self.tile_size_h + 1, self.stride_h),
+ range(0, image_w - self.tile_size_w + 1, self.stride_w),
+ ),
+ ):
+ tiles[tile_i, tile_j, :] = tensor[
+ :, :, loc_i : (loc_i + self.tile_size_h), loc_j : (loc_j + self.tile_size_w)
+ ]
+
+ # rearrange the tiles in order [tile_count * batch, channels, tile_height, tile_width]
+ tiles = tiles.permute(2, 0, 1, 3, 4, 5)
+ tiles = tiles.contiguous().view(-1, channels, self.tile_size_h, self.tile_size_w)
+
+ return tiles
+
+ def __fold(self, tiles: Tensor) -> Tensor:
+ """Fold the tiles back into the original tensor.
+
+ This is the core method to reconstruct the original image from its tiled version.
+
+ Args:
+ tiles: Tiles from the input image, generated via __unfold method.
+
+ Returns:
+ Output that is the reconstructed version of the input tensor.
+ """
+ # number of channels differs between image and anomaly map, so infer from input tiles.
+ _, num_channels, tile_size_h, tile_size_w = tiles.shape
+ scale_h, scale_w = (tile_size_h / self.tile_size_h), (tile_size_w / self.tile_size_w)
+ # identify device type based on input tensor
+ device = tiles.device
+ # calculate tile size after borders removed
+ reduced_tile_h = tile_size_h - (2 * self.remove_border_count)
+ reduced_tile_w = tile_size_w - (2 * self.remove_border_count)
+ # reconstructed image dimension
+ image_size = (self.batch_size, num_channels, int(self.resized_h * scale_h), int(self.resized_w * scale_w))
+
+ # rearrange input tiles in format [tile_count, batch, channel, tile_h, tile_w]
+ tiles = tiles.contiguous().view(
+ self.batch_size,
+ self.num_patches_h,
+ self.num_patches_w,
+ num_channels,
+ tile_size_h,
+ tile_size_w,
+ )
+ tiles = tiles.permute(0, 3, 1, 2, 4, 5)
+ tiles = tiles.contiguous().view(self.batch_size, num_channels, -1, tile_size_h, tile_size_w)
+ tiles = tiles.permute(2, 0, 1, 3, 4)
+
+ # remove tile borders by defined count
+ tiles = tiles[
+ :,
+ :,
+ :,
+ self.remove_border_count : reduced_tile_h + self.remove_border_count,
+ self.remove_border_count : reduced_tile_w + self.remove_border_count,
+ ]
+
+ # create tensors to store intermediate results and outputs
+ img = torch.zeros(image_size, device=device)
+ lookup = torch.zeros(image_size, device=device)
+ ones = torch.ones(reduced_tile_h, reduced_tile_w, device=device)
+
+ # reconstruct image by adding patches to their respective location and
+ # create a lookup for patch count in every location
+ for patch, (loc_i, loc_j) in zip(
+ tiles,
+ product(
+ range(
+ self.remove_border_count,
+ int(self.resized_h * scale_h) - reduced_tile_h + 1,
+ int(self.stride_h * scale_h),
+ ),
+ range(
+ self.remove_border_count,
+ int(self.resized_w * scale_w) - reduced_tile_w + 1,
+ int(self.stride_w * scale_w),
+ ),
+ ),
+ ):
+ img[:, :, loc_i : (loc_i + reduced_tile_h), loc_j : (loc_j + reduced_tile_w)] += patch
+ lookup[:, :, loc_i : (loc_i + reduced_tile_h), loc_j : (loc_j + reduced_tile_w)] += ones
+
+ # divide the reconstucted image by the lookup to average out the values
+ img = torch.divide(img, lookup)
+ # alternative way of removing nan values (isnan not supported by openvino)
+ img[img != img] = 0 # pylint: disable=comparison-with-itself
+
+ return img
+
+ def tile(self, image: Tensor, use_random_tiling: Optional[bool] = False) -> Tensor:
+ """Tiles an input image to either overlapping, non-overlapping or random patches.
+
+ Args:
+ image: Input image to tile.
+
+ Examples:
+ >>> from anomalib.data.tiler import Tiler
+ >>> tiler = Tiler(tile_size=512,stride=256)
+ >>> image = torch.rand(size=(2, 3, 1024, 1024))
+ >>> image.shape
+ torch.Size([2, 3, 1024, 1024])
+ >>> tiles = tiler.tile(image)
+ >>> tiles.shape
+ torch.Size([18, 3, 512, 512])
+
+ Returns:
+ Tiles generated from the image.
+ """
+ if image.dim() == 3:
+ image = image.unsqueeze(0)
+
+ self.batch_size, self.num_channels, self.input_h, self.input_w = image.shape
+
+ if self.input_h < self.tile_size_h or self.input_w < self.tile_size_w:
+ raise ValueError(
+ f"One of the edges of the tile size {self.tile_size_h, self.tile_size_w} "
+ "is larger than that of the image {self.input_h, self.input_w}."
+ )
+
+ self.resized_h, self.resized_w = compute_new_image_size(
+ image_size=(self.input_h, self.input_w),
+ tile_size=(self.tile_size_h, self.tile_size_w),
+ stride=(self.stride_h, self.stride_w),
+ )
+
+ image = upscale_image(image, size=(self.resized_h, self.resized_w), mode=self.mode)
+
+ if use_random_tiling:
+ image_tiles = self.__random_tile(image)
+ else:
+ image_tiles = self.__unfold(image)
+ return image_tiles
+
+ def untile(self, tiles: Tensor) -> Tensor:
+ """Untiles patches to reconstruct the original input image.
+
+ If patches, are overlapping patches, the function averages the overlapping pixels,
+ and return the reconstructed image.
+
+ Args:
+ tiles: Tiles from the input image, generated via tile()..
+
+ Examples:
+ >>> from anomalib.datasets.tiler import Tiler
+ >>> tiler = Tiler(tile_size=512,stride=256)
+ >>> image = torch.rand(size=(2, 3, 1024, 1024))
+ >>> image.shape
+ torch.Size([2, 3, 1024, 1024])
+ >>> tiles = tiler.tile(image)
+ >>> tiles.shape
+ torch.Size([18, 3, 512, 512])
+ >>> reconstructed_image = tiler.untile(tiles)
+ >>> reconstructed_image.shape
+ torch.Size([2, 3, 1024, 1024])
+ >>> torch.equal(image, reconstructed_image)
+ True
+
+ Returns:
+ Output that is the reconstructed version of the input tensor.
+ """
+ image = self.__fold(tiles)
+ image = downscale_image(image=image, size=(self.input_h, self.input_w), mode=self.mode)
+
+ return image
diff --git a/anomalib/pre_processing/transforms/__init__.py b/anomalib/pre_processing/transforms/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..66cedf5d4592030634cc5f6de38d075ccd0ffa60
--- /dev/null
+++ b/anomalib/pre_processing/transforms/__init__.py
@@ -0,0 +1,19 @@
+"""Anomalib Data Transforms."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .custom import Denormalize, ToNumpy
+
+__all__ = ["Denormalize", "ToNumpy"]
diff --git a/anomalib/pre_processing/transforms/custom.py b/anomalib/pre_processing/transforms/custom.py
new file mode 100644
index 0000000000000000000000000000000000000000..1f3b6d398e8cc9649478d0bc8f9ab042df92e5f4
--- /dev/null
+++ b/anomalib/pre_processing/transforms/custom.py
@@ -0,0 +1,98 @@
+"""Dataset Utils."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import List, Optional, Tuple
+
+import numpy as np
+from torch import Tensor
+
+
+class Denormalize:
+ """Denormalize Torch Tensor into np image format."""
+
+ def __init__(self, mean: Optional[List[float]] = None, std: Optional[List[float]] = None):
+ """Denormalize Torch Tensor into np image format.
+
+ Args:
+ mean: Mean
+ std: Standard deviation.
+ """
+ # If no mean and std provided, assign ImageNet values.
+ if mean is None:
+ mean = [0.485, 0.456, 0.406]
+
+ if std is None:
+ std = [0.229, 0.224, 0.225]
+
+ self.mean = Tensor(mean)
+ self.std = Tensor(std)
+
+ def __call__(self, tensor: Tensor) -> np.ndarray:
+ """Denormalize the input.
+
+ Args:
+ tensor (Tensor): Input tensor image (C, H, W)
+
+ Returns:
+ Denormalized numpy array (H, W, C).
+ """
+ if tensor.dim() == 4:
+ if tensor.size(0):
+ tensor = tensor.squeeze(0)
+ else:
+ raise ValueError(f"Tensor has batch size of {tensor.size(0)}. Only single batch is supported.")
+
+ for tnsr, mean, std in zip(tensor, self.mean, self.std):
+ tnsr.mul_(std).add_(mean)
+
+ array = (tensor * 255).permute(1, 2, 0).cpu().numpy().astype(np.uint8)
+ return array
+
+ def __repr__(self):
+ """Representational string."""
+ return self.__class__.__name__ + "()"
+
+
+class ToNumpy:
+ """Convert Tensor into Numpy Array."""
+
+ def __call__(self, tensor: Tensor, dims: Optional[Tuple[int, ...]] = None) -> np.ndarray:
+ """Convert Tensor into Numpy Array.
+
+ Args:
+ tensor (Tensor): Tensor to convert. Input tensor in range 0-1.
+ dims (Optional[Tuple[int, ...]], optional): Convert dimensions from torch to numpy format.
+ Tuple corresponding to axis permutation from torch tensor to numpy array. Defaults to None.
+
+ Returns:
+ Converted numpy ndarray.
+ """
+ # Default support is (C, H, W) or (N, C, H, W)
+ if dims is None:
+ dims = (0, 2, 3, 1) if len(tensor.shape) == 4 else (1, 2, 0)
+
+ array = (tensor * 255).permute(dims).cpu().numpy().astype(np.uint8)
+
+ if array.shape[0] == 1:
+ array = array.squeeze(0)
+ if array.shape[-1] == 1:
+ array = array.squeeze(-1)
+
+ return array
+
+ def __repr__(self) -> str:
+ """Representational string."""
+ return self.__class__.__name__ + "()"
diff --git a/anomalib/utils/__init__.py b/anomalib/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c9027199abeceda1ae45748606d0b6665812cda
--- /dev/null
+++ b/anomalib/utils/__init__.py
@@ -0,0 +1,15 @@
+"""Helpers for downloading files, calculating metrics, computing anomaly maps, and visualization."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/anomalib/utils/callbacks/__init__.py b/anomalib/utils/callbacks/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..688d4706aae00823f8ba1c357718b84bfb9abfd4
--- /dev/null
+++ b/anomalib/utils/callbacks/__init__.py
@@ -0,0 +1,112 @@
+"""Callbacks for Anomalib models."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import os
+from importlib import import_module
+from typing import List, Union
+
+import yaml
+from omegaconf import DictConfig, ListConfig, OmegaConf
+from pytorch_lightning.callbacks import Callback, ModelCheckpoint
+
+from .cdf_normalization import CdfNormalizationCallback
+from .min_max_normalization import MinMaxNormalizationCallback
+from .model_loader import LoadModelCallback
+from .timer import TimerCallback
+from .visualizer_callback import VisualizerCallback
+
+__all__ = [
+ "LoadModelCallback",
+ "TimerCallback",
+ "VisualizerCallback",
+]
+
+
+def get_callbacks(config: Union[ListConfig, DictConfig]) -> List[Callback]:
+ """Return base callbacks for all the lightning models.
+
+ Args:
+ config (DictConfig): Model config
+
+ Return:
+ (List[Callback]): List of callbacks.
+ """
+ callbacks: List[Callback] = []
+
+ monitor_metric = None if "early_stopping" not in config.model.keys() else config.model.early_stopping.metric
+ monitor_mode = "max" if "early_stopping" not in config.model.keys() else config.model.early_stopping.mode
+
+ checkpoint = ModelCheckpoint(
+ dirpath=os.path.join(config.project.path, "weights"),
+ filename="model",
+ monitor=monitor_metric,
+ mode=monitor_mode,
+ auto_insert_metric_name=False,
+ )
+
+ callbacks.extend([checkpoint, TimerCallback()])
+
+ if "weight_file" in config.model.keys():
+ load_model = LoadModelCallback(os.path.join(config.project.path, config.model.weight_file))
+ callbacks.append(load_model)
+
+ if "normalization_method" in config.model.keys() and not config.model.normalization_method == "none":
+ if config.model.normalization_method == "cdf":
+ if config.model.name in ["padim", "stfpm"]:
+ if "nncf" in config.optimization and config.optimization.nncf.apply:
+ raise NotImplementedError("CDF Score Normalization is currently not compatible with NNCF.")
+ callbacks.append(CdfNormalizationCallback())
+ else:
+ raise NotImplementedError("Score Normalization is currently supported for PADIM and STFPM only.")
+ elif config.model.normalization_method == "min_max":
+ callbacks.append(MinMaxNormalizationCallback())
+ else:
+ raise ValueError(f"Normalization method not recognized: {config.model.normalization_method}")
+
+ if not config.project.log_images_to == []:
+ callbacks.append(
+ VisualizerCallback(
+ task=config.dataset.task, inputs_are_normalized=not config.model.normalization_method == "none"
+ )
+ )
+
+ if "optimization" in config.keys():
+ if "nncf" in config.optimization and config.optimization.nncf.apply:
+ # NNCF wraps torch's jit which conflicts with kornia's jit calls.
+ # Hence, nncf is imported only when required
+ nncf_module = import_module("anomalib.utils.callbacks.nncf.callback")
+ nncf_callback = getattr(nncf_module, "NNCFCallback")
+ nncf_config = yaml.safe_load(OmegaConf.to_yaml(config.optimization.nncf))
+ callbacks.append(
+ nncf_callback(
+ config=nncf_config,
+ export_dir=os.path.join(config.project.path, "compressed"),
+ )
+ )
+ if "openvino" in config.optimization and config.optimization.openvino.apply:
+ from .openvino import ( # pylint: disable=import-outside-toplevel
+ OpenVINOCallback,
+ )
+
+ callbacks.append(
+ OpenVINOCallback(
+ input_size=config.model.input_size,
+ dirpath=os.path.join(config.project.path, "openvino"),
+ filename="openvino_model",
+ )
+ )
+
+ return callbacks
diff --git a/anomalib/utils/callbacks/cdf_normalization.py b/anomalib/utils/callbacks/cdf_normalization.py
new file mode 100644
index 0000000000000000000000000000000000000000..19f3c1325c1bf301c2c4bd32d7d4b3e4395e771c
--- /dev/null
+++ b/anomalib/utils/callbacks/cdf_normalization.py
@@ -0,0 +1,131 @@
+"""Anomaly Score Normalization Callback."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+from typing import Any, Dict, Optional
+
+import pytorch_lightning as pl
+from pytorch_lightning import Callback, Trainer
+from pytorch_lightning.utilities.types import STEP_OUTPUT
+from torch.distributions import LogNormal
+
+from anomalib.models import get_model
+from anomalib.models.components import AnomalyModule
+from anomalib.post_processing.normalization.cdf import normalize, standardize
+
+logger = logging.getLogger(__name__)
+
+
+class CdfNormalizationCallback(Callback):
+ """Callback that standardizes the image-level and pixel-level anomaly scores."""
+
+ def __init__(self):
+ self.image_dist: Optional[LogNormal] = None
+ self.pixel_dist: Optional[LogNormal] = None
+
+ def on_test_start(self, _trainer: pl.Trainer, pl_module: AnomalyModule) -> None:
+ """Called when the test begins."""
+ pl_module.image_metrics.set_threshold(0.5)
+ pl_module.pixel_metrics.set_threshold(0.5)
+
+ def on_validation_epoch_start(self, trainer: "pl.Trainer", pl_module: AnomalyModule) -> None:
+ """Called when the validation starts after training.
+
+ Use the current model to compute the anomaly score distributions
+ of the normal training data. This is needed after every epoch, because the statistics must be
+ stored in the state dict of the checkpoint file.
+ """
+ logger.info("Collecting the statistics of the normal training data to normalize the scores.")
+ self._collect_stats(trainer, pl_module)
+
+ def on_validation_batch_end(
+ self,
+ _trainer: pl.Trainer,
+ pl_module: AnomalyModule,
+ outputs: Optional[STEP_OUTPUT],
+ _batch: Any,
+ _batch_idx: int,
+ _dataloader_idx: int,
+ ) -> None:
+ """Called when the validation batch ends, standardizes the predicted scores and anomaly maps."""
+ self._standardize_batch(outputs, pl_module)
+
+ def on_test_batch_end(
+ self,
+ _trainer: pl.Trainer,
+ pl_module: AnomalyModule,
+ outputs: Optional[STEP_OUTPUT],
+ _batch: Any,
+ _batch_idx: int,
+ _dataloader_idx: int,
+ ) -> None:
+ """Called when the test batch ends, normalizes the predicted scores and anomaly maps."""
+ self._standardize_batch(outputs, pl_module)
+ self._normalize_batch(outputs, pl_module)
+
+ def on_predict_batch_end(
+ self,
+ _trainer: pl.Trainer,
+ pl_module: AnomalyModule,
+ outputs: Dict,
+ _batch: Any,
+ _batch_idx: int,
+ _dataloader_idx: int,
+ ) -> None:
+ """Called when the predict batch ends, normalizes the predicted scores and anomaly maps."""
+ self._standardize_batch(outputs, pl_module)
+ self._normalize_batch(outputs, pl_module)
+ outputs["pred_labels"] = outputs["pred_scores"] >= 0.5
+
+ def _collect_stats(self, trainer, pl_module):
+ """Collect the statistics of the normal training data.
+
+ Create a trainer and use it to predict the anomaly maps and scores of the normal training data. Then
+ estimate the distribution of anomaly scores for normal data at the image and pixel level by computing
+ the mean and standard deviations. A dictionary containing the computed statistics is stored in self.stats.
+ """
+ predictions = Trainer(gpus=trainer.gpus).predict(
+ model=self._create_inference_model(pl_module), dataloaders=trainer.datamodule.train_dataloader()
+ )
+ pl_module.training_distribution.reset()
+ for batch in predictions:
+ if "pred_scores" in batch.keys():
+ pl_module.training_distribution.update(anomaly_scores=batch["pred_scores"])
+ if "anomaly_maps" in batch.keys():
+ pl_module.training_distribution.update(anomaly_maps=batch["anomaly_maps"])
+ pl_module.training_distribution.compute()
+
+ @staticmethod
+ def _create_inference_model(pl_module):
+ """Create a duplicate of the PL module that can be used to perform inference on the training set."""
+ new_model = get_model(pl_module.hparams)
+ new_model.load_state_dict(pl_module.state_dict())
+ return new_model
+
+ @staticmethod
+ def _standardize_batch(outputs: STEP_OUTPUT, pl_module) -> None:
+ stats = pl_module.training_distribution.to(outputs["pred_scores"].device)
+ outputs["pred_scores"] = standardize(outputs["pred_scores"], stats.image_mean, stats.image_std)
+ if "anomaly_maps" in outputs.keys():
+ outputs["anomaly_maps"] = standardize(
+ outputs["anomaly_maps"], stats.pixel_mean, stats.pixel_std, center_at=stats.image_mean
+ )
+
+ @staticmethod
+ def _normalize_batch(outputs: STEP_OUTPUT, pl_module: AnomalyModule) -> None:
+ outputs["pred_scores"] = normalize(outputs["pred_scores"], pl_module.image_threshold.value)
+ if "anomaly_maps" in outputs.keys():
+ outputs["anomaly_maps"] = normalize(outputs["anomaly_maps"], pl_module.pixel_threshold.value)
diff --git a/anomalib/utils/callbacks/min_max_normalization.py b/anomalib/utils/callbacks/min_max_normalization.py
new file mode 100644
index 0000000000000000000000000000000000000000..f7265f715b31ef6a38f6504ab3736be673efe2cb
--- /dev/null
+++ b/anomalib/utils/callbacks/min_max_normalization.py
@@ -0,0 +1,84 @@
+"""Anomaly Score Normalization Callback that uses min-max normalization."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Any, Dict
+
+import pytorch_lightning as pl
+from pytorch_lightning import Callback
+from pytorch_lightning.utilities.types import STEP_OUTPUT
+
+from anomalib.models.components import AnomalyModule
+from anomalib.post_processing.normalization.min_max import normalize
+
+
+class MinMaxNormalizationCallback(Callback):
+ """Callback that normalizes the image-level and pixel-level anomaly scores using min-max normalization."""
+
+ def on_test_start(self, _trainer: pl.Trainer, pl_module: AnomalyModule) -> None:
+ """Called when the test begins."""
+ pl_module.image_metrics.set_threshold(0.5)
+ pl_module.pixel_metrics.set_threshold(0.5)
+
+ def on_validation_batch_end(
+ self,
+ _trainer: pl.Trainer,
+ pl_module: AnomalyModule,
+ outputs: STEP_OUTPUT,
+ _batch: Any,
+ _batch_idx: int,
+ _dataloader_idx: int,
+ ) -> None:
+ """Called when the validation batch ends, update the min and max observed values."""
+ if "anomaly_maps" in outputs.keys():
+ pl_module.min_max(outputs["anomaly_maps"])
+ else:
+ pl_module.min_max(outputs["pred_scores"])
+
+ def on_test_batch_end(
+ self,
+ _trainer: pl.Trainer,
+ pl_module: AnomalyModule,
+ outputs: STEP_OUTPUT,
+ _batch: Any,
+ _batch_idx: int,
+ _dataloader_idx: int,
+ ) -> None:
+ """Called when the test batch ends, normalizes the predicted scores and anomaly maps."""
+ self._normalize_batch(outputs, pl_module)
+
+ def on_predict_batch_end(
+ self,
+ _trainer: pl.Trainer,
+ pl_module: AnomalyModule,
+ outputs: Dict,
+ _batch: Any,
+ _batch_idx: int,
+ _dataloader_idx: int,
+ ) -> None:
+ """Called when the predict batch ends, normalizes the predicted scores and anomaly maps."""
+ self._normalize_batch(outputs, pl_module)
+
+ @staticmethod
+ def _normalize_batch(outputs, pl_module):
+ """Normalize a batch of predictions."""
+ stats = pl_module.min_max.cpu()
+ outputs["pred_scores"] = normalize(
+ outputs["pred_scores"], pl_module.image_threshold.value.cpu(), stats.min, stats.max
+ )
+ if "anomaly_maps" in outputs.keys():
+ outputs["anomaly_maps"] = normalize(
+ outputs["anomaly_maps"], pl_module.pixel_threshold.value.cpu(), stats.min, stats.max
+ )
diff --git a/anomalib/utils/callbacks/model_loader.py b/anomalib/utils/callbacks/model_loader.py
new file mode 100644
index 0000000000000000000000000000000000000000..146b1663fe78aafb33e2f7614951ca8ffda880ed
--- /dev/null
+++ b/anomalib/utils/callbacks/model_loader.py
@@ -0,0 +1,39 @@
+"""Callback that loads model weights from the state dict."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+
+import torch
+from pytorch_lightning import Callback
+
+from anomalib.models.components import AnomalyModule
+
+logger = logging.getLogger(__name__)
+
+
+class LoadModelCallback(Callback):
+ """Callback that loads the model weights from the state dict."""
+
+ def __init__(self, weights_path):
+ self.weights_path = weights_path
+
+ def on_test_start(self, trainer, pl_module: AnomalyModule) -> None: # pylint: disable=W0613
+ """Call when the test begins.
+
+ Loads the model weights from ``weights_path`` into the PyTorch module.
+ """
+ logger.info("Loading the model from %s", self.weights_path)
+ pl_module.load_state_dict(torch.load(self.weights_path)["state_dict"])
diff --git a/anomalib/utils/callbacks/nncf/__init__.py b/anomalib/utils/callbacks/nncf/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..37ff514a8d117a8b36db2fb30bef2724e5d34b4f
--- /dev/null
+++ b/anomalib/utils/callbacks/nncf/__init__.py
@@ -0,0 +1,15 @@
+"""Integration NNCF."""
+
+# Copyright (C) 2021 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/anomalib/utils/callbacks/nncf/callback.py b/anomalib/utils/callbacks/nncf/callback.py
new file mode 100644
index 0000000000000000000000000000000000000000..3efaf7e1e0c18c80674f752aea8fe9f6d382ac2e
--- /dev/null
+++ b/anomalib/utils/callbacks/nncf/callback.py
@@ -0,0 +1,98 @@
+"""Callbacks for NNCF optimization."""
+
+# Copyright (C) 2022 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import os
+from typing import Any, Dict, Optional
+
+import pytorch_lightning as pl
+from nncf import NNCFConfig
+from nncf.api.compression import CompressionAlgorithmController
+from nncf.torch import register_default_init_args
+from pytorch_lightning import Callback
+
+from anomalib.utils.callbacks.nncf.utils import InitLoader, wrap_nncf_model
+
+
+class NNCFCallback(Callback):
+ """Callback for NNCF compression.
+
+ Assumes that the pl module contains a 'model' attribute, which is
+ the PyTorch module that must be compressed.
+
+ Args:
+ config (Dict): NNCF Configuration
+ export_dir (Str): Path where the export `onnx` and the OpenVINO `xml` and `bin` IR are saved.
+ If None model will not be exported.
+ """
+
+ def __init__(self, nncf_config: Dict, export_dir: str = None):
+ self.export_dir = export_dir
+ self.nncf_config = NNCFConfig(nncf_config)
+ self.nncf_ctrl: Optional[CompressionAlgorithmController] = None
+
+ # pylint: disable=unused-argument
+ def setup(self, trainer: pl.Trainer, pl_module: pl.LightningModule, stage: Optional[str] = None) -> None:
+ """Call when fit or test begins.
+
+ Takes the pytorch model and wraps it using the compression controller
+ so that it is ready for nncf fine-tuning.
+ """
+ if self.nncf_ctrl is not None:
+ return
+
+ init_loader = InitLoader(trainer.datamodule.train_dataloader()) # type: ignore
+ nncf_config = register_default_init_args(self.nncf_config, init_loader)
+
+ self.nncf_ctrl, pl_module.model = wrap_nncf_model(
+ model=pl_module.model, config=nncf_config, dataloader=trainer.datamodule.train_dataloader() # type: ignore
+ )
+
+ def on_train_batch_start(
+ self,
+ trainer: pl.Trainer,
+ _pl_module: pl.LightningModule,
+ _batch: Any,
+ _batch_idx: int,
+ _unused: Optional[int] = 0,
+ ) -> None:
+ """Call when the train batch begins.
+
+ Prepare compression method to continue training the model in the next step.
+ """
+ if self.nncf_ctrl:
+ self.nncf_ctrl.scheduler.step()
+
+ def on_train_epoch_start(self, _trainer: pl.Trainer, _pl_module: pl.LightningModule) -> None:
+ """Call when the train epoch starts.
+
+ Prepare compression method to continue training the model in the next epoch.
+ """
+ if self.nncf_ctrl:
+ self.nncf_ctrl.scheduler.epoch_step()
+
+ def on_train_end(self, _trainer: pl.Trainer, _pl_module: pl.LightningModule) -> None:
+ """Call when the train ends.
+
+ Exports onnx model and if compression controller is not None, uses the onnx model to generate the OpenVINO IR.
+ """
+ if self.export_dir is None or self.nncf_ctrl is None:
+ return
+
+ os.makedirs(self.export_dir, exist_ok=True)
+ onnx_path = os.path.join(self.export_dir, "model_nncf.onnx")
+ self.nncf_ctrl.export_model(onnx_path)
+ optimize_command = "mo --input_model " + onnx_path + " --output_dir " + self.export_dir
+ os.system(optimize_command)
diff --git a/anomalib/utils/callbacks/nncf/utils.py b/anomalib/utils/callbacks/nncf/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f60fa591a145633546167bc63fb1e89ea036e9f
--- /dev/null
+++ b/anomalib/utils/callbacks/nncf/utils.py
@@ -0,0 +1,209 @@
+"""Utils for NNCf optimization."""
+
+# Copyright (C) 2022 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+from copy import copy
+from typing import Any, Dict, Iterator, List, Tuple
+
+from nncf import NNCFConfig
+from nncf.api.compression import CompressionAlgorithmController
+from nncf.torch import create_compressed_model, load_state, register_default_init_args
+from nncf.torch.initialization import PTInitializingDataLoader
+from nncf.torch.nncf_network import NNCFNetwork
+from torch import nn
+from torch.utils.data.dataloader import DataLoader
+
+logger = logging.getLogger(name="NNCF compression")
+
+
+class InitLoader(PTInitializingDataLoader):
+ """Initializing data loader for NNCF to be used with unsupervised training algorithms."""
+
+ def __init__(self, data_loader: DataLoader):
+ super().__init__(data_loader)
+ self._data_loader_iter: Iterator
+
+ def __iter__(self):
+ """Create iterator for dataloader."""
+ self._data_loader_iter = iter(self._data_loader)
+ return self
+
+ def __next__(self) -> Any:
+ """Return next item from dataloader iterator."""
+ loaded_item = next(self._data_loader_iter)
+ return loaded_item["image"]
+
+ def get_inputs(self, dataloader_output) -> Tuple[Tuple, Dict]:
+ """Get input to model.
+
+ Returns:
+ (dataloader_output,), {}: Tuple[Tuple, Dict]: The current model call to be made during
+ the initialization process
+ """
+ return (dataloader_output,), {}
+
+ def get_target(self, _):
+ """Return structure for ground truth in loss criterion based on dataloader output.
+
+ This implementation does not do anything and is a placeholder.
+
+ Returns:
+ None
+ """
+ return None
+
+
+def wrap_nncf_model(
+ model: nn.Module, config: Dict, dataloader: DataLoader = None, init_state_dict: Dict = None
+) -> Tuple[CompressionAlgorithmController, NNCFNetwork]:
+ """Wrap model by NNCF.
+
+ :param model: Anomalib model.
+ :param config: NNCF config.
+ :param dataloader: Dataloader for initialization of NNCF model.
+ :param init_state_dict: Opti
+ :return: compression controller, compressed model
+ """
+ nncf_config = NNCFConfig.from_dict(config)
+
+ if not dataloader and not init_state_dict:
+ logger.warning(
+ "Either dataloader or NNCF pre-trained "
+ "model checkpoint should be set. Without this, "
+ "quantizers will not be initialized"
+ )
+
+ compression_state = None
+ resuming_state_dict = None
+ if init_state_dict:
+ resuming_state_dict = init_state_dict.get("model")
+ compression_state = init_state_dict.get("compression_state")
+
+ if dataloader:
+ init_loader = InitLoader(dataloader) # type: ignore
+ nncf_config = register_default_init_args(nncf_config, init_loader)
+
+ nncf_ctrl, nncf_model = create_compressed_model(
+ model=model, config=nncf_config, dump_graphs=False, compression_state=compression_state
+ )
+
+ if resuming_state_dict:
+ load_state(nncf_model, resuming_state_dict, is_resume=True)
+
+ return nncf_ctrl, nncf_model
+
+
+def is_state_nncf(state: Dict) -> bool:
+ """The function to check if sate is the result of NNCF-compressed model."""
+ return bool(state.get("meta", {}).get("nncf_enable_compression", False))
+
+
+def compose_nncf_config(nncf_config: Dict, enabled_options: List[str]) -> Dict:
+ """Compose NNCf config by selected options.
+
+ :param nncf_config:
+ :param enabled_options:
+ :return: config
+ """
+ optimisation_parts = nncf_config
+ optimisation_parts_to_choose = []
+ if "order_of_parts" in optimisation_parts:
+ # The result of applying the changes from optimisation parts
+ # may depend on the order of applying the changes
+ # (e.g. if for nncf_quantization it is sufficient to have `total_epochs=2`,
+ # but for sparsity it is required `total_epochs=50`)
+ # So, user can define `order_of_parts` in the optimisation_config
+ # to specify the order of applying the parts.
+ order_of_parts = optimisation_parts["order_of_parts"]
+ assert isinstance(order_of_parts, list), 'The field "order_of_parts" in optimisation config should be a list'
+
+ for part in enabled_options:
+ assert part in order_of_parts, (
+ f"The part {part} is selected, " "but it is absent in order_of_parts={order_of_parts}"
+ )
+
+ optimisation_parts_to_choose = [part for part in order_of_parts if part in enabled_options]
+
+ assert "base" in optimisation_parts, 'Error: the optimisation config does not contain the "base" part'
+ nncf_config_part = optimisation_parts["base"]
+
+ for part in optimisation_parts_to_choose:
+ assert part in optimisation_parts, f'Error: the optimisation config does not contain the part "{part}"'
+ optimisation_part_dict = optimisation_parts[part]
+ try:
+ nncf_config_part = merge_dicts_and_lists_b_into_a(nncf_config_part, optimisation_part_dict)
+ except AssertionError as cur_error:
+ err_descr = (
+ f"Error during merging the parts of nncf configs:\n"
+ f"the current part={part}, "
+ f"the order of merging parts into base is {optimisation_parts_to_choose}.\n"
+ f"The error is:\n{cur_error}"
+ )
+ raise RuntimeError(err_descr) from None
+
+ return nncf_config_part
+
+
+# pylint: disable=invalid-name
+def merge_dicts_and_lists_b_into_a(a, b):
+ """The function to merge dict configs."""
+ return _merge_dicts_and_lists_b_into_a(a, b, "")
+
+
+def _merge_dicts_and_lists_b_into_a(a, b, cur_key=None):
+ """The function is inspired by mmcf.Config._merge_a_into_b.
+
+ * works with usual dicts and lists and derived types
+ * supports merging of lists (by concatenating the lists)
+ * makes recursive merging for dict + dict case
+ * overwrites when merging scalar into scalar
+ Note that we merge b into a (whereas Config makes merge a into b),
+ since otherwise the order of list merging is counter-intuitive.
+ """
+
+ def _err_str(_a, _b, _key):
+ if _key is None:
+ _key_str = "of whole structures"
+ else:
+ _key_str = f"during merging for key=`{_key}`"
+ return (
+ f"Error in merging parts of config: different types {_key_str},"
+ f" type(a) = {type(_a)},"
+ f" type(b) = {type(_b)}"
+ )
+
+ assert isinstance(a, (dict, list)), f"Can merge only dicts and lists, whereas type(a)={type(a)}"
+ assert isinstance(b, (dict, list)), _err_str(a, b, cur_key)
+ assert isinstance(a, list) == isinstance(b, list), _err_str(a, b, cur_key)
+ if isinstance(a, list):
+ # the main diff w.r.t. mmcf.Config -- merging of lists
+ return a + b
+
+ a = copy(a)
+ for k in b.keys():
+ if k not in a:
+ a[k] = copy(b[k])
+ continue
+ new_cur_key = cur_key + "." + k if cur_key else k
+ if isinstance(a[k], (dict, list)):
+ a[k] = _merge_dicts_and_lists_b_into_a(a[k], b[k], new_cur_key)
+ continue
+
+ assert not isinstance(b[k], (dict, list)), _err_str(a[k], b[k], new_cur_key)
+
+ # suppose here that a[k] and b[k] are scalars, just overwrite
+ a[k] = b[k]
+ return a
diff --git a/anomalib/utils/callbacks/openvino.py b/anomalib/utils/callbacks/openvino.py
new file mode 100644
index 0000000000000000000000000000000000000000..6df3634ccd5e8da574f6bafb9541d11490db2c97
--- /dev/null
+++ b/anomalib/utils/callbacks/openvino.py
@@ -0,0 +1,59 @@
+"""Callback that compresses a trained model by first exporting to .onnx format, and then converting to OpenVINO IR."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import os
+from typing import Tuple
+
+from pytorch_lightning import Callback
+
+from anomalib.deploy import export_convert
+from anomalib.models.components import AnomalyModule
+
+logger = logging.getLogger(__name__)
+
+
+class OpenVINOCallback(Callback):
+ """Callback to compresses a trained model.
+
+ Model is first exported to ``.onnx`` format, and then converted to OpenVINO IR.
+
+ Args:
+ input_size (Tuple[int, int]): Tuple of image height, width
+ dirpath (str): Path for model output
+ filename (str): Name of output model
+ """
+
+ def __init__(self, input_size: Tuple[int, int], dirpath: str, filename: str):
+ self.input_size = input_size
+ self.dirpath = dirpath
+ self.filename = filename
+
+ def on_train_end(self, trainer, pl_module: AnomalyModule) -> None: # pylint: disable=W0613
+ """Call when the train ends.
+
+ Converts the model to ``onnx`` format and then calls OpenVINO's model optimizer to get the
+ ``.xml`` and ``.bin`` IR files.
+ """
+ logger.info("Exporting the model to OpenVINO")
+ os.makedirs(self.dirpath, exist_ok=True)
+ onnx_path = os.path.join(self.dirpath, self.filename + ".onnx")
+ export_convert(
+ model=pl_module,
+ input_size=self.input_size,
+ onnx_path=onnx_path,
+ export_path=self.dirpath,
+ )
diff --git a/anomalib/utils/callbacks/timer.py b/anomalib/utils/callbacks/timer.py
new file mode 100644
index 0000000000000000000000000000000000000000..44effcd54a8e3caebf104f2a984736547cb9c0c7
--- /dev/null
+++ b/anomalib/utils/callbacks/timer.py
@@ -0,0 +1,98 @@
+"""Callback to measure training and testing time of a PyTorch Lightning module."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import time
+
+from pytorch_lightning import Callback, LightningModule, Trainer
+
+logger = logging.getLogger(__name__)
+
+
+class TimerCallback(Callback):
+ """Callback that measures the training and testing time of a PyTorch Lightning module."""
+
+ # pylint: disable=unused-argument
+ def __init__(self):
+ self.start: float
+ self.num_images: int = 0
+
+ def on_fit_start(self, trainer: Trainer, pl_module: LightningModule) -> None: # pylint: disable=W0613
+ """Call when fit begins.
+
+ Sets the start time to the time training started.
+
+ Args:
+ trainer (Trainer): PyTorch Lightning trainer.
+ pl_module (LightningModule): Current training module.
+
+ Returns:
+ None
+ """
+ self.start = time.time()
+
+ def on_fit_end(self, trainer: Trainer, pl_module: LightningModule) -> None: # pylint: disable=W0613
+ """Call when fit ends.
+
+ Prints the time taken for training.
+
+ Args:
+ trainer (Trainer): PyTorch Lightning trainer.
+ pl_module (LightningModule): Current training module.
+
+ Returns:
+ None
+ """
+ logger.info("Training took %5.2f seconds", (time.time() - self.start))
+
+ def on_test_start(self, trainer: Trainer, pl_module: LightningModule) -> None: # pylint: disable=W0613
+ """Call when the test begins.
+
+ Sets the start time to the time testing started.
+ Goes over all the test dataloaders and adds the number of images in each.
+
+ Args:
+ trainer (Trainer): PyTorch Lightning trainer.
+ pl_module (LightningModule): Current training module.
+
+ Returns:
+ None
+ """
+ self.start = time.time()
+ self.num_images = 0
+
+ if trainer.test_dataloaders is not None: # Check to placate Mypy.
+ for dataloader in trainer.test_dataloaders:
+ self.num_images += len(dataloader.dataset)
+
+ def on_test_end(self, trainer: Trainer, pl_module: LightningModule) -> None: # pylint: disable=W0613
+ """Call when the test ends.
+
+ Prints the time taken for testing and the throughput in frames per second.
+
+ Args:
+ trainer (Trainer): PyTorch Lightning trainer.
+ pl_module (LightningModule): Current training module.
+
+ Returns:
+ None
+ """
+ testing_time = time.time() - self.start
+ output = f"Testing took {testing_time} seconds\nThroughput "
+ if trainer.test_dataloaders is not None:
+ output += f"(batch_size={trainer.test_dataloaders[0].batch_size})"
+ output += f" : {self.num_images/testing_time} FPS"
+ logger.info(output)
diff --git a/anomalib/utils/callbacks/visualizer_callback.py b/anomalib/utils/callbacks/visualizer_callback.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d2b674b92d42bbb0c1f44b7641a9b2f7408601b
--- /dev/null
+++ b/anomalib/utils/callbacks/visualizer_callback.py
@@ -0,0 +1,169 @@
+"""Visualizer Callback."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from pathlib import Path
+from typing import Any, Optional, cast
+from warnings import warn
+
+import pytorch_lightning as pl
+from pytorch_lightning import Callback
+from pytorch_lightning.utilities.types import STEP_OUTPUT
+from skimage.segmentation import mark_boundaries
+
+from anomalib.models.components import AnomalyModule
+from anomalib.post_processing import Visualizer, compute_mask, superimpose_anomaly_map
+from anomalib.pre_processing.transforms import Denormalize
+from anomalib.utils import loggers
+from anomalib.utils.loggers import AnomalibWandbLogger
+from anomalib.utils.loggers.base import ImageLoggerBase
+
+
+class VisualizerCallback(Callback):
+ """Callback that visualizes the inference results of a model.
+
+ The callback generates a figure showing the original image, the ground truth segmentation mask,
+ the predicted error heat map, and the predicted segmentation mask.
+
+ To save the images to the filesystem, add the 'local' keyword to the `project.log_images_to` parameter in the
+ config.yaml file.
+ """
+
+ def __init__(self, task: str, inputs_are_normalized: bool = True):
+ """Visualizer callback."""
+ self.task = task
+ self.inputs_are_normalized = inputs_are_normalized
+
+ def _add_images(
+ self,
+ visualizer: Visualizer,
+ module: AnomalyModule,
+ trainer: pl.Trainer,
+ filename: Path,
+ ):
+ """Save image to logger/local storage.
+
+ Saves the image in `visualizer.figure` to the respective loggers and local storage if specified in
+ `log_images_to` in `config.yaml` of the models.
+
+ Args:
+ visualizer (Visualizer): Visualizer object from which the `figure` is saved/logged.
+ module (AnomalyModule): Anomaly module which holds reference to `hparams`.
+ trainer (Trainer): Pytorch Lightning trainer which holds reference to `logger`
+ filename (Path): Path of the input image. This name is used as name for the generated image.
+ """
+ # Store names of logger and the logger in a dict
+ available_loggers = {
+ type(logger).__name__.lower().rstrip("logger").lstrip("anomalib"): logger for logger in trainer.loggers
+ }
+ # save image to respective logger
+ for log_to in module.hparams.project.log_images_to:
+ if log_to in loggers.AVAILABLE_LOGGERS:
+ # check if logger object is same as the requested object
+ if log_to in available_loggers and isinstance(available_loggers[log_to], ImageLoggerBase):
+ logger: ImageLoggerBase = cast(ImageLoggerBase, available_loggers[log_to]) # placate mypy
+ logger.add_image(
+ image=visualizer.figure,
+ name=filename.parent.name + "_" + filename.name,
+ global_step=module.global_step,
+ )
+ else:
+ warn(
+ f"Requested {log_to} logging but logger object is of type: {type(module.logger)}."
+ f" Skipping logging to {log_to}"
+ )
+ else:
+ warn(f"{log_to} not in the list of supported image loggers.")
+
+ if "local" in module.hparams.project.log_images_to:
+ visualizer.save(Path(module.hparams.project.path) / "images" / filename.parent.name / filename.name)
+
+ def on_test_batch_end(
+ self,
+ trainer: pl.Trainer,
+ pl_module: AnomalyModule,
+ outputs: Optional[STEP_OUTPUT],
+ _batch: Any,
+ _batch_idx: int,
+ _dataloader_idx: int,
+ ) -> None:
+ """Log images at the end of every batch.
+
+ Args:
+ trainer (Trainer): Pytorch lightning trainer object (unused).
+ pl_module (LightningModule): Lightning modules derived from BaseAnomalyLightning object as
+ currently only they support logging images.
+ outputs (Dict[str, Any]): Outputs of the current test step.
+ _batch (Any): Input batch of the current test step (unused).
+ _batch_idx (int): Index of the current test batch (unused).
+ _dataloader_idx (int): Index of the dataloader that yielded the current batch (unused).
+ """
+ assert outputs is not None
+
+ if self.inputs_are_normalized:
+ normalize = False # anomaly maps are already normalized
+ else:
+ normalize = True # raw anomaly maps. Still need to normalize
+ threshold = pl_module.pixel_metrics.threshold
+
+ for i, (filename, image, anomaly_map, pred_score, gt_label) in enumerate(
+ zip(
+ outputs["image_path"],
+ outputs["image"],
+ outputs["anomaly_maps"],
+ outputs["pred_scores"],
+ outputs["label"],
+ )
+ ):
+ image = Denormalize()(image.cpu())
+ anomaly_map = anomaly_map.cpu().numpy()
+ heat_map = superimpose_anomaly_map(anomaly_map, image, normalize=normalize)
+ pred_mask = compute_mask(anomaly_map, threshold)
+ vis_img = mark_boundaries(image, pred_mask, color=(1, 0, 0), mode="thick")
+
+ num_cols = 6 if self.task == "segmentation" else 5
+ visualizer = Visualizer(num_rows=1, num_cols=num_cols, figure_size=(12, 3))
+ visualizer.add_image(image=image, title="Image")
+
+ if "mask" in outputs:
+ true_mask = outputs["mask"][i].cpu().numpy() * 255
+ visualizer.add_image(image=true_mask, color_map="gray", title="Ground Truth")
+
+ visualizer.add_image(image=heat_map, title="Predicted Heat Map")
+ visualizer.add_image(image=pred_mask, color_map="gray", title="Predicted Mask")
+ visualizer.add_image(image=vis_img, title="Segmentation Result")
+
+ image_classified = visualizer.add_text(
+ image=image,
+ text=f"""Pred: { "anomalous" if pred_score > threshold else "normal"}({pred_score:.3f}) \n
+ GT: {"anomalous" if bool(gt_label) else "normal"}""",
+ )
+ visualizer.add_image(image=image_classified, title="Classified Image")
+
+ self._add_images(visualizer, pl_module, trainer, Path(filename))
+ visualizer.close()
+
+ def on_test_end(self, _trainer: pl.Trainer, pl_module: AnomalyModule) -> None:
+ """Sync logs.
+
+ Currently only ``AnomalibWandbLogger`` is called from this method. This is because logging as a single batch
+ ensures that all images appear as part of the same step.
+
+ Args:
+ _trainer (pl.Trainer): Pytorch Lightning trainer (unused)
+ pl_module (AnomalyModule): Anomaly module
+ """
+ if pl_module.logger is not None and isinstance(pl_module.logger, AnomalibWandbLogger):
+ pl_module.logger.save()
diff --git a/anomalib/utils/loggers/__init__.py b/anomalib/utils/loggers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a7f331d5251ec02a53f510b2bec4e3c3a066bf18
--- /dev/null
+++ b/anomalib/utils/loggers/__init__.py
@@ -0,0 +1,112 @@
+"""Load PyTorch Lightning Loggers."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import os
+from typing import Iterable, List, Union
+
+from omegaconf.dictconfig import DictConfig
+from omegaconf.listconfig import ListConfig
+from pytorch_lightning.loggers import CSVLogger, LightningLoggerBase
+
+from .tensorboard import AnomalibTensorBoardLogger
+from .wandb import AnomalibWandbLogger
+
+__all__ = [
+ "AnomalibTensorBoardLogger",
+ "AnomalibWandbLogger",
+ "configure_logger",
+ "get_experiment_logger",
+]
+AVAILABLE_LOGGERS = ["tensorboard", "wandb", "csv"]
+
+
+class UnknownLogger(Exception):
+ """This is raised when the logger option in `config.yaml` file is set incorrectly."""
+
+
+def configure_logger(level: Union[int, str] = logging.INFO):
+ """Get console logger by name.
+
+ Args:
+ level (Union[int, str], optional): Logger Level. Defaults to logging.INFO.
+
+ Returns:
+ Logger: The expected logger.
+ """
+
+ if isinstance(level, str):
+ level = logging.getLevelName(level)
+
+ format_string = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ logging.basicConfig(format=format_string, level=level)
+
+ # Set Pytorch Lightning logs to have a the consistent formatting with anomalib.
+ for handler in logging.getLogger("pytorch_lightning").handlers:
+ handler.setFormatter(logging.Formatter(format_string))
+ handler.setLevel(level)
+
+
+def get_experiment_logger(
+ config: Union[DictConfig, ListConfig]
+) -> Union[LightningLoggerBase, Iterable[LightningLoggerBase], bool]:
+ """Return a logger based on the choice of logger in the config file.
+
+ Args:
+ config (DictConfig): config.yaml file for the corresponding anomalib model.
+
+ Raises:
+ ValueError: for any logger types apart from false and tensorboard
+
+ Returns:
+ Union[LightningLoggerBase, Iterable[LightningLoggerBase], bool]: Logger
+ """
+ if config.project.logger in [None, False]:
+ return False
+
+ logger_list: List[LightningLoggerBase] = []
+ if isinstance(config.project.logger, str):
+ config.project.logger = [config.project.logger]
+
+ for logger in config.project.logger:
+ if logger == "tensorboard":
+ logger_list.append(
+ AnomalibTensorBoardLogger(
+ name="Tensorboard Logs",
+ save_dir=os.path.join(config.project.path, "logs"),
+ )
+ )
+ elif logger == "wandb":
+ wandb_logdir = os.path.join(config.project.path, "logs")
+ os.makedirs(wandb_logdir, exist_ok=True)
+ logger_list.append(
+ AnomalibWandbLogger(
+ project=config.dataset.name,
+ name=f"{config.dataset.category} {config.model.name}",
+ save_dir=wandb_logdir,
+ )
+ )
+ elif logger == "csv":
+ logger_list.append(CSVLogger(save_dir=os.path.join(config.project.path, "logs")))
+ else:
+ raise UnknownLogger(
+ f"Unknown logger type: {config.project.logger}. "
+ f"Available loggers are: {AVAILABLE_LOGGERS}.\n"
+ f"To enable the logger, set `project.logger` to `true` or use one of available loggers in config.yaml\n"
+ f"To disable the logger, set `project.logger` to `false`."
+ )
+
+ return logger_list
diff --git a/anomalib/utils/loggers/base.py b/anomalib/utils/loggers/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..c515e374ba9211705f0cdc0555d8806fb0939054
--- /dev/null
+++ b/anomalib/utils/loggers/base.py
@@ -0,0 +1,30 @@
+"""Base logger for image logging consistency across all loggers used in anomalib."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from abc import abstractmethod
+from typing import Any, Optional, Union
+
+import numpy as np
+from matplotlib.figure import Figure
+
+
+class ImageLoggerBase:
+ """Adds a common interface for logging the images."""
+
+ @abstractmethod
+ def add_image(self, image: Union[np.ndarray, Figure], name: Optional[str] = None, **kwargs: Any) -> None:
+ """Interface to log images in the respective loggers."""
+ raise NotImplementedError()
diff --git a/anomalib/utils/loggers/tensorboard.py b/anomalib/utils/loggers/tensorboard.py
new file mode 100644
index 0000000000000000000000000000000000000000..56695babd6b800872b60634a39f6be940345bc98
--- /dev/null
+++ b/anomalib/utils/loggers/tensorboard.py
@@ -0,0 +1,105 @@
+"""Tensorboard logger with add image interface."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Any, Optional, Union
+
+import numpy as np
+from matplotlib.figure import Figure
+from pytorch_lightning.loggers.tensorboard import TensorBoardLogger
+from pytorch_lightning.utilities import rank_zero_only
+
+from .base import ImageLoggerBase
+
+
+class AnomalibTensorBoardLogger(ImageLoggerBase, TensorBoardLogger):
+ """Logger for tensorboard.
+
+ Adds interface for `add_image` in the logger rather than calling the experiment object.
+
+ Note:
+ Same as the Tensorboard Logger provided by PyTorch Lightning and the doc string is reproduced below.
+
+ Logs are saved to
+ ``os.path.join(save_dir, name, version)``. This is the default logger in Lightning, it comes
+ preinstalled.
+
+ Example:
+ >>> from pytorch_lightning import Trainer
+ >>> from anomalib.utils.loggers import AnomalibTensorBoardLogger
+ >>> logger = AnomalibTensorBoardLogger("tb_logs", name="my_model")
+ >>> trainer = Trainer(logger=logger)
+
+ Args:
+ save_dir (str): Save directory
+ name (Optional, str): Experiment name. Defaults to ``'default'``. If it is the empty string then no
+ per-experiment subdirectory is used.
+ version (Optional, int, str): Experiment version. If version is not specified the logger inspects the save
+ directory for existing versions, then automatically assigns the next available version.
+ If it is a string then it is used as the run-specific subdirectory name,
+ otherwise ``'version_${version}'`` is used.
+ log_graph (bool): Adds the computational graph to tensorboard. This requires that
+ the user has defined the `self.example_input_array` attribute in their
+ model.
+ default_hp_metric (bool): Enables a placeholder metric with key `hp_metric` when `log_hyperparams` is
+ called without a metric (otherwise calls to log_hyperparams without a metric are ignored).
+ prefix (str): A string to put at the beginning of metric keys.
+ **kwargs: Additional arguments like `comment`, `filename_suffix`, etc. used by
+ :class:`SummaryWriter` can be passed as keyword arguments in this logger.
+ """
+
+ def __init__(
+ self,
+ save_dir: str,
+ name: Optional[str] = "default",
+ version: Optional[Union[int, str]] = None,
+ log_graph: bool = False,
+ default_hp_metric: bool = True,
+ prefix: str = "",
+ **kwargs
+ ):
+ super().__init__(
+ save_dir,
+ name=name,
+ version=version,
+ log_graph=log_graph,
+ default_hp_metric=default_hp_metric,
+ prefix=prefix,
+ **kwargs
+ )
+
+ @rank_zero_only
+ def add_image(self, image: Union[np.ndarray, Figure], name: Optional[str] = None, **kwargs: Any):
+ """Interface to add image to tensorboard logger.
+
+ Args:
+ image (Union[np.ndarray, Figure]): Image to log
+ name (Optional[str]): The tag of the image
+ kwargs: Accepts only `global_step` (int). The step at which to log the image.
+ """
+ if "global_step" not in kwargs:
+ raise ValueError("`global_step` is required for tensorboard logger")
+
+ # Matplotlib Figure is not supported by tensorboard
+ if isinstance(image, Figure):
+ axis = image.gca()
+ axis.axis("off")
+ axis.margins(0)
+ image.canvas.draw() # cache the renderer
+ buffer = np.frombuffer(image.canvas.tostring_rgb(), dtype=np.uint8)
+ image = buffer.reshape(image.canvas.get_width_height()[::-1] + (3,))
+ kwargs["dataformats"] = "HWC"
+
+ self.experiment.add_image(img_tensor=image, tag=name, **kwargs)
diff --git a/anomalib/utils/loggers/wandb.py b/anomalib/utils/loggers/wandb.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4f7e6d32d4ba21a76fdcda9410328af49b8bb8c
--- /dev/null
+++ b/anomalib/utils/loggers/wandb.py
@@ -0,0 +1,129 @@
+"""wandb logger with add image interface."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Any, List, Optional, Union
+
+import numpy as np
+from matplotlib.figure import Figure
+from pytorch_lightning.loggers.wandb import WandbLogger
+from pytorch_lightning.utilities import rank_zero_only
+
+import wandb
+
+from .base import ImageLoggerBase
+
+
+class AnomalibWandbLogger(ImageLoggerBase, WandbLogger):
+ """Logger for wandb.
+
+ Adds interface for `add_image` in the logger rather than calling the experiment object.
+
+ Note:
+ Same as the wandb Logger provided by PyTorch Lightning and the doc string is reproduced below.
+
+ Log using `Weights and Biases `_.
+
+ Install it with pip:
+
+ .. code-block:: bash
+
+ $ pip install wandb
+
+ Args:
+ name: Display name for the run.
+ save_dir: Path where data is saved (wandb dir by default).
+ offline: Run offline (data can be streamed later to wandb servers).
+ id: Sets the version, mainly used to resume a previous run.
+ version: Same as id.
+ anonymous: Enables or explicitly disables anonymous logging.
+ project: The name of the project to which this run will belong.
+ log_model: Save checkpoints in wandb dir to upload on W&B servers.
+ prefix: A string to put at the beginning of metric keys.
+ experiment: WandB experiment object. Automatically set when creating a run.
+ **kwargs: Arguments passed to :func:`wandb.init` like `entity`, `group`, `tags`, etc.
+
+ Raises:
+ ImportError:
+ If required WandB package is not installed on the device.
+ MisconfigurationException:
+ If both ``log_model`` and ``offline``is set to ``True``.
+
+ Example:
+ >>> from anomalib.utils.loggers import AnomalibWandbLogger
+ >>> from pytorch_lightning import Trainer
+ >>> wandb_logger = AnomalibWandbLogger()
+ >>> trainer = Trainer(logger=wandb_logger)
+
+ Note: When logging manually through `wandb.log` or `trainer.logger.experiment.log`,
+ make sure to use `commit=False` so the logging step does not increase.
+
+ See Also:
+ - `Tutorial `__
+ on how to use W&B with PyTorch Lightning
+ - `W&B Documentation `__
+
+ """
+
+ def __init__(
+ self,
+ name: Optional[str] = None,
+ save_dir: Optional[str] = None,
+ offline: Optional[bool] = False,
+ id: Optional[str] = None, # kept to match wandb init pylint: disable=redefined-builtin
+ anonymous: Optional[bool] = None,
+ version: Optional[str] = None,
+ project: Optional[str] = None,
+ log_model: Union[str, bool] = False,
+ experiment=None,
+ prefix: Optional[str] = "",
+ **kwargs
+ ) -> None:
+ super().__init__(
+ name=name,
+ save_dir=save_dir,
+ offline=offline,
+ id=id,
+ anonymous=anonymous,
+ version=version,
+ project=project,
+ log_model=log_model,
+ experiment=experiment,
+ prefix=prefix,
+ **kwargs
+ )
+ self.image_list: List[wandb.Image] = [] # Cache images
+
+ @rank_zero_only
+ def add_image(self, image: Union[np.ndarray, Figure], name: Optional[str] = None, **kwargs: Any):
+ """Interface to add image to wandb logger.
+
+ Args:
+ image (Union[np.ndarray, Figure]): Image to log
+ name (Optional[str]): The tag of the image
+ """
+ image = wandb.Image(image, caption=name)
+ self.image_list.append(image)
+
+ @rank_zero_only
+ def save(self) -> None:
+ """Upload images to wandb server.
+
+ Note:
+ There is a limit on the number of images that can be logged together to the `wandb` server.
+ """
+ super().save()
+ if len(self.image_list) > 1:
+ wandb.log({"Predictions": self.image_list})
diff --git a/anomalib/utils/metrics/__init__.py b/anomalib/utils/metrics/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..30b41c9c3eedce52696bf9cc40673e0a3098e549
--- /dev/null
+++ b/anomalib/utils/metrics/__init__.py
@@ -0,0 +1,63 @@
+"""Custom anomaly evaluation metrics."""
+import importlib
+import warnings
+from typing import List, Optional, Tuple, Union
+
+import torchmetrics
+from omegaconf import DictConfig, ListConfig
+
+from .adaptive_threshold import AdaptiveThreshold
+from .anomaly_score_distribution import AnomalyScoreDistribution
+from .auroc import AUROC
+from .collection import AnomalibMetricCollection
+from .min_max import MinMax
+from .optimal_f1 import OptimalF1
+
+__all__ = ["AUROC", "OptimalF1", "AdaptiveThreshold", "AnomalyScoreDistribution", "MinMax"]
+
+
+def get_metrics(config: Union[ListConfig, DictConfig]) -> Tuple[AnomalibMetricCollection, AnomalibMetricCollection]:
+ """Create metric collections based on the config.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Config.yaml loaded using OmegaConf
+
+ Returns:
+ AnomalibMetricCollection: Image-level metric collection
+ AnomalibMetricCollection: Pixel-level metric collection
+ """
+ image_metric_names = config.metrics.image if "image" in config.metrics.keys() else []
+ pixel_metric_names = config.metrics.pixel if "pixel" in config.metrics.keys() else []
+ image_metrics = metric_collection_from_names(image_metric_names, "image_")
+ pixel_metrics = metric_collection_from_names(pixel_metric_names, "pixel_")
+ return image_metrics, pixel_metrics
+
+
+def metric_collection_from_names(metric_names: List[str], prefix: Optional[str]) -> AnomalibMetricCollection:
+ """Create a metric collection from a list of metric names.
+
+ The function will first try to retrieve the metric from the metrics defined in Anomalib metrics module,
+ then in TorchMetrics package.
+
+ Args:
+ metric_names (List[str]): List of metric names to be included in the collection.
+ prefix (Optional[str]): prefix to assign to the metrics in the collection.
+
+ Returns:
+ AnomalibMetricCollection: Collection of metrics.
+ """
+ metrics_module = importlib.import_module("anomalib.utils.metrics")
+ metrics = AnomalibMetricCollection([], prefix=prefix, compute_groups=False)
+ for metric_name in metric_names:
+ if hasattr(metrics_module, metric_name):
+ metric_cls = getattr(metrics_module, metric_name)
+ metrics.add_metrics(metric_cls(compute_on_step=False))
+ elif hasattr(torchmetrics, metric_name):
+ try:
+ metric_cls = getattr(torchmetrics, metric_name)
+ metrics.add_metrics(metric_cls(compute_on_step=False))
+ except TypeError:
+ warnings.warn(f"Incorrect constructor arguments for {metric_name} metric from TorchMetrics package.")
+ else:
+ warnings.warn(f"No metric with name {metric_name} found in Anomalib metrics or TorchMetrics.")
+ return metrics
diff --git a/anomalib/utils/metrics/adaptive_threshold.py b/anomalib/utils/metrics/adaptive_threshold.py
new file mode 100644
index 0000000000000000000000000000000000000000..d29a6c5376085a5c00aef8d171b08ba5ad59bbf1
--- /dev/null
+++ b/anomalib/utils/metrics/adaptive_threshold.py
@@ -0,0 +1,46 @@
+"""Implementation of Optimal F1 score based on TorchMetrics."""
+import torch
+from torchmetrics import Metric, PrecisionRecallCurve
+
+
+class AdaptiveThreshold(Metric):
+ """Optimal F1 Metric.
+
+ Compute the optimal F1 score at the adaptive threshold, based on the F1 metric of the true labels and the
+ predicted anomaly scores.
+ """
+
+ def __init__(self, default_value: float, **kwargs):
+ super().__init__(**kwargs)
+
+ self.precision_recall_curve = PrecisionRecallCurve(num_classes=1, compute_on_step=False)
+ self.add_state("value", default=torch.tensor(default_value), persistent=True) # pylint: disable=not-callable
+ self.value = torch.tensor(default_value) # pylint: disable=not-callable
+
+ # pylint: disable=arguments-differ
+ def update(self, preds: torch.Tensor, target: torch.Tensor) -> None: # type: ignore
+ """Update the precision-recall curve metric."""
+ self.precision_recall_curve.update(preds, target)
+
+ def compute(self) -> torch.Tensor:
+ """Compute the threshold that yields the optimal F1 score.
+
+ Compute the F1 scores while varying the threshold. Store the optimal
+ threshold as attribute and return the maximum value of the F1 score.
+
+ Returns:
+ Value of the F1 score at the optimal threshold.
+ """
+ precision: torch.Tensor
+ recall: torch.Tensor
+ thresholds: torch.Tensor
+
+ precision, recall, thresholds = self.precision_recall_curve.compute()
+ f1_score = (2 * precision * recall) / (precision + recall + 1e-10)
+ if thresholds.dim() == 0:
+ # special case where recall is 1.0 even for the highest threshold.
+ # In this case 'thresholds' will be scalar.
+ self.value = thresholds
+ else:
+ self.value = thresholds[torch.argmax(f1_score)]
+ return self.value
diff --git a/anomalib/utils/metrics/anomaly_score_distribution.py b/anomalib/utils/metrics/anomaly_score_distribution.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2c324d652679cbf5e016181634ef9ee6600b891
--- /dev/null
+++ b/anomalib/utils/metrics/anomaly_score_distribution.py
@@ -0,0 +1,52 @@
+"""Module that computes the parameters of the normal data distribution of the training set."""
+from typing import Optional, Tuple
+
+import torch
+from torch import Tensor
+from torchmetrics import Metric
+
+
+class AnomalyScoreDistribution(Metric):
+ """Mean and standard deviation of the anomaly scores of normal training data."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.anomaly_maps = []
+ self.anomaly_scores = []
+
+ self.add_state("image_mean", torch.empty(0), persistent=True)
+ self.add_state("image_std", torch.empty(0), persistent=True)
+ self.add_state("pixel_mean", torch.empty(0), persistent=True)
+ self.add_state("pixel_std", torch.empty(0), persistent=True)
+
+ self.image_mean = torch.empty(0)
+ self.image_std = torch.empty(0)
+ self.pixel_mean = torch.empty(0)
+ self.pixel_std = torch.empty(0)
+
+ # pylint: disable=arguments-differ
+ def update( # type: ignore
+ self, anomaly_scores: Optional[Tensor] = None, anomaly_maps: Optional[Tensor] = None
+ ) -> None:
+ """Update the precision-recall curve metric."""
+ if anomaly_maps is not None:
+ self.anomaly_maps.append(anomaly_maps)
+ if anomaly_scores is not None:
+ self.anomaly_scores.append(anomaly_scores)
+
+ def compute(self) -> Tuple[Tensor, Tensor, Tensor, Tensor]:
+ """Compute stats."""
+ anomaly_scores = torch.hstack(self.anomaly_scores)
+ anomaly_scores = torch.log(anomaly_scores)
+
+ self.image_mean = anomaly_scores.mean()
+ self.image_std = anomaly_scores.std()
+
+ if self.anomaly_maps:
+ anomaly_maps = torch.vstack(self.anomaly_maps)
+ anomaly_maps = torch.log(anomaly_maps).cpu()
+
+ self.pixel_mean = anomaly_maps.mean(dim=0).squeeze()
+ self.pixel_std = anomaly_maps.std(dim=0).squeeze()
+
+ return self.image_mean, self.image_std, self.pixel_mean, self.pixel_std
diff --git a/anomalib/utils/metrics/auroc.py b/anomalib/utils/metrics/auroc.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ce844b989d5547ee06ea8be9d265d9fc82cce83
--- /dev/null
+++ b/anomalib/utils/metrics/auroc.py
@@ -0,0 +1,24 @@
+"""Implementation of AUROC metric based on TorchMetrics."""
+import torch
+from torch import Tensor
+from torchmetrics import ROC
+from torchmetrics.functional import auc
+
+
+class AUROC(ROC):
+ """Area under the ROC curve."""
+
+ def compute(self) -> Tensor:
+ """First compute ROC curve, then compute area under the curve.
+
+ Returns:
+ Value of the AUROC metric
+ """
+ tpr: Tensor
+ fpr: Tensor
+
+ fpr, tpr, _thresholds = super().compute()
+ # TODO: use stable sort after upgrading to pytorch 1.9.x (https://github.com/openvinotoolkit/anomalib/issues/92)
+ if not (torch.all(fpr.diff() <= 0) or torch.all(fpr.diff() >= 0)):
+ return auc(fpr, tpr, reorder=True) # only reorder if fpr is not increasing or decreasing
+ return auc(fpr, tpr)
diff --git a/anomalib/utils/metrics/collection.py b/anomalib/utils/metrics/collection.py
new file mode 100644
index 0000000000000000000000000000000000000000..911b3a76549a419bcc971ad27709664f248553ff
--- /dev/null
+++ b/anomalib/utils/metrics/collection.py
@@ -0,0 +1,48 @@
+"""Anomalib Metric Collection."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from torchmetrics import MetricCollection
+
+
+class AnomalibMetricCollection(MetricCollection):
+ """Extends the MetricCollection class for use in the Anomalib pipeline."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._update_called = False
+ self._threshold = 0.5
+
+ def set_threshold(self, threshold_value):
+ """Update the threshold value for all metrics that have the threshold attribute."""
+ self._threshold = threshold_value
+ for metric in self.values():
+ if hasattr(metric, "threshold"):
+ metric.threshold = threshold_value
+
+ def update(self, *args, **kwargs) -> None:
+ """Add data to the metrics."""
+ super().update(*args, **kwargs)
+ self._update_called = True
+
+ @property
+ def update_called(self) -> bool:
+ """Returns a boolean indicating if the update method has been called at least once."""
+ return self._update_called
+
+ @property
+ def threshold(self) -> float:
+ """Return the value of the anomaly threshold."""
+ return self._threshold
diff --git a/anomalib/utils/metrics/min_max.py b/anomalib/utils/metrics/min_max.py
new file mode 100644
index 0000000000000000000000000000000000000000..9def695626e975ff5deb5b580aa91ef592eb694d
--- /dev/null
+++ b/anomalib/utils/metrics/min_max.py
@@ -0,0 +1,43 @@
+"""Module that tracks the min and max values of the observations in each batch."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import Tuple
+
+import torch
+from torch import Tensor
+from torchmetrics import Metric
+
+
+class MinMax(Metric):
+ """Track the min and max values of the observations in each batch."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.add_state("min", torch.tensor(float("inf")), persistent=True) # pylint: disable=not-callable
+ self.add_state("max", torch.tensor(float("-inf")), persistent=True) # pylint: disable=not-callable
+
+ self.min = torch.tensor(float("inf")) # pylint: disable=not-callable
+ self.max = torch.tensor(float("-inf")) # pylint: disable=not-callable
+
+ # pylint: disable=arguments-differ
+ def update(self, predictions: Tensor) -> None: # type: ignore
+ """Update the min and max values."""
+ self.max = torch.max(self.max, torch.max(predictions))
+ self.min = torch.min(self.min, torch.min(predictions))
+
+ def compute(self) -> Tuple[Tensor, Tensor]:
+ """Return min and max values."""
+ return self.min, self.max
diff --git a/anomalib/utils/metrics/optimal_f1.py b/anomalib/utils/metrics/optimal_f1.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b47be59840e4dce795295a6ff1ae41ac26b5559
--- /dev/null
+++ b/anomalib/utils/metrics/optimal_f1.py
@@ -0,0 +1,42 @@
+"""Implementation of Optimal F1 score based on TorchMetrics."""
+import torch
+from torchmetrics import Metric, PrecisionRecallCurve
+
+
+class OptimalF1(Metric):
+ """Optimal F1 Metric.
+
+ Compute the optimal F1 score at the adaptive threshold, based on the F1 metric of the true labels and the
+ predicted anomaly scores.
+ """
+
+ def __init__(self, num_classes: int, **kwargs):
+ super().__init__(**kwargs)
+
+ self.precision_recall_curve = PrecisionRecallCurve(num_classes=num_classes, compute_on_step=False)
+
+ self.threshold: torch.Tensor
+
+ # pylint: disable=arguments-differ
+ def update(self, preds: torch.Tensor, target: torch.Tensor) -> None: # type: ignore
+ """Update the precision-recall curve metric."""
+ self.precision_recall_curve.update(preds, target)
+
+ def compute(self) -> torch.Tensor:
+ """Compute the value of the optimal F1 score.
+
+ Compute the F1 scores while varying the threshold. Store the optimal
+ threshold as attribute and return the maximum value of the F1 score.
+
+ Returns:
+ Value of the F1 score at the optimal threshold.
+ """
+ precision: torch.Tensor
+ recall: torch.Tensor
+ thresholds: torch.Tensor
+
+ precision, recall, thresholds = self.precision_recall_curve.compute()
+ f1_score = (2 * precision * recall) / (precision + recall + 1e-10)
+ self.threshold = thresholds[torch.argmax(f1_score)]
+ optimal_f1_score = torch.max(f1_score)
+ return optimal_f1_score
diff --git a/anomalib/utils/sweep/__init__.py b/anomalib/utils/sweep/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..46744f3583cbd9554357ea1e626fdf35aaec208f
--- /dev/null
+++ b/anomalib/utils/sweep/__init__.py
@@ -0,0 +1,33 @@
+"""Utils for Benchmarking and Sweep."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .config import flatten_sweep_params, get_run_config, set_in_nested_config
+from .helpers import (
+ get_meta_data,
+ get_openvino_throughput,
+ get_sweep_callbacks,
+ get_torch_throughput,
+)
+
+__all__ = [
+ "get_run_config",
+ "set_in_nested_config",
+ "get_sweep_callbacks",
+ "get_meta_data",
+ "get_openvino_throughput",
+ "get_torch_throughput",
+ "flatten_sweep_params",
+]
diff --git a/anomalib/utils/sweep/config.py b/anomalib/utils/sweep/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..96238f44c37b66e348246fe909f1b69d61069b38
--- /dev/null
+++ b/anomalib/utils/sweep/config.py
@@ -0,0 +1,144 @@
+"""Utilities for modifying the configuration."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import itertools
+import operator
+from functools import reduce
+from typing import Any, Generator, List
+
+from omegaconf import DictConfig
+
+
+def flatten_sweep_params(params_dict: DictConfig) -> DictConfig:
+ """Flatten the nested parameters section of the config object.
+
+ We need to flatten the params so that all the nested keys are concatenated into a single string.
+ This is useful when
+ - We need to do a cartesian product of all the combinations of the configuration for grid search.
+ - Save keys as headers for csv
+ - Add the config to `wandb` sweep.
+
+ Args:
+ params_dict: DictConfig: The dictionary containing the hpo parameters in the original, nested, structure.
+
+ Returns:
+ flattened version of the parameter dictionary.
+ """
+
+ def flatten_nested_dict(nested_params: DictConfig, keys: List[str], flattened_params: DictConfig):
+ """Flatten nested dictionary.
+
+ Recursive helper function that traverses the nested config object and stores the leaf nodes in a flattened
+ dictionary.
+
+ Args:
+ nested_params: DictConfig: config object containing the original parameters.
+ keys: List[str]: list of keys leading to the current location in the config.
+ flattened_params: DictConfig: Dictionary in which the flattened parameters are stored.
+ """
+ for name, cfg in nested_params.items():
+ if isinstance(cfg, DictConfig):
+ flatten_nested_dict(cfg, keys + [str(name)], flattened_params)
+ else:
+ key = ".".join(keys + [str(name)])
+ flattened_params[key] = cfg
+
+ flattened_params_dict = DictConfig({})
+ flatten_nested_dict(params_dict, [], flattened_params_dict)
+
+ return flattened_params_dict
+
+
+def get_run_config(params_dict: DictConfig) -> Generator[DictConfig, None, None]:
+ """Yields configuration for a single run.
+
+ Args:
+ params_dict (DictConfig): Configuration for grid search.
+
+ Example:
+ >>> dummy_config = DictConfig({
+ "parent1":{
+ "child1": ['a', 'b', 'c'],
+ "child2": [1, 2, 3]
+ },
+ "parent2":['model1', 'model2']
+ })
+ >>> for run_config in get_run_config(dummy_config):
+ >>> print(run_config)
+ {'parent1.child1': 'a', 'parent1.child2': 1, 'parent2': 'model1'}
+ {'parent1.child1': 'a', 'parent1.child2': 1, 'parent2': 'model2'}
+ {'parent1.child1': 'a', 'parent1.child2': 2, 'parent2': 'model1'}
+ ...
+
+ Yields:
+ Generator[DictConfig]: Dictionary containing flattened keys
+ and values for current run.
+ """
+ params = flatten_sweep_params(params_dict)
+ combinations = list(itertools.product(*params.values()))
+ keys = params.keys()
+ for combination in combinations:
+ run_config = DictConfig({})
+ for key, val in zip(keys, combination):
+ run_config[key] = val
+ yield run_config
+
+
+def get_from_nested_config(config: DictConfig, keymap: List) -> Any:
+ """Retrieves an item from a nested config object using a list of keys.
+
+ Args:
+ config: DictConfig: nested DictConfig object
+ keymap: List[str]: list of keys corresponding to item that should be retrieved.
+ """
+ return reduce(operator.getitem, keymap, config)
+
+
+def set_in_nested_config(config: DictConfig, keymap: List, value: Any):
+ """Set an item in a nested config object using a list of keys.
+
+ Args:
+ config: DictConfig: nested DictConfig object
+ keymap: List[str]: list of keys corresponding to item that should be set.
+ value: Any: Value that should be assigned to the dictionary item at the specified location.
+
+ Example:
+ >>> dummy_config = DictConfig({
+ "parent1":{
+ "child1": ['a', 'b', 'c'],
+ "child2": [1, 2, 3]
+ },
+ "parent2":['model1', 'model2']
+ })
+ >>> model_config = DictConfig({
+ "parent1":{
+ "child1": 'e',
+ "child2": 4,
+ },
+ "parent3": False
+ })
+ >>> for run_config in get_run_config(dummy_config):
+ >>> print("Original model config", model_config)
+ >>> print("Suggested config", run_config)
+ >>> for param in run_config.keys():
+ >>> set_in_nested_config(model_config, param.split('.'), run_config[param])
+ >>> print("Replaced model config", model_config)
+ >>> break
+ Original model config {'parent1': {'child1': 'e', 'child2': 4}, 'parent3': False}
+ Suggested config {'parent1.child1': 'a', 'parent1.child2': 1, 'parent2': 'model1'}
+ Replaced model config {'parent1': {'child1': 'a', 'child2': 1}, 'parent3': False, 'parent2': 'model1'}
+ """
+ get_from_nested_config(config, keymap[:-1])[keymap[-1]] = value
diff --git a/anomalib/utils/sweep/helpers/__init__.py b/anomalib/utils/sweep/helpers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..05f42fd5133a171f3e06cb63a654c2f0dd236749
--- /dev/null
+++ b/anomalib/utils/sweep/helpers/__init__.py
@@ -0,0 +1,20 @@
+"""Helpers for benchmarking and hyperparameter optimization."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .callbacks import get_sweep_callbacks
+from .inference import get_meta_data, get_openvino_throughput, get_torch_throughput
+
+__all__ = ["get_meta_data", "get_openvino_throughput", "get_torch_throughput", "get_sweep_callbacks"]
diff --git a/anomalib/utils/sweep/helpers/callbacks.py b/anomalib/utils/sweep/helpers/callbacks.py
new file mode 100644
index 0000000000000000000000000000000000000000..e09267c91e663949cf706a2ac3357dfa7f769a81
--- /dev/null
+++ b/anomalib/utils/sweep/helpers/callbacks.py
@@ -0,0 +1,36 @@
+"""Get callbacks related to sweep."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+
+from typing import List
+
+from pytorch_lightning import Callback
+
+from anomalib.utils.callbacks.timer import TimerCallback
+
+
+def get_sweep_callbacks() -> List[Callback]:
+ """Gets callbacks relevant to sweep.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Model config loaded from anomalib
+
+ Returns:
+ List[Callback]: List of callbacks
+ """
+ callbacks: List[Callback] = [TimerCallback()]
+
+ return callbacks
diff --git a/anomalib/utils/sweep/helpers/inference.py b/anomalib/utils/sweep/helpers/inference.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b8544bf59a449ff6b66145a11c33ebf392d8a48
--- /dev/null
+++ b/anomalib/utils/sweep/helpers/inference.py
@@ -0,0 +1,152 @@
+"""Utils to help compute inference statistics."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import time
+from pathlib import Path
+from typing import Dict, Iterable, List, Tuple, Union
+
+import numpy as np
+import torch
+from omegaconf import DictConfig, ListConfig
+from torch.utils.data import DataLoader
+
+from anomalib.deploy import OpenVINOInferencer, TorchInferencer
+from anomalib.models.components import AnomalyModule
+
+
+class MockImageLoader:
+ """Create mock images for inference on CPU based on the specifics of the original torch test dataset.
+
+ Uses yield so as to avoid storing everything in the memory.
+
+ Args:
+ image_size (List[int]): Size of input image
+ total_count (int): Total images in the test dataset
+ """
+
+ def __init__(self, image_size: List[int], total_count: int):
+ self.total_count = total_count
+ self.image_size = image_size
+ self.image = np.ones((*self.image_size, 3)).astype(np.uint8)
+
+ def __len__(self):
+ """Get total count of images."""
+ return self.total_count
+
+ def __call__(self) -> Iterable[np.ndarray]:
+ """Yield batch of generated images.
+
+ Args:
+ idx (int): Unused
+ """
+ for _ in range(self.total_count):
+ yield self.image
+
+
+def get_meta_data(model: AnomalyModule, input_size: Tuple[int, int]) -> Dict:
+ """Get meta data for inference.
+
+ Args:
+ model (AnomalyModule): Trained model from which the metadata is extracted.
+ input_size (Tuple[int, int]): Input size used to resize the pixel level mean and std.
+
+ Returns:
+ (Dict): Metadata as dictionary.
+ """
+ meta_data = {
+ "image_threshold": model.image_threshold.value.cpu().numpy(),
+ "pixel_threshold": model.pixel_threshold.value.cpu().numpy(),
+ "min": model.min_max.min.cpu().numpy(),
+ "max": model.min_max.max.cpu().numpy(),
+ "stats": {},
+ }
+
+ image_mean = model.training_distribution.image_mean.cpu().numpy()
+ if image_mean.size > 0:
+ meta_data["stats"]["image_mean"] = image_mean
+
+ image_std = model.training_distribution.image_std.cpu().numpy()
+ if image_std.size > 0:
+ meta_data["stats"]["image_std"] = image_std
+
+ pixel_mean = model.training_distribution.pixel_mean.cpu().numpy()
+ if pixel_mean.size > 0:
+ meta_data["stats"]["pixel_mean"] = pixel_mean.reshape(input_size)
+
+ pixel_std = model.training_distribution.pixel_std.cpu().numpy()
+ if pixel_std.size > 0:
+ meta_data["stats"]["pixel_std"] = pixel_std.reshape(input_size)
+
+ return meta_data
+
+
+def get_torch_throughput(
+ config: Union[DictConfig, ListConfig], model: AnomalyModule, test_dataset: DataLoader, meta_data: Dict
+) -> float:
+ """Tests the model on dummy data. Images are passed sequentially to make the comparision with OpenVINO model fair.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Model config.
+ model (Path): Model on which inference is called.
+ test_dataset (DataLoader): The test dataset used as a reference for the mock dataset.
+ meta_data (Dict): Metadata used for normalization.
+
+ Returns:
+ float: Inference throughput
+ """
+ torch.set_grad_enabled(False)
+ model.eval()
+ inferencer = TorchInferencer(config, model)
+ torch_dataloader = MockImageLoader(config.dataset.image_size, len(test_dataset))
+ start_time = time.time()
+ # Since we don't care about performance metrics and just the throughput, use mock data.
+ for image in torch_dataloader():
+ inferencer.predict(image, superimpose=False, meta_data=meta_data)
+
+ # get throughput
+ inference_time = time.time() - start_time
+ throughput = len(test_dataset) / inference_time
+
+ torch.set_grad_enabled(True)
+ return throughput
+
+
+def get_openvino_throughput(
+ config: Union[DictConfig, ListConfig], model_path: Path, test_dataset: DataLoader, meta_data: Dict
+) -> float:
+ """Runs the generated OpenVINO model on a dummy dataset to get throughput.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Model config.
+ model_path (Path): Path to folder containing the OpenVINO models. It then searches `model.xml` in the folder.
+ test_dataset (DataLoader): The test dataset used as a reference for the mock dataset.
+ meta_data (Dict): Metadata used for normalization.
+
+ Returns:
+ float: Inference throughput
+ """
+ inferencer = OpenVINOInferencer(config, model_path / "model.xml")
+ openvino_dataloader = MockImageLoader(config.dataset.image_size, total_count=len(test_dataset))
+ start_time = time.time()
+ # Create test images on CPU. Since we don't care about performance metrics and just the throughput, use mock data.
+ for image in openvino_dataloader():
+ inferencer.predict(image, superimpose=False, meta_data=meta_data)
+
+ # get throughput
+ inference_time = time.time() - start_time
+ throughput = len(test_dataset) / inference_time
+
+ return throughput
diff --git a/app.py b/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4df4c22e2990d9d8ba615cfc781d379e04f0ced
--- /dev/null
+++ b/app.py
@@ -0,0 +1,126 @@
+"""Anomalib Gradio Script.
+
+This script provide a gradio web interface
+"""
+
+from argparse import ArgumentParser, Namespace
+from importlib import import_module
+from pathlib import Path
+from typing import Tuple, Union
+
+import gradio as gr
+import gradio.inputs
+import gradio.outputs
+import numpy as np
+from omegaconf import DictConfig, ListConfig
+from skimage.segmentation import mark_boundaries
+
+from anomalib.config import get_configurable_parameters
+from anomalib.deploy.inferencers.base import Inferencer
+from anomalib.post_processing import compute_mask, superimpose_anomaly_map
+
+
+def infer(
+ image: np.ndarray, inferencer: Inferencer, threshold: float = 50.0
+) -> Tuple[np.ndarray, float, np.ndarray, np.ndarray, np.ndarray]:
+ """Inference fonction, return anomaly map, score, heat map, prediction mask ans visualisation.
+
+ :param image: image
+ :type image: np.ndarray
+ :param inferencer: model inferencer
+ :type inferencer: Inferencer
+ :param threshold: threshold between 0 and 100, defaults to 50.0
+ :type threshold: float, optional
+ :return: anomaly_map, anomaly_score, heat_map, pred_mask, vis_img
+ :rtype: Tuple[np.ndarray, float, np.ndarray, np.ndarray, np.ndarray]
+ """
+ # Perform inference for the given image.
+ threshold = threshold / 100
+ anomaly_map, anomaly_score = inferencer.predict(image=image, superimpose=False)
+ heat_map = superimpose_anomaly_map(anomaly_map, image)
+ pred_mask = compute_mask(anomaly_map, threshold)
+ vis_img = mark_boundaries(image, pred_mask, color=(1, 0, 0), mode="thick")
+ return anomaly_map, anomaly_score, heat_map, pred_mask, vis_img
+
+
+def get_args() -> Namespace:
+ """Get command line arguments.
+
+ Returns:
+ Namespace: List of arguments.
+ """
+ parser = ArgumentParser()
+ parser.add_argument("--config", type=Path, default="./anomalib/models/padim/config.yaml", required=False, help="Path to a model config file")
+ parser.add_argument("--weight_path", type=Path, default="./results/padim//mvtec/bottle/weights/model.ckpt", required=False, help="Path to a model weights")
+ parser.add_argument("--meta_data", type=Path, required=False, help="Path to JSON file containing the metadata.")
+
+ parser.add_argument(
+ "--threshold",
+ type=float,
+ required=False,
+ default=75.0,
+ help="Value to threshold anomaly scores into 0-100 range",
+ )
+
+ parser.add_argument("--share", type=bool, required=False, default=False, help="Share Gradio `share_url`")
+
+ args = parser.parse_args()
+
+ return args
+
+
+def get_inferencer(gladio_args: Union[DictConfig, ListConfig]) -> Inferencer:
+ """Parse args and open inferencer."""
+ config = get_configurable_parameters(config_path=gladio_args.config)
+ # Get the inferencer. We use .ckpt extension for Torch models and (onnx, bin)
+ # for the openvino models.
+ extension = gladio_args.weight_path.suffix
+ inferencer: Inferencer
+ if extension in (".ckpt"):
+ module = import_module("anomalib.deploy.inferencers.torch")
+ TorchInferencer = getattr(module, "TorchInferencer") # pylint: disable=invalid-name
+ inferencer = TorchInferencer(
+ config=config, model_source=gladio_args.weight_path, meta_data_path=gladio_args.meta_data
+ )
+
+ elif extension in (".onnx", ".bin", ".xml"):
+ module = import_module("anomalib.deploy.inferencers.openvino")
+ OpenVINOInferencer = getattr(module, "OpenVINOInferencer") # pylint: disable=invalid-name
+ inferencer = OpenVINOInferencer(
+ config=config, path=gladio_args.weight_path, meta_data_path=gladio_args.meta_data
+ )
+
+ else:
+ raise ValueError(
+ f"Model extension is not supported. Torch Inferencer exptects a .ckpt file,"
+ f"OpenVINO Inferencer expects either .onnx, .bin or .xml file. Got {extension}"
+ )
+
+ return inferencer
+
+
+if __name__ == "__main__":
+ session_args = get_args()
+
+ gladio_inferencer = get_inferencer(session_args)
+
+ iface = gr.Interface(
+ fn=lambda image, threshold: infer(image, gladio_inferencer, threshold),
+ inputs=[
+ gradio.inputs.Image(
+ shape=None, image_mode="RGB", source="upload", tool="editor", type="numpy", label="Image"
+ ),
+ gradio.inputs.Slider(default=session_args.threshold, label="threshold", optional=False),
+ ],
+ outputs=[
+ gradio.outputs.Image(type="numpy", label="Anomaly Map"),
+ gradio.outputs.Textbox(type="number", label="Anomaly Score"),
+ gradio.outputs.Image(type="numpy", label="Predicted Heat Map"),
+ gradio.outputs.Image(type="numpy", label="Predicted Mask"),
+ gradio.outputs.Image(type="numpy", label="Segmentation Result"),
+ ],
+ title="Anomalib",
+ description="Anomalib Gradio",
+ )
+
+ iface.launch(share=session_args.share)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..5b0859faeec6dff1d7fed12237e0dbe6fad1dd57
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,11 @@
+[build-system]
+requires = ["setuptools>=42", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.isort]
+profile = "black"
+known_first_party = "wandb"
+sections = ['FUTURE', 'STDLIB', 'THIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER']
+
+[tool.black]
+line-length = 120
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..6f74c5e21bfb89c0e4ca8346bf3f65a5bcdfa2e8
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,13 @@
+albumentations>=1.1.0
+einops>=0.3.2
+kornia>=0.5.6
+omegaconf>=2.1.1
+opencv-python>=4.5.3.56
+pandas>=1.1.0
+pytorch-lightning>=1.6.0
+torchmetrics>=0.8.0
+torchvision>=0.9.1
+torchtext>=0.9.1
+wandb==0.12.9
+matplotlib>=3.4.3
+gradio>=2.9.4
diff --git a/requirements/base.txt b/requirements/base.txt
new file mode 100644
index 0000000000000000000000000000000000000000..6f74c5e21bfb89c0e4ca8346bf3f65a5bcdfa2e8
--- /dev/null
+++ b/requirements/base.txt
@@ -0,0 +1,13 @@
+albumentations>=1.1.0
+einops>=0.3.2
+kornia>=0.5.6
+omegaconf>=2.1.1
+opencv-python>=4.5.3.56
+pandas>=1.1.0
+pytorch-lightning>=1.6.0
+torchmetrics>=0.8.0
+torchvision>=0.9.1
+torchtext>=0.9.1
+wandb==0.12.9
+matplotlib>=3.4.3
+gradio>=2.9.4
diff --git a/requirements/dev.txt b/requirements/dev.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0129b1d59f4fe99d1161414911b7b0cda1854fd2
--- /dev/null
+++ b/requirements/dev.txt
@@ -0,0 +1,13 @@
+black==22.3.0
+coverage
+flake8
+flaky
+isort==5.10.1
+mypy
+pytest
+pylint
+pre-commit>=2.15.0
+setuptools
+tox>=3.24.3
+types-PyYAML
+types-setuptools
diff --git a/requirements/docs.txt b/requirements/docs.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d7b01d6bcd036866c3933708972b14a12e6dd7f9
--- /dev/null
+++ b/requirements/docs.txt
@@ -0,0 +1,5 @@
+furo==2021.7.31b41
+myst-parser
+sphinx>=4.1.2
+sphinx-autoapi
+sphinxemoji==0.1.8
diff --git a/requirements/openvino.txt b/requirements/openvino.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e172447abee64b22e4e4ab0897dad511c8fd84bc
--- /dev/null
+++ b/requirements/openvino.txt
@@ -0,0 +1,6 @@
+defusedxml==0.7.1
+requests==2.26.0
+networkx~=2.5
+nncf==2.1.0
+onnx==1.10.1
+openvino-dev==2021.4.2
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..861904a453088d824483b18adadbfd734b61b2c4
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,113 @@
+"""Setup file for anomalib."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+import os
+from importlib.util import module_from_spec, spec_from_file_location
+from typing import List
+
+from setuptools import find_packages, setup
+
+
+def load_module(name: str = "anomalib/__init__.py"):
+ """Load Python Module.
+
+ Args:
+ name (str, optional): Name of the module to load.
+ Defaults to "anomalib/__init__.py".
+
+ Returns:
+ _type_: _description_
+ """
+ location = os.path.join(os.path.dirname(__file__), name)
+ spec = spec_from_file_location(name=name, location=location)
+ module = module_from_spec(spec) # type: ignore
+ spec.loader.exec_module(module) # type: ignore
+ return module
+
+
+def get_version() -> str:
+ """Get version from `anomalib.__init__`.
+
+ Version is stored in the main __init__ module in `anomalib`.
+ The varible storing the version is `__version__`. This function
+ reads `__init__` file, checks `__version__ variable and return
+ the value assigned to it.
+
+ Example:
+ >>> # Assume that __version__ = "0.2.6"
+ >>> get_version()
+ "0.2.6"
+
+ Returns:
+ str: `anomalib` version.
+ """
+ anomalib = load_module(name="anomalib/__init__.py")
+ version = anomalib.__version__
+ return version
+
+
+def get_required_packages(requirement_files: List[str]) -> List[str]:
+ """Get packages from requirements.txt file.
+
+ This function returns list of required packages from requirement files.
+
+ Args:
+ requirement_files (List[str]): txt files that contains list of required
+ packages.
+
+ Example:
+ >>> get_required_packages(requirement_files=["openvino"])
+ ['onnx>=1.8.1', 'networkx~=2.5', 'openvino-dev==2021.4.1', ...]
+
+ Returns:
+ List[str]: List of required packages
+ """
+
+ required_packages: List[str] = []
+
+ for requirement_file in requirement_files:
+ with open(f"requirements/{requirement_file}.txt", "r", encoding="utf8") as file:
+ for line in file:
+ package = line.strip()
+ if package and not package.startswith(("#", "-f")):
+ required_packages.append(package)
+
+ return required_packages
+
+
+VERSION = get_version()
+INSTALL_REQUIRES = get_required_packages(requirement_files=["base"])
+EXTRAS_REQUIRE = {
+ "dev": get_required_packages(requirement_files=["dev", "docs"]),
+ "openvino": get_required_packages(requirement_files=["openvino"]),
+ "full": get_required_packages(requirement_files=["dev", "docs", "openvino"]),
+}
+
+setup(
+ name="anomalib",
+ version=get_version(),
+ author="Intel OpenVINO",
+ author_email="help@openvino.intel.com",
+ description="anomalib - Anomaly Detection Library",
+ url="",
+ license="Copyright (c) Intel - All Rights Reserved. "
+ 'Licensed under the Apache License, Version 2.0 (the "License")'
+ "See LICENSE file for more details.",
+ python_requires=">=3.7",
+ packages=find_packages("."),
+ install_requires=INSTALL_REQUIRES,
+ extras_require=EXTRAS_REQUIRE,
+ package_data={"": ["config.yaml"]},
+)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..756ffd04c915cf63bb91889b5fc1594c797ce895
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,15 @@
+"""Tests."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/helpers/config.py b/tests/helpers/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..df55d4d2af03e58264d1de58adc0d1a8f1627f0f
--- /dev/null
+++ b/tests/helpers/config.py
@@ -0,0 +1,38 @@
+from pathlib import Path
+from typing import List, Optional, Union
+
+from omegaconf import DictConfig, ListConfig
+
+from anomalib.config import get_configurable_parameters
+
+from .dataset import get_dataset_path
+
+
+def get_test_configurable_parameters(
+ dataset_path: Optional[str] = None,
+ model_name: Optional[str] = None,
+ config_path: Optional[Union[Path, str]] = None,
+ weight_file: Optional[str] = None,
+ config_filename: Optional[str] = "config",
+ config_file_extension: Optional[str] = "yaml",
+) -> Union[DictConfig, ListConfig]:
+ """Get configurable parameters for testing.
+
+ Args:
+ datset_path: Optional[Path]: Path to dataset
+ model_name: Optional[str]: (Default value = None)
+ config_path: Optional[Union[Path, str]]: (Default value = None)
+ weight_file: Path to the weight file
+ config_filename: Optional[str]: (Default value = "config")
+ config_file_extension: Optional[str]: (Default value = "yaml")
+
+ Returns:
+ Union[DictConfig, ListConfig]: Configurable parameters in DictConfig object.
+ """
+
+ config = get_configurable_parameters(model_name, config_path, weight_file, config_filename, config_file_extension)
+
+ # Update path to match the dataset path in the test image/runner
+ config.dataset.path = get_dataset_path() if dataset_path is None else dataset_path
+
+ return config
diff --git a/tests/helpers/dataset.py b/tests/helpers/dataset.py
new file mode 100644
index 0000000000000000000000000000000000000000..0afdeedea86d75fa4d3ea4713e0e497066bc7195
--- /dev/null
+++ b/tests/helpers/dataset.py
@@ -0,0 +1,254 @@
+import os
+import shutil
+from contextlib import ContextDecorator
+from functools import wraps
+from pathlib import Path
+from tempfile import mkdtemp
+from typing import Dict, List, Optional, Union
+
+import numpy as np
+from skimage.io import imsave
+
+from .shapes import random_shapes
+
+
+def get_dataset_path(dataset: str = "MVTec") -> str:
+ """Selects path based on tests in local system or docker image.
+
+ Local install assumes datasets are located in anomaly/datasets/.
+ In either case, if the location is empty, the dataset is downloaded again.
+ This speeds up tests in docker images where dataset is already stored in /tmp/anomalib
+
+ Example:
+ Assume that `datasets directory exists in ~/anomalib/,
+
+ >>> get_dataset_path(dataset="MVTec")
+ './datasets/MVTec'
+
+ """
+ # Initially check if `datasets` directory exists locally and look
+ # for the `dataset`. This is useful for local testing.
+ path = os.path.join("./datasets", dataset)
+
+ # For docker deployment or a CI that runs on server, dataset directory
+ # may not necessarily be located in the repo. Therefore, check anomalib
+ # dataset path environment variable.
+ if not os.path.isdir(path):
+ path = os.path.join(os.environ["ANOMALIB_DATASET_PATH"], dataset)
+ return path
+
+
+def generate_random_anomaly_image(
+ image_width: int,
+ image_height: int,
+ shapes: List[str] = ["triangle", "rectangle"],
+ max_shapes: Optional[int] = 5,
+ generate_mask: Optional[bool] = False,
+) -> Dict:
+ """Generate a random image with the corresponding shape entities.
+
+ Args:
+ image_width (int): Width of the image
+ image_height (int): Height of the image
+ shapes (List[str]): List of shapes to draw in the image. Make sure these are different from `anomalous_shapes`
+ max_shapes (int): Maximum shapes of a kind in the image. Defaults to 5.
+ max_size (Optional[int], optional): Maximum size of the test shapes. Defaults to 10.
+ generate_mask (bool): Toggle between train/test split. Train images are restricted to first quadrant.
+ Also generates the mask for the image. Defaults to False.
+ Returns:
+ Tuple: image if `train` is False. Else return image, mask
+ """
+
+ image: np.ndarray = np.full((image_height, image_width, 3), 255, dtype=np.uint8)
+
+ input_region = [0, 0, image_width - 1, image_height - 1]
+
+ for shape in shapes:
+ shape_image = random_shapes(input_region, (image_height, image_width), max_shapes=max_shapes, shape=shape)
+ image = np.minimum(image, shape_image) # since white is 255
+
+ result = {"image": image}
+
+ if generate_mask:
+ mask = np.zeros((image_height, image_width), dtype=np.uint8)
+ # if color exists in any channel turn the mask bit white.
+ # not sure if there is a better way to do this.
+ mask[image[..., 0] < 255] = 255
+ mask[image[..., 1] < 255] = 255
+ mask[image[..., 2] < 255] = 255
+ result["mask"] = mask
+
+ return result
+
+
+class TestDataset:
+ def __init__(
+ self,
+ num_train: int = 1000,
+ num_test: int = 100,
+ img_height: int = 128,
+ img_width: int = 128,
+ max_size: int = 10,
+ train_shapes: List[str] = ["triangle", "rectangle"],
+ test_shapes: List[str] = ["hexagon", "star"],
+ path: Union[str, Path] = "./datasets/MVTec",
+ use_mvtec: bool = False,
+ seed: int = 0,
+ ) -> None:
+ """Creates a context for Generating Dummy Dataset. Useful for wrapping test functions.
+ NOTE: for MVTec AD dataset it does not return a category.
+ It is adviced to use a default parameter in the function
+
+ Args:
+ num_train (int, optional): Number of training images to generate. Defaults to 1000.
+ num_test (int, optional): Number of testing images to generate per category. Defaults to 100.
+ img_height (int, optional): Height of the image. Defaults to 128.
+ img_width (int, optional): Width of the image. Defaults to 128.
+ max_size (Optional[int], optional): Maximum size of the test shapes. Defaults to 10.
+ train_shapes (List[str], optional): List of good shapes. Defaults to ["circle", "rectangle"].
+ test_shapes (List[str], optional): List of anomalous shapes. Defaults to ["triangle", "ellipse"].
+ path (Union[str, Path], optional): Path to MVTec AD dataset. Defaults to "./datasets/MVTec".
+ use_mvtec (bool, optional): Use MVTec AD dataset or dummy dataset. Defaults to False.
+ seed (int, optional): Fixes seed if any number greater than 0 is provided. 0 means no seed. Defaults to 0.
+
+ Example:
+ >>> @TestDataset
+ >>> def test_some_function(path, category="leather"):
+ >>> do something
+ """
+ self.num_train = num_train
+ self.num_test = num_test
+ self.img_height = img_height
+ self.img_width = img_width
+ self.max_size = max_size
+ self.train_shapes = train_shapes
+ self.test_shapes = test_shapes
+ self.path = path
+ self.use_mvtec = use_mvtec
+ self.seed = seed
+
+ def __call__(self, func):
+ @wraps(func)
+ def inner(*args, **kwds):
+ # If true, will use MVTech AD dataset for testing.
+ # Useful for nightly builds
+ if self.use_mvtec:
+ return func(*args, path=self.path, **kwds)
+ else:
+ with GeneratedDummyDataset(
+ num_train=self.num_train,
+ num_test=self.num_test,
+ img_height=self.img_height,
+ img_width=self.img_width,
+ train_shapes=self.train_shapes,
+ test_shapes=self.test_shapes,
+ max_size=self.max_size,
+ seed=self.seed,
+ ) as dataset_path:
+ kwds["category"] = "shapes"
+ return func(*args, path=dataset_path, **kwds)
+
+ return inner
+
+
+class GeneratedDummyDataset(ContextDecorator):
+ """Context for generating dummy shapes dataset.
+ Example:
+ >>> with GeneratedDummyDataset(num_train=1000,num_test=100) as dataset_path:
+ >>> some_function()
+
+ Args:
+ num_train (int, optional): Number of training images to generate. Defaults to 1000.
+ num_test (int, optional): Number of testing images to generate per category. Defaults to 100.
+ img_height (int, optional): Height of the image. Defaults to 128.
+ img_width (int, optional): Width of the image. Defaults to 128.
+ max_size (Optional[int], optional): Maximum size of the test shapes. Defaults to 10.
+ train_shapes (List[str], optional): List of good shapes. Defaults to ["circle", "rectangle"].
+ test_shapes (List[str], optional): List of anomalous shapes. Defaults to ["triangle", "ellipse"].
+ seed (int, optional): Fixes seed if any number greater than 0 is provided. 0 means no seed. Defaults to 0.
+ """
+
+ def __init__(
+ self,
+ num_train: int = 1000,
+ num_test: int = 100,
+ img_height: int = 128,
+ img_width: int = 128,
+ max_size: Optional[int] = 10,
+ train_shapes: List[str] = ["triangle", "rectangle"],
+ test_shapes: List[str] = ["star", "hexagon"],
+ seed: int = 0,
+ ) -> None:
+ self.root_dir = mkdtemp()
+ self.num_train = num_train
+ self.num_test = num_test
+ self.train_shapes = train_shapes
+ self.test_shapes = test_shapes
+ self.image_height = img_height
+ self.image_width = img_width
+ self.max_size = max_size
+ self.seed = seed
+
+ def _generate_dataset(self):
+ """Generates dummy dataset in a temporary directory using the same
+ convention as MVTec AD."""
+ # create train images
+ train_path = os.path.join(self.root_dir, "shapes", "train", "good")
+ os.makedirs(train_path, exist_ok=True)
+ for i in range(self.num_train):
+ result = generate_random_anomaly_image(
+ image_width=self.image_width,
+ image_height=self.image_height,
+ shapes=self.train_shapes,
+ generate_mask=False,
+ )
+ image = result["image"]
+ imsave(os.path.join(train_path, f"{i:03}.png"), image, check_contrast=False)
+
+ # create test images
+ for test_category in self.test_shapes:
+ test_path = os.path.join(self.root_dir, "shapes", "test", test_category)
+ mask_path = os.path.join(self.root_dir, "shapes", "ground_truth", test_category)
+ os.makedirs(test_path, exist_ok=True)
+ os.makedirs(mask_path, exist_ok=True)
+ # anomaly and masks. The idea is to superimpose anomalous shapes on top of correct ones
+ for i in range(self.num_test):
+ correct_shapes = generate_random_anomaly_image(
+ image_width=self.image_width,
+ image_height=self.image_height,
+ shapes=self.train_shapes,
+ generate_mask=False,
+ )
+ result = generate_random_anomaly_image(
+ image_width=self.image_width,
+ image_height=self.image_height,
+ shapes=[test_category],
+ generate_mask=True,
+ )
+ correct_shapes = correct_shapes["image"]
+ image, mask = result["image"], result["mask"]
+ image = np.minimum(image, correct_shapes) # since 255 is white
+ imsave(os.path.join(test_path, f"{i:03}.png"), image, check_contrast=False)
+ imsave(os.path.join(mask_path, f"{i:03}_mask.png"), mask, check_contrast=False)
+ # good test
+ test_good = os.path.join(self.root_dir, "shapes", "test", "good")
+ os.makedirs(test_good, exist_ok=True)
+ for i in range(self.num_test):
+ result = generate_random_anomaly_image(
+ image_width=self.image_width,
+ image_height=self.image_height,
+ shapes=self.train_shapes,
+ )
+ image = result["image"]
+ imsave(os.path.join(test_good, f"{i:03}.png"), image, check_contrast=False)
+
+ def __enter__(self):
+ """Creates the dataset in temp folder."""
+ if self.seed > 0:
+ np.random.seed(self.seed)
+ self._generate_dataset()
+ return self.root_dir
+
+ def __exit__(self, _exc_type, _exc_value, _exc_traceback):
+ """Cleanup the directory."""
+ shutil.rmtree(self.root_dir)
diff --git a/tests/helpers/detection.py b/tests/helpers/detection.py
new file mode 100644
index 0000000000000000000000000000000000000000..10de219e112b834a682e58439e6e0595764e8c54
--- /dev/null
+++ b/tests/helpers/detection.py
@@ -0,0 +1,93 @@
+"""Helpers for detection tests."""
+import os
+import xml.etree.cElementTree as ET # nosec
+from glob import glob
+from typing import List, Tuple
+
+import cv2
+import numpy as np
+
+
+class BBFromMasks:
+ """Creates temporary XML files from masks for testing. Intended to be used
+ as a context so that the XML files are automatically deleted when the
+ execution goes out of scope.
+
+ Example:
+
+ >>> with BBFromMasks(root="/tmp/datasets/MVTec", datast_name="MVTec"):
+ >>> tests_case()
+
+ Args:
+ root (str, optional): Path to the dataset location. Defaults to "datasets/MVTec".
+ dataset_name (str, optional): Name of the dataset to write to the XML file. Defaults to "MVTec".
+ """
+
+ def __init__(self, root: str = "datasets/MVTec", dataset_name: str = "MVTec") -> None:
+ self.root = root
+ self.dataset_name = dataset_name
+ self.generated_xml_files: List[str] = []
+
+ def __enter__(self):
+ """Generate XML files."""
+ for mask_path in glob(os.path.join(self.root, "*/ground_truth/*/*_mask.png")):
+ path_tree = mask_path.split("/")
+ image = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)
+ image = np.array(image, dtype=np.uint8)
+ im_size = image.shape
+ contours, _ = cv2.findContours(image, 1, 1)
+
+ boxes = []
+ for contour in contours:
+ p1 = [np.min(contour[..., 0]), np.min(contour[..., 1])]
+ p2 = [np.max(contour[..., 0]), np.max(contour[..., 1])]
+ boxes.append([p1, p2])
+
+ contents = self._create_xml_contents(boxes, path_tree, im_size)
+ tree = ET.ElementTree(contents)
+ output_loc = "/".join(path_tree[:-1]) + f"/{path_tree[-1].rstrip('_mask.png')}.xml"
+ tree.write(output_loc)
+ # write the xml
+ self.generated_xml_files.append(output_loc)
+
+ def __exit__(self, _exc_type, _exc_value, _exc_traceback):
+ """Cleans up generated XML files."""
+ for file in self.generated_xml_files:
+ os.remove(file)
+
+ def _create_xml_contents(
+ self, boxes: List[List[List[np.int]]], path_tree: List[str], image_size: Tuple[int, int]
+ ) -> ET.Element:
+ """Create the contents of the annotation file in Pascal VOC format.
+
+ Args:
+ boxes (List[List[List[np.int]]]): The calculated pox corners from the masks
+ path_tree (List[str]): The entire filepath of the mask.png image split into a list
+ image_size (Tuple[int, int]): Tuple of image size for writing into annotation
+
+ Returns:
+ ET.Element: annotation root element
+ """
+ annotation = ET.Element("annotation")
+ ET.SubElement(annotation, "folder").text = path_tree[-2]
+ ET.SubElement(annotation, "filename").text = path_tree[-1]
+
+ source = ET.SubElement(annotation, "source")
+ ET.SubElement(source, "database").text = self.dataset_name
+ ET.SubElement(source, "annotation").text = "PASCAL VOC"
+
+ size = ET.SubElement(annotation, "size")
+ ET.SubElement(size, "width").text = str(image_size[0])
+ ET.SubElement(size, "height").text = str(image_size[1])
+ ET.SubElement(size, "depth").text = "1"
+ for box in boxes:
+ object = ET.SubElement(annotation, "object")
+ ET.SubElement(object, "name").text = "anomaly"
+ ET.SubElement(object, "difficult").text = "1"
+ bndbox = ET.SubElement(object, "bndbox")
+ ET.SubElement(bndbox, "xmin").text = str(box[0][0])
+ ET.SubElement(bndbox, "ymin").text = str(box[0][1])
+ ET.SubElement(bndbox, "xmax").text = str(box[1][0])
+ ET.SubElement(bndbox, "ymax").text = str(box[1][1])
+
+ return annotation
diff --git a/tests/helpers/inference.py b/tests/helpers/inference.py
new file mode 100644
index 0000000000000000000000000000000000000000..90701f0576689663703cb44b7f7571386cc1a7ae
--- /dev/null
+++ b/tests/helpers/inference.py
@@ -0,0 +1,81 @@
+"""Utilities to help tests inferencers"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+
+from typing import Dict, Iterable, List, Tuple
+
+import numpy as np
+
+from anomalib.models.components import AnomalyModule
+
+
+class MockImageLoader:
+ """Create mock images for inference on CPU based on the specifics of the original torch test dataset.
+ Uses yield so as to avoid storing everything in the memory.
+ Args:
+ image_size (List[int]): Size of input image
+ total_count (int): Total images in the test dataset
+ """
+
+ def __init__(self, image_size: List[int], total_count: int):
+ self.total_count = total_count
+ self.image_size = image_size
+ self.image = np.ones((*self.image_size, 3)).astype(np.uint8)
+
+ def __len__(self):
+ """Get total count of images."""
+ return self.total_count
+
+ def __call__(self) -> Iterable[np.ndarray]:
+ """Yield batch of generated images.
+ Args:
+ idx (int): Unused
+ """
+ for _ in range(self.total_count):
+ yield self.image
+
+
+def get_meta_data(model: AnomalyModule, input_size: Tuple[int, int]) -> Dict:
+ """Get meta data for inference.
+ Args:
+ model (AnomalyModule): Trained model from which the metadata is extracted.
+ input_size (Tuple[int, int]): Input size used to resize the pixel level mean and std.
+ Returns:
+ (Dict): Metadata as dictionary.
+ """
+ meta_data = {
+ "image_threshold": model.image_threshold.value.cpu().numpy(),
+ "pixel_threshold": model.pixel_threshold.value.cpu().numpy(),
+ "stats": {},
+ }
+
+ image_mean = model.training_distribution.image_mean.cpu().numpy()
+ if image_mean.size > 0:
+ meta_data["stats"]["image_mean"] = image_mean
+
+ image_std = model.training_distribution.image_std.cpu().numpy()
+ if image_std.size > 0:
+ meta_data["stats"]["image_std"] = image_std
+
+ pixel_mean = model.training_distribution.pixel_mean.cpu().numpy()
+ if pixel_mean.size > 0:
+ meta_data["stats"]["pixel_mean"] = pixel_mean.reshape(input_size)
+
+ pixel_std = model.training_distribution.pixel_std.cpu().numpy()
+ if pixel_std.size > 0:
+ meta_data["stats"]["pixel_std"] = pixel_std.reshape(input_size)
+
+ return meta_data
diff --git a/tests/helpers/model.py b/tests/helpers/model.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1c53bc10dc7561ee03d4e2cd3c1481df005e12f
--- /dev/null
+++ b/tests/helpers/model.py
@@ -0,0 +1,148 @@
+"""Common helpers for both nightly and pre-merge model tests."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import os
+from typing import Dict, List, Tuple, Union
+
+import numpy as np
+from omegaconf import DictConfig, ListConfig
+from pytorch_lightning import LightningDataModule, Trainer
+from pytorch_lightning.callbacks import ModelCheckpoint
+
+from anomalib.config import get_configurable_parameters, update_nncf_config
+from anomalib.data import get_datamodule
+from anomalib.models import get_model
+from anomalib.models.components import AnomalyModule
+from anomalib.utils.callbacks import VisualizerCallback, get_callbacks
+
+
+def setup_model_train(
+ model_name: str,
+ dataset_path: str,
+ project_path: str,
+ nncf: bool,
+ category: str,
+ score_type: str = None,
+ weight_file: str = "weights/model.ckpt",
+ fast_run: bool = False,
+ device: Union[List[int], int] = [0],
+) -> Tuple[Union[DictConfig, ListConfig], LightningDataModule, AnomalyModule, Trainer]:
+ """Train the model based on the parameters passed.
+
+ Args:
+ model_name (str): Name of the model to train.
+ dataset_path (str): Location of the dataset.
+ project_path (str): Path to temporary project folder.
+ nncf (bool): Add nncf callback.
+ category (str): Category to train on.
+ score_type (str, optional): Only used for DFM. Defaults to None.
+ weight_file (str, optional): Path to weight file.
+ fast_run (bool, optional): If set to true, the model trains for only 1 epoch. We train for one epoch as
+ this ensures that both anomalous and non-anomalous images are present in the validation step.
+ device (List[int], int, optional): Select which device you want to train the model on. Defaults to first GPU.
+
+ Returns:
+ Tuple[DictConfig, LightningDataModule, AnomalyModule, Trainer]: config, datamodule, trained model, trainer
+ """
+ config = get_configurable_parameters(model_name=model_name)
+ if score_type is not None:
+ config.model.score_type = score_type
+ config.project.seed = 42
+ config.dataset.category = category
+ config.dataset.path = dataset_path
+ config.project.log_images_to = []
+ config.trainer.gpus = device
+
+ # If weight file is empty, remove the key from config
+ if "weight_file" in config.model.keys() and weight_file == "":
+ config.model.pop("weight_file")
+ else:
+ config.model.weight_file = weight_file if not fast_run else "weights/last.ckpt"
+
+ if nncf:
+ config.optimization.nncf.apply = True
+ config = update_nncf_config(config)
+ config.init_weights = None
+
+ # reassign project path as config is updated in `update_config_for_nncf`
+ config.project.path = project_path
+
+ datamodule = get_datamodule(config)
+ model = get_model(config)
+
+ callbacks = get_callbacks(config)
+
+ # Force model checkpoint to create checkpoint after first epoch
+ if fast_run == True:
+ for index, callback in enumerate(callbacks):
+ if isinstance(callback, ModelCheckpoint):
+ callbacks.pop(index)
+ break
+ model_checkpoint = ModelCheckpoint(
+ dirpath=os.path.join(config.project.path, "weights"),
+ filename="last",
+ monitor=None,
+ mode="max",
+ save_last=True,
+ auto_insert_metric_name=False,
+ )
+ callbacks.append(model_checkpoint)
+
+ for index, callback in enumerate(callbacks):
+ if isinstance(callback, VisualizerCallback):
+ callbacks.pop(index)
+ break
+
+ # Train the model.
+ if fast_run:
+ config.trainer.max_epochs = 1
+ config.trainer.check_val_every_n_epoch = 1
+
+ trainer = Trainer(callbacks=callbacks, **config.trainer)
+ trainer.fit(model=model, datamodule=datamodule)
+ return config, datamodule, model, trainer
+
+
+def model_load_test(config: Union[DictConfig, ListConfig], datamodule: LightningDataModule, results: Dict):
+ """Create a new model based on the weights specified in config.
+
+ Args:
+ config ([Union[DictConfig, ListConfig]): Model config.
+ datamodule (LightningDataModule): Dataloader
+ results (Dict): Results from original model.
+
+ """
+ loaded_model = get_model(config) # get new model
+
+ callbacks = get_callbacks(config)
+
+ for index, callback in enumerate(callbacks):
+ # Remove visualizer callback as saving results takes time
+ if isinstance(callback, VisualizerCallback):
+ callbacks.pop(index)
+ break
+
+ # create new trainer object with LoadModel callback (assumes it is present)
+ trainer = Trainer(callbacks=callbacks, **config.trainer)
+ # Assumes the new model has LoadModel callback and the old one had ModelCheckpoint callback
+ new_results = trainer.test(model=loaded_model, datamodule=datamodule)[0]
+ assert np.isclose(
+ results["image_AUROC"], new_results["image_AUROC"]
+ ), "Loaded model does not yield close performance results"
+ if config.dataset.task == "segmentation":
+ assert np.isclose(
+ results["pixel_AUROC"], new_results["pixel_AUROC"]
+ ), "Loaded model does not yield close performance results"
diff --git a/tests/helpers/shapes.py b/tests/helpers/shapes.py
new file mode 100644
index 0000000000000000000000000000000000000000..b013fa127328350d278aaa693f8be929435fcb6d
--- /dev/null
+++ b/tests/helpers/shapes.py
@@ -0,0 +1,210 @@
+from typing import List, Tuple
+
+import numpy as np
+from skimage.draw import polygon
+
+
+def random_square_patch(input_region: List[int], min_width: int = 10) -> List[int]:
+ """Gets a random patch in the input region.
+
+ Args:
+ input_region (List[int]): Coordinates of the input region. [x1, y1, x2, y2]
+ min_width (int): Minimum width of the returned patch.
+ Example:
+ >>> image = np.zeros((200,200,3))
+ >>> x1, y1, x2, y2 = random_square_patch([100,100,200,200])
+ >>> patched = image.copy()
+ >>> patched[y1:y2, x1:x2, :] = 1
+ >>> plt.imshow(patched)
+
+ Returns:
+ List[int]: Random square patch [x1, y1, x2, y2]
+ """
+ x1_i, y1_i, x2_i, y2_i = input_region
+ cx, cy = np.random.randint(x1_i, x2_i), np.random.randint(y1_i, y2_i)
+ shortest_dim = min(x2_i - x1_i, y2_i - y1_i)
+ # make sure that shortest_dim is larger than min_width
+ shortest_dim = max(shortest_dim, min_width + 1)
+ rand_half_width = np.random.randint(min_width, shortest_dim) // 2
+ x1, y1, x2, y2 = cx - rand_half_width, cy - rand_half_width, cx + rand_half_width, cy + rand_half_width
+
+ # border check
+ if x1 < 0:
+ x1 = 0
+ x2 = 2 * rand_half_width
+ elif x2 > x2_i:
+ x2 = x2_i
+ x1 = x2_i - 2 * rand_half_width
+
+ if y1 < 0:
+ y1 = 0
+ y2 = 2 * rand_half_width
+ elif y2 > y2_i:
+ y2 = y2_i
+ y1 = y2_i - 2 * rand_half_width
+
+ return [x1, y1, x2, y2]
+
+
+def triangle(input_region: List[int]) -> Tuple[List[int], List[int]]:
+ """Get coordinates of points inside a triangle.
+
+ Args:
+ input_region (List[int]): Region in which to draw the triangle. [x1, y1, x2, y2]
+ Example:
+ >>> image = np.full((200,200,3),fill_value=255, dtype=np.uint8)
+ >>> patch_region = random_square_patch([100, 100, 200, 200])
+ >>> xx, yy = triangle(patch_region)
+ >>> patched = image.copy()
+ >>> patched[yy, xx, :] = 1
+ >>> plt.imshow(patched)
+ Returns:
+ Tuple[List[int], List[int]]: Array of cols and rows which denote the mask.
+ """
+ x1_i, y1_i, x2_i, y2_i = input_region
+
+ x1, y1 = x1_i + (x2_i - x1_i) // 2, y1_i
+ x2, y2 = x1_i, y2_i
+ x3, y3 = x2_i, y2_i
+ return polygon([x1, x2, x3], [y1, y2, y3])
+
+
+def rectangle(input_region: List[int], min_side: int = 10) -> Tuple[List[int], List[int]]:
+ """Get coordinates of corners of a rectangle. Only vertical rectangles are
+ generated.
+
+ Args:
+ input_region (List[int]): Region in which to draw the rectangle. [x1, y1, x2, y2]
+ min_side (int, optional): Minimum side of the rectangle. Defaults to 10.
+ Example:
+ >>> image = np.full((200,200,3),fill_value=255, dtype=np.uint8)
+ >>> patch_region = random_square_patch([100, 100, 200, 200])
+ >>> x1, y1, x2, y2 = rectangle(patch_region)
+ >>> patched = image.copy()
+ >>> patched[y1:y2, x1:x2, :] = 1
+ >>> plt.imshow(patched)
+ Returns:
+ Tuple[List[int], List[int]]: Random rectangle region. [x1, y1, x2, y2]
+ """
+ x1_i, y1, x2_i, y2 = input_region
+ shortest_dim = min(x2_i - x1_i, y2 - y1)
+ # make sure that shortest_dim is larger than min_side
+ shortest_dim = max(shortest_dim, min_side + 1)
+ cx = (x2_i - x1_i) // 2
+ rand_half_width = np.random.randint(min_side, shortest_dim) // 2
+ x1 = cx - rand_half_width
+ x2 = cx + rand_half_width
+
+ xs = np.arange(x1, x2, 1)
+ ys = np.arange(y1, y2, 1)
+
+ yy, xx = np.meshgrid(ys, xs, sparse=True)
+
+ return xx, yy
+
+
+def hexagon(input_region: List[int]) -> Tuple[List[int], List[int]]:
+ """Get coordinates of points inside a hexagon.
+
+ Args:
+ input_region (List[int]): Region in which to draw the hexagon. [x1, y1, x2, y2]
+ Example:
+ >>> image = np.full((200,200,3),fill_value=255, dtype=np.uint8)
+ >>> patch_region = random_square_patch([100, 100, 200, 200])
+ >>> xx, yy = hexagon(patch_region)
+ >>> patched = image.copy()
+ >>> patched[yy, xx, :] = 1
+ >>> plt.imshow(patched)
+ Returns:
+ Tuple[List[int], List[int]]: Array of cols and rows which denote the mask.
+ """
+ x1_i, y1_i, x2_i, _ = input_region
+
+ cx = (x2_i - x1_i) // 2
+ hex_half_side = (x2_i - x1_i) // 4 # assume side of hexagon to be 1/2 of the square size
+
+ x1, y1 = x1_i + hex_half_side, y1_i
+ x2, y2 = x1_i + cx + hex_half_side, y1_i
+ x3, y3 = x2_i, y1_i + int(1.732 * hex_half_side) # 2cos(30)
+ x4, y4 = x1_i + cx + hex_half_side, y1_i + int(3.4641 * hex_half_side) # 4 * cos(30)
+ x5, y5 = x1_i + hex_half_side, y1_i + int(3.4641 * hex_half_side) # 4 * cos(30)
+ x6, y6 = x1_i, y1_i + int(1.732 * hex_half_side)
+ return polygon([x1, x2, x3, x4, x5, x6], [y1, y2, y3, y4, y5, y6])
+
+
+def star(input_region: List[int]) -> Tuple[List[int], List[int]]:
+ """Get coordinates of points inside a star.
+
+ Args:
+ input_region (List[int]): Region in which to draw the star. [x1, y1, x2, y2]
+ Example:
+ >>> image = np.full((200,200,3),fill_value=255, dtype=np.uint8)
+ >>> patch_region = random_square_patch([100, 100, 200, 200])
+ >>> xx, yy = star(patch_region)
+ >>> patched = image.copy()
+ >>> patched[yy, xx, :] = 1
+ >>> plt.imshow(patched)
+ Returns:
+ Tuple[List[int], List[int]]: Array of cols and rows which denote the mask.
+ """
+ x1_i, y1_i, x2_i, y2_i = input_region
+
+ outer_dim = (x2_i - x1_i) // 2
+ inner_dim = (x2_i - x1_i) // 4
+
+ cx = x1_i + (x2_i - x1_i) // 2
+ cy = y1_i + (y2_i - y1_i) // 2
+ x1, y1 = cx + int(outer_dim * np.cos(0.314159)), cy + int(outer_dim * np.sin(0.314159))
+ x2, y2 = cx + int(inner_dim * np.cos(0.942478)), cy + int(inner_dim * np.sin(0.942478))
+
+ x3, y3 = cx + int(outer_dim * np.cos(1.5708)), cy + int(outer_dim * np.sin(1.5708))
+ x4, y4 = cx + int(inner_dim * np.cos(2.19911)), cy + int(inner_dim * np.sin(2.19911))
+
+ x5, y5 = cx + int(outer_dim * np.cos(2.82743)), cy + int(outer_dim * np.sin(2.82743))
+ x6, y6 = cx + int(inner_dim * np.cos(3.45575)), cy + int(inner_dim * np.sin(3.45575))
+
+ x7, y7 = cx + int(outer_dim * np.cos(4.08407)), cy + int(outer_dim * np.sin(4.08407))
+ x8, y8 = cx, cy - inner_dim
+
+ x9, y9 = cx + int(outer_dim * np.cos(5.34071)), cy + int(outer_dim * np.sin(5.34071))
+ x10, y10 = cx + int(inner_dim * np.cos(5.96903)), cy + int(inner_dim * np.sin(5.96903))
+ print([x1, x2, x3, x4, x5, x6, x7, x8, x9, x10], [y1, y2, y3, y4, y5, y6, y7, y8, y9, y10])
+
+ return polygon([x1, x2, x3, x4, x5, x6, x7, x8, x9, x10], [y1, y2, y3, y4, y5, y6, y7, y8, y9, y10])
+
+
+def random_shapes(
+ input_region: List[int], size: Tuple[int, int], max_shapes: int, shape: str = "rectangle"
+) -> np.ndarray:
+ """Generate image with random shape.
+
+ Args:
+ input_region (List[int]): Coordinates of the input region. [x1, y1, x2, y2]
+ size (Tuple[int, int]): Size of the input image
+ max_shapes (int): Maximum number of shapes of a certain kind to draw
+ shape (str): Name of the shape. Defaults to rectangle
+ Returns:
+ np.ndarray: Image containing the shape
+ """
+ shape_fn: Tuple[List[int], List[int]]
+ if shape == "rectangle":
+ shape_fn = rectangle
+ elif shape == "triangle":
+ shape_fn = triangle
+ elif shape == "hexagon":
+ shape_fn = hexagon
+ elif shape == "star":
+ shape_fn = star
+ else:
+ raise ValueError(f"Shape function {shape} not supported!")
+
+ shape_image: np.ndarray = np.full((*size, 3), fill_value=255, dtype=np.uint8)
+ for _ in range(max_shapes):
+ image = np.full((*size, 3), fill_value=255, dtype=np.uint8)
+ patch_region = random_square_patch(input_region)
+ xx, yy = shape_fn(patch_region)
+ # assign random colour
+ image[yy, xx, :] = (np.random.randint(0, 255), np.random.randint(0, 255), np.random.randint(0, 255))
+ shape_image = np.minimum(image, shape_image) # since 255 is max
+
+ return shape_image
diff --git a/tests/nightly/deploy/__init__.py b/tests/nightly/deploy/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/nightly/deploy/test_inferencer.py b/tests/nightly/deploy/test_inferencer.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e41e9608a334a36f074f257edd77981c1164530
--- /dev/null
+++ b/tests/nightly/deploy/test_inferencer.py
@@ -0,0 +1,118 @@
+"""Tests for Torch and OpenVINO inferencers."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import os
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Union
+
+import pytest
+import torch
+from omegaconf import DictConfig, ListConfig
+from pytorch_lightning import Trainer
+
+from anomalib.config import get_configurable_parameters
+from anomalib.data import get_datamodule
+from anomalib.deploy import OpenVINOInferencer, TorchInferencer, export_convert
+from anomalib.models import get_model
+from tests.helpers.dataset import TestDataset, get_dataset_path
+from tests.helpers.inference import MockImageLoader, get_meta_data
+
+
+def get_model_config(
+ model_name: str, project_path: str, dataset_path: str, category: str
+) -> Union[DictConfig, ListConfig]:
+ model_config = get_configurable_parameters(model_name=model_name)
+ model_config.project.path = project_path
+ model_config.dataset.path = dataset_path
+ model_config.dataset.category = category
+ model_config.trainer.max_epochs = 1
+ return model_config
+
+
+class TestInferencers:
+ @pytest.mark.parametrize(
+ "model_name",
+ ["padim", "stfpm", "patchcore", "dfm", "dfkde", "ganomaly", "cflow"],
+ )
+ @TestDataset(num_train=20, num_test=1, path=get_dataset_path(), use_mvtec=False)
+ def test_torch_inference(self, model_name: str, category: str = "shapes", path: str = "./datasets/MVTec"):
+ """Tests Torch inference.
+ Model is not trained as this checks that the inferencers are working.
+ Args:
+ model_name (str): Name of the model
+ """
+ with TemporaryDirectory() as project_path:
+ model_config = get_model_config(
+ model_name=model_name, dataset_path=path, category=category, project_path=project_path
+ )
+
+ model = get_model(model_config)
+ trainer = Trainer(logger=False, **model_config.trainer)
+ datamodule = get_datamodule(model_config)
+
+ trainer.fit(model=model, datamodule=datamodule)
+
+ model.eval()
+
+ # Test torch inferencer
+ torch_inferencer = TorchInferencer(model_config, model)
+ torch_dataloader = MockImageLoader(model_config.dataset.image_size, total_count=1)
+ meta_data = get_meta_data(model, model_config.dataset.image_size)
+ with torch.no_grad():
+ for image in torch_dataloader():
+ torch_inferencer.predict(image, superimpose=False, meta_data=meta_data)
+
+ @pytest.mark.parametrize(
+ "model_name",
+ [
+ "padim",
+ "stfpm",
+ "dfm",
+ "ganomaly",
+ ],
+ )
+ @TestDataset(num_train=20, num_test=1, path=get_dataset_path(), use_mvtec=False)
+ def test_openvino_inference(self, model_name: str, category: str = "shapes", path: str = "./datasets/MVTec"):
+ """Tests OpenVINO inference.
+ Model is not trained as this checks that the inferencers are working.
+ Args:
+ model_name (str): Name of the model
+ """
+ with TemporaryDirectory() as project_path:
+ model_config = get_model_config(
+ model_name=model_name, dataset_path=path, category=category, project_path=project_path
+ )
+ export_path = Path(project_path)
+
+ model = get_model(model_config)
+ trainer = Trainer(logger=False, **model_config.trainer)
+ datamodule = get_datamodule(model_config)
+ trainer.fit(model=model, datamodule=datamodule)
+
+ export_convert(
+ model=model,
+ input_size=model_config.dataset.image_size,
+ onnx_path=export_path / "model.onnx",
+ export_path=export_path,
+ )
+
+ # Test OpenVINO inferencer
+ openvino_inferencer = OpenVINOInferencer(model_config, export_path / "model.xml")
+ openvino_dataloader = MockImageLoader(model_config.dataset.image_size, total_count=1)
+ meta_data = get_meta_data(model, model_config.dataset.image_size)
+ for image in openvino_dataloader():
+ openvino_inferencer.predict(image, superimpose=False, meta_data=meta_data)
diff --git a/tests/nightly/models/__init__.py b/tests/nightly/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/nightly/models/performance_thresholds.yaml b/tests/nightly/models/performance_thresholds.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..baae1865c6834d7bafab8b48561a3cc124338ed7
--- /dev/null
+++ b/tests/nightly/models/performance_thresholds.yaml
@@ -0,0 +1,328 @@
+padim:
+ bottle:
+ image_AUROC: 0.993
+ pixel_AUROC: 0.983
+ cable:
+ image_AUROC: 0.830
+ pixel_AUROC: 0.947
+ capsule:
+ image_AUROC: 0.860
+ pixel_AUROC: 0.984
+ carpet:
+ image_AUROC: 0.945
+ pixel_AUROC: 0.983
+ grid:
+ image_AUROC: 0.857
+ pixel_AUROC: 0.917
+ hazelnut:
+ image_AUROC: 0.750
+ pixel_AUROC: 0.977
+ leather:
+ image_AUROC: 0.982
+ pixel_AUROC: 0.991
+ metal_nut:
+ image_AUROC: 0.961
+ pixel_AUROC: 0.969
+ pill:
+ image_AUROC: 0.851
+ pixel_AUROC: 0.956
+ screw:
+ image_AUROC: 0.758
+ pixel_AUROC: 0.975
+ tile:
+ image_AUROC: 0.943
+ pixel_AUROC: 0.933
+ toothbrush:
+ image_AUROC: 0.844
+ pixel_AUROC: 0.988
+ transistor:
+ image_AUROC: 0.919
+ pixel_AUROC: 0.967
+ wood:
+ image_AUROC: 0.976
+ pixel_AUROC: 0.940
+ zipper:
+ image_AUROC: 0.738
+ pixel_AUROC: 0.970
+
+dfkde:
+ bottle:
+ image_AUROC: 0.969
+ cable:
+ image_AUROC: 0.671
+ capsule:
+ image_AUROC: 0.781
+ carpet:
+ image_AUROC: 0.621
+ grid:
+ image_AUROC: 0.494
+ hazelnut:
+ image_AUROC: 0.786
+ leather:
+ image_AUROC: 0.757
+ metal_nut:
+ image_AUROC: 0.759
+ pill:
+ image_AUROC: 0.642
+ screw:
+ image_AUROC: 0.681
+ tile:
+ image_AUROC: 0.965
+ toothbrush:
+ image_AUROC: 0.830
+ transistor:
+ image_AUROC: 0.799
+ wood:
+ image_AUROC: 0.850
+ zipper:
+ image_AUROC: 0.857
+
+dfm:
+ bottle:
+ image_AUROC: 0.996
+ cable:
+ image_AUROC: 0.926
+ capsule:
+ image_AUROC: 0.876
+ carpet:
+ image_AUROC: 0.846
+ grid:
+ image_AUROC: 0.496
+ hazelnut:
+ image_AUROC: 0.984
+ leather:
+ image_AUROC: 0.931
+ metal_nut:
+ image_AUROC: 0.946
+ pill:
+ image_AUROC: 0.816
+ screw:
+ image_AUROC: 0.760
+ tile:
+ image_AUROC: 0.980
+ toothbrush:
+ image_AUROC: 0.963
+ transistor:
+ image_AUROC: 0.935
+ wood:
+ image_AUROC: 0.957
+ zipper:
+ image_AUROC: 0.945
+
+stfpm:
+ bottle:
+ image_AUROC: 0.857
+ pixel_AUROC: 0.962
+ nncf:
+ image_AUROC: 0.902
+ pixel_AUROC: 0.512
+ cable:
+ image_AUROC: 0.939
+ pixel_AUROC: 0.943
+ nncf:
+ image_AUROC: 0.873
+ pixel_AUROC: 0.936
+ capsule:
+ image_AUROC: 0.624
+ pixel_AUROC: 0.955
+ nncf:
+ image_AUROC: 0.576
+ pixel_AUROC: 0.950
+ carpet:
+ image_AUROC: 0.985
+ pixel_AUROC: 0.986
+ nncf:
+ image_AUROC: 0.980
+ pixel_AUROC: 0.985
+ grid:
+ image_AUROC: 0.974
+ pixel_AUROC: 0.985
+ nncf:
+ image_AUROC: 0.984
+ pixel_AUROC: 0.987
+ hazelnut:
+ image_AUROC: 0.978
+ pixel_AUROC: 0.976
+ nncf:
+ image_AUROC: 0.929
+ pixel_AUROC: 0.966
+ leather:
+ image_AUROC: 0.995
+ pixel_AUROC: 0.983
+ nncf:
+ image_AUROC: 0.889
+ pixel_AUROC: 0.970
+ metal_nut:
+ image_AUROC: 0.978
+ pixel_AUROC: 0.969
+ nncf:
+ image_AUROC: 0.729
+ pixel_AUROC: 0.924
+ pill:
+ image_AUROC: 0.584
+ pixel_AUROC: 0.902
+ nncf:
+ image_AUROC: 0.505
+ pixel_AUROC: 0.877
+ screw:
+ image_AUROC: 0.375
+ pixel_AUROC: 0.949
+ nncf:
+ image_AUROC: 0.409
+ pixel_AUROC: 0.936
+ tile:
+ image_AUROC: 0.955
+ pixel_AUROC: 0.959
+ nncf:
+ image_AUROC: 0.944
+ pixel_AUROC: 0.940
+ toothbrush:
+ image_AUROC: 0.491
+ pixel_AUROC: 0.170
+ nncf:
+ image_AUROC: 0.791
+ pixel_AUROC: 0.956
+ transistor:
+ image_AUROC: 0.746
+ pixel_AUROC: 0.759
+ nncf:
+ image_AUROC: 0.798
+ pixel_AUROC: 0.759
+ wood:
+ image_AUROC: 0.989
+ pixel_AUROC: 0.948
+ nncf:
+ image_AUROC: 0.978
+ pixel_AUROC: 0.951
+ zipper:
+ image_AUROC: 0.837
+ pixel_AUROC: 0.982
+ nncf:
+ image_AUROC: 0.835
+ pixel_AUROC: 0.963
+
+patchcore:
+ bottle:
+ image_AUROC: 1.0
+ pixel_AUROC: 0.984
+ cable:
+ image_AUROC: 0.992
+ pixel_AUROC: 0.987
+ capsule:
+ image_AUROC: 0.976
+ pixel_AUROC: 0.987
+ carpet:
+ image_AUROC: 0.978
+ pixel_AUROC: 0.988
+ grid:
+ image_AUROC: 0.961
+ pixel_AUROC: 0.868
+ hazelnut:
+ image_AUROC: 1.0
+ pixel_AUROC: 0.987
+ leather:
+ image_AUROC: 1.0
+ pixel_AUROC: 0.990
+ metal_nut:
+ image_AUROC: 0.994
+ pixel_AUROC: 0.989
+ pill:
+ image_AUROC: 0.925
+ pixel_AUROC: 0.980
+ screw:
+ image_AUROC: 0.928
+ pixel_AUROC: 0.989
+ tile:
+ image_AUROC: 1.0
+ pixel_AUROC: 0.960
+ toothbrush:
+ image_AUROC: 0.947
+ pixel_AUROC: 0.988
+ transistor:
+ image_AUROC: 0.999
+ pixel_AUROC: 0.981
+ wood:
+ image_AUROC: 0.989
+ pixel_AUROC: 0.934
+ zipper:
+ image_AUROC: 0.981
+ pixel_AUROC: 0.983
+
+cflow:
+ bottle:
+ image_AUROC: 1.0
+ pixel_AUROC: 0.980
+ cable:
+ image_AUROC: 0.960
+ pixel_AUROC: 0.965
+ capsule:
+ image_AUROC: 0.948
+ pixel_AUROC: 0.987
+ carpet:
+ image_AUROC: 0.979
+ pixel_AUROC: 0.985
+ grid:
+ image_AUROC: 0.959
+ pixel_AUROC: 0.965
+ hazelnut:
+ image_AUROC: 0.999
+ pixel_AUROC: 0.986
+ leather:
+ image_AUROC: 1.0
+ pixel_AUROC: 0.994
+ metal_nut:
+ image_AUROC: 0.984
+ pixel_AUROC: 0.981
+ pill:
+ image_AUROC: 0.933
+ pixel_AUROC: 0.986
+ screw:
+ image_AUROC: 0.766
+ pixel_AUROC: 0.981
+ tile:
+ image_AUROC: 0.996
+ pixel_AUROC: 0.965
+ toothbrush:
+ image_AUROC: 0.880
+ pixel_AUROC: 0.984
+ transistor:
+ image_AUROC: 0.949
+ pixel_AUROC: 0.930
+ wood:
+ image_AUROC: 0.942
+ pixel_AUROC: 0.926
+ zipper:
+ image_AUROC: 0.979
+ pixel_AUROC: 0.979
+
+ganomaly:
+ bottle:
+ image_AUROC: 0.270
+ cable:
+ image_AUROC: 0.488
+ capsule:
+ image_AUROC: 0.311
+ carpet:
+ image_AUROC: 0.211
+ grid:
+ image_AUROC: 0.446
+ hazelnut:
+ image_AUROC: 0.497
+ leather:
+ image_AUROC: 0.409
+ metal_nut:
+ image_AUROC: 0.277
+ pill:
+ image_AUROC: 0.390
+ screw:
+ image_AUROC: 0.336
+ tile:
+ image_AUROC: 0.555
+ toothbrush:
+ image_AUROC: 0.349
+ transistor:
+ image_AUROC: 0.348
+ wood:
+ image_AUROC: 0.617
+ zipper:
+ image_AUROC: 0.389
diff --git a/tests/nightly/models/test_model_nightly.py b/tests/nightly/models/test_model_nightly.py
new file mode 100644
index 0000000000000000000000000000000000000000..30dfda6da1e86aeb6a1f854f3f03f8a14d341f5b
--- /dev/null
+++ b/tests/nightly/models/test_model_nightly.py
@@ -0,0 +1,183 @@
+"""Test Models on all MVTec AD Categories."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import itertools
+import math
+import multiprocessing
+import random
+import tempfile
+from concurrent.futures import ProcessPoolExecutor
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Union
+
+import numpy as np
+import pandas as pd
+import torch
+from omegaconf import DictConfig, ListConfig, OmegaConf
+from pytorch_lightning import seed_everything
+
+from anomalib.utils.sweep.config import flatten_sweep_params
+from tests.helpers.dataset import get_dataset_path
+from tests.helpers.model import model_load_test, setup_model_train
+
+
+def get_model_nncf_cat() -> List:
+ """Test helper for getting cartesian product of models and categories.
+
+ Returns:
+ List: Returns a combination of models with their nncf support for each category.
+ """
+ model_support = [
+ ("padim", False),
+ ("dfkde", False),
+ ("dfm", False),
+ ("stfpm", False),
+ # ("stfpm", True),
+ ("patchcore", False),
+ ("cflow", False),
+ ("ganomaly", False),
+ ]
+ categories = random.sample(
+ [
+ "bottle",
+ "cable",
+ "capsule",
+ "carpet",
+ "grid",
+ "hazelnut",
+ "leather",
+ "metal_nut",
+ "pill",
+ "screw",
+ "tile",
+ "toothbrush",
+ "transistor",
+ "wood",
+ "zipper",
+ ],
+ k=3,
+ )
+
+ return [
+ (model, nncf, category) for ((model, nncf), category) in list(itertools.product(*[model_support, categories]))
+ ]
+
+
+class TestModel:
+ """Run Model on all categories."""
+
+ def _test_metrics(self, trainer, config, model, datamodule):
+ """Tests the model metrics but also acts as a setup."""
+
+ results = trainer.test(model=model, datamodule=datamodule)[0]
+
+ thresholds = OmegaConf.load("tests/nightly/models/performance_thresholds.yaml")
+
+ threshold = thresholds[config.model.name][config.dataset.category]
+ if "optimization" in config.keys() and config.optimization.nncf.apply:
+ threshold = threshold.nncf
+ if not (
+ np.isclose(results["image_AUROC"], threshold["image_AUROC"], rtol=0.02)
+ or (results["image_AUROC"] >= threshold["image_AUROC"])
+ ):
+ raise AssertionError(
+ f"results['image_AUROC']:{results['image_AUROC']} >= threshold['image_AUROC']:{threshold['image_AUROC']}"
+ )
+
+ if config.dataset.task == "segmentation":
+ if not (
+ np.isclose(results["pixel_AUROC"], threshold["pixel_AUROC"], rtol=0.02)
+ or (results["pixel_AUROC"] >= threshold["pixel_AUROC"])
+ ):
+ raise AssertionError(
+ f"results['pixel_AUROC']:{results['pixel_AUROC']} >= threshold['pixel_AUROC']:{threshold['pixel_AUROC']}"
+ )
+ return results
+
+ def _save_to_csv(self, config: Union[DictConfig, ListConfig], results: Dict):
+ """Save model results to csv. Useful for tracking model drift.
+
+ Args:
+ config (Union[DictConfig, ListConfig]): Model config which is also added to csv for complete picture.
+ results (Dict): Metrics from trainer.test
+ """
+ # Save results in csv for tracking model drift
+ model_metrics = flatten_sweep_params(config)
+ # convert dict, list values to string
+ for key, val in model_metrics.items():
+ if isinstance(val, (list, dict, ListConfig, DictConfig)):
+ model_metrics[key] = str(val)
+ for metric, value in results.items():
+ model_metrics[metric] = value
+ model_metrics_df = pd.DataFrame([model_metrics])
+
+ result_path = Path(f"tests/artifacts/{datetime.now().strftime('%m_%d_%Y')}.csv")
+ result_path.parent.mkdir(parents=True, exist_ok=True)
+ if not result_path.is_file():
+ model_metrics_df.to_csv(result_path)
+ else:
+ model_metrics_df.to_csv(result_path, mode="a", header=False)
+
+ def runner(self, run_configs, path, score_type, device_id):
+ for model_name, nncf, category in run_configs:
+ try:
+ with tempfile.TemporaryDirectory() as project_path:
+ # Fix seed
+ seed_everything(42, workers=True)
+ config, datamodule, model, trainer = setup_model_train(
+ model_name=model_name,
+ dataset_path=path,
+ nncf=nncf,
+ project_path=project_path,
+ category=category,
+ score_type=score_type,
+ device=[device_id],
+ )
+
+ # test model metrics
+ results = self._test_metrics(trainer=trainer, config=config, model=model, datamodule=datamodule)
+
+ # test model load
+ model_load_test(config=config, datamodule=datamodule, results=results)
+
+ self._save_to_csv(config, results)
+ except AssertionError as assertion_error:
+ raise Exception(f"Model: {model_name} NNCF:{nncf} Category:{category}") from assertion_error
+
+ def test_model(self, path=get_dataset_path(), score_type=None):
+ run_configs = get_model_nncf_cat()
+ with ProcessPoolExecutor(
+ max_workers=torch.cuda.device_count(), mp_context=multiprocessing.get_context("spawn")
+ ) as executor:
+ jobs = []
+ for device_id, run_split in enumerate(
+ range(0, len(run_configs), math.ceil(len(run_configs) / torch.cuda.device_count()))
+ ):
+ jobs.append(
+ executor.submit(
+ self.runner,
+ run_configs[run_split : run_split + math.ceil(len(run_configs) / torch.cuda.device_count())],
+ path,
+ score_type,
+ device_id,
+ )
+ )
+ for job in jobs:
+ try:
+ job.result()
+ except Exception as e:
+ raise e
diff --git a/tests/pre_merge/__init__.py b/tests/pre_merge/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..da49853df63a36597e80aac21ae4b8dd9d769575
--- /dev/null
+++ b/tests/pre_merge/__init__.py
@@ -0,0 +1,15 @@
+"""Pre-merge tests."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/pre_merge/config/__init__.py b/tests/pre_merge/config/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8d05a0bcff3f9ff73f83f4770dfaf4c025a9d8af
--- /dev/null
+++ b/tests/pre_merge/config/__init__.py
@@ -0,0 +1,15 @@
+"""Test callbacks."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/pre_merge/config/test_config.py b/tests/pre_merge/config/test_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d5cf5706929d5b9be28bef42f7bc22b7726cfad
--- /dev/null
+++ b/tests/pre_merge/config/test_config.py
@@ -0,0 +1,34 @@
+"""Test Config Getter."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import pytest
+
+from anomalib.config import get_configurable_parameters
+
+
+class TestConfig:
+ """Test Config Getter."""
+
+ def test_get_configurable_parameters_return_correct_model_name(self):
+ """Configurable parameter should return the correct model name."""
+ model_name = "stfpm"
+ configurable_parameters = get_configurable_parameters(model_name)
+ assert configurable_parameters.model.name == model_name
+
+ def test_get_configurable_parameter_fails_with_none_arguments(self):
+ """Configurable parameter should raise an error with none arguments."""
+ with pytest.raises(ValueError):
+ get_configurable_parameters()
diff --git a/tests/pre_merge/datasets/__init__.py b/tests/pre_merge/datasets/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d3fed93f9d6bb8cd3eca046a99d10d828373807
--- /dev/null
+++ b/tests/pre_merge/datasets/__init__.py
@@ -0,0 +1,15 @@
+"""Test dataset, tiler and transforms."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/pre_merge/datasets/dummy_config.yml b/tests/pre_merge/datasets/dummy_config.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aaba8229d9f1918b5bc29797376893a1bddf8828
--- /dev/null
+++ b/tests/pre_merge/datasets/dummy_config.yml
@@ -0,0 +1,7 @@
+dataset:
+ name: FakeData
+ category: fakedata
+ image_size: 256
+
+model:
+ name: DummyModel
diff --git a/tests/pre_merge/datasets/test_dataset.py b/tests/pre_merge/datasets/test_dataset.py
new file mode 100644
index 0000000000000000000000000000000000000000..ff17499c27549b76ebc12932a186cfaa1bafffa0
--- /dev/null
+++ b/tests/pre_merge/datasets/test_dataset.py
@@ -0,0 +1,215 @@
+"""Test Dataset."""
+
+import os
+
+import numpy as np
+import pytest
+
+from anomalib.config import update_input_size_config
+from anomalib.data import (
+ BTechDataModule,
+ FolderDataModule,
+ MVTecDataModule,
+ get_datamodule,
+)
+from anomalib.pre_processing.transforms import Denormalize, ToNumpy
+from tests.helpers.config import get_test_configurable_parameters
+from tests.helpers.dataset import TestDataset, get_dataset_path
+
+
+@pytest.fixture(autouse=True)
+def mvtec_data_module():
+ datamodule = MVTecDataModule(
+ root=get_dataset_path(dataset="MVTec"),
+ category="leather",
+ image_size=(256, 256),
+ train_batch_size=1,
+ test_batch_size=1,
+ num_workers=0,
+ )
+ datamodule.prepare_data()
+ datamodule.setup()
+
+ return datamodule
+
+
+@pytest.fixture(autouse=True)
+def btech_data_module():
+ """Create BTech Data Module."""
+ datamodule = BTechDataModule(
+ root=get_dataset_path(dataset="BTech"),
+ category="01",
+ image_size=(256, 256),
+ train_batch_size=1,
+ test_batch_size=1,
+ num_workers=0,
+ )
+ datamodule.prepare_data()
+ datamodule.setup()
+
+ return datamodule
+
+
+@pytest.fixture(autouse=True)
+def folder_data_module():
+ """Create Folder Data Module."""
+ root = get_dataset_path(dataset="bottle")
+ datamodule = FolderDataModule(
+ root=root,
+ normal_dir="good",
+ abnormal_dir="broken_large",
+ mask_dir=os.path.join(root, "ground_truth/broken_large"),
+ task="segmentation",
+ split_ratio=0.2,
+ seed=0,
+ image_size=(256, 256),
+ train_batch_size=32,
+ test_batch_size=32,
+ num_workers=8,
+ create_validation_set=True,
+ )
+ datamodule.setup()
+
+ return datamodule
+
+
+@pytest.fixture(autouse=True)
+def data_sample(mvtec_data_module):
+ _, data = next(enumerate(mvtec_data_module.train_dataloader()))
+ return data
+
+
+class TestMVTecDataModule:
+ """Test MVTec AD Data Module."""
+
+ def test_batch_size(self, mvtec_data_module):
+ """test_mvtec_datamodule [summary]"""
+ _, train_data_sample = next(enumerate(mvtec_data_module.train_dataloader()))
+ _, val_data_sample = next(enumerate(mvtec_data_module.val_dataloader()))
+ assert train_data_sample["image"].shape[0] == 1
+ assert val_data_sample["image"].shape[0] == 1
+
+ def test_val_and_test_dataloaders_has_mask_and_gt(self, mvtec_data_module):
+ """Test Validation and Test dataloaders should return filenames, image, mask and label."""
+ _, val_data = next(enumerate(mvtec_data_module.val_dataloader()))
+ _, test_data = next(enumerate(mvtec_data_module.test_dataloader()))
+
+ assert sorted(["image_path", "mask_path", "image", "label", "mask"]) == sorted(val_data.keys())
+ assert sorted(["image_path", "mask_path", "image", "label", "mask"]) == sorted(test_data.keys())
+
+
+class TestBTechDataModule:
+ """Test BTech Data Module."""
+
+ def test_batch_size(self, btech_data_module):
+ """Test batch size."""
+ _, train_data_sample = next(enumerate(btech_data_module.train_dataloader()))
+ _, val_data_sample = next(enumerate(btech_data_module.val_dataloader()))
+ assert train_data_sample["image"].shape[0] == 1
+ assert val_data_sample["image"].shape[0] == 1
+
+ def test_val_and_test_dataloaders_has_mask_and_gt(self, btech_data_module):
+ """Test Validation and Test dataloaders should return filenames, image, mask and label."""
+ _, val_data = next(enumerate(btech_data_module.val_dataloader()))
+ _, test_data = next(enumerate(btech_data_module.test_dataloader()))
+
+ assert sorted(["image_path", "mask_path", "image", "label", "mask"]) == sorted(val_data.keys())
+ assert sorted(["image_path", "mask_path", "image", "label", "mask"]) == sorted(test_data.keys())
+
+
+class TestFolderDataModule:
+ """Test Folder Data Module."""
+
+ def test_batch_size(self, folder_data_module):
+ """Test batch size."""
+ _, train_data_sample = next(enumerate(folder_data_module.train_dataloader()))
+ _, val_data_sample = next(enumerate(folder_data_module.val_dataloader()))
+ assert train_data_sample["image"].shape[0] == 16
+ assert val_data_sample["image"].shape[0] == 12
+
+ def test_val_and_test_dataloaders_has_mask_and_gt(self, folder_data_module):
+ """Test Validation and Test dataloaders should return filenames, image, mask and label."""
+ _, val_data = next(enumerate(folder_data_module.val_dataloader()))
+ _, test_data = next(enumerate(folder_data_module.test_dataloader()))
+
+ assert sorted(["image_path", "mask_path", "image", "label", "mask"]) == sorted(val_data.keys())
+ assert sorted(["image_path", "mask_path", "image", "label", "mask"]) == sorted(test_data.keys())
+
+
+class TestDenormalize:
+ """Test Denormalize Util."""
+
+ def test_denormalize_image_pixel_values(self, data_sample):
+ """Test Denormalize denormalizes tensor into [0, 256] range."""
+ denormalized_sample = Denormalize().__call__(data_sample["image"].squeeze())
+ assert denormalized_sample.min() >= 0 and denormalized_sample.max() <= 256
+
+ def test_denormalize_return_numpy(self, data_sample):
+ """Denormalize should return a numpy array."""
+ denormalized_sample = Denormalize()(data_sample["image"].squeeze())
+ assert isinstance(denormalized_sample, np.ndarray)
+
+ def test_denormalize_channel_order(self, data_sample):
+ """Denormalize should return a numpy array of order [HxWxC]"""
+ denormalized_sample = Denormalize().__call__(data_sample["image"].squeeze())
+ assert len(denormalized_sample.shape) == 3 and denormalized_sample.shape[-1] == 3
+
+ def test_representation(self):
+ """Test Denormalize representation should return string
+ Denormalize()"""
+ assert str(Denormalize()) == "Denormalize()"
+
+
+class TestToNumpy:
+ """Test ToNumpy whether it properly converts tensor into numpy array."""
+
+ def test_to_numpy_image_pixel_values(self, data_sample):
+ """Test ToNumpy should return an array whose pixels in the range of [0,
+ 256]"""
+ array = ToNumpy()(data_sample["image"])
+ assert array.min() >= 0 and array.max() <= 256
+
+ def test_to_numpy_converts_tensor_to_np_array(self, data_sample):
+ """ToNumpy returns a numpy array."""
+ array = ToNumpy()(data_sample["image"])
+ assert isinstance(array, np.ndarray)
+
+ def test_to_numpy_channel_order(self, data_sample):
+ """ToNumpy() should return a numpy array of order [HxWxC]"""
+ array = ToNumpy()(data_sample["image"])
+ assert len(array.shape) == 3 and array.shape[-1] == 3
+
+ def test_one_channel_images(self, data_sample):
+ """One channel tensor should be converted to HxW np array."""
+ data = data_sample["image"][:, 0, :, :].unsqueeze(0)
+ array = ToNumpy()(data)
+ assert len(array.shape) == 2
+
+ def test_representation(self):
+ """Test ToNumpy() representation should return string `ToNumpy()`"""
+ assert str(ToNumpy()) == "ToNumpy()"
+
+
+class TestConfigToDataModule:
+ """Tests that check if the dataset parameters in the config achieve the desired effect."""
+
+ @pytest.mark.parametrize(
+ ["input_size", "effective_image_size"],
+ [
+ (512, (512, 512)),
+ ((245, 276), (245, 276)),
+ ((263, 134), (263, 134)),
+ ((267, 267), (267, 267)),
+ ],
+ )
+ @TestDataset(num_train=20, num_test=10)
+ def test_image_size(self, input_size, effective_image_size, category="shapes", path=None):
+ """Test if the image size parameter works as expected."""
+ configurable_parameters = get_test_configurable_parameters(dataset_path=path, model_name="stfpm")
+ configurable_parameters.dataset.category = category
+ configurable_parameters.dataset.image_size = input_size
+ configurable_parameters = update_input_size_config(configurable_parameters)
+
+ data_module = get_datamodule(configurable_parameters)
+ data_module.setup()
+ assert iter(data_module.train_dataloader()).__next__()["image"].shape[-2:] == effective_image_size
diff --git a/tests/pre_merge/datasets/test_tiler.py b/tests/pre_merge/datasets/test_tiler.py
new file mode 100644
index 0000000000000000000000000000000000000000..4555ce1490e6e8133f5c9315f3f442036bc64568
--- /dev/null
+++ b/tests/pre_merge/datasets/test_tiler.py
@@ -0,0 +1,135 @@
+"""Image Tiling Tests."""
+
+import pytest
+import torch
+from omegaconf import ListConfig
+
+from anomalib.pre_processing.tiler import StrideSizeError, Tiler
+
+tile_data = [
+ ([3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512]), False),
+ ([1, 3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512]), False),
+ ([3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512]), True),
+ ([1, 3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512]), True),
+]
+
+untile_data = [
+ ([3, 1024, 1024], 512, 256, torch.Size([4, 3, 512, 512])),
+ ([1, 3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512])),
+]
+
+overlapping_data = [
+ (
+ torch.Size([1, 3, 1024, 1024]),
+ 512,
+ 256,
+ torch.Size([16, 3, 512, 512]),
+ "padding",
+ ),
+ (
+ torch.Size([1, 3, 1024, 1024]),
+ 512,
+ 256,
+ torch.Size([16, 3, 512, 512]),
+ "interpolation",
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ "tile_size, stride",
+ [(512, 256), ([512, 512], [256, 256]), (ListConfig([512, 512]), 256)],
+)
+def test_size_types_should_be_int_tuple_or_list_config(tile_size, stride):
+ """Size type could only be integer, tuple or ListConfig type."""
+ tiler = Tiler(tile_size=tile_size, stride=stride)
+ assert isinstance(tiler.tile_size_h, int)
+ assert isinstance(tiler.stride_w, int)
+
+
+@pytest.mark.parametrize("image_size, tile_size, stride, shape, use_random_tiling", tile_data)
+def test_tiler_handles_single_image_without_batch_dimension(image_size, tile_size, stride, shape, use_random_tiling):
+ """Tiler should add batch dimension if image is 3D (CxHxW)."""
+ tiler = Tiler(tile_size=tile_size, stride=stride)
+ image = torch.rand(image_size)
+ patches = tiler.tile(image, use_random_tiling=use_random_tiling)
+ assert patches.shape == shape
+
+
+def test_stride_size_cannot_be_larger_than_tile_size():
+ """Larger stride size than tile size is not desired, and causes issues."""
+ kernel_size = (128, 128)
+ stride = 256
+ with pytest.raises(StrideSizeError):
+ tiler = Tiler(tile_size=kernel_size, stride=stride)
+
+
+def test_tile_size_cannot_be_larger_than_image_size():
+ """Larger tile size than image size is not desired, and causes issues."""
+ with pytest.raises(ValueError):
+ tiler = Tiler(tile_size=1024, stride=512)
+ image = torch.rand(1, 3, 512, 512)
+ tiler.tile(image)
+
+
+@pytest.mark.parametrize("tile_size, kernel_size, stride, image_size", untile_data)
+def test_untile_non_overlapping_patches(tile_size, kernel_size, stride, image_size):
+ """Non-Overlapping Tiling/Untiling should return the same image size."""
+ tiler = Tiler(tile_size=kernel_size, stride=stride)
+ image = torch.rand(image_size)
+ tiles = tiler.tile(image)
+
+ untiled_image = tiler.untile(tiles)
+ assert untiled_image.shape == torch.Size(image_size)
+
+
+@pytest.mark.parametrize("mode", ["pad", "padded", "interpolate", "interplation"])
+def test_upscale_downscale_mode(mode):
+ with pytest.raises(ValueError):
+ tiler = Tiler(tile_size=(512, 512), stride=(256, 256), mode=mode)
+
+
+@pytest.mark.parametrize("image_size, kernel_size, stride, tile_size, mode", overlapping_data)
+@pytest.mark.parametrize("remove_border_count", [0, 5])
+def test_untile_overlapping_patches(image_size, kernel_size, stride, remove_border_count, tile_size, mode):
+ """Overlapping Tiling/Untiling should return the same image size."""
+ tiler = Tiler(
+ tile_size=kernel_size,
+ stride=stride,
+ remove_border_count=remove_border_count,
+ mode=mode,
+ )
+
+ image = torch.rand(image_size)
+ tiles = tiler.tile(image)
+ reconstructed_image = tiler.untile(tiles)
+ image = image[
+ :,
+ :,
+ remove_border_count:-remove_border_count,
+ remove_border_count:-remove_border_count,
+ ]
+ reconstructed_image = reconstructed_image[
+ :,
+ :,
+ remove_border_count:-remove_border_count,
+ remove_border_count:-remove_border_count,
+ ]
+ assert torch.equal(image, reconstructed_image)
+
+
+@pytest.mark.parametrize("image_size", [(1, 3, 512, 512)])
+@pytest.mark.parametrize("tile_size", [(256, 256), (200, 200), (211, 213), (312, 333), (511, 511)])
+@pytest.mark.parametrize("stride", [(64, 64), (111, 111), (128, 111), (128, 128)])
+@pytest.mark.parametrize("mode", ["padding", "interpolation"])
+def test_divisible_tile_size_and_stride(image_size, tile_size, stride, mode):
+ """When the image is not divisible by tile size and stride, Tiler should up
+ samples the image before tiling, and downscales before untiling."""
+ tiler = Tiler(tile_size, stride, mode=mode)
+ image = torch.rand(image_size)
+ tiles = tiler.tile(image)
+ reconstructed_image = tiler.untile(tiles)
+ assert image.shape == reconstructed_image.shape
+
+ if mode == "padding":
+ assert torch.allclose(image, reconstructed_image)
diff --git a/tests/pre_merge/models/__init__.py b/tests/pre_merge/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f80c33af48ce8aef726c48ff1e6dc224262b780
--- /dev/null
+++ b/tests/pre_merge/models/__init__.py
@@ -0,0 +1,15 @@
+"""Pre-merge model tests."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/pre_merge/models/test_model_premerge.py b/tests/pre_merge/models/test_model_premerge.py
new file mode 100644
index 0000000000000000000000000000000000000000..f99adbad95235f9827e1a287d792c26a688dc12a
--- /dev/null
+++ b/tests/pre_merge/models/test_model_premerge.py
@@ -0,0 +1,56 @@
+"""Quick sanity check on models."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import tempfile
+
+import pytest
+
+from tests.helpers.dataset import TestDataset
+from tests.helpers.model import model_load_test, setup_model_train
+
+
+class TestModel:
+ """Do a sanity check on the models."""
+
+ @pytest.mark.parametrize(
+ ["model_name", "nncf"],
+ [
+ ("padim", False),
+ ("dfkde", False),
+ ("dfm", False),
+ ("stfpm", False),
+ ("patchcore", False),
+ ("cflow", False),
+ ("ganomaly", False),
+ ],
+ )
+ @TestDataset(num_train=20, num_test=10)
+ def test_model(self, model_name, nncf, category="shapes", path=""):
+ """Test the models on only 1 epoch as a sanity check before merge."""
+ with tempfile.TemporaryDirectory() as project_path:
+ # Train test
+ config, datamodule, model, trainer = setup_model_train(
+ model_name,
+ dataset_path=path,
+ project_path=project_path,
+ nncf=nncf,
+ category=category,
+ fast_run=True,
+ )
+ results = trainer.test(model=model, datamodule=datamodule)[0]
+
+ # Test model load
+ model_load_test(config, datamodule, results)
diff --git a/tests/pre_merge/post_processing/__init__.py b/tests/pre_merge/post_processing/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4895d290253f14f87108baac766e3f484929a263
--- /dev/null
+++ b/tests/pre_merge/post_processing/__init__.py
@@ -0,0 +1,15 @@
+"""Post-processing tests."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/pre_merge/post_processing/test_visualizer.py b/tests/pre_merge/post_processing/test_visualizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..660f49bfbaf7c0c03826286929f9c5a7625ce408
--- /dev/null
+++ b/tests/pre_merge/post_processing/test_visualizer.py
@@ -0,0 +1,37 @@
+"""Tests for the Visualizer class."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import numpy as np
+from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
+
+from anomalib.post_processing.visualizer import Visualizer
+
+
+def test_visualize_fully_defected_masks():
+ """Test if a fully defected anomaly mask results in a completely white image."""
+
+ # create visualizer and add fully defected mask
+ visualizer = Visualizer(num_rows=1, num_cols=2, figure_size=(3, 3))
+ mask = np.ones((256, 256)) * 255
+ visualizer.add_image(image=mask, color_map="gray", title="fully defected mask")
+
+ # retrieve plotted image
+ canvas = FigureCanvas(visualizer.figure)
+ canvas.draw()
+ plotted_img = visualizer.axis[0].images[0].make_image(canvas.renderer)
+
+ # assert that the plotted image is completely white
+ assert np.all(plotted_img[0][..., 0] == 255)
diff --git a/tests/pre_merge/pre_processing/__init__.py b/tests/pre_merge/pre_processing/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/pre_merge/pre_processing/test_tiler.py b/tests/pre_merge/pre_processing/test_tiler.py
new file mode 100644
index 0000000000000000000000000000000000000000..4555ce1490e6e8133f5c9315f3f442036bc64568
--- /dev/null
+++ b/tests/pre_merge/pre_processing/test_tiler.py
@@ -0,0 +1,135 @@
+"""Image Tiling Tests."""
+
+import pytest
+import torch
+from omegaconf import ListConfig
+
+from anomalib.pre_processing.tiler import StrideSizeError, Tiler
+
+tile_data = [
+ ([3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512]), False),
+ ([1, 3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512]), False),
+ ([3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512]), True),
+ ([1, 3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512]), True),
+]
+
+untile_data = [
+ ([3, 1024, 1024], 512, 256, torch.Size([4, 3, 512, 512])),
+ ([1, 3, 1024, 1024], 512, 512, torch.Size([4, 3, 512, 512])),
+]
+
+overlapping_data = [
+ (
+ torch.Size([1, 3, 1024, 1024]),
+ 512,
+ 256,
+ torch.Size([16, 3, 512, 512]),
+ "padding",
+ ),
+ (
+ torch.Size([1, 3, 1024, 1024]),
+ 512,
+ 256,
+ torch.Size([16, 3, 512, 512]),
+ "interpolation",
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ "tile_size, stride",
+ [(512, 256), ([512, 512], [256, 256]), (ListConfig([512, 512]), 256)],
+)
+def test_size_types_should_be_int_tuple_or_list_config(tile_size, stride):
+ """Size type could only be integer, tuple or ListConfig type."""
+ tiler = Tiler(tile_size=tile_size, stride=stride)
+ assert isinstance(tiler.tile_size_h, int)
+ assert isinstance(tiler.stride_w, int)
+
+
+@pytest.mark.parametrize("image_size, tile_size, stride, shape, use_random_tiling", tile_data)
+def test_tiler_handles_single_image_without_batch_dimension(image_size, tile_size, stride, shape, use_random_tiling):
+ """Tiler should add batch dimension if image is 3D (CxHxW)."""
+ tiler = Tiler(tile_size=tile_size, stride=stride)
+ image = torch.rand(image_size)
+ patches = tiler.tile(image, use_random_tiling=use_random_tiling)
+ assert patches.shape == shape
+
+
+def test_stride_size_cannot_be_larger_than_tile_size():
+ """Larger stride size than tile size is not desired, and causes issues."""
+ kernel_size = (128, 128)
+ stride = 256
+ with pytest.raises(StrideSizeError):
+ tiler = Tiler(tile_size=kernel_size, stride=stride)
+
+
+def test_tile_size_cannot_be_larger_than_image_size():
+ """Larger tile size than image size is not desired, and causes issues."""
+ with pytest.raises(ValueError):
+ tiler = Tiler(tile_size=1024, stride=512)
+ image = torch.rand(1, 3, 512, 512)
+ tiler.tile(image)
+
+
+@pytest.mark.parametrize("tile_size, kernel_size, stride, image_size", untile_data)
+def test_untile_non_overlapping_patches(tile_size, kernel_size, stride, image_size):
+ """Non-Overlapping Tiling/Untiling should return the same image size."""
+ tiler = Tiler(tile_size=kernel_size, stride=stride)
+ image = torch.rand(image_size)
+ tiles = tiler.tile(image)
+
+ untiled_image = tiler.untile(tiles)
+ assert untiled_image.shape == torch.Size(image_size)
+
+
+@pytest.mark.parametrize("mode", ["pad", "padded", "interpolate", "interplation"])
+def test_upscale_downscale_mode(mode):
+ with pytest.raises(ValueError):
+ tiler = Tiler(tile_size=(512, 512), stride=(256, 256), mode=mode)
+
+
+@pytest.mark.parametrize("image_size, kernel_size, stride, tile_size, mode", overlapping_data)
+@pytest.mark.parametrize("remove_border_count", [0, 5])
+def test_untile_overlapping_patches(image_size, kernel_size, stride, remove_border_count, tile_size, mode):
+ """Overlapping Tiling/Untiling should return the same image size."""
+ tiler = Tiler(
+ tile_size=kernel_size,
+ stride=stride,
+ remove_border_count=remove_border_count,
+ mode=mode,
+ )
+
+ image = torch.rand(image_size)
+ tiles = tiler.tile(image)
+ reconstructed_image = tiler.untile(tiles)
+ image = image[
+ :,
+ :,
+ remove_border_count:-remove_border_count,
+ remove_border_count:-remove_border_count,
+ ]
+ reconstructed_image = reconstructed_image[
+ :,
+ :,
+ remove_border_count:-remove_border_count,
+ remove_border_count:-remove_border_count,
+ ]
+ assert torch.equal(image, reconstructed_image)
+
+
+@pytest.mark.parametrize("image_size", [(1, 3, 512, 512)])
+@pytest.mark.parametrize("tile_size", [(256, 256), (200, 200), (211, 213), (312, 333), (511, 511)])
+@pytest.mark.parametrize("stride", [(64, 64), (111, 111), (128, 111), (128, 128)])
+@pytest.mark.parametrize("mode", ["padding", "interpolation"])
+def test_divisible_tile_size_and_stride(image_size, tile_size, stride, mode):
+ """When the image is not divisible by tile size and stride, Tiler should up
+ samples the image before tiling, and downscales before untiling."""
+ tiler = Tiler(tile_size, stride, mode=mode)
+ image = torch.rand(image_size)
+ tiles = tiler.tile(image)
+ reconstructed_image = tiler.untile(tiles)
+ assert image.shape == reconstructed_image.shape
+
+ if mode == "padding":
+ assert torch.allclose(image, reconstructed_image)
diff --git a/tests/pre_merge/pre_processing/transforms/__init__.py b/tests/pre_merge/pre_processing/transforms/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/pre_merge/pre_processing/transforms/test_transforms.py b/tests/pre_merge/pre_processing/transforms/test_transforms.py
new file mode 100644
index 0000000000000000000000000000000000000000..3a6c954435353d0c4ecf95c02dcb5a92fdb480fe
--- /dev/null
+++ b/tests/pre_merge/pre_processing/transforms/test_transforms.py
@@ -0,0 +1,80 @@
+"""Data transformation test.
+
+This test contains the following test:
+ - Transformations could be ``None``, ``yaml``, ``json`` or ``dict``.
+ - When it is ``None``, the script loads the default transforms
+ - When it is ``yaml``, ``json`` or ``dict``, `albumentations` package
+ deserializes the transformations.
+"""
+
+import tempfile
+
+import albumentations as A
+import numpy as np
+import pytest
+import skimage
+from torch import Tensor
+
+from anomalib.pre_processing import PreProcessor
+
+
+def test_transforms_and_image_size_cannot_be_none():
+ """When transformations ``config`` and ``image_size`` are ``None``
+ ``PreProcessor`` class should raise a ``ValueError``."""
+
+ with pytest.raises(ValueError):
+ PreProcessor(config=None, image_size=None)
+
+
+def test_image_size_could_be_int_or_tuple():
+ """When ``config`` is None, ``image_size`` could be either ``int`` or
+ ``Tuple[int, int]``."""
+
+ PreProcessor(config=None, image_size=256)
+ PreProcessor(config=None, image_size=(256, 512))
+ with pytest.raises(ValueError):
+ PreProcessor(config=None, image_size=0.0)
+
+
+def test_load_transforms_from_string():
+ """When the pre-processor is instantiated via a transform config file, it
+ should work with either string or A.Compose and return a ValueError
+ otherwise."""
+
+ config_path = tempfile.NamedTemporaryFile(suffix=".yaml").name
+
+ # Create a dummy transformation.
+ transforms = A.Compose(
+ [
+ A.Resize(1024, 1024, always_apply=True),
+ A.CenterCrop(256, 256, always_apply=True),
+ A.Resize(224, 224, always_apply=True),
+ ]
+ )
+ A.save(transform=transforms, filepath=config_path, data_format="yaml")
+
+ # Pass a path to config
+ pre_processor = PreProcessor(config=config_path)
+ assert isinstance(pre_processor.transforms, A.Compose)
+
+ # Pass a config of type A.Compose
+ pre_processor = PreProcessor(config=transforms)
+ assert isinstance(pre_processor.transforms, A.Compose)
+
+ # Anything else should raise an error
+ with pytest.raises(ValueError):
+ PreProcessor(config=0)
+
+
+def test_to_tensor_returns_correct_type():
+ """`to_tensor` flag should ensure that pre-processor returns the expected
+ type."""
+ image = skimage.data.astronaut()
+
+ pre_processor = PreProcessor(config=None, image_size=256, to_tensor=True)
+ transformed = pre_processor(image=image)["image"]
+ assert isinstance(transformed, Tensor)
+
+ pre_processor = PreProcessor(config=None, image_size=256, to_tensor=False)
+ transformed = pre_processor(image=image)["image"]
+ assert isinstance(transformed, np.ndarray)
diff --git a/tests/pre_merge/utils/callbacks/__init__.py b/tests/pre_merge/utils/callbacks/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8d05a0bcff3f9ff73f83f4770dfaf4c025a9d8af
--- /dev/null
+++ b/tests/pre_merge/utils/callbacks/__init__.py
@@ -0,0 +1,15 @@
+"""Test callbacks."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/pre_merge/utils/callbacks/normalization_callback/__init__.py b/tests/pre_merge/utils/callbacks/normalization_callback/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/pre_merge/utils/callbacks/normalization_callback/test_normalization_callback.py b/tests/pre_merge/utils/callbacks/normalization_callback/test_normalization_callback.py
new file mode 100644
index 0000000000000000000000000000000000000000..fdb7fd18a59c2b81936ce86248b5adb4f94e3464
--- /dev/null
+++ b/tests/pre_merge/utils/callbacks/normalization_callback/test_normalization_callback.py
@@ -0,0 +1,50 @@
+from pytorch_lightning import Trainer, seed_everything
+
+from anomalib.config import get_configurable_parameters
+from anomalib.data import get_datamodule
+from anomalib.models import get_model
+from anomalib.utils.callbacks import get_callbacks
+from tests.helpers.dataset import TestDataset, get_dataset_path
+
+
+def run_train_test(config):
+ model = get_model(config)
+ datamodule = get_datamodule(config)
+ callbacks = get_callbacks(config)
+
+ trainer = Trainer(**config.trainer, callbacks=callbacks)
+ trainer.fit(model=model, datamodule=datamodule)
+ results = trainer.test(model=model, datamodule=datamodule)
+ return results
+
+
+@TestDataset(num_train=200, num_test=30, path=get_dataset_path(), seed=42)
+def test_normalizer(path=get_dataset_path(), category="shapes"):
+ config = get_configurable_parameters(config_path="anomalib/models/padim/config.yaml")
+ config.dataset.path = path
+ config.dataset.category = category
+ config.model.threshold.adaptive = True
+ config.project.log_images_to = []
+ config.metrics.image = ["F1Score", "AUROC"]
+
+ # run without normalization
+ config.model.normalization_method = "none"
+ seed_everything(42)
+ results_without_normalization = run_train_test(config)
+
+ # run with cdf normalization
+ config.model.normalization_method = "cdf"
+ seed_everything(42)
+ results_with_cdf_normalization = run_train_test(config)
+
+ # run without normalization
+ config.model.normalization_method = "min_max"
+ seed_everything(42)
+ results_with_minmax_normalization = run_train_test(config)
+
+ # performance should be the same
+ for metric in ["image_AUROC", "image_F1Score"]:
+ assert round(results_without_normalization[0][metric], 3) == round(results_with_cdf_normalization[0][metric], 3)
+ assert round(results_without_normalization[0][metric], 3) == round(
+ results_with_minmax_normalization[0][metric], 3
+ )
diff --git a/tests/pre_merge/utils/callbacks/openvino_callback/__init__.py b/tests/pre_merge/utils/callbacks/openvino_callback/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/pre_merge/utils/callbacks/openvino_callback/dummy_config.yml b/tests/pre_merge/utils/callbacks/openvino_callback/dummy_config.yml
new file mode 100644
index 0000000000000000000000000000000000000000..91cf90ea2d49bafac38db19ff4adec8928089164
--- /dev/null
+++ b/tests/pre_merge/utils/callbacks/openvino_callback/dummy_config.yml
@@ -0,0 +1,25 @@
+dataset:
+ name: FakeData
+ category: fakedata
+ image_size: 32
+
+model:
+ dropout: 0
+ lr: 1e-3
+ metric: loss
+ momentum: 0.9
+ name: DummyModel
+ weight_decay: 1e-4
+ threshold:
+ image_default: 0.0
+ pixel_default: 0.0
+
+project:
+ path: ./results
+
+optimization:
+ openvino:
+ apply: true
+
+trainer:
+ accelerator: auto # <"cpu", "gpu", "tpu", "ipu", "hpu", "auto">
diff --git a/tests/pre_merge/utils/callbacks/openvino_callback/dummy_lightning_model.py b/tests/pre_merge/utils/callbacks/openvino_callback/dummy_lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..4f6d0ee5c8cd683e3e87e6b42f1e035597ac2985
--- /dev/null
+++ b/tests/pre_merge/utils/callbacks/openvino_callback/dummy_lightning_model.py
@@ -0,0 +1,104 @@
+from typing import Union
+
+import pytorch_lightning as pl
+import torch.nn.functional as F
+from omegaconf import DictConfig, ListConfig
+from torch import nn, optim
+from torch.utils.data import DataLoader
+from torchvision import transforms
+from torchvision.datasets import FakeData
+
+from anomalib.utils.callbacks.visualizer_callback import VisualizerCallback
+from anomalib.utils.metrics import AdaptiveThreshold, AnomalyScoreDistribution, MinMax
+
+
+class FakeDataModule(pl.LightningDataModule):
+ def __init__(self, batch_size: int = 32):
+ super(FakeDataModule, self).__init__()
+ self.batch_size = batch_size
+ self.pre_process = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))])
+
+ def train_dataloader(self):
+ return DataLoader(
+ FakeData(
+ size=1000,
+ num_classes=10,
+ transform=self.pre_process,
+ image_size=(3, 32, 32),
+ ),
+ batch_size=self.batch_size,
+ )
+
+ def test_dataloader(self):
+ return DataLoader(
+ FakeData(
+ size=100,
+ num_classes=10,
+ transform=self.pre_process,
+ image_size=(3, 32, 32),
+ ),
+ batch_size=self.batch_size,
+ )
+
+
+class DummyModel(nn.Module):
+ """Creates a very basic CNN model to fit image data for classification task
+ The test uses this to check if this model is converted to OpenVINO IR."""
+
+ def __init__(self, hparams: Union[DictConfig, ListConfig]):
+ super().__init__()
+ self.hparams = hparams
+ self.conv1 = nn.Conv2d(3, 32, 3)
+ self.conv2 = nn.Conv2d(32, 32, 5)
+ self.conv3 = nn.Conv2d(32, 1, 7)
+ self.fc1 = nn.Linear(400, 256)
+ self.fc2 = nn.Linear(256, 10)
+
+ def forward(self, x):
+ batch_size, _, _, _ = x.size()
+ x = self.conv1(x)
+ x = self.conv2(x)
+ x = self.conv3(x)
+ x = x.view(batch_size, -1)
+ x = self.fc1(x)
+ x = F.dropout(x, p=self.hparams.model.dropout)
+ x = self.fc2(x)
+ x = F.log_softmax(x, dim=1)
+ return x
+
+
+class DummyLightningModule(pl.LightningModule):
+ """A dummy model which fits the torchvision FakeData dataset."""
+
+ def __init__(self, hparams: Union[DictConfig, ListConfig]):
+ super().__init__()
+ self.save_hyperparameters(hparams)
+ self.loss_fn = nn.NLLLoss()
+ self.callbacks = [VisualizerCallback(task="segmentation")] # test if this is removed
+
+ self.image_threshold = AdaptiveThreshold(hparams.model.threshold.image_default).cpu()
+ self.pixel_threshold = AdaptiveThreshold(hparams.model.threshold.pixel_default).cpu()
+
+ self.training_distribution = AnomalyScoreDistribution().cpu()
+ self.min_max = MinMax().cpu()
+ self.model = DummyModel(hparams)
+
+ def training_step(self, batch, _):
+ x, y = batch
+ y_hat = self.model(x)
+ loss = self.loss_fn(y_hat, y)
+ return {"loss": loss}
+
+ def validation_step(self, batch, _):
+ x, y = batch
+ y_hat = self.model(x)
+ loss = self.loss_fn(y_hat, y)
+ self.log(name="loss", value=loss.item(), prog_bar=True)
+
+ def configure_optimizers(self):
+ return optim.SGD(
+ self.parameters(),
+ lr=self.hparams.model.lr,
+ momentum=self.hparams.model.momentum,
+ weight_decay=self.hparams.model.weight_decay,
+ )
diff --git a/tests/pre_merge/utils/callbacks/openvino_callback/test_openvino.py b/tests/pre_merge/utils/callbacks/openvino_callback/test_openvino.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a0ac750b547e30db2a886235cc0e0d6aad9a5cb
--- /dev/null
+++ b/tests/pre_merge/utils/callbacks/openvino_callback/test_openvino.py
@@ -0,0 +1,42 @@
+import os
+import tempfile
+
+import pytorch_lightning as pl
+from pytorch_lightning.callbacks.early_stopping import EarlyStopping
+
+from anomalib.utils.callbacks.openvino import OpenVINOCallback
+from tests.helpers.config import get_test_configurable_parameters
+from tests.pre_merge.utils.callbacks.openvino_callback.dummy_lightning_model import (
+ DummyLightningModule,
+ FakeDataModule,
+)
+
+
+def test_openvino_model_callback():
+ """Tests if an optimized model is created."""
+
+ config = get_test_configurable_parameters(
+ config_path="tests/pre_merge/utils/callbacks/openvino_callback/dummy_config.yml"
+ )
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ config.project.path = tmp_dir
+ model = DummyLightningModule(hparams=config)
+ model.callbacks = [
+ OpenVINOCallback(
+ input_size=config.model.input_size, dirpath=os.path.join(tmp_dir), filename="openvino_model"
+ ),
+ EarlyStopping(monitor=config.model.metric),
+ ]
+ datamodule = FakeDataModule()
+ trainer = pl.Trainer(
+ gpus=1,
+ callbacks=model.callbacks,
+ logger=False,
+ checkpoint_callback=False,
+ max_epochs=1,
+ val_check_interval=3,
+ )
+ trainer.fit(model, datamodule=datamodule)
+
+ assert os.path.exists(os.path.join(tmp_dir, "openvino_model.bin")), "Failed to generate OpenVINO model"
diff --git a/tests/pre_merge/utils/callbacks/visualizer_callback/__init__.py b/tests/pre_merge/utils/callbacks/visualizer_callback/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/pre_merge/utils/callbacks/visualizer_callback/dummy_lightning_model.py b/tests/pre_merge/utils/callbacks/visualizer_callback/dummy_lightning_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..ffd34723c3767cf0bcfa455a9ec8aaa261b505d4
--- /dev/null
+++ b/tests/pre_merge/utils/callbacks/visualizer_callback/dummy_lightning_model.py
@@ -0,0 +1,72 @@
+from pathlib import Path
+from typing import Union
+
+import pytorch_lightning as pl
+import torch
+from omegaconf.dictconfig import DictConfig
+from omegaconf.listconfig import ListConfig
+from torch import nn
+from torch.utils.data import DataLoader, Dataset
+
+from anomalib.models.components import AnomalyModule
+from anomalib.utils.callbacks.visualizer_callback import VisualizerCallback
+
+
+class DummyDataset(Dataset):
+ def __init__(self):
+ super().__init__()
+
+ def __len__(self):
+ return 1
+
+ def __getitem__(self, idx):
+ return torch.ones(1)
+
+
+class DummyDataModule(pl.LightningDataModule):
+ def test_dataloader(self) -> DataLoader:
+ return DataLoader(DummyDataset())
+
+
+class DummyAnomalyMapGenerator:
+ def __init__(self):
+ self.input_size = (100, 100)
+ self.sigma = 4
+
+
+class DummyModel(nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.anomaly_map_generator = DummyAnomalyMapGenerator()
+
+
+class DummyModule(AnomalyModule):
+ """A dummy model which calls visualizer callback on fake images and
+ masks."""
+
+ def __init__(self, hparams: Union[DictConfig, ListConfig]):
+ super().__init__(hparams)
+ self.model = DummyModel()
+ self.task = "segmentation"
+ self.callbacks = [VisualizerCallback(task=self.task)] # test if this is removed
+
+ def test_step(self, batch, _):
+ """Only used to trigger on_test_epoch_end."""
+ self.log(name="loss", value=0.0, prog_bar=True)
+ outputs = dict(
+ image_path=[Path("test1.jpg")],
+ image=torch.rand((1, 3, 100, 100)),
+ mask=torch.zeros((1, 100, 100)),
+ anomaly_maps=torch.ones((1, 100, 100)),
+ label=torch.Tensor([0]),
+ )
+ return outputs
+
+ def validation_epoch_end(self, output):
+ return None
+
+ def test_epoch_end(self, outputs):
+ return None
+
+ def configure_optimizers(self):
+ return None
diff --git a/tests/pre_merge/utils/callbacks/visualizer_callback/test_visualizer.py b/tests/pre_merge/utils/callbacks/visualizer_callback/test_visualizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..9195e2b0a1730f86c88fc13eb37acff8411aabcc
--- /dev/null
+++ b/tests/pre_merge/utils/callbacks/visualizer_callback/test_visualizer.py
@@ -0,0 +1,46 @@
+import glob
+import os
+import tempfile
+from unittest import mock
+
+import pytest
+import pytorch_lightning as pl
+from omegaconf.omegaconf import OmegaConf
+
+from anomalib.utils.loggers import AnomalibTensorBoardLogger
+
+from .dummy_lightning_model import DummyDataModule, DummyModule
+
+
+def get_dummy_module(config):
+ return DummyModule(config)
+
+
+def get_dummy_logger(config, tempdir):
+ logger = AnomalibTensorBoardLogger(name=f"tensorboard_logs", save_dir=tempdir)
+ return logger
+
+
+@pytest.mark.parametrize("dataset", ["segmentation"])
+def test_add_images(dataset):
+ """Tests if tensorboard logs are generated."""
+ with tempfile.TemporaryDirectory() as dir_loc:
+ config = OmegaConf.create(
+ {
+ "dataset": {"task": dataset},
+ "model": {"threshold": {"image_default": 0.5, "pixel_default": 0.5, "adaptive": True}},
+ "project": {"path": dir_loc, "log_images_to": ["tensorboard", "local"]},
+ "metrics": {},
+ }
+ )
+ logger = get_dummy_logger(config, dir_loc)
+ model = get_dummy_module(config)
+ trainer = pl.Trainer(callbacks=model.callbacks, logger=logger, checkpoint_callback=False)
+ trainer.test(model=model, datamodule=DummyDataModule())
+ # test if images are logged
+ if len(glob.glob(os.path.join(dir_loc, "images", "*.jpg"))) != 1:
+ raise Exception("Failed to save to local path")
+
+ # test if tensorboard logs are created
+ if len(glob.glob(os.path.join(dir_loc, "tensorboard_logs", "version_*"))) == 0:
+ raise Exception("Failed to save to tensorboard")
diff --git a/tests/pre_merge/utils/loggers/__init__.py b/tests/pre_merge/utils/loggers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..23adccdc4b9e93e91fafb04c0ddb2035d5041a5d
--- /dev/null
+++ b/tests/pre_merge/utils/loggers/__init__.py
@@ -0,0 +1,15 @@
+"""Test supported loggers."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/pre_merge/utils/loggers/test_get_logger.py b/tests/pre_merge/utils/loggers/test_get_logger.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c607a9ebedc3e4cf6efcfe04c70ff2dc8f3f60d
--- /dev/null
+++ b/tests/pre_merge/utils/loggers/test_get_logger.py
@@ -0,0 +1,72 @@
+"""Tests to ascertain requested logger."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import pytest
+from omegaconf import OmegaConf
+from pytorch_lightning.loggers import CSVLogger
+
+from anomalib.utils.loggers import (
+ AnomalibTensorBoardLogger,
+ AnomalibWandbLogger,
+ UnknownLogger,
+ get_experiment_logger,
+)
+
+
+def test_get_experiment_logger():
+ """Test whether the right logger is returned."""
+
+ config = OmegaConf.create(
+ {
+ "project": {"logger": None, "path": "/tmp"},
+ "dataset": {"name": "dummy", "category": "cat1"},
+ "model": {"name": "DummyModel"},
+ }
+ )
+
+ # get no logger
+ logger = get_experiment_logger(config=config)
+ assert isinstance(logger, bool)
+ config.project.logger = False
+ logger = get_experiment_logger(config=config)
+ assert isinstance(logger, bool)
+
+ # get tensorboard
+ config.project.logger = "tensorboard"
+ logger = get_experiment_logger(config=config)
+ assert isinstance(logger[0], AnomalibTensorBoardLogger)
+
+ # get wandb logger
+ config.project.logger = "wandb"
+ logger = get_experiment_logger(config=config)
+ assert isinstance(logger[0], AnomalibWandbLogger)
+
+ # get csv logger.
+ config.project.logger = "csv"
+ logger = get_experiment_logger(config=config)
+ assert isinstance(logger[0], CSVLogger)
+
+ # get multiple loggers
+ config.project.logger = ["tensorboard", "wandb", "csv"]
+ logger = get_experiment_logger(config=config)
+ assert isinstance(logger[0], AnomalibTensorBoardLogger)
+ assert isinstance(logger[1], AnomalibWandbLogger)
+ assert isinstance(logger[2], CSVLogger)
+
+ # raise unknown
+ with pytest.raises(UnknownLogger):
+ config.project.logger = "randomlogger"
+ logger = get_experiment_logger(config=config)
diff --git a/tests/pre_merge/utils/metrics/__init__.py b/tests/pre_merge/utils/metrics/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7154d5f958b548f3ca2096ad57276ae7666423ca
--- /dev/null
+++ b/tests/pre_merge/utils/metrics/__init__.py
@@ -0,0 +1,13 @@
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
diff --git a/tests/pre_merge/utils/metrics/test_adaptive_threshold.py b/tests/pre_merge/utils/metrics/test_adaptive_threshold.py
new file mode 100644
index 0000000000000000000000000000000000000000..216e5f7915e1fa1e8ed26532bb54cf6a3f78b85d
--- /dev/null
+++ b/tests/pre_merge/utils/metrics/test_adaptive_threshold.py
@@ -0,0 +1,71 @@
+"""Tests for the adaptive threshold metric."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+import random
+
+import pytest
+import torch
+from pytorch_lightning import Trainer
+
+from anomalib.data import get_datamodule
+from anomalib.models import get_model
+from anomalib.utils.callbacks import get_callbacks
+from anomalib.utils.metrics import AdaptiveThreshold
+from tests.helpers.config import get_test_configurable_parameters
+
+
+@pytest.mark.parametrize(
+ ["labels", "preds", "target_threshold"],
+ [
+ (torch.Tensor([0, 0, 0, 1, 1]), torch.Tensor([2.3, 1.6, 2.6, 7.9, 3.3]), 3.3), # standard case
+ (torch.Tensor([1, 0, 0, 0]), torch.Tensor([4, 3, 2, 1]), 4), # 100% recall for all thresholds
+ ],
+)
+def test_adaptive_threshold(labels, preds, target_threshold):
+ """Test if the adaptive threshold computation returns the desired value."""
+
+ adaptive_threshold = AdaptiveThreshold(default_value=0.5)
+ adaptive_threshold.update(preds, labels)
+ threshold_value = adaptive_threshold.compute()
+
+ assert threshold_value == target_threshold
+
+
+def test_non_adaptive_threshold():
+ """
+ Test if the non-adaptive threshold gets used in the F1 score computation when
+ adaptive thresholding is disabled and no normalization is used.
+ """
+ config = get_test_configurable_parameters(config_path="anomalib/models/padim/config.yaml")
+
+ config.model.normalization_method = "none"
+ config.model.threshold.adaptive = False
+ config.trainer.fast_dev_run = True
+ config.metrics.image = ["F1Score"]
+ config.metrics.pixel = ["F1Score"]
+
+ image_threshold = random.random()
+ pixel_threshold = random.random()
+ config.model.threshold.image_default = image_threshold
+ config.model.threshold.pixel_default = pixel_threshold
+
+ model = get_model(config)
+ datamodule = get_datamodule(config)
+ callbacks = get_callbacks(config)
+
+ trainer = Trainer(**config.trainer, callbacks=callbacks)
+ trainer.fit(model=model, datamodule=datamodule)
+ assert trainer.model.image_metrics.F1Score.threshold == image_threshold
+ assert trainer.model.pixel_metrics.F1Score.threshold == pixel_threshold
diff --git a/tests/pre_merge/utils/test_config.py b/tests/pre_merge/utils/test_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d5cf5706929d5b9be28bef42f7bc22b7726cfad
--- /dev/null
+++ b/tests/pre_merge/utils/test_config.py
@@ -0,0 +1,34 @@
+"""Test Config Getter."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import pytest
+
+from anomalib.config import get_configurable_parameters
+
+
+class TestConfig:
+ """Test Config Getter."""
+
+ def test_get_configurable_parameters_return_correct_model_name(self):
+ """Configurable parameter should return the correct model name."""
+ model_name = "stfpm"
+ configurable_parameters = get_configurable_parameters(model_name)
+ assert configurable_parameters.model.name == model_name
+
+ def test_get_configurable_parameter_fails_with_none_arguments(self):
+ """Configurable parameter should raise an error with none arguments."""
+ with pytest.raises(ValueError):
+ get_configurable_parameters()
diff --git a/third-party-programs.txt b/third-party-programs.txt
new file mode 100644
index 0000000000000000000000000000000000000000..23b6dce10fc5bfd0a312c2d5af607fcb6c18bf47
--- /dev/null
+++ b/third-party-programs.txt
@@ -0,0 +1,24 @@
+
+Third Party Programs File
+
+This file contains the list of third party software ("third party programs")
+contained in the Intel software and their required notices and/or license
+terms. This third party software, even if included with the distribution of
+the Intel software, may be governed by separate license terms, including
+without limitation, third party license terms, other Intel software license
+terms, and open source software license terms. These separate license terms
+govern your use of the third party programs as set forth in the
+"third-party-programs.txt" or other similarly-named text file.
+
+Third party programs and their corresponding required notices and/or license
+terms are listed below.
+
+-------------------------------------------------------------
+
+1. Encoder, Decoder, Discriminator, Generator
+ Copyright (c) 2018-2022 Samet Akcay, Durham University, UK
+ SPDX-License-Identifier: MIT
+
+2. SequenceINN, InvertibleModule, AllInOneBlock
+ Copyright (c) 2018-2022 Lynton Ardizzone, Visual Learning Lab Heidelberg.
+ SPDX-License-Identifier: MIT
diff --git a/tools/benchmarking/README.md b/tools/benchmarking/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..d54e2542532fbc5273417e29ab35ec49457a4010
--- /dev/null
+++ b/tools/benchmarking/README.md
@@ -0,0 +1,36 @@
+# Performance Benchmarking tools
+
+These bash scripts will assist in measuring the training performance of the anomalib library.
+
+The python script (`benchmark.py`) will assist in computing metrics for all the models in the repository.
+
+## Usage
+Run the train.sh with the same args as the tools/train.py. Refer to [`../README.md`](https://github.com/openvinotoolkit/anomalib/blob/development/README.md) for those details.
+
+Note: To collect memory read/write numbers, run the script with sudo privileges. Otherwise, those values will be blank.
+
+```
+sudo -E ./train.sh # Train STFPM on MVTec AD leather
+
+sudo -E ./train.sh --config
+
+sudo -E ./train.sh --model stfpm
+```
+
+The training script will create an output directory in this location, and inside it will have a time stamped directory for each training run you do. You can find the raw logs in there, as well as any errors captured in the train.log file.
+
+For post processing, run the post-process.sh script with the results directory you want to post process.
+
+```
+./post-process.sh ./output/2021Aug31_2351
+```
+
+---
+
+To use the python script, run it from the root directory.
+
+```
+python tools/benchmarking/benchmark.py
+```
+
+The output will be generated in results folder and a csv file for each model.
diff --git a/tools/benchmarking/benchmark.py b/tools/benchmarking/benchmark.py
new file mode 100644
index 0000000000000000000000000000000000000000..313d63339c1b588b29ddd588b6af7c2d01128f09
--- /dev/null
+++ b/tools/benchmarking/benchmark.py
@@ -0,0 +1,302 @@
+"""Benchmark all the algorithms in the repo."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+
+import functools
+import io
+import logging
+import math
+import multiprocessing
+import sys
+import time
+import warnings
+from concurrent.futures import ProcessPoolExecutor, as_completed
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Dict, List, Union, cast
+
+import torch
+from omegaconf import DictConfig, ListConfig, OmegaConf
+from pytorch_lightning import Trainer, seed_everything
+from utils import convert_to_openvino, upload_to_wandb, write_metrics
+
+from anomalib.config import get_configurable_parameters, update_input_size_config
+from anomalib.data import get_datamodule
+from anomalib.models import get_model
+from anomalib.utils.loggers import configure_logger
+from anomalib.utils.sweep import (
+ get_meta_data,
+ get_openvino_throughput,
+ get_run_config,
+ get_sweep_callbacks,
+ get_torch_throughput,
+ set_in_nested_config,
+)
+
+warnings.filterwarnings("ignore")
+
+logger = logging.getLogger(__name__)
+configure_logger()
+pl_logger = logging.getLogger(__file__)
+for logger_name in ["pytorch_lightning", "torchmetrics", "os"]:
+ logging.getLogger(logger_name).setLevel(logging.ERROR)
+
+
+def hide_output(func):
+ """Decorator to hide output of the function.
+
+ Args:
+ func (function): Hides output of this function.
+
+ Raises:
+ Exception: Incase the execution of function fails, it raises an exception.
+
+ Returns:
+ object of the called function
+ """
+
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ std_out = sys.stdout
+ sys.stdout = buf = io.StringIO()
+ try:
+ value = func(*args, **kwargs)
+ except Exception as exp:
+ raise Exception(buf.getvalue()) from exp
+ sys.stdout = std_out
+ return value
+
+ return wrapper
+
+
+@hide_output
+def get_single_model_metrics(model_config: Union[DictConfig, ListConfig], openvino_metrics: bool = False) -> Dict:
+ """Collects metrics for `model_name` and returns a dict of results.
+
+ Args:
+ model_config (DictConfig, ListConfig): Configuration for run
+ openvino_metrics (bool): If True, converts the model to OpenVINO format and gathers inference metrics.
+
+ Returns:
+ Dict: Collection of all the metrics such as time taken, throughput and performance scores.
+ """
+
+ with TemporaryDirectory() as project_path:
+ model_config.project.path = project_path
+ datamodule = get_datamodule(model_config)
+ model = get_model(model_config)
+
+ callbacks = get_sweep_callbacks()
+
+ trainer = Trainer(**model_config.trainer, logger=None, callbacks=callbacks)
+
+ start_time = time.time()
+
+ trainer.fit(model=model, datamodule=datamodule)
+
+ # get start time
+ training_time = time.time() - start_time
+
+ # Creating new variable is faster according to https://stackoverflow.com/a/4330829
+ start_time = time.time()
+ # get test results
+ test_results = trainer.test(model=model, datamodule=datamodule)
+
+ # get testing time
+ testing_time = time.time() - start_time
+
+ meta_data = get_meta_data(model, model_config.model.input_size)
+
+ throughput = get_torch_throughput(model_config, model, datamodule.test_dataloader().dataset, meta_data)
+
+ # Get OpenVINO metrics
+ openvino_throughput = float("nan")
+ if openvino_metrics:
+ # Create dirs for openvino model export
+ openvino_export_path = project_path / Path("exported_models")
+ openvino_export_path.mkdir(parents=True, exist_ok=True)
+ convert_to_openvino(model, openvino_export_path, model_config.model.input_size)
+ openvino_throughput = get_openvino_throughput(
+ model_config, openvino_export_path, datamodule.test_dataloader().dataset, meta_data
+ )
+
+ # arrange the data
+ data = {
+ "Training Time (s)": training_time,
+ "Testing Time (s)": testing_time,
+ "Inference Throughput (fps)": throughput,
+ "OpenVINO Inference Throughput (fps)": openvino_throughput,
+ }
+ for key, val in test_results[0].items():
+ data[key] = float(val)
+
+ return data
+
+
+def compute_on_cpu():
+ """Compute all run configurations over a sigle CPU."""
+ sweep_config = OmegaConf.load("tools/benchmarking/benchmark_params.yaml")
+ for run_config in get_run_config(sweep_config.grid_search):
+ model_metrics = sweep(run_config, 0, sweep_config.seed, False)
+ write_metrics(model_metrics, sweep_config.writer)
+
+
+def compute_on_gpu(
+ run_configs: Union[DictConfig, ListConfig],
+ device: int,
+ seed: int,
+ writers: List[str],
+ compute_openvino: bool = False,
+):
+ """Go over each run config and collect the result.
+
+ Args:
+ run_configs (Union[DictConfig, ListConfig]): List of run configurations.
+ device (int): The GPU id used for running the sweep.
+ seed (int): Fix a seed.
+ writers (List[str]): Destinations to write to.
+ compute_openvino (bool, optional): Compute OpenVINO throughput. Defaults to False.
+ """
+ for run_config in run_configs:
+ if isinstance(run_config, (DictConfig, ListConfig)):
+ model_metrics = sweep(run_config, device, seed, compute_openvino)
+ write_metrics(model_metrics, writers)
+ else:
+ raise ValueError(
+ f"Expecting `run_config` of type DictConfig or ListConfig. Got {type(run_config)} instead."
+ )
+
+
+def distribute_over_gpus():
+ """Distribute metric collection over all available GPUs. This is done by splitting the list of configurations."""
+ sweep_config = OmegaConf.load("tools/benchmarking/benchmark_params.yaml")
+ with ProcessPoolExecutor(
+ max_workers=torch.cuda.device_count(), mp_context=multiprocessing.get_context("spawn")
+ ) as executor:
+ run_configs = list(get_run_config(sweep_config.grid_search))
+ jobs = []
+ for device_id, run_split in enumerate(
+ range(0, len(run_configs), math.ceil(len(run_configs) / torch.cuda.device_count()))
+ ):
+ jobs.append(
+ executor.submit(
+ compute_on_gpu,
+ run_configs[run_split : run_split + math.ceil(len(run_configs) / torch.cuda.device_count())],
+ device_id + 1,
+ sweep_config.seed,
+ sweep_config.writer,
+ sweep_config.compute_openvino,
+ )
+ )
+ for job in jobs:
+ try:
+ job.result()
+ except Exception as exc:
+ raise Exception(f"Error occurred while computing benchmark on device {job}") from exc
+
+
+def distribute():
+ """Run all cpu experiments on a single process. Distribute gpu experiments over all available gpus.
+
+ Args:
+ device_count (int, optional): If device count is 0, uses only cpu else spawn processes according
+ to number of gpus available on the machine. Defaults to 0.
+ """
+ sweep_config = OmegaConf.load("tools/benchmarking/benchmark_params.yaml")
+ devices = sweep_config.hardware
+ if not torch.cuda.is_available() and "gpu" in devices:
+ pl_logger.warning("Config requested GPU benchmarking but torch could not detect any cuda enabled devices")
+ elif {"cpu", "gpu"}.issubset(devices):
+ # Create process for gpu and cpu
+ with ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context("spawn")) as executor:
+ jobs = [executor.submit(compute_on_cpu), executor.submit(distribute_over_gpus)]
+ for job in as_completed(jobs):
+ try:
+ job.result()
+ except Exception as exception:
+ raise Exception(f"Error occurred while computing benchmark on device {job}") from exception
+ elif "cpu" in devices:
+ compute_on_cpu()
+ elif "gpu" in devices:
+ distribute_over_gpus()
+ if "wandb" in sweep_config.writer:
+ upload_to_wandb(team="anomalib")
+
+
+def sweep(
+ run_config: Union[DictConfig, ListConfig], device: int = 0, seed: int = 42, convert_openvino: bool = False
+) -> Dict[str, Union[float, str]]:
+ """Go over all the values mentioned in `grid_search` parameter of the benchmarking config.
+
+ Args:
+ run_config: (Union[DictConfig, ListConfig], optional): Configuration for current run.
+ device (int, optional): Name of the device on which the model is trained. Defaults to 0 "cpu".
+ convert_openvino (bool, optional): Whether to convert the model to openvino format. Defaults to False.
+
+ Returns:
+ Dict[str, Union[float, str]]: Dictionary containing the metrics gathered from the sweep.
+ """
+ seed_everything(seed, workers=True)
+ # This assumes that `model_name` is always present in the sweep config.
+ model_config = get_configurable_parameters(model_name=run_config.model_name)
+ model_config.project.seed = seed
+
+ model_config = cast(DictConfig, model_config) # placate mypy
+ for param in run_config.keys():
+ # grid search keys are always assumed to be strings
+ param = cast(str, param) # placate mypy
+ set_in_nested_config(model_config, param.split("."), run_config[param]) # type: ignore
+
+ # convert image size to tuple in case it was updated by run config
+ model_config = update_input_size_config(model_config)
+
+ # Set device in config. 0 - cpu, [0], [1].. - gpu id
+ model_config.trainer.gpus = 0 if device == 0 else [device - 1]
+
+ if run_config.model_name in ["patchcore", "cflow"]:
+ convert_openvino = False # `torch.cdist` is not supported by onnx version 11
+ # TODO Remove this line when issue #40 is fixed https://github.com/openvinotoolkit/anomalib/issues/40
+ if model_config.model.input_size != (224, 224):
+ return {} # go to next run
+
+ # Run benchmarking for current config
+ model_metrics = get_single_model_metrics(model_config=model_config, openvino_metrics=convert_openvino)
+ output = f"One sweep run complete for model {model_config.model.name}"
+ output += f" On category {model_config.dataset.category}" if model_config.dataset.category is not None else ""
+ output += str(model_metrics)
+ logger.info(output)
+
+ # Append configuration of current run to the collected metrics
+ for key, value in run_config.items():
+ # Skip adding model name to the dataframe
+ if key != "model_name":
+ model_metrics[key] = value
+
+ # Add device name to list
+ model_metrics["device"] = "gpu" if device > 0 else "cpu"
+ model_metrics["model_name"] = run_config.model_name
+
+ return model_metrics
+
+
+if __name__ == "__main__":
+ # Benchmarking entry point.
+ # Spawn multiple processes one for cpu and rest for the number of gpus available in the system.
+ # The idea is to distribute metrics collection over all the available devices.
+
+ logger.info("Benchmarking started 🏃♂️. This will take a while ⏲ depending on your configuration.")
+ distribute()
+ logger.info("Finished gathering results ⚡")
diff --git a/tools/benchmarking/benchmark_params.yaml b/tools/benchmarking/benchmark_params.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..ced9933db5ac113e415466b4567fa3fed5f4adbc
--- /dev/null
+++ b/tools/benchmarking/benchmark_params.yaml
@@ -0,0 +1,30 @@
+seed: 42
+compute_openvino: false
+hardware:
+ - cpu
+ - gpu
+writer:
+ - wandb
+ - tensorboard
+grid_search:
+ dataset:
+ category:
+ - bottle
+ - cable
+ - capsule
+ - carpet
+ - grid
+ - hazelnut
+ - leather
+ - metal_nut
+ - pill
+ - screw
+ - tile
+ - toothbrush
+ - transistor
+ - wood
+ - zipper
+ image_size: [224]
+ model_name:
+ - padim
+ - patchcore
diff --git a/tools/benchmarking/post-process.sh b/tools/benchmarking/post-process.sh
new file mode 100755
index 0000000000000000000000000000000000000000..449179a295c5775ed5d6efb31d117c7c125350f7
--- /dev/null
+++ b/tools/benchmarking/post-process.sh
@@ -0,0 +1,38 @@
+#!/bin/bash -e
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+results_dir=$1
+
+pushd $results_dir
+
+echo "tools/train.py CPU %:"
+grep tools/train.py top.log | awk '{ sum += $9} END { if (NR > 0) printf ("%0.3f\n", sum / NR)}'
+
+echo "Memory Read, Write, IO (GB/s):"
+grep ^MEM -A2 pcm.log | grep SKT | awk '{print $3}' | awk '{sum += $1 } END { if (NR > 0) printf ("%0.3f, ", sum / NR)}'
+grep ^MEM -A2 pcm.log | grep SKT | awk '{print $4}' | awk '{sum += $1 } END { if (NR > 0) printf ("%0.3f, ", sum / NR)}'
+grep ^MEM -A2 pcm.log | grep SKT | awk '{print $5}' | awk '{sum += $1 } END { if (NR > 0) printf ("%0.3f\n", sum / NR)}'
+
+
+echo "Total CPU%:"
+grep "top -" top.log -A 16 | grep -v top | grep -v Tasks | grep -v "%Cpu(s)" | grep -v KiB | grep -v PID | awk '{ printf "%s"",",$9 }' | sed -e 's/,,,/\n/g' | awk '{ for(i=1; i<=NF; i++) j+=$i; print j; j=0 }' | awk '{ sum +=$1 } END { if (NR > 0) print sum / NR}'
+echo "Top Average Percent Memory usage:"
+avg=`grep 'KiB Mem' top.log | awk '{ sum += $8 } END { if (NR > 0) printf ("%0.3f\n", sum / NR)}'`
+total=`grep 'KiB Mem' top.log | awk '{ sum += $4 } END { if (NR > 0) printf ("%0.3f\n", sum / NR)}'`
+echo "scale=4; $avg / $total" | bc
+echo "Top Memory utilization (KiB):"
+echo "$avg"
+popd
diff --git a/tools/benchmarking/train.sh b/tools/benchmarking/train.sh
new file mode 100755
index 0000000000000000000000000000000000000000..fce3c399a8fa20817c7e8ba7771237fab236f8c9
--- /dev/null
+++ b/tools/benchmarking/train.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+# Kill all subprocesses and exit when ctrl-c is pressed
+function stop_all() {
+ echo "Stopping processes"
+ pkill top
+ pkill pcm
+ tokill=`ps aux | grep tools/train.py | grep -v grep | head -n 1 | awk '{print $2}'`
+ kill "${tokill}"
+}
+
+trap "echo; echo Killing processes...; stop_all; exit 0" SIGINT SIGTERM
+
+#Install PCM if it is not already
+if [ -d "./pcm" ]
+then
+ echo PCM installed
+ modprobe msr
+else
+ git clone https://github.com/opcm/pcm.git
+ pushd pcm
+ make
+ make install
+ apt install sysstat
+ modprobe msr
+ popd
+fi
+
+#Creat output dir
+DATETIME=`date -u +"%Y%b%d_%H%M"`
+mkdir -p output/$DATETIME
+rundir="$PWD/output/$DATETIME"
+echo ${rundir}
+
+#Run training
+pushd ..
+python tools/train.py "$@" 2>&1 | tee -a ${rundir}/train.log &
+popd
+sleep 10
+
+#Collect system-level metrics
+top -b -i -o %CPU > "${rundir}"/top.log &
+echo TOP STARTED
+./pcm/pcm.x > "${rundir}"/pcm.log &
+echo PCM STARTED
+
+echo NVIDIA LOOP STARTING
+for i in `seq 1 60`
+do
+ nvidia-smi --query-gpu=utilization.gpu --format=csv >> "${rundir}"/gpu_utilization.log
+ nvidia-smi --query-gpu=utilization.memory --format=csv >> "${rundir}"/gpu_mem.log
+ nvidia-smi --query-gpu=temperature.gpu --format=csv >> "${rundir}"/gpu_temp.log
+ sleep 1
+done
+
+#Cleanup when done
+pkill top
+pkill pcm
diff --git a/tools/benchmarking/utils/__init__.py b/tools/benchmarking/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f2c54b4286617c9abb8906fd37c39110a4146ef
--- /dev/null
+++ b/tools/benchmarking/utils/__init__.py
@@ -0,0 +1,20 @@
+"""Utils specific to running benchmarking scripts."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .convert import convert_to_openvino
+from .metrics import upload_to_wandb, write_metrics
+
+__all__ = ["convert_to_openvino", "write_metrics", "upload_to_wandb"]
diff --git a/tools/benchmarking/utils/convert.py b/tools/benchmarking/utils/convert.py
new file mode 100644
index 0000000000000000000000000000000000000000..70c64023befc8c38bc0e531ba16f46a324ee832c
--- /dev/null
+++ b/tools/benchmarking/utils/convert.py
@@ -0,0 +1,28 @@
+"""Model converters."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from pathlib import Path
+from typing import List, Union
+
+from anomalib.deploy import export_convert
+from anomalib.models import AnomalyModule
+
+
+def convert_to_openvino(model: AnomalyModule, export_path: Union[Path, str], input_size: List[int]):
+ """Convert the trained model to OpenVINO."""
+ export_path = export_path if isinstance(export_path, Path) else Path(export_path)
+ onnx_path = export_path / "model.onnx"
+ export_convert(model, input_size, onnx_path, export_path)
diff --git a/tools/benchmarking/utils/metrics.py b/tools/benchmarking/utils/metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..51236ac75dbf5c9a59a50125153116e3edd9c820
--- /dev/null
+++ b/tools/benchmarking/utils/metrics.py
@@ -0,0 +1,117 @@
+"""Methods to compute and save metrics."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+import random
+import string
+from glob import glob
+from pathlib import Path
+from typing import Dict, List, Union
+
+import pandas as pd
+from torch.utils.tensorboard.writer import SummaryWriter
+
+import wandb
+
+
+def write_metrics(model_metrics: Dict[str, Union[str, float]], writers: List[str]):
+ """Writes metrics to destination provided in the sweep config.
+
+ Args:
+ model_metrics (Dict): Dictionary to be written
+ writers (List[str]): List of destinations.
+ """
+ # Write to file as each run is computed
+ if model_metrics == {} or model_metrics is None:
+ return
+
+ # Write to CSV
+ metrics_df = pd.DataFrame(model_metrics, index=[0])
+ result_path = Path(f"runs/{model_metrics['model_name']}_{model_metrics['device']}.csv")
+ Path.mkdir(result_path.parent, parents=True, exist_ok=True)
+ if not result_path.is_file():
+ metrics_df.to_csv(result_path)
+ else:
+ metrics_df.to_csv(result_path, mode="a", header=False)
+
+ if "tensorboard" in writers:
+ write_to_tensorboard(model_metrics)
+
+
+def write_to_tensorboard(
+ model_metrics: Dict[str, Union[str, float]],
+):
+ """Write model_metrics to tensorboard.
+
+ Args:
+ model_metrics (Dict[str, Union[str, float]]): Dictionary containing collected results.
+ """
+ scalar_metrics = {}
+ scalar_prefixes: List[str] = []
+ string_metrics = {}
+ for key, metric in model_metrics.items():
+ if isinstance(metric, (int, float, bool)):
+ scalar_metrics[key] = metric
+ else:
+ string_metrics[key] = metric
+ scalar_prefixes.append(metric)
+ writer = SummaryWriter(f"runs/{model_metrics['model_name']}_{model_metrics['device']}")
+ for key, metric in model_metrics.items():
+ if isinstance(metric, (int, float, bool)):
+ scalar_metrics[key.replace(".", "/")] = metric # need to join by / for tensorboard grouping
+ writer.add_scalar(key, metric)
+ else:
+ if key == "model_name":
+ continue
+ scalar_prefixes.append(metric)
+ scalar_prefix: str = "/".join(scalar_prefixes)
+ for key, metric in scalar_metrics.items():
+ writer.add_scalar(scalar_prefix + "/" + str(key), metric)
+ writer.close()
+
+
+def get_unique_key(str_len: int) -> str:
+ """Returns a random string of length str_len.
+
+ Args:
+ str_len (int): Length of string.
+
+ Returns:
+ str: Random string
+ """
+ return "".join([random.choice(string.ascii_lowercase) for _ in range(str_len)])
+
+
+def upload_to_wandb(team: str = "anomalib"):
+ """Upload the data in csv files to wandb.
+
+ Creates a project named benchmarking_[two random characters]. This is so that the project names are unique.
+ One issue is that it does not check for collision
+
+ Args:
+ team (str, optional): Name of the team on wandb. This can also be the id of your personal account.
+ Defaults to "anomalib".
+ """
+ project = f"benchmarking_{get_unique_key(2)}"
+ tag_list = ["dataset.category", "model_name", "dataset.image_size", "model.backbone", "device"]
+ for csv_file in glob("runs/*.csv"):
+ table = pd.read_csv(csv_file)
+ for index, row in table.iterrows():
+ row = dict(row[1:]) # remove index column
+ tags = [str(row[column]) for column in tag_list if column in row.keys()]
+ wandb.init(
+ entity=team, project=project, name=f"{row['model_name']}_{row['dataset.category']}_{index}", tags=tags
+ )
+ wandb.log(row)
+ wandb.finish()
diff --git a/tools/hpo/sweep.yaml b/tools/hpo/sweep.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c24beb1006858ab3b0124ceff45821579079a1d6
--- /dev/null
+++ b/tools/hpo/sweep.yaml
@@ -0,0 +1,13 @@
+observation_budget: 10
+method: bayes
+metric:
+ name: pixel_AUROC
+ goal: minimize
+parameters:
+ dataset:
+ category: capsule
+ image_size:
+ values: [128, 256]
+ model:
+ backbone:
+ values: [resnet18, wide_resnet50_2]
diff --git a/tools/hpo/utils/__init__.py b/tools/hpo/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8aaa2ddf1642a8e1a14a2a13247d35697643438
--- /dev/null
+++ b/tools/hpo/utils/__init__.py
@@ -0,0 +1,19 @@
+"""Utils to help in HPO search."""
+
+# Copyright (C) 2021 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from .config import flatten_hpo_params
+
+__all__ = ["flatten_hpo_params"]
diff --git a/tools/hpo/utils/config.py b/tools/hpo/utils/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..63d1eb76ba063afa8630ed32a8465cdc4cf64dc5
--- /dev/null
+++ b/tools/hpo/utils/config.py
@@ -0,0 +1,54 @@
+"""Utils to update configuration files."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from typing import List
+
+from omegaconf import DictConfig
+
+
+def flatten_hpo_params(params_dict: DictConfig) -> DictConfig:
+ """Flatten the nested hpo parameter section of the config object.
+
+ Args:
+ params_dict: DictConfig: The dictionary containing the hpo parameters in the original, nested, structure.
+
+ Returns:
+ flattened version of the parameter dictionary.
+ """
+
+ def process_params(nested_params: DictConfig, keys: List[str], flattened_params: DictConfig):
+ """Flatten nested dictionary till the time it reaches the hpo params.
+
+ Recursive helper function that traverses the nested config object and stores the leaf nodes in a flattened
+ dictionary.
+
+ Args:
+ nested_params: DictConfig: config object containing the original parameters.
+ keys: List[str]: list of keys leading to the current location in the config.
+ flattened_params: DictConfig: Dictionary in which the flattened parameters are stored.
+ """
+ if len({"values", "min", "max"}.intersection(nested_params.keys())) > 0:
+ key = ".".join(keys)
+ flattened_params[key] = nested_params
+ else:
+ for name, cfg in nested_params.items():
+ if isinstance(cfg, DictConfig):
+ process_params(cfg, keys + [str(name)], flattened_params)
+
+ flattened_params_dict = DictConfig({})
+ process_params(params_dict, [], flattened_params_dict)
+
+ return flattened_params_dict
diff --git a/tools/hpo/wandb_sweep.py b/tools/hpo/wandb_sweep.py
new file mode 100644
index 0000000000000000000000000000000000000000..1992ec9897237ce9a77e5405bc29dcfeb385f81f
--- /dev/null
+++ b/tools/hpo/wandb_sweep.py
@@ -0,0 +1,99 @@
+"""Run wandb sweep."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+from argparse import ArgumentParser
+from pathlib import Path
+from typing import Union
+
+import pytorch_lightning as pl
+from omegaconf import DictConfig, ListConfig, OmegaConf
+from pytorch_lightning import seed_everything
+from pytorch_lightning.loggers import WandbLogger
+from utils import flatten_hpo_params
+
+import wandb
+from anomalib.config import get_configurable_parameters, update_input_size_config
+from anomalib.data import get_datamodule
+from anomalib.models import get_model
+from anomalib.utils.sweep import flatten_sweep_params, set_in_nested_config
+
+
+class WandbSweep:
+ """wandb sweep.
+
+ Args:
+ config (DictConfig): Original model configuration.
+ sweep_config (DictConfig): Sweep configuration.
+ """
+
+ def __init__(self, config: Union[DictConfig, ListConfig], sweep_config: Union[DictConfig, ListConfig]) -> None:
+ self.config = config
+ self.sweep_config = sweep_config
+ self.observation_budget = sweep_config.observation_budget
+ if "observation_budget" in self.sweep_config.keys():
+ # this instance check is to silence mypy.
+ if isinstance(self.sweep_config, DictConfig):
+ self.sweep_config.pop("observation_budget")
+
+ def run(self):
+ """Run the sweep."""
+ flattened_hpo_params = flatten_hpo_params(self.sweep_config.parameters)
+ self.sweep_config.parameters = flattened_hpo_params
+ sweep_id = wandb.sweep(
+ OmegaConf.to_object(self.sweep_config),
+ project=f"{self.config.model.name}_{self.config.dataset.name}",
+ )
+ wandb.agent(sweep_id, function=self.sweep, count=self.observation_budget)
+
+ def sweep(self):
+ """Method to load the model, update config and call fit. The metrics are logged to ```wandb``` dashboard."""
+ wandb_logger = WandbLogger(config=flatten_sweep_params(self.sweep_config), log_model=False)
+ sweep_config = wandb_logger.experiment.config
+
+ for param in sweep_config.keys():
+ set_in_nested_config(self.config, param.split("."), sweep_config[param])
+ config = update_input_size_config(self.config)
+
+ model = get_model(config)
+ datamodule = get_datamodule(config)
+
+ # Disable saving checkpoints as all checkpoints from the sweep will get uploaded
+ config.trainer.checkpoint_callback = False
+
+ trainer = pl.Trainer(**config.trainer, logger=wandb_logger)
+ trainer.fit(model, datamodule=datamodule)
+
+
+def get_args():
+ """Gets parameters from commandline."""
+ parser = ArgumentParser()
+ parser.add_argument("--model", type=str, default="padim", help="Name of the algorithm to train/test")
+ parser.add_argument("--model_config", type=Path, required=False, help="Path to a model config file")
+ parser.add_argument("--sweep_config", type=Path, required=True, help="Path to sweep configuration")
+
+ return parser.parse_args()
+
+
+if __name__ == "__main__":
+ args = get_args()
+ model_config = get_configurable_parameters(model_name=args.model, config_path=args.model_config)
+ hpo_config = OmegaConf.load(args.sweep_config)
+
+ if model_config.project.seed != 0:
+ seed_everything(model_config.project.seed)
+
+ sweep = WandbSweep(model_config, hpo_config)
+ sweep.run()
diff --git a/tools/inference.py b/tools/inference.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec7b9dac06db2575ebb6736137b669d2de696e5a
--- /dev/null
+++ b/tools/inference.py
@@ -0,0 +1,170 @@
+"""Anomalib Inferencer Script.
+
+This script performs inference by reading a model config file from
+command line, and show the visualization results.
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import warnings
+from argparse import ArgumentParser, Namespace
+from importlib import import_module
+from pathlib import Path
+from typing import Optional
+
+import cv2
+import numpy as np
+
+from anomalib.config import get_configurable_parameters
+from anomalib.deploy.inferencers.base import Inferencer
+
+
+def get_args() -> Namespace:
+ """Get command line arguments.
+
+ Returns:
+ Namespace: List of arguments.
+ """
+ parser = ArgumentParser()
+ # --model_config_path will be deprecated in 0.2.8 and removed in 0.2.9
+ parser.add_argument("--model_config_path", type=str, required=False, help="Path to a model config file")
+ parser.add_argument("--config", type=Path, required=True, help="Path to a model config file")
+ parser.add_argument("--weight_path", type=Path, required=True, help="Path to a model weights")
+ parser.add_argument("--image_path", type=Path, required=True, help="Path to an image to infer.")
+ parser.add_argument("--save_path", type=Path, required=False, help="Path to save the output image.")
+ parser.add_argument("--meta_data", type=Path, required=False, help="Path to JSON file containing the metadata.")
+ parser.add_argument(
+ "--overlay_mask",
+ type=bool,
+ required=False,
+ default=False,
+ help="Overlay the segmentation mask on the image. It assumes that the task is segmentation.",
+ )
+
+ args = parser.parse_args()
+ if args.model_config_path is not None:
+ warnings.warn(
+ message="--model_config_path will be deprecated in v0.2.8 and removed in v0.2.9. Use --config instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ args.config = args.model_config_path
+
+ return args
+
+
+def add_label(prediction: np.ndarray, scores: float, font: int = cv2.FONT_HERSHEY_PLAIN) -> np.ndarray:
+ """If the model outputs score, it adds the score to the output image.
+
+ Args:
+ prediction (np.ndarray): Resized anomaly map.
+ scores (float): Confidence score.
+
+ Returns:
+ np.ndarray: Image with score text.
+ """
+ text = f"Confidence Score {scores:.0%}"
+ font_size = prediction.shape[1] // 1024 + 1 # Text scale is calculated based on the reference size of 1024
+ (width, height), baseline = cv2.getTextSize(text, font, font_size, thickness=font_size // 2)
+ label_patch = np.zeros((height + baseline, width + baseline, 3), dtype=np.uint8)
+ label_patch[:, :] = (225, 252, 134)
+ cv2.putText(label_patch, text, (0, baseline // 2 + height), font, font_size, 0)
+ prediction[: baseline + height, : baseline + width] = label_patch
+ return prediction
+
+
+def stream() -> None:
+ """Stream predictions.
+
+ Show/save the output if path is to an image. If the path is a directory, go over each image in the directory.
+ """
+ # Get the command line arguments, and config from the config.yaml file.
+ # This config file is also used for training and contains all the relevant
+ # information regarding the data, model, train and inference details.
+ args = get_args()
+ config = get_configurable_parameters(config_path=args.config)
+
+ # Get the inferencer. We use .ckpt extension for Torch models and (onnx, bin)
+ # for the openvino models.
+ extension = args.weight_path.suffix
+ inferencer: Inferencer
+ if extension in (".ckpt"):
+ module = import_module("anomalib.deploy.inferencers.torch")
+ TorchInferencer = getattr(module, "TorchInferencer") # pylint: disable=invalid-name
+ inferencer = TorchInferencer(config=config, model_source=args.weight_path, meta_data_path=args.meta_data)
+
+ elif extension in (".onnx", ".bin", ".xml"):
+ module = import_module("anomalib.deploy.inferencers.openvino")
+ OpenVINOInferencer = getattr(module, "OpenVINOInferencer") # pylint: disable=invalid-name
+ inferencer = OpenVINOInferencer(config=config, path=args.weight_path, meta_data_path=args.meta_data)
+
+ else:
+ raise ValueError(
+ f"Model extension is not supported. Torch Inferencer exptects a .ckpt file,"
+ f"OpenVINO Inferencer expects either .onnx, .bin or .xml file. Got {extension}"
+ )
+ if args.image_path.is_dir():
+ # Write the output to save_path in the same structure as the input directory.
+ for image in args.image_path.glob("**/*"):
+ if image.is_file() and image.suffix in (".jpg", ".png", ".jpeg"):
+ # Here save_path is assumed to be a directory. Image subdirectories are appended to the save_path.
+ save_path = Path(args.save_path / image.relative_to(args.image_path).parent) if args.save_path else None
+ infer(image, inferencer, save_path, args.overlay_mask)
+ elif args.image_path.suffix in (".jpg", ".png", ".jpeg"):
+ infer(args.image_path, inferencer, args.save_path, args.overlay_mask)
+ else:
+ raise ValueError(
+ f"Image extension is not supported. Supported extensions are .jpg, .png, .jpeg."
+ f" Got {args.image_path.suffix}"
+ )
+
+
+def infer(image_path: Path, inferencer: Inferencer, save_path: Optional[Path] = None, overlay: bool = False) -> None:
+ """Perform inference on a single image.
+
+ Args:
+ image_path (Path): Path to image/directory containing images.
+ inferencer (Inferencer): Inferencer to use.
+ save_path (Path, optional): Path to save the output image. If this is None, the output is visualized.
+ overlay (bool, optional): Overlay the segmentation mask on the image. It assumes that the task is segmentation.
+ """
+ # Perform inference for the given image or image path. if image
+ # path is provided, `predict` method will read the image from
+ # file for convenience. We set the superimpose flag to True
+ # to overlay the predicted anomaly map on top of the input image.
+ output = inferencer.predict(image=image_path, superimpose=True, overlay_mask=overlay)
+
+ # Incase both anomaly map and scores are returned add scores to the image.
+ if isinstance(output, tuple):
+ anomaly_map, score = output
+ output = add_label(anomaly_map, score)
+
+ # Show or save the output image, depending on what's provided as
+ # the command line argument.
+ output = cv2.cvtColor(output, cv2.COLOR_RGB2BGR)
+ if save_path is None:
+ cv2.imshow("Anomaly Map", output)
+ cv2.waitKey(0) # wait for any key press
+ else:
+ # Create directory for parents if it doesn't exist.
+ save_path.parent.mkdir(parents=True, exist_ok=True)
+ if save_path.suffix == "": # This is a directory
+ save_path.mkdir(exist_ok=True) # Create current directory
+ save_path = save_path / image_path.name
+ cv2.imwrite(filename=str(save_path), img=output)
+
+
+if __name__ == "__main__":
+ stream()
diff --git a/tools/inference_gradio.py b/tools/inference_gradio.py
new file mode 100644
index 0000000000000000000000000000000000000000..016418cf746e1a829b89b71dcd59478c76328240
--- /dev/null
+++ b/tools/inference_gradio.py
@@ -0,0 +1,126 @@
+"""Anomalib Gradio Script.
+
+This script provide a gradio web interface
+"""
+
+from argparse import ArgumentParser, Namespace
+from importlib import import_module
+from pathlib import Path
+from typing import Tuple, Union
+
+import gradio as gr
+import gradio.inputs
+import gradio.outputs
+import numpy as np
+from omegaconf import DictConfig, ListConfig
+from skimage.segmentation import mark_boundaries
+
+from anomalib.config import get_configurable_parameters
+from anomalib.deploy.inferencers.base import Inferencer
+from anomalib.post_processing import compute_mask, superimpose_anomaly_map
+
+
+def infer(
+ image: np.ndarray, inferencer: Inferencer, threshold: float = 50.0
+) -> Tuple[np.ndarray, float, np.ndarray, np.ndarray, np.ndarray]:
+ """Inference fonction, return anomaly map, score, heat map, prediction mask ans visualisation.
+
+ :param image: image
+ :type image: np.ndarray
+ :param inferencer: model inferencer
+ :type inferencer: Inferencer
+ :param threshold: threshold between 0 and 100, defaults to 50.0
+ :type threshold: float, optional
+ :return: anomaly_map, anomaly_score, heat_map, pred_mask, vis_img
+ :rtype: Tuple[np.ndarray, float, np.ndarray, np.ndarray, np.ndarray]
+ """
+ # Perform inference for the given image.
+ threshold = threshold / 100
+ anomaly_map, anomaly_score = inferencer.predict(image=image, superimpose=False)
+ heat_map = superimpose_anomaly_map(anomaly_map, image)
+ pred_mask = compute_mask(anomaly_map, threshold)
+ vis_img = mark_boundaries(image, pred_mask, color=(1, 0, 0), mode="thick")
+ return anomaly_map, anomaly_score, heat_map, pred_mask, vis_img
+
+
+def get_args() -> Namespace:
+ """Get command line arguments.
+
+ Returns:
+ Namespace: List of arguments.
+ """
+ parser = ArgumentParser()
+ parser.add_argument("--config", type=Path, required=True, help="Path to a model config file")
+ parser.add_argument("--weight_path", type=Path, required=True, help="Path to a model weights")
+ parser.add_argument("--meta_data", type=Path, required=False, help="Path to JSON file containing the metadata.")
+
+ parser.add_argument(
+ "--threshold",
+ type=float,
+ required=False,
+ default=75.0,
+ help="Value to threshold anomaly scores into 0-100 range",
+ )
+
+ parser.add_argument("--share", type=bool, required=False, default=False, help="Share Gradio `share_url`")
+
+ args = parser.parse_args()
+
+ return args
+
+
+def get_inferencer(gladio_args: Union[DictConfig, ListConfig]) -> Inferencer:
+ """Parse args and open inferencer."""
+ config = get_configurable_parameters(config_path=gladio_args.config)
+ # Get the inferencer. We use .ckpt extension for Torch models and (onnx, bin)
+ # for the openvino models.
+ extension = gladio_args.weight_path.suffix
+ inferencer: Inferencer
+ if extension in (".ckpt"):
+ module = import_module("anomalib.deploy.inferencers.torch")
+ TorchInferencer = getattr(module, "TorchInferencer") # pylint: disable=invalid-name
+ inferencer = TorchInferencer(
+ config=config, model_source=gladio_args.weight_path, meta_data_path=gladio_args.meta_data
+ )
+
+ elif extension in (".onnx", ".bin", ".xml"):
+ module = import_module("anomalib.deploy.inferencers.openvino")
+ OpenVINOInferencer = getattr(module, "OpenVINOInferencer") # pylint: disable=invalid-name
+ inferencer = OpenVINOInferencer(
+ config=config, path=gladio_args.weight_path, meta_data_path=gladio_args.meta_data
+ )
+
+ else:
+ raise ValueError(
+ f"Model extension is not supported. Torch Inferencer exptects a .ckpt file,"
+ f"OpenVINO Inferencer expects either .onnx, .bin or .xml file. Got {extension}"
+ )
+
+ return inferencer
+
+
+if __name__ == "__main__":
+ session_args = get_args()
+
+ gladio_inferencer = get_inferencer(session_args)
+
+ iface = gr.Interface(
+ fn=lambda image, threshold: infer(image, gladio_inferencer, threshold),
+ inputs=[
+ gradio.inputs.Image(
+ shape=None, image_mode="RGB", source="upload", tool="editor", type="numpy", label="Image"
+ ),
+ gradio.inputs.Slider(default=session_args.threshold, label="threshold", optional=False),
+ ],
+ outputs=[
+ gradio.outputs.Image(type="numpy", label="Anomaly Map"),
+ gradio.outputs.Textbox(type="number", label="Anomaly Score"),
+ gradio.outputs.Image(type="numpy", label="Predicted Heat Map"),
+ gradio.outputs.Image(type="numpy", label="Predicted Mask"),
+ gradio.outputs.Image(type="numpy", label="Segmentation Result"),
+ ],
+ title="Anomalib",
+ description="Anomalib Gradio",
+ )
+
+ iface.launch(share=session_args.share)
diff --git a/tools/test.py b/tools/test.py
new file mode 100644
index 0000000000000000000000000000000000000000..cf0b6b8c386067cd8ba6f2e884d7e5b466f9f8ad
--- /dev/null
+++ b/tools/test.py
@@ -0,0 +1,75 @@
+"""Test This script performs inference on the test dataset and saves the output visualizations into a directory."""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import warnings
+from argparse import ArgumentParser, Namespace
+
+from pytorch_lightning import Trainer
+
+from anomalib.config import get_configurable_parameters
+from anomalib.data import get_datamodule
+from anomalib.models import get_model
+from anomalib.utils.callbacks import get_callbacks
+
+
+def get_args() -> Namespace:
+ """Get CLI arguments.
+
+ Returns:
+ Namespace: CLI arguments.
+ """
+ parser = ArgumentParser()
+ parser.add_argument("--model", type=str, default="stfpm", help="Name of the algorithm to train/test")
+ # --model_config_path will be deprecated in 0.2.8 and removed in 0.2.9
+ parser.add_argument("--model_config_path", type=str, required=False, help="Path to a model config file")
+ parser.add_argument("--config", type=str, required=False, help="Path to a model config file")
+ parser.add_argument("--weight_file", type=str, default="weights/model.ckpt")
+
+ args = parser.parse_args()
+ if args.model_config_path is not None:
+ warnings.warn(
+ message="--model_config_path will be deprecated in v0.2.8 and removed in v0.2.9. Use --config instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ args.config = args.model_config_path
+
+ return args
+
+
+def test():
+ """Test an anomaly classification and segmentation model that is initially trained via `tools/train.py`.
+
+ The script is able to write the results into both filesystem and a logger such as Tensorboard.
+ """
+ args = get_args()
+ config = get_configurable_parameters(
+ model_name=args.model,
+ config_path=args.config,
+ weight_file=args.weight_file,
+ )
+
+ datamodule = get_datamodule(config)
+ model = get_model(config)
+
+ callbacks = get_callbacks(config)
+
+ trainer = Trainer(callbacks=callbacks, **config.trainer)
+ trainer.test(model=model, datamodule=datamodule)
+
+
+if __name__ == "__main__":
+ test()
diff --git a/tools/train.py b/tools/train.py
new file mode 100644
index 0000000000000000000000000000000000000000..bbe859de90079ddb00fca10a71fdde4bb03f0524
--- /dev/null
+++ b/tools/train.py
@@ -0,0 +1,99 @@
+"""Anomalib Traning Script.
+
+This script reads the name of the model or config file from command
+line, train/test the anomaly model to get quantitative and qualitative
+results.
+"""
+
+# Copyright (C) 2020 Intel Corporation
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions
+# and limitations under the License.
+
+import logging
+import warnings
+from argparse import ArgumentParser, Namespace
+
+from pytorch_lightning import Trainer, seed_everything
+
+from anomalib.config import get_configurable_parameters
+from anomalib.data import get_datamodule
+from anomalib.models import get_model
+from anomalib.utils.callbacks import LoadModelCallback, get_callbacks
+from anomalib.utils.loggers import configure_logger, get_experiment_logger
+
+logger = logging.getLogger("anomalib")
+
+
+def get_args() -> Namespace:
+ """Get command line arguments.
+
+ Returns:
+ Namespace: List of arguments.
+ """
+ parser = ArgumentParser()
+ parser.add_argument("--model", type=str, default="padim", help="Name of the algorithm to train/test")
+ # --model_config_path will be deprecated in 0.2.8 and removed in 0.2.9
+ parser.add_argument("--model_config_path", type=str, required=False, help="Path to a model config file")
+ parser.add_argument("--config", type=str, required=False, help="Path to a model config file")
+ parser.add_argument("--log-level", type=str, default="INFO", help="")
+
+ args = parser.parse_args()
+ if args.model_config_path is not None:
+ warnings.warn(
+ message="--model_config_path will be deprecated in v0.2.8 and removed in v0.2.9. Use --config instead.",
+ category=DeprecationWarning,
+ stacklevel=2,
+ )
+ args.config = args.model_config_path
+
+ return args
+
+
+def train():
+ """Train an anomaly classification or segmentation model based on a provided configuration file."""
+ args = get_args()
+ configure_logger(level=args.log_level)
+
+ config = get_configurable_parameters(model_name=args.model, config_path=args.config)
+ if config.project.seed != 0:
+ seed_everything(config.project.seed)
+
+ if args.log_level == "ERROR":
+ warnings.filterwarnings("ignore")
+
+ logger.info("Loading the datamodule")
+ datamodule = get_datamodule(config)
+
+ logger.info("Loading the model.")
+ model = get_model(config)
+
+ logger.info("Loading the experiment logger(s)")
+ experiment_logger = get_experiment_logger(config)
+
+ logger.info("Loading the callbacks")
+ callbacks = get_callbacks(config)
+
+ trainer = Trainer(**config.trainer, logger=experiment_logger, callbacks=callbacks)
+ logger.info("Training the model.")
+ trainer.fit(model=model, datamodule=datamodule)
+
+ logger.info("Loading the best model weights.")
+ load_model_callback = LoadModelCallback(weights_path=trainer.checkpoint_callback.best_model_path)
+ trainer.callbacks.insert(0, load_model_callback)
+
+ logger.info("Testing the model.")
+ trainer.test(model=model, datamodule=datamodule)
+
+
+if __name__ == "__main__":
+ train()
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000000000000000000000000000000000000..88b916fa7a0abb4bfe12e95067265f1dddd103c7
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,157 @@
+[tox]
+isolated_build = True
+skip_missing_interpreters = true
+envlist =
+ black
+ isort
+ flake8
+ pylint
+ mypy
+ pydocstyle
+ pre_merge
+ nightly
+
+[testenv:black]
+basepython = python3
+deps = black==22.3.0
+commands = black --check --diff anomalib -l 120
+
+[testenv:isort]
+basepython = python3
+deps = isort==5.10.1
+commands = isort --check-only --df anomalib --profile=black
+
+[testenv:flake8]
+skip_install = true
+basepython = python3
+deps =
+ flake8
+ mccabe
+commands = flake8 anomalib --exclude=anomalib/models/components/freia
+
+
+[testenv:pylint]
+skip_install = true
+basepython = python3
+deps =
+ pylint
+ -r{toxinidir}/requirements/base.txt
+commands = pylint anomalib --rcfile=tox.ini --ignore=anomalib/models/components/freia/
+
+[testenv:mypy]
+basepython = python3
+passenv = ftp_proxy
+ HTTP_PROXY
+ HTTPS_PROXY
+deps =
+ mypy
+ -r{toxinidir}/requirements/base.txt
+commands =
+ python -m mypy --install-types --non-interactive anomalib --config-file tox.ini
+ python -m mypy --install-types --non-interactive tools/train.py --config-file tox.ini
+ python -m mypy --install-types --non-interactive tools/test.py --config-file tox.ini
+
+
+[testenv:pydocstyle]
+basepython = python3
+deps =
+ pydocstyle
+commands = pydocstyle anomalib --config=tox.ini
+
+[testenv:pre_merge]
+basepython = python3
+passenv = ftp_proxy
+ HTTP_PROXY
+ HTTPS_PROXY
+ CUDA_VISIBLE_DEVICES
+ ANOMALIB_DATASET_PATH
+deps =
+ coverage
+ pytest
+ flaky
+ -r{toxinidir}/requirements/base.txt
+ -r{toxinidir}/requirements/openvino.txt
+commands =
+ coverage erase
+ coverage run --include=anomalib/* -m pytest tests/pre_merge/ -ra --showlocals
+ ; https://github.com/openvinotoolkit/anomalib/issues/94
+ coverage report -m --fail-under=85
+ coverage xml -o {toxworkdir}/coverage.xml
+
+[testenv:nightly]
+basepython = python3
+passenv = ftp_proxy
+ HTTP_PROXY
+ HTTPS_PROXY
+ CUDA_VISIBLE_DEVICES
+ ANOMALIB_DATASET_PATH
+deps =
+ coverage
+ pytest
+ flaky
+ -r{toxinidir}/requirements/base.txt
+ -r{toxinidir}/requirements/openvino.txt
+commands =
+ coverage erase
+ coverage run --include=anomalib/* -m pytest tests/nightly/ -ra --showlocals
+ ; https://github.com/openvinotoolkit/anomalib/issues/94
+ coverage report -m --fail-under=64
+ coverage xml -o {toxworkdir}/coverage.xml
+
+[flake8]
+max-line-length = 120
+ignore = E203,W503
+
+[pylint]
+extension-pkg-whitelist = cv2
+ignored-modules = cv2
+disable = duplicate-code,
+ arguments-differ,
+ fixme,
+ import-error,
+ no-self-use,
+ too-many-arguments,
+ too-many-branches,
+ too-many-instance-attributes,
+ too-many-locals,
+ too-few-public-methods,
+
+generated-members = numpy.*, torch.*
+good-names = e, i, id
+ignore = tests,docs,anomalib/models/components/freia
+
+max-line-length = 120
+max-parents = 15
+min-similarity-lines = 5
+
+
+[mypy]
+ignore_missing_imports = True
+show_error_codes = True
+exclude = anomalib/models/components/freia/
+[mypy-anomalib.models.components.freia.*]
+follow_imports = skip
+[mypy-torch.*]
+follow_imports = skip
+follow_imports_for_stubs = True
+
+
+[coverage:report]
+exclude_lines =
+ except ImportError
+ raise ImportError
+ except ApiException
+ raise ApiException
+ raise ValueError
+
+[pydocstyle]
+inherit = false
+ignore = D107, ; Missing docstring in __init__
+ D202, ; No blank lines allowed after function docstring
+ D203, ; 1 blank line required before class docstring
+ D213, ; Multi-line docstring summary should start at the second line
+ D401, ; First line should be in imperative mood; try rephrasing
+ D404, ; First word of the docstring should not be This
+ D406, ; Section name should end with a newline
+ D407, ; Missing dashed underline after section
+ D413 ; Missing blank line after last section