diff --git a/docs/source/examples b/docs/source/examples deleted file mode 120000 index d15735c1df..0000000000 --- a/docs/source/examples +++ /dev/null @@ -1 +0,0 @@ -../../examples \ No newline at end of file diff --git a/docs/source/examples b/docs/source/examples new file mode 100644 index 0000000000..86a31e1e83 --- /dev/null +++ b/docs/source/examples @@ -0,0 +1 @@ +../../examples diff --git a/examples/configs/data/adam_3d.yaml b/examples/configs/data/adam_3d.yaml new file mode 100644 index 0000000000..31452eb422 --- /dev/null +++ b/examples/configs/data/adam_3d.yaml @@ -0,0 +1,10 @@ +class_path: anomalib.data.ADA3D +init_args: + root: ./datasets/ADAM3D + category: "1m1" + train_batch_size: 32 + eval_batch_size: 32 + num_workers: 8 + test_split_mode: from_dir + val_split_mode: from_dir + seed: null diff --git a/examples/configs/model/fre.yaml b/examples/configs/model/fre.yaml old mode 100755 new mode 100644 diff --git a/src/anomalib/data/__init__.py b/src/anomalib/data/__init__.py index 835b2dd5a7..f9cc656cd5 100644 --- a/src/anomalib/data/__init__.py +++ b/src/anomalib/data/__init__.py @@ -48,7 +48,7 @@ # Datamodules from .datamodules.base import AnomalibDataModule -from .datamodules.depth import DepthDataFormat, Folder3D, MVTec3D +from .datamodules.depth import ADAM3D, DepthDataFormat, Folder3D, MVTec3D from .datamodules.image import ( MPDD, VAD, @@ -69,7 +69,7 @@ # Datasets from .datasets import AnomalibDataset -from .datasets.depth import Folder3DDataset, MVTec3DDataset +from .datasets.depth import ADAM3DDataset, Folder3DDataset, MVTec3DDataset from .datasets.image import ( BTechDataset, DatumaroDataset, @@ -175,6 +175,7 @@ def get_datamodule(config: DictConfig | ListConfig | dict) -> AnomalibDataModule # Depth Data Modules "Folder3D", "MVTec3D", + "ADAM3D", # Image Data Modules "BTech", "Datumaro", @@ -201,6 +202,7 @@ def get_datamodule(config: DictConfig | ListConfig | dict) -> AnomalibDataModule "FolderDataset", "KolektorDataset", "MPDDDataset", + "ADAM3DDataset", "MVTecADDataset", "MVTecLOCODataset", "TabularDataset", diff --git a/src/anomalib/data/datamodules/depth/__init__.py b/src/anomalib/data/datamodules/depth/__init__.py index d6ee8ffb2b..98256e3f77 100644 --- a/src/anomalib/data/datamodules/depth/__init__.py +++ b/src/anomalib/data/datamodules/depth/__init__.py @@ -5,6 +5,7 @@ from enum import Enum +from .adam_3d import ADAM3D from .folder_3d import Folder3D from .mvtec_3d import MVTec3D @@ -14,6 +15,7 @@ class DepthDataFormat(str, Enum): MVTEC_3D = "mvtec_3d" FOLDER_3D = "folder_3d" + ADAM_3D = "adam_3d" -__all__ = ["Folder3D", "MVTec3D"] +__all__ = ["Folder3D", "MVTec3D", "ADAM3D"] diff --git a/src/anomalib/data/datamodules/depth/adam_3d.py b/src/anomalib/data/datamodules/depth/adam_3d.py new file mode 100644 index 0000000000..0483676ab1 --- /dev/null +++ b/src/anomalib/data/datamodules/depth/adam_3d.py @@ -0,0 +1,139 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""3D-ADAM Datamodule. + +This module provides a PyTorch Lightning DataModule for the 3D-ADAM dataset. +The dataset contains RGB and depth image pairs for anomaly detection tasks. + +Example: + Create a ADAM3D datamodule:: + + >>> from anomalib.data import ADAM3D + >>> datamodule = ADAM3D( + ... root="./datasets/ADAM3D", + ... category="1m1" + ... ) + +License: + 3D-ADAM 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: https://arxiv.org/abs/2507.07838 + +""" + +import logging +from pathlib import Path + +from torchvision.transforms.v2 import Transform + +from anomalib.data.datamodules.base.image import AnomalibDataModule +from anomalib.data.datasets.depth.adam_3d import ADAM3DDataset +from anomalib.data.utils import DownloadInfo, Split, TestSplitMode, ValSplitMode, download_and_extract + +logger = logging.getLogger(__name__) + + +DOWNLOAD_INFO = DownloadInfo( + name="adam_3d", + url="https://huggingface.co/datasets/pmchard/3D-ADAM_anomalib/resolve/main/adam3d_cropped.zip", + hashsum="ffc4c52afa1566a4165c42300c21758ec8292ff04305c65e81e830abb8200c36", +) + + +class ADAM3D(AnomalibDataModule): + """3D-ADAM Datamodule. + + Args: + root (Path | str): Path to the root of the dataset. + Defaults to ``"./datasets/ADAM3D"``. + category (str): Category of the 3D-ADAM dataset (e.g. ``"1m1"`` or + ``"spiral_gear"``). Defaults to ``"1m1"``. + train_batch_size (int, optional): Training batch size. + Defaults to ``32``. + eval_batch_size (int, optional): Test batch size. + Defaults to ``32``. + num_workers (int, optional): Number of workers for data loading. + Defaults to ``8``. + train_augmentations (Transform | None): Augmentations to apply dto the training images + Defaults to ``None``. + val_augmentations (Transform | None): Augmentations to apply to the validation images. + Defaults to ``None``. + test_augmentations (Transform | None): Augmentations to apply to the test images. + Defaults to ``None``. + augmentations (Transform | None): General augmentations to apply if stage-specific + augmentations are not provided. + test_split_mode (TestSplitMode | str): Method to create test set. + Defaults to ``TestSplitMode.FROM_DIR``. + test_split_ratio (float): Fraction of data to use for testing. + Defaults to ``0.2``. + val_split_mode (ValSplitMode | str): Method to create validation set. + Defaults to ``ValSplitMode.SAME_AS_TEST``. + val_split_ratio (float): Fraction of data to use for validation. + Defaults to ``0.5``. + seed (int | None, optional): Random seed for reproducibility. + Defaults to ``None``. + """ + + def __init__( + self, + root: Path | str = "./datasets/ADAM3D", + category: str = "1m1", + train_batch_size: int = 32, + eval_batch_size: int = 32, + num_workers: int = 8, + train_augmentations: Transform | None = None, + val_augmentations: Transform | None = None, + test_augmentations: Transform | None = None, + augmentations: Transform | None = None, + test_split_mode: TestSplitMode | str = TestSplitMode.FROM_DIR, + test_split_ratio: float = 0.2, + val_split_mode: ValSplitMode | str = ValSplitMode.SAME_AS_TEST, + val_split_ratio: float = 0.5, + seed: int | None = None, + ) -> None: + super().__init__( + train_batch_size=train_batch_size, + eval_batch_size=eval_batch_size, + num_workers=num_workers, + train_augmentations=train_augmentations, + val_augmentations=val_augmentations, + test_augmentations=test_augmentations, + augmentations=augmentations, + test_split_mode=test_split_mode, + test_split_ratio=test_split_ratio, + val_split_mode=val_split_mode, + val_split_ratio=val_split_ratio, + seed=seed, + ) + + self.root = Path(root) + self.category = category + + def _setup(self, _stage: str | None = None) -> None: + """Set up the datasets. + + Args: + _stage (str | None, optional): Stage of setup. Not used. + Defaults to ``None``. + """ + self.train_data = ADAM3DDataset( + split=Split.TRAIN, + root=self.root, + category=self.category, + ) + self.test_data = ADAM3DDataset( + split=Split.TEST, + root=self.root, + category=self.category, + ) + + def prepare_data(self) -> None: + """Download the dataset if not available.""" + if (self.root / self.category).is_dir(): + logger.info("Found the dataset.") + else: + download_and_extract(self.root, DOWNLOAD_INFO) diff --git a/src/anomalib/data/datasets/__init__.py b/src/anomalib/data/datasets/__init__.py index 85568ab1b1..0986e6faa1 100644 --- a/src/anomalib/data/datasets/__init__.py +++ b/src/anomalib/data/datasets/__init__.py @@ -13,6 +13,7 @@ Depth Datasets: - ``Folder3DDataset``: Custom RGB-D dataset from folder structure - ``MVTec3DDataset``: MVTec 3D AD dataset with industrial objects + - ``ADAM3DDataset``: 3D ADAM dataset with additive manufactured objects Image Datasets: - ``BTechDataset``: BTech dataset containing industrial objects @@ -40,7 +41,7 @@ """ from .base import AnomalibDataset, AnomalibDepthDataset, AnomalibVideoDataset -from .depth import Folder3DDataset, MVTec3DDataset +from .depth import ADAM3DDataset, Folder3DDataset, MVTec3DDataset from .image import ( BTechDataset, DatumaroDataset, @@ -62,6 +63,7 @@ # Depth "Folder3DDataset", "MVTec3DDataset", + "ADAM3DDataset", # Image "BTechDataset", "DatumaroDataset", diff --git a/src/anomalib/data/datasets/depth/__init__.py b/src/anomalib/data/datasets/depth/__init__.py index 969661745c..ae3e8fcf3d 100644 --- a/src/anomalib/data/datasets/depth/__init__.py +++ b/src/anomalib/data/datasets/depth/__init__.py @@ -8,6 +8,7 @@ - ``Folder3DDataset``: Custom dataset for loading RGB-D data from a folder structure - ``MVTec3DDataset``: Implementation of the MVTec 3D-AD dataset +- ``ADAM3DDataset``: Implementation of the 3D-ADAM dataset Example: >>> from anomalib.data.datasets import Folder3DDataset @@ -25,7 +26,8 @@ ... ) """ +from .adam_3d import ADAM3DDataset from .folder_3d import Folder3DDataset from .mvtec_3d import MVTec3DDataset -__all__ = ["Folder3DDataset", "MVTec3DDataset"] +__all__ = ["Folder3DDataset", "MVTec3DDataset", "ADAM3DDataset"] diff --git a/src/anomalib/data/datasets/depth/adam_3d.py b/src/anomalib/data/datasets/depth/adam_3d.py new file mode 100644 index 0000000000..584ec7adb7 --- /dev/null +++ b/src/anomalib/data/datasets/depth/adam_3d.py @@ -0,0 +1,223 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""3D-ADAM Datamodule. + +This module provides PyTorch Dataset, Dataloader and PyTorch Lightning DataModule for +the 3D-ADAM dataset. If the dataset is not available locally, it will be +downloaded and extracted automatically. + +License: + 3D-ADAM 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: https://arxiv.org/abs/2507.07838 + +""" + +from collections.abc import Sequence +from pathlib import Path + +from pandas import DataFrame +from torchvision.transforms.v2 import Transform + +from anomalib.data.datasets.base.depth import AnomalibDepthDataset +from anomalib.data.errors import MisMatchError +from anomalib.data.utils import LabelName, Split, validate_path + +IMG_EXTENSIONS = [".png", ".PNG", ".tiff"] +CATEGORIES = ( + "1m1", + "1m2", + "1m3", + "2m1", + "2m2h", + "2m2m", + "3m1", + "3m2", + "3m2c", + "4m1", + "4m2", + "4m2c", + "gripper_closed", + "gripper_open", + "helicalgear1", + "helicalgear2", + "rackgear", + "spiralgear", + "spurgear", + "tapa2m1", + "tapa3m1", + "tapa4m1", + "tapatbb", +) + + +class ADAM3DDataset(AnomalibDepthDataset): + """3D ADAM dataset class. + + Args: + root (Path | str): Path to the root of the dataset. + Defaults to ``"./datasets/ADAM3D"``. + category (str): Category name, e.g. ``"1m1"``. + Defaults to ``"1m1"``. + augmentations (Transform, optional): Augmentations that should be applied to the input images. + Defaults to ``None``. + split (str | Split | None): Dataset split - usually ``Split.TRAIN`` or + ``Split.TEST``. Defaults to ``None``. + + Example: + >>> from pathlib import Path + >>> dataset = ADAM3DDataset( + ... root=Path("./datasets/ADAM3D"), + ... category="1m1", + ... split="train" + ... ) + """ + + def __init__( + self, + root: Path | str = "./datasets/Adam3D", + category: str = "1m1", + augmentations: Transform | None = None, + split: str | Split | None = None, + ) -> None: + super().__init__(augmentations=augmentations) + + self.root_category = Path(root) / Path(category) + self.split = split + self.samples = make_adam_3d_dataset( + self.root_category, + split=self.split, + extensions=IMG_EXTENSIONS, + ) + + +def make_adam_3d_dataset( + root: str | Path, + split: str | Split | None = None, + extensions: Sequence[str] | None = None, +) -> DataFrame: + """Create 3D-ADAM samples by parsing the data directory structure. + + The files are expected to follow this structure:: + + path/to/dataset/split/category/image_filename.png + path/to/dataset/ground_truth/category/mask_filename.png + + The function creates a DataFrame with the following format:: + + +---+---------------+-------+---------+---------------+--------------------+ + | | path | split | label | image_path | mask_path | + +---+---------------+-------+---------+---------------+--------------------+ + | 0 | datasets/name | test | defect | filename.png | defect/mask.png | + +---+---------------+-------+---------+---------------+--------------------+ + + Args: + root (Path | str): Path to the dataset root directory. + split (str | Split | None, optional): Dataset split (e.g., ``"train"`` or + ``"test"``). Defaults to ``None``. + extensions (Sequence[str] | None, optional): List of valid file extensions. + Defaults to ``None``. + + Returns: + DataFrame: DataFrame containing the dataset samples. + + Example: + >>> from pathlib import Path + >>> root = Path("./datasets/ADAM3D/1m1") + >>> samples = make_adam_3d_dataset(root, split="train") + >>> samples.head() + path split label image_path mask_path + 0 ADAM3D train good train/good/rgb/001_C.png ground_truth/001_C.png + 1 ADAM3D train good train/good/rgb/015_D.png ground_truth/015_D.png + + Raises: + RuntimeError: If no images are found in the root directory. + MisMatchError: If there is a mismatch between images and their + corresponding mask/depth files. + """ + if extensions is None: + extensions = IMG_EXTENSIONS + + root = validate_path(root) + samples_list = [(str(root), *f.parts[-4:]) for f in root.glob(r"**/*") if f.suffix in extensions] + if not samples_list: + msg = f"Found 0 images in {root}" + raise RuntimeError(msg) + + samples = DataFrame( + samples_list, + columns=["path", "split", "label", "type", "file_name"], + ) + + # Modify image_path column by converting to absolute path + samples.loc[(samples.type == "rgb"), "image_path"] = ( + samples.path + "/" + samples.split + "/" + samples.label + "/" + "rgb/" + samples.file_name + ) + samples.loc[(samples.type == "rgb"), "depth_path"] = ( + samples.path + + "/" + + samples.split + + "/" + + samples.label + + "/" + + "xyz/" + + samples.file_name.str.split(".").str[0] + + ".tiff" + ) + + # Create label index for normal (0) and anomalous (1) images. + samples.loc[(samples.label == "good"), "label_index"] = LabelName.NORMAL + samples.loc[(samples.label != "good"), "label_index"] = LabelName.ABNORMAL + samples.label_index = samples.label_index.astype(int) + + # separate masks from samples + mask_samples = samples.loc[((samples.split == "test") & (samples.type == "rgb"))].sort_values( + by="image_path", + ignore_index=True, + ) + samples = samples.sort_values(by="image_path", ignore_index=True) + + # assign mask paths to all test images + samples.loc[((samples.split == "test") & (samples.type == "rgb")), "mask_path"] = ( + mask_samples.path + "/" + samples.split + "/" + samples.label + "/" + "ground_truth/" + samples.file_name + ) + samples = samples.dropna(subset=["image_path"]) + samples = samples.astype({"image_path": "str", "mask_path": "str", "depth_path": "str"}) + + # assert that the right mask files are associated with the right test images + mismatch_masks = ( + samples.loc[samples.label_index == LabelName.ABNORMAL] + .apply(lambda x: Path(x.image_path).stem in Path(x.mask_path).stem, axis=1) + .all() + ) + if not mismatch_masks: + msg = ( + "Mismatch between anomalous images and ground truth masks. Ensure mask " + "files in 'ground_truth' folder follow the same naming convention as " + "the anomalous images (e.g. image: '000.png', mask: '000.png')." + ) + raise MisMatchError(msg) + + mismatch_depth = ( + samples.loc[samples.label_index == LabelName.ABNORMAL] + .apply(lambda x: Path(x.image_path).stem in Path(x.depth_path).stem, axis=1) + .all() + ) + if not mismatch_depth: + msg = ( + "Mismatch between anomalous images and depth images. Ensure depth " + "files in 'xyz' folder follow the same naming convention as the " + "anomalous images (e.g. image: '000.png', depth: '000.tiff')." + ) + raise MisMatchError(msg) + + # infer the task type + samples.attrs["task"] = "classification" if (samples["mask_path"] == "").all() else "segmentation" + + if split: + samples = samples[samples.split == split].reset_index(drop=True) + + return samples diff --git a/src/anomalib/models/image/cfa/README.md b/src/anomalib/models/image/cfa/README.md old mode 100755 new mode 100644 diff --git a/src/anomalib/models/image/fre/README.md b/src/anomalib/models/image/fre/README.md old mode 100755 new mode 100644 diff --git a/src/anomalib/models/image/fre/__init__.py b/src/anomalib/models/image/fre/__init__.py old mode 100755 new mode 100644 diff --git a/src/anomalib/models/image/fre/lightning_model.py b/src/anomalib/models/image/fre/lightning_model.py old mode 100755 new mode 100644 diff --git a/src/anomalib/models/image/fre/torch_model.py b/src/anomalib/models/image/fre/torch_model.py old mode 100755 new mode 100644 diff --git a/tests/unit/data/datamodule/depth/test_adam_3d.py b/tests/unit/data/datamodule/depth/test_adam_3d.py new file mode 100644 index 0000000000..fd455d780e --- /dev/null +++ b/tests/unit/data/datamodule/depth/test_adam_3d.py @@ -0,0 +1,52 @@ +# Copyright (C) 2025 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Unit Tests - ADAM3D Datamodule.""" + +from pathlib import Path + +import pytest +from torchvision.transforms.v2 import Resize + +from anomalib.data.datamodules.depth import ADAM3D +from tests.unit.data.datamodule.base.depth import _TestAnomalibDepthDatamodule + + +class TestADAM3D(_TestAnomalibDepthDatamodule): + """3D ADAM Datamodule Unit Tests.""" + + @pytest.fixture() + @staticmethod + def datamodule(dataset_path: Path) -> ADAM3D: + """Create and return a 3D ADAM datamodule.""" + datamodule_ = ADAM3D( + root=dataset_path, + category="dummy", + train_batch_size=4, + eval_batch_size=4, + num_workers=0, + augmentations=Resize((256, 256)), + ) + datamodule_.prepare_data() + datamodule_.setup() + + return datamodule_ + + @pytest.fixture() + @staticmethod + def fxt_data_config_path() -> str: + """Return the path to the data config file.""" + return "examples/configs/data/adam_3d.yaml" + + @staticmethod + def test_datamodule_from_config(fxt_data_config_path: str) -> None: + """Test method to create a datamodule from a configuration file. + + Args: + fxt_data_config_path (str): The path to the configuration file. + + Returns: + None + """ + pytest.skip("The configuration file does not exist.") + _ = fxt_data_config_path