diff --git a/src/safeds/data/image/utils/__init__.py b/src/safeds/data/image/_utils/__init__.py similarity index 100% rename from src/safeds/data/image/utils/__init__.py rename to src/safeds/data/image/_utils/__init__.py diff --git a/src/safeds/data/image/utils/_image_transformation_error_and_warning_checks.py b/src/safeds/data/image/_utils/_image_transformation_error_and_warning_checks.py similarity index 100% rename from src/safeds/data/image/utils/_image_transformation_error_and_warning_checks.py rename to src/safeds/data/image/_utils/_image_transformation_error_and_warning_checks.py diff --git a/src/safeds/data/image/containers/_empty_image_list.py b/src/safeds/data/image/containers/_empty_image_list.py index 05f941441..07427f91b 100644 --- a/src/safeds/data/image/containers/_empty_image_list.py +++ b/src/safeds/data/image/containers/_empty_image_list.py @@ -4,9 +4,7 @@ from typing import TYPE_CHECKING, Self from safeds._utils import _structural_hash -from safeds.data.image.containers._image_list import ImageList -from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList -from safeds.data.image.utils._image_transformation_error_and_warning_checks import ( +from safeds.data.image._utils._image_transformation_error_and_warning_checks import ( _check_add_noise_errors, _check_adjust_brightness_errors_and_warnings, _check_adjust_color_balance_errors_and_warnings, @@ -17,6 +15,8 @@ _check_resize_errors, _check_sharpen_errors_and_warnings, ) +from safeds.data.image.containers._image_list import ImageList +from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList from safeds.exceptions import IndexOutOfBoundsError if TYPE_CHECKING: @@ -25,6 +25,7 @@ from torch import Tensor from safeds.data.image.containers import Image + from safeds.data.image.typing import ImageSize class _EmptyImageList(ImageList): @@ -91,6 +92,10 @@ def heights(self) -> list[int]: def channel(self) -> int: return NotImplemented + @property + def sizes(self) -> list[ImageSize]: + return [] + @property def number_of_sizes(self) -> int: return 0 diff --git a/src/safeds/data/image/containers/_image.py b/src/safeds/data/image/containers/_image.py index 362c4b32f..7a6a00830 100644 --- a/src/safeds/data/image/containers/_image.py +++ b/src/safeds/data/image/containers/_image.py @@ -8,7 +8,7 @@ from safeds._config import _get_device from safeds._utils import _structural_hash -from safeds.data.image.utils._image_transformation_error_and_warning_checks import ( +from safeds.data.image._utils._image_transformation_error_and_warning_checks import ( _check_add_noise_errors, _check_adjust_brightness_errors_and_warnings, _check_adjust_color_balance_errors_and_warnings, @@ -18,9 +18,11 @@ _check_resize_errors, _check_sharpen_errors_and_warnings, ) +from safeds.data.image.typing import ImageSize from safeds.exceptions import IllegalFormatError if TYPE_CHECKING: + from numpy import dtype, ndarray from torch import Tensor from torch.types import Device @@ -137,7 +139,7 @@ def __eq__(self, other: object) -> bool: if not isinstance(other, Image): return NotImplemented - return ( + return (self is other) or ( self._image_tensor.size() == other._image_tensor.size() and torch.all(torch.eq(self._image_tensor, other._set_device(self.device)._image_tensor)).item() ) @@ -164,6 +166,25 @@ def __sizeof__(self) -> int: """ return sys.getsizeof(self._image_tensor) + self._image_tensor.element_size() * self._image_tensor.nelement() + def __array__(self, numpy_dtype: str | dtype = None) -> ndarray: + """ + Return the image as a numpy array. + + Returns + ------- + numpy_array: + The image as numpy array. + """ + from numpy import uint8 + + return ( + self._image_tensor.permute(1, 2, 0) + .detach() + .cpu() + .numpy() + .astype(uint8 if numpy_dtype is None else numpy_dtype) + ) + def _repr_jpeg_(self) -> bytes | None: """ Return a JPEG image as bytes. @@ -261,6 +282,18 @@ def channel(self) -> int: """ return self._image_tensor.size(dim=0) + @property + def size(self) -> ImageSize: + """ + Get the `ImageSize` of the image. + + Returns + ------- + image_size: + The size of the image. + """ + return ImageSize(self.width, self.height, self.channel) + @property def device(self) -> Device: """ diff --git a/src/safeds/data/image/containers/_image_list.py b/src/safeds/data/image/containers/_image_list.py index b4ae64644..9224a908a 100644 --- a/src/safeds/data/image/containers/_image_list.py +++ b/src/safeds/data/image/containers/_image_list.py @@ -5,7 +5,7 @@ import os from abc import ABCMeta, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, overload from safeds.data.image.containers._image import Image @@ -16,6 +16,7 @@ from safeds.data.image.containers._multi_size_image_list import _MultiSizeImageList from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList + from safeds.data.image.typing import ImageSize class ImageList(metaclass=ABCMeta): @@ -80,7 +81,32 @@ def from_images(images: list[Image]) -> ImageList: return _SingleSizeImageList._create_image_list([image._image_tensor for image in images], indices) @staticmethod - def from_files(path: str | Path | Sequence[str | Path]) -> ImageList: + @overload + def from_files(path: str | Path | Sequence[str | Path]) -> ImageList: ... + + @staticmethod + @overload + def from_files(path: str | Path | Sequence[str | Path], return_filenames: Literal[False]) -> ImageList: ... + + @staticmethod + @overload + def from_files( + path: str | Path | Sequence[str | Path], + return_filenames: Literal[True], + ) -> tuple[ImageList, list[str]]: ... + + @staticmethod + @overload + def from_files( + path: str | Path | Sequence[str | Path], + return_filenames: bool, + ) -> ImageList | tuple[ImageList, list[str]]: ... + + @staticmethod + def from_files( + path: str | Path | Sequence[str | Path], + return_filenames: bool = False, + ) -> ImageList | tuple[ImageList, list[str]]: """ Create an ImageList from a directory or a list of files. @@ -90,6 +116,8 @@ def from_files(path: str | Path | Sequence[str | Path]) -> ImageList: ---------- path: the path to the directory or a list of files + return_filenames: + if True the output will be a tuple which contains a list of the filenames in order of the images Returns ------- @@ -102,7 +130,7 @@ def from_files(path: str | Path | Sequence[str | Path]) -> ImageList: If the directory or one of the files of the path cannot be found """ from PIL.Image import open as pil_image_open - from torchvision.transforms.functional import pil_to_tensor + from torchvision.transforms.v2.functional import pil_to_tensor from safeds.data.image.containers._empty_image_list import _EmptyImageList from safeds.data.image.containers._multi_size_image_list import _MultiSizeImageList @@ -112,6 +140,7 @@ def from_files(path: str | Path | Sequence[str | Path]) -> ImageList: return _EmptyImageList() image_tensors = [] + file_names = [] fixed_size = True path_list: list[str | Path] @@ -125,6 +154,7 @@ def from_files(path: str | Path | Sequence[str | Path]) -> ImageList: path_list += sorted([p / name for name in os.listdir(p)]) else: image_tensors.append(pil_to_tensor(pil_image_open(p))) + file_names.append(str(p)) if fixed_size and ( image_tensors[0].size(dim=2) != image_tensors[-1].size(dim=2) or image_tensors[0].size(dim=1) != image_tensors[-1].size(dim=1) @@ -137,9 +167,14 @@ def from_files(path: str | Path | Sequence[str | Path]) -> ImageList: indices = list(range(len(image_tensors))) if fixed_size: - return _SingleSizeImageList._create_image_list(image_tensors, indices) + image_list = _SingleSizeImageList._create_image_list(image_tensors, indices) + else: + image_list = _MultiSizeImageList._create_image_list(image_tensors, indices) + + if return_filenames: + return image_list, file_names else: - return _MultiSizeImageList._create_image_list(image_tensors, indices) + return image_list @abstractmethod def _clone(self) -> ImageList: @@ -300,6 +335,18 @@ def channel(self) -> int: The channel of all images """ + @property + @abstractmethod + def sizes(self) -> list[ImageSize]: + """ + Return the sizes of all images. + + Returns + ------- + sizes: + The sizes of all images + """ + @property @abstractmethod def number_of_sizes(self) -> int: diff --git a/src/safeds/data/image/containers/_multi_size_image_list.py b/src/safeds/data/image/containers/_multi_size_image_list.py index 90ab0730c..00cadf17f 100644 --- a/src/safeds/data/image/containers/_multi_size_image_list.py +++ b/src/safeds/data/image/containers/_multi_size_image_list.py @@ -6,11 +6,11 @@ from typing import TYPE_CHECKING from safeds._utils import _structural_hash -from safeds.data.image.containers import Image, ImageList -from safeds.data.image.utils._image_transformation_error_and_warning_checks import ( +from safeds.data.image._utils._image_transformation_error_and_warning_checks import ( _check_blur_errors_and_warnings, _check_remove_images_with_size_errors, ) +from safeds.data.image.containers import Image, ImageList from safeds.exceptions import ( DuplicateIndexError, IllegalFormatError, @@ -23,6 +23,7 @@ from torch import Tensor from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList + from safeds.data.image.typing import ImageSize class _MultiSizeImageList(ImageList): @@ -111,6 +112,8 @@ def __eq__(self, other: object) -> bool: return NotImplemented if not isinstance(other, _MultiSizeImageList) or set(other._image_list_dict) != set(self._image_list_dict): return False + if self is other: + return True for image_list_key, image_list_value in self._image_list_dict.items(): if image_list_value != other._image_list_dict[image_list_key]: return False @@ -158,6 +161,15 @@ def heights(self) -> list[int]: def channel(self) -> int: return next(iter(self._image_list_dict.values())).channel + @property + def sizes(self) -> list[ImageSize]: + sizes = {} + for image_list in self._image_list_dict.values(): + indices = image_list._as_single_size_image_list()._tensor_positions_to_indices + for i, index in enumerate(indices): + sizes[index] = image_list.sizes[i] + return [sizes[index] for index in sorted(sizes)] + @property def number_of_sizes(self) -> int: return len(self._image_list_dict) diff --git a/src/safeds/data/image/containers/_single_size_image_list.py b/src/safeds/data/image/containers/_single_size_image_list.py index c0a6b8df2..972f1edb5 100644 --- a/src/safeds/data/image/containers/_single_size_image_list.py +++ b/src/safeds/data/image/containers/_single_size_image_list.py @@ -7,9 +7,7 @@ from typing import TYPE_CHECKING from safeds._utils import _structural_hash -from safeds.data.image.containers._image import Image -from safeds.data.image.containers._image_list import ImageList -from safeds.data.image.utils._image_transformation_error_and_warning_checks import ( +from safeds.data.image._utils._image_transformation_error_and_warning_checks import ( _check_add_noise_errors, _check_adjust_brightness_errors_and_warnings, _check_adjust_color_balance_errors_and_warnings, @@ -20,6 +18,9 @@ _check_resize_errors, _check_sharpen_errors_and_warnings, ) +from safeds.data.image.containers._image import Image +from safeds.data.image.containers._image_list import ImageList +from safeds.data.image.typing import ImageSize from safeds.exceptions import ( DuplicateIndexError, IllegalFormatError, @@ -49,6 +50,9 @@ class _SingleSizeImageList(ImageList): def __init__(self) -> None: import torch + self._next_batch_index = 0 + self._batch_size = 1 + self._tensor: Tensor = torch.empty(0) self._tensor_positions_to_indices: list[int] = [] # list[tensor_position] = index self._indices_to_tensor_positions: dict[int, int] = {} # {index: tensor_position} @@ -95,6 +99,46 @@ def _create_image_list(images: list[Tensor], indices: list[int]) -> ImageList: return image_list + @staticmethod + def _create_from_tensor(images_tensor: Tensor, indices: list[int]) -> _SingleSizeImageList: + if images_tensor.dim() == 3: + images_tensor = images_tensor.unsqueeze(dim=1) + if images_tensor.dim() != 4: + raise ValueError(f"Invalid Tensor. This Tensor requires 3 or 4 dimensions but has {images_tensor.dim()}") + + image_list = _SingleSizeImageList() + image_list._tensor = images_tensor.detach().clone() + image_list._tensor_positions_to_indices = indices + image_list._indices_to_tensor_positions = image_list._calc_new_indices_to_tensor_positions() + + return image_list + + def __iter__(self) -> _SingleSizeImageList: + im_ds = copy.copy(self) + im_ds._next_batch_index = 0 + return im_ds + + def __next__(self) -> Tensor: + if self._next_batch_index * self._batch_size >= len(self): + raise StopIteration + self._next_batch_index += 1 + return self._get_batch(self._next_batch_index - 1) + + def _get_batch(self, batch_number: int, batch_size: int | None = None) -> Tensor: + import torch + + if batch_size is None: + batch_size = self._batch_size + if batch_size * batch_number >= len(self): + raise IndexOutOfBoundsError(batch_size * batch_number) + max_index = batch_size * (batch_number + 1) if batch_size * (batch_number + 1) < len(self) else len(self) + return ( + self._tensor[ + [self._indices_to_tensor_positions[index] for index in range(batch_size * batch_number, max_index)] + ].to(torch.float32) + / 255 + ) + def _clone(self) -> ImageList: cloned_image_list = self._clone_without_tensor() cloned_image_list._tensor = self._tensor.detach().clone() @@ -135,7 +179,7 @@ def __eq__(self, other: object) -> bool: return NotImplemented if not isinstance(other, _SingleSizeImageList): return False - return ( + return (self is other) or ( self._tensor.size() == other._tensor.size() and set(self._tensor_positions_to_indices) == set(self._tensor_positions_to_indices) and set(self._indices_to_tensor_positions) == set(self._indices_to_tensor_positions) @@ -183,6 +227,12 @@ def heights(self) -> list[int]: def channel(self) -> int: return self._tensor.size(dim=1) + @property + def sizes(self) -> list[ImageSize]: + return [ + ImageSize(self._tensor.size(dim=3), self._tensor.size(dim=2), self._tensor.size(dim=1)), + ] * self.number_of_images + @property def number_of_sizes(self) -> int: return 1 diff --git a/src/safeds/data/image/typing/__init__.py b/src/safeds/data/image/typing/__init__.py new file mode 100644 index 000000000..92ab61a47 --- /dev/null +++ b/src/safeds/data/image/typing/__init__.py @@ -0,0 +1,19 @@ +"""Types used to define the attributes of image data.""" + +from typing import TYPE_CHECKING + +import apipkg + +if TYPE_CHECKING: + from ._image_size import ImageSize + +apipkg.initpkg( + __name__, + { + "ImageSize": "._image_size:ImageSize", + }, +) + +__all__ = [ + "ImageSize", +] diff --git a/src/safeds/data/image/typing/_image_size.py b/src/safeds/data/image/typing/_image_size.py new file mode 100644 index 000000000..3a3e400fc --- /dev/null +++ b/src/safeds/data/image/typing/_image_size.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +from safeds._utils import _structural_hash +from safeds.exceptions import ClosedBound, OutOfBoundsError + +if TYPE_CHECKING: + from safeds.data.image.containers import Image + + +class ImageSize: + """ + A container for image size data. + + Parameters + ---------- + width: + the width of the image + height: + the height of the image + channel: + the channel of the image + + Raises + ------ + OutOfBoundsError: + if width or height are below 1 + ValueError + if an invalid channel is given + """ + + def __init__(self, width: int, height: int, channel: int, *, _ignore_invalid_channel: bool = False) -> None: + if width < 1 or height < 1: + raise OutOfBoundsError(min(width, height), lower_bound=ClosedBound(1)) + elif not _ignore_invalid_channel and channel not in (1, 3, 4): + raise ValueError(f"Channel {channel} is not a valid channel option. Use either 1, 3 or 4") + elif channel < 1: + raise OutOfBoundsError(channel, name="channel", lower_bound=ClosedBound(1)) + self._width = width + self._height = height + self._channel = channel + + @staticmethod + def from_image(image: Image) -> ImageSize: + """ + Create a `ImageSize` of a given image. + + Parameters + ---------- + image: + the given image for the `ImageSize` + + Returns + ------- + image_size: + the calculated `ImageSize` + """ + return ImageSize(image.width, image.height, image.channel) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ImageSize): + return NotImplemented + return (self is other) or ( + self._width == other._width and self._height == other._height and self._channel == other._channel + ) + + def __hash__(self) -> int: + return _structural_hash(self._width, self._height, self._channel) + + def __sizeof__(self) -> int: + return sys.getsizeof(self._width) + sys.getsizeof(self._height) + sys.getsizeof(self._channel) + + def __str__(self) -> str: + return f"{self._width}x{self._height}x{self._channel} (WxHxC)" + + @property + def width(self) -> int: + """ + Get the width of this `ImageSize` in pixels. + + Returns + ------- + width: + The width of this `ImageSize`. + """ + return self._width + + @property + def height(self) -> int: + """ + Get the height of this `ImageSize` in pixels. + + Returns + ------- + height: + The height of this `ImageSize`. + """ + return self._height + + @property + def channel(self) -> int: + """ + Get the channel of this `ImageSize` in pixels. + + Returns + ------- + channel: + The channel of this `ImageSize`. + """ + return self._channel diff --git a/src/safeds/data/labeled/containers/__init__.py b/src/safeds/data/labeled/containers/__init__.py index 3c30586f9..402c635b6 100644 --- a/src/safeds/data/labeled/containers/__init__.py +++ b/src/safeds/data/labeled/containers/__init__.py @@ -5,15 +5,18 @@ import apipkg if TYPE_CHECKING: + from ._image_dataset import ImageDataset from ._tabular_dataset import TabularDataset apipkg.initpkg( __name__, { + "ImageDataset": "._image_dataset:ImageDataset", "TabularDataset": "._tabular_dataset:TabularDataset", }, ) __all__ = [ + "ImageDataset", "TabularDataset", ] diff --git a/src/safeds/data/labeled/containers/_image_dataset.py b/src/safeds/data/labeled/containers/_image_dataset.py new file mode 100644 index 000000000..1b2f72a8c --- /dev/null +++ b/src/safeds/data/labeled/containers/_image_dataset.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import copy +import sys +import warnings +from typing import TYPE_CHECKING, Generic, TypeVar + +from safeds._utils import _structural_hash +from safeds.data.image.containers import ImageList +from safeds.data.image.containers._empty_image_list import _EmptyImageList +from safeds.data.image.containers._multi_size_image_list import _MultiSizeImageList +from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList +from safeds.data.image.typing import ImageSize +from safeds.data.tabular.containers import Column, Table +from safeds.data.tabular.transformation import OneHotEncoder +from safeds.exceptions import ( + ClosedBound, + IndexOutOfBoundsError, + NonNumericColumnError, + OutOfBoundsError, + OutputLengthMismatchError, + TransformerNotFittedError, +) + +if TYPE_CHECKING: + from torch import Tensor + +T = TypeVar("T", Column, Table, ImageList) + + +class ImageDataset(Generic[T]): + """ + A Dataset for ImageLists as input and ImageLists, Tables or Columns as output. + + Parameters + ---------- + input_data: + the input ImageList + output_data: + the output data + batch_size: + the batch size used for training + shuffle: + weather the data should be shuffled after each epoch of training + """ + + def __init__(self, input_data: ImageList, output_data: T, batch_size: int = 1, shuffle: bool = False) -> None: + import torch + + self._shuffle_tensor_indices: torch.LongTensor = torch.LongTensor(list(range(len(input_data)))) + self._shuffle_after_epoch: bool = shuffle + self._batch_size: int = batch_size + self._next_batch_index: int = 0 + + if isinstance(input_data, _MultiSizeImageList): + raise ValueError("The given input ImageList contains images of different sizes.") # noqa: TRY004 + elif isinstance(input_data, _EmptyImageList): + raise ValueError("The given input ImageList contains no images.") # noqa: TRY004 + else: + self._input_size: ImageSize = ImageSize(input_data.widths[0], input_data.heights[0], input_data.channel) + self._input: _SingleSizeImageList = input_data._as_single_size_image_list() + if ((isinstance(output_data, Column | Table)) and len(input_data) != output_data.number_of_rows) or ( + isinstance(output_data, ImageList) and len(input_data) != len(output_data) + ): + if isinstance(output_data, Table): + output_len = output_data.number_of_rows + else: + output_len = len(output_data) + raise OutputLengthMismatchError(f"{len(input_data)} != {output_len}") + if isinstance(output_data, Table): + non_numerical_columns = [] + wrong_interval_columns = [] + for column_name in output_data.column_names: + if not output_data.get_column_type(column_name).is_numeric(): + non_numerical_columns.append(column_name) + elif ( + output_data.get_column(column_name).minimum() < 0 + or output_data.get_column(column_name).maximum() > 1 + ): + wrong_interval_columns.append(column_name) + if len(non_numerical_columns) > 0: + raise NonNumericColumnError(f"Columns {non_numerical_columns} are not numerical.") + if len(wrong_interval_columns) > 0: + raise ValueError(f"Columns {wrong_interval_columns} have values outside of the interval [0, 1].") + _output: _TableAsTensor | _ColumnAsTensor | _SingleSizeImageList = _TableAsTensor(output_data) + _output_size: int | ImageSize = output_data.number_of_columns + elif isinstance(output_data, Column): + _column_as_tensor = _ColumnAsTensor(output_data) + _output_size = len(_column_as_tensor._one_hot_encoder.get_names_of_added_columns()) + _output = _column_as_tensor + elif isinstance(output_data, _SingleSizeImageList): + _output = output_data._clone()._as_single_size_image_list() + _output_size = ImageSize(output_data.widths[0], output_data.heights[0], output_data.channel) + else: + raise ValueError("The given output ImageList contains images of different sizes.") # noqa: TRY004 + self._output = _output + self._output_size = _output_size + + def __iter__(self) -> ImageDataset: + if self._shuffle_after_epoch: + im_ds = self.shuffle() + else: + im_ds = copy.copy(self) + im_ds._next_batch_index = 0 + return im_ds + + def __next__(self) -> tuple[Tensor, Tensor]: + if self._next_batch_index * self._batch_size >= len(self._input): + raise StopIteration + self._next_batch_index += 1 + return self._get_batch(self._next_batch_index - 1) + + def __len__(self) -> int: + return self._input.number_of_images + + def __eq__(self, other: object) -> bool: + """ + Compare two image datasets. + + Parameters + ---------- + other: + The image dataset to compare to. + + Returns + ------- + equals: + Whether the two image datasets are the same. + """ + if not isinstance(other, ImageDataset): + return NotImplemented + return (self is other) or ( + self._shuffle_after_epoch == other._shuffle_after_epoch + and self._batch_size == other._batch_size + and isinstance(other._output, type(self._output)) + and (self._input == other._input) + and (self._output == other._output) + ) + + def __hash__(self) -> int: + """ + Return a deterministic hash value for this image dataset. + + Returns + ------- + hash: + the hash value + """ + return _structural_hash(self._input, self._output, self._shuffle_after_epoch, self._batch_size) + + def __sizeof__(self) -> int: + """ + Return the complete size of this object. + + Returns + ------- + size: + Size of this object in bytes. + """ + return ( + sys.getsizeof(self._shuffle_tensor_indices) + + self._shuffle_tensor_indices.element_size() * self._shuffle_tensor_indices.nelement() + + sys.getsizeof(self._input) + + sys.getsizeof(self._output) + + sys.getsizeof(self._input_size) + + sys.getsizeof(self._output_size) + + sys.getsizeof(self._shuffle_after_epoch) + + sys.getsizeof(self._batch_size) + + sys.getsizeof(self._next_batch_index) + ) + + @property + def input_size(self) -> ImageSize: + """ + Get the input `ImageSize` of this dataset. + + Returns + ------- + input_size: + the input `ImageSize` + """ + return self._input_size + + @property + def output_size(self) -> ImageSize | int: + """ + Get the output size of this dataset. + + Returns + ------- + output_size: + the output size + """ + return self._output_size + + def get_input(self) -> ImageList: + """ + Get the input data of this dataset. + + Returns + ------- + input: + the input data of this dataset + """ + return self._input + + def get_output(self) -> T: + """ + Get the output data of this dataset. + + Returns + ------- + output: + the output data of this dataset + """ + output = self._output + if isinstance(output, _TableAsTensor): + return output._to_table() # type: ignore[return-value] + elif isinstance(output, _ColumnAsTensor): + return output._to_column() # type: ignore[return-value] + else: + return output # type: ignore[return-value] + + def _get_batch(self, batch_number: int, batch_size: int | None = None) -> tuple[Tensor, Tensor]: + import torch + + if batch_size is None: + batch_size = self._batch_size + if batch_size < 1: + raise OutOfBoundsError(batch_size, name="batch_size", lower_bound=ClosedBound(1)) + if batch_number < 0 or batch_size * batch_number >= len(self._input): + raise IndexOutOfBoundsError(batch_size * batch_number) + max_index = ( + batch_size * (batch_number + 1) if batch_size * (batch_number + 1) < len(self._input) else len(self._input) + ) + input_tensor = ( + self._input._tensor[ + self._shuffle_tensor_indices[ + [ + self._input._indices_to_tensor_positions[index] + for index in range(batch_size * batch_number, max_index) + ] + ] + ].to(torch.float32) + / 255 + ) + output_tensor: Tensor + if isinstance(self._output, _SingleSizeImageList): + output_tensor = ( + self._output._tensor[ + self._shuffle_tensor_indices[ + [ + self._output._indices_to_tensor_positions[index] + for index in range(batch_size * batch_number, max_index) + ] + ] + ].to(torch.float32) + / 255 + ) + else: # _output is instance of _TableAsTensor + output_tensor = self._output._tensor[self._shuffle_tensor_indices[batch_size * batch_number : max_index]] + return input_tensor, output_tensor + + def shuffle(self) -> ImageDataset[T]: + """ + Return a new `ImageDataset` with shuffled data. + + The original dataset list is not modified. + + Returns + ------- + image_dataset: + the shuffled `ImageDataset` + """ + import torch + + im_dataset: ImageDataset[T] = copy.copy(self) + im_dataset._shuffle_tensor_indices = torch.randperm(len(self)) + im_dataset._next_batch_index = 0 + return im_dataset + + +class _TableAsTensor: + + def __init__(self, table: Table) -> None: + import torch + + self._column_names = table.column_names + self._tensor = torch.Tensor(table._data.to_numpy(copy=True)).to(torch.get_default_device()) + + if not torch.all(self._tensor.sum(dim=1) == torch.ones(self._tensor.size(dim=0))): + raise ValueError( + "The given table is not correctly one hot encoded as it contains rows that have a sum not equal to 1.", + ) + + def __eq__(self, other: object) -> bool: + import torch + + if not isinstance(other, _TableAsTensor): + return NotImplemented + return (self is other) or ( + self._column_names == other._column_names and torch.all(torch.eq(self._tensor, other._tensor)).item() + ) + + def __hash__(self) -> int: + return _structural_hash(self._tensor.size(), self._column_names) + + def __sizeof__(self) -> int: + return ( + sys.getsizeof(self._tensor) + + self._tensor.element_size() * self._tensor.nelement() + + sys.getsizeof(self._column_names) + ) + + @staticmethod + def _from_tensor(tensor: Tensor, column_names: list[str]) -> _TableAsTensor: + if tensor.dim() != 2: + raise ValueError(f"Tensor has an invalid amount of dimensions. Needed 2 dimensions but got {tensor.dim()}.") + if tensor.size(dim=1) != len(column_names): + raise ValueError( + f"Tensor and column_names have different amounts of classes ({tensor.size(dim=1)}!={len(column_names)}).", + ) + table_as_tensor = _TableAsTensor.__new__(_TableAsTensor) + table_as_tensor._tensor = tensor + table_as_tensor._column_names = column_names + return table_as_tensor + + def _to_table(self) -> Table: + return Table(dict(zip(self._column_names, self._tensor.T.tolist(), strict=False))) + + +class _ColumnAsTensor: + + def __init__(self, column: Column) -> None: + import torch + + self._column_name = column.name + column_as_table = Table.from_columns([column]) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=rf"The columns \['{self._column_name}'\] contain numerical data. The OneHotEncoder is designed to encode non-numerical values into numerical values", + category=UserWarning, + ) + self._one_hot_encoder = OneHotEncoder().fit(column_as_table, [self._column_name]) + self._tensor = torch.Tensor(self._one_hot_encoder.transform(column_as_table)._data.to_numpy(copy=True)).to( + torch.get_default_device(), + ) + + def __eq__(self, other: object) -> bool: + import torch + + if not isinstance(other, _ColumnAsTensor): + return NotImplemented + return (self is other) or ( + self._column_name == other._column_name + and self._one_hot_encoder == other._one_hot_encoder + and torch.all(torch.eq(self._tensor, other._tensor)).item() + ) + + def __hash__(self) -> int: + return _structural_hash(self._tensor.size(), self._column_name, self._one_hot_encoder) + + def __sizeof__(self) -> int: + return ( + sys.getsizeof(self._tensor) + + self._tensor.element_size() * self._tensor.nelement() + + sys.getsizeof(self._column_name) + + sys.getsizeof(self._one_hot_encoder) + ) + + @staticmethod + def _from_tensor(tensor: Tensor, column_name: str, one_hot_encoder: OneHotEncoder) -> _ColumnAsTensor: + if tensor.dim() != 2: + raise ValueError(f"Tensor has an invalid amount of dimensions. Needed 2 dimensions but got {tensor.dim()}.") + if not one_hot_encoder.is_fitted: + raise TransformerNotFittedError + if tensor.size(dim=1) != len(one_hot_encoder.get_names_of_added_columns()): + raise ValueError( + f"Tensor and one_hot_encoder have different amounts of classes ({tensor.size(dim=1)}!={len(one_hot_encoder.get_names_of_added_columns())}).", + ) + table_as_tensor = _ColumnAsTensor.__new__(_ColumnAsTensor) + table_as_tensor._tensor = tensor + table_as_tensor._column_name = column_name + table_as_tensor._one_hot_encoder = one_hot_encoder + return table_as_tensor + + def _to_column(self) -> Column: + table = Table( + dict(zip(self._one_hot_encoder.get_names_of_added_columns(), self._tensor.T.tolist(), strict=False)), + ) + return self._one_hot_encoder.inverse_transform(table).get_column(self._column_name) diff --git a/src/safeds/data/tabular/transformation/_one_hot_encoder.py b/src/safeds/data/tabular/transformation/_one_hot_encoder.py index b4fe1d073..f9694e6f9 100644 --- a/src/safeds/data/tabular/transformation/_one_hot_encoder.py +++ b/src/safeds/data/tabular/transformation/_one_hot_encoder.py @@ -65,6 +65,18 @@ def __init__(self) -> None: # Maps nan values (str of old column) to corresponding new column name self._value_to_column_nans: dict[str, str] | None = None + def __hash__(self) -> int: + return super().__hash__() + + def __eq__(self, other: object) -> bool: + if not isinstance(other, OneHotEncoder): + return NotImplemented + return ( + self._column_names == other._column_names + and self._value_to_column == other._value_to_column + and self._value_to_column_nans == other._value_to_column_nans + ) + def fit(self, table: Table, column_names: list[str] | None) -> OneHotEncoder: """ Learn a transformation for a set of columns in a table. @@ -212,7 +224,7 @@ def transform(self, table: Table) -> Table: values_not_present_when_fitted.append((value, old_column_name)) for new_column in self._column_names[old_column_name]: - table = table.add_column(Column(new_column, encoded_values[new_column])) + table = table.add_columns([Column(new_column, encoded_values[new_column])]) if len(values_not_present_when_fitted) > 0: raise ValueNotPresentWhenFittedError(values_not_present_when_fitted) diff --git a/src/safeds/exceptions/__init__.py b/src/safeds/exceptions/__init__.py index 3d50cad65..cc8366298 100644 --- a/src/safeds/exceptions/__init__.py +++ b/src/safeds/exceptions/__init__.py @@ -17,6 +17,7 @@ IndexOutOfBoundsError, MissingValuesColumnError, NonNumericColumnError, + OutputLengthMismatchError, TransformerNotFittedError, UnknownColumnNameError, ValueNotPresentWhenFittedError, @@ -33,6 +34,7 @@ DatasetMissesFeaturesError, FeatureDataMismatchError, InputSizeError, + InvalidModelStructureError, LearningError, ModelNotFittedError, NonTimeSeriesError, @@ -57,6 +59,7 @@ "IndexOutOfBoundsError": "._data:IndexOutOfBoundsError", "MissingValuesColumnError": "._data:MissingValuesColumnError", "NonNumericColumnError": "._data:NonNumericColumnError", + "OutputLengthMismatchError": "._data:OutputLengthMismatchError", "TransformerNotFittedError": "._data:TransformerNotFittedError", "UnknownColumnNameError": "._data:UnknownColumnNameError", "ValueNotPresentWhenFittedError": "._data:ValueNotPresentWhenFittedError", @@ -66,6 +69,7 @@ "DatasetMissesFeaturesError": "._ml:DatasetMissesFeaturesError", "FeatureDataMismatchError": "._ml:FeatureDataMismatchError", "InputSizeError": "._ml:InputSizeError", + "InvalidModelStructureError": "._ml:InvalidModelStructureError", "LearningError": "._ml:LearningError", "ModelNotFittedError": "._ml:ModelNotFittedError", "NonTimeSeriesError": "._ml:NonTimeSeriesError", @@ -93,6 +97,7 @@ "IndexOutOfBoundsError", "MissingValuesColumnError", "NonNumericColumnError", + "OutputLengthMismatchError", "TransformerNotFittedError", "UnknownColumnNameError", "ValueNotPresentWhenFittedError", @@ -102,6 +107,7 @@ "DatasetMissesFeaturesError", "FeatureDataMismatchError", "InputSizeError", + "InvalidModelStructureError", "LearningError", "ModelNotFittedError", "NonTimeSeriesError", diff --git a/src/safeds/exceptions/_data.py b/src/safeds/exceptions/_data.py index 61d89038e..54e733c5b 100644 --- a/src/safeds/exceptions/_data.py +++ b/src/safeds/exceptions/_data.py @@ -127,6 +127,13 @@ def __init__(self, column_info: str): super().__init__(f"The length of at least one column differs: \n{column_info}") +class OutputLengthMismatchError(Exception): + """Exception raised when the lengths of the input and output container does not match.""" + + def __init__(self, output_info: str): + super().__init__(f"The length of the output container differs: \n{output_info}") + + class TransformerNotFittedError(Exception): """Raised when a transformer is used before fitting it.""" diff --git a/src/safeds/exceptions/_ml.py b/src/safeds/exceptions/_ml.py index 5cdb20c92..d87960f94 100644 --- a/src/safeds/exceptions/_ml.py +++ b/src/safeds/exceptions/_ml.py @@ -1,3 +1,6 @@ +from safeds.data.image.typing import ImageSize + + class DatasetMissesFeaturesError(ValueError): """ Raised when a dataset misses feature columns. @@ -40,6 +43,13 @@ def __init__(self) -> None: super().__init__("The model has not been fitted yet.") +class InvalidModelStructureError(Exception): + """Raised when the structure of the model is invalid.""" + + def __init__(self, reason: str) -> None: + super().__init__(f"The model structure is invalid: {reason}") + + class PredictionError(Exception): """ Raised when an error occurred while prediction a target vector using a model. @@ -66,9 +76,9 @@ def __init__(self) -> None: class InputSizeError(Exception): """Raised when the amount of features being passed to a network does not match with its input size.""" - def __init__(self, table_size: int, input_layer_size: int) -> None: + def __init__(self, data_size: int | ImageSize, input_layer_size: int | ImageSize) -> None: super().__init__( - f"The amount of columns being passed to the network({table_size}) does not match with its input size({input_layer_size}). Consider changing the number of neurons in the first layer or reformatting the table.", + f"The data size being passed to the network({data_size}) does not match with its input size({input_layer_size}). Consider changing the data size of the model or reformatting the data.", ) diff --git a/src/safeds/ml/nn/__init__.py b/src/safeds/ml/nn/__init__.py index 84da47c45..e43ad88fb 100644 --- a/src/safeds/ml/nn/__init__.py +++ b/src/safeds/ml/nn/__init__.py @@ -5,35 +5,62 @@ import apipkg if TYPE_CHECKING: + from ._convolutional2d_layer import Convolutional2DLayer, ConvolutionalTranspose2DLayer + from ._flatten_layer import FlattenLayer from ._forward_layer import ForwardLayer from ._input_conversion import InputConversion + from ._input_conversion_image import InputConversionImage from ._input_conversion_table import InputConversionTable from ._layer import Layer from ._model import NeuralNetworkClassifier, NeuralNetworkRegressor from ._output_conversion import OutputConversion + from ._output_conversion_image import ( + OutputConversionImageToColumn, + OutputConversionImageToImage, + OutputConversionImageToTable, + ) from ._output_conversion_table import OutputConversionTable + from ._pooling2d_layer import AvgPooling2DLayer, MaxPooling2DLayer apipkg.initpkg( __name__, { + "AvgPooling2DLayer": "._pooling2d_layer:AvgPooling2DLayer", + "Convolutional2DLayer": "._convolutional2d_layer:Convolutional2DLayer", + "ConvolutionalTranspose2DLayer": "._convolutional2d_layer:ConvolutionalTranspose2DLayer", + "FlattenLayer": "._flatten_layer:FlattenLayer", "ForwardLayer": "._forward_layer:ForwardLayer", "InputConversion": "._input_conversion:InputConversion", + "InputConversionImage": "._input_conversion_image:InputConversionImage", "InputConversionTable": "._input_conversion_table:InputConversionTable", "Layer": "._layer:Layer", - "OutputConversion": "._output_conversion:OutputConversion", - "OutputConversionTable": "._output_conversion_table:OutputConversionTable", + "MaxPooling2DLayer": "._pooling2d_layer:MaxPooling2DLayer", "NeuralNetworkClassifier": "._model:NeuralNetworkClassifier", "NeuralNetworkRegressor": "._model:NeuralNetworkRegressor", + "OutputConversion": "._output_conversion:OutputConversion", + "OutputConversionImageToColumn": "._output_conversion_image:OutputConversionImageToColumn", + "OutputConversionImageToImage": "._output_conversion_image:OutputConversionImageToImage", + "OutputConversionImageToTable": "._output_conversion_image:OutputConversionImageToTable", + "OutputConversionTable": "._output_conversion_table:OutputConversionTable", }, ) __all__ = [ + "AvgPooling2DLayer", + "Convolutional2DLayer", + "ConvolutionalTranspose2DLayer", + "FlattenLayer", "ForwardLayer", "InputConversion", + "InputConversionImage", "InputConversionTable", "Layer", - "OutputConversion", - "OutputConversionTable", + "MaxPooling2DLayer", "NeuralNetworkClassifier", "NeuralNetworkRegressor", + "OutputConversion", + "OutputConversionImageToColumn", + "OutputConversionImageToImage", + "OutputConversionImageToTable", + "OutputConversionTable", ] diff --git a/src/safeds/ml/nn/_convolutional2d_layer.py b/src/safeds/ml/nn/_convolutional2d_layer.py new file mode 100644 index 000000000..59e0c2dde --- /dev/null +++ b/src/safeds/ml/nn/_convolutional2d_layer.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import math +import sys +from typing import TYPE_CHECKING, Any, Literal + +from safeds._utils import _structural_hash +from safeds.data.image.typing import ImageSize + +if TYPE_CHECKING: + from torch import Tensor, nn + +from safeds.ml.nn import Layer + + +def _create_internal_model( + input_size: int, + output_size: int, + kernel_size: int, + activation_function: Literal["sigmoid", "relu", "softmax"], + padding: int, + stride: int, + transpose: bool, + output_padding: int = 0, +) -> nn.Module: + from torch import nn + + class _InternalLayer(nn.Module): + def __init__( + self, + input_size: int, + output_size: int, + kernel_size: int, + activation_function: Literal["sigmoid", "relu", "softmax"], + padding: int, + stride: int, + transpose: bool, + output_padding: int, + ): + super().__init__() + if transpose: + self._layer = nn.ConvTranspose2d( + in_channels=input_size, + out_channels=output_size, + kernel_size=kernel_size, + padding=padding, + stride=stride, + output_padding=output_padding, + ) + else: + self._layer = nn.Conv2d( + in_channels=input_size, + out_channels=output_size, + kernel_size=kernel_size, + padding=padding, + stride=stride, + ) + match activation_function: + case "sigmoid": + self._fn = nn.Sigmoid() + case "relu": + self._fn = nn.ReLU() + case "softmax": + self._fn = nn.Softmax() + + def forward(self, x: Tensor) -> Tensor: + return self._fn(self._layer(x)) + + return _InternalLayer( + input_size, + output_size, + kernel_size, + activation_function, + padding, + stride, + transpose, + output_padding, + ) + + +class Convolutional2DLayer(Layer): + def __init__(self, output_channel: int, kernel_size: int, *, stride: int = 1, padding: int = 0): + """ + Create a Convolutional 2D Layer. + + Parameters + ---------- + output_channel: + the amount of output channels + kernel_size: + the size of the kernel + stride: + the stride of the convolution + padding: + the padding of the convolution + """ + self._output_channel = output_channel + self._kernel_size = kernel_size + self._stride = stride + self._padding = padding + self._input_size: ImageSize | None = None + self._output_size: ImageSize | None = None + + def _get_internal_layer(self, **kwargs: Any) -> nn.Module: + if self._input_size is None: + raise ValueError( + "The input_size is not yet set. The internal layer can only be created when the input_size is set.", + ) + if "activation_function" not in kwargs: + raise ValueError( + "The activation_function is not set. The internal layer can only be created when the activation_function is provided in the kwargs.", + ) + if kwargs.get("activation_function") not in ["sigmoid", "relu", "softmax"]: + raise ValueError( + f"The activation_function '{kwargs.get('activation_function')}' is not supported. Please choose one of the following: ['sigmoid', 'relu', 'softmax'].", + ) + else: + activation_function: Literal["sigmoid", "relu", "softmax"] = kwargs["activation_function"] + return _create_internal_model( + self._input_size.channel, + self._output_channel, + self._kernel_size, + activation_function, + self._padding, + self._stride, + transpose=False, + ) + + @property + def input_size(self) -> ImageSize: + """ + Get the input_size of this layer. + + Returns + ------- + result: + The amount of values being passed into this layer. + + Raises + ------ + ValueError + If the input_size is not yet set + """ + if self._input_size is None: + raise ValueError("The input_size is not yet set.") + return self._input_size + + @property + def output_size(self) -> ImageSize: + """ + Get the output_size of this layer. + + Returns + ------- + result: + The Number of Neurons in this layer. + + Raises + ------ + ValueError + If the input_size is not yet set + """ + if self._input_size is None: + raise ValueError( + "The input_size is not yet set. The layer cannot compute the output_size if the input_size is not set.", + ) + if self._output_size is None: + new_width = math.ceil( + (self._input_size.width + self._padding * 2 - self._kernel_size + 1) / (1.0 * self._stride), + ) + new_height = math.ceil( + (self._input_size.height + self._padding * 2 - self._kernel_size + 1) / (1.0 * self._stride), + ) + self._output_size = ImageSize(new_width, new_height, self._output_channel, _ignore_invalid_channel=True) + return self._output_size + + def _set_input_size(self, input_size: int | ImageSize) -> None: + if isinstance(input_size, int): + raise TypeError("The input_size of a convolution layer has to be of type ImageSize.") + self._input_size = input_size + self._output_size = None + + def __hash__(self) -> int: + """ + Return a deterministic hash value for this convolutional 2d layer. + + Returns + ------- + hash: + the hash value + """ + return _structural_hash( + self._output_channel, + self._kernel_size, + self._stride, + self._padding, + self._input_size, + self._output_size, + ) + + def __eq__(self, other: object) -> bool: + """ + Compare two convolutional 2d layer. + + Parameters + ---------- + other: + The convolutional 2d layer to compare to. + + Returns + ------- + equals: + Whether the two convolutional 2d layer are the same. + """ + if not isinstance(other, Convolutional2DLayer) or isinstance(other, ConvolutionalTranspose2DLayer): + return NotImplemented + return (self is other) or ( + self._output_channel == other._output_channel + and self._kernel_size == other._kernel_size + and self._stride == other._stride + and self._padding == other._padding + and self._input_size == other._input_size + and self._output_size == other._output_size + ) + + def __sizeof__(self) -> int: + """ + Return the complete size of this object. + + Returns + ------- + size: + Size of this object in bytes. + """ + return ( + sys.getsizeof(self._output_channel) + + sys.getsizeof(self._kernel_size) + + sys.getsizeof(self._stride) + + sys.getsizeof(self._padding) + + sys.getsizeof(self._input_size) + + sys.getsizeof(self._output_size) + ) + + +class ConvolutionalTranspose2DLayer(Convolutional2DLayer): + + def __init__( + self, + output_channel: int, + kernel_size: int, + *, + stride: int = 1, + padding: int = 0, + output_padding: int = 0, + ): + """ + Create a Convolutional Transpose 2D Layer. + + Parameters + ---------- + output_channel: + the amount of output channels + kernel_size: + the size of the kernel + stride: + the stride of the transposed convolution + padding: + the padding of the transposed convolution + output_padding: + the output padding of the transposed convolution + """ + super().__init__(output_channel, kernel_size, stride=stride, padding=padding) + self._output_padding = output_padding + + def _get_internal_layer(self, **kwargs: Any) -> nn.Module: + if self._input_size is None: + raise ValueError( + "The input_size is not yet set. The internal layer can only be created when the input_size is set.", + ) + if "activation_function" not in kwargs: + raise ValueError( + "The activation_function is not set. The internal layer can only be created when the activation_function is provided in the kwargs.", + ) + if kwargs.get("activation_function") not in ["sigmoid", "relu", "softmax"]: + raise ValueError( + f"The activation_function '{kwargs.get('activation_function')}' is not supported. Please choose one of the following: ['sigmoid', 'relu', 'softmax'].", + ) + else: + activation_function: Literal["sigmoid", "relu", "softmax"] = kwargs["activation_function"] + return _create_internal_model( + self._input_size.channel, + self._output_channel, + self._kernel_size, + activation_function, + self._padding, + self._stride, + transpose=True, + output_padding=self._output_padding, + ) + + @property + def output_size(self) -> ImageSize: + if self._input_size is None: + raise ValueError( + "The input_size is not yet set. The layer cannot compute the output_size if the input_size is not set.", + ) + if self._output_size is None: + new_width = ( + (self.input_size.width - 1) * self._stride + - 2 * self._padding + + self._kernel_size + + self._output_padding + ) + new_height = ( + (self.input_size.height - 1) * self._stride + - 2 * self._padding + + self._kernel_size + + self._output_padding + ) + self._output_size = ImageSize(new_width, new_height, self._output_channel, _ignore_invalid_channel=True) + return self._output_size + + def __hash__(self) -> int: + """ + Return a deterministic hash value for this convolutional transpose 2d layer. + + Returns + ------- + hash: + the hash value + """ + return _structural_hash(super().__hash__(), self._output_padding) + + def __eq__(self, other: object) -> bool: + """ + Compare two convolutional transpose 2d layer. + + Parameters + ---------- + other: + The convolutional transpose 2d layer to compare to. + + Returns + ------- + equals: + Whether the two convolutional transpose 2d layer are the same. + """ + if not isinstance(other, ConvolutionalTranspose2DLayer): + return NotImplemented + return (self is other) or ( + self._output_channel == other._output_channel + and self._kernel_size == other._kernel_size + and self._stride == other._stride + and self._padding == other._padding + and self._input_size == other._input_size + and self._output_size == other._output_size + and self._output_padding == other._output_padding + ) + + def __sizeof__(self) -> int: + """ + Return the complete size of this object. + + Returns + ------- + size: + Size of this object in bytes. + """ + return sys.getsizeof(self._output_padding) + super().__sizeof__() diff --git a/src/safeds/ml/nn/_flatten_layer.py b/src/safeds/ml/nn/_flatten_layer.py new file mode 100644 index 000000000..50269b448 --- /dev/null +++ b/src/safeds/ml/nn/_flatten_layer.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any + +from safeds._utils import _structural_hash + +if TYPE_CHECKING: + from torch import Tensor, nn + + from safeds.data.image.typing import ImageSize + +from safeds.ml.nn import Layer + + +def _create_internal_model() -> nn.Module: + from torch import nn + + class _InternalLayer(nn.Module): + def __init__(self) -> None: + super().__init__() + self._layer = nn.Flatten() + + def forward(self, x: Tensor) -> Tensor: + return self._layer(x) + + return _InternalLayer() + + +class FlattenLayer(Layer): + def __init__(self) -> None: + """Create a Flatten Layer.""" + self._input_size: ImageSize | None = None + self._output_size: int | None = None + + def _get_internal_layer(self, **kwargs: Any) -> nn.Module: # noqa: ARG002 + return _create_internal_model() + + @property + def input_size(self) -> ImageSize: + """ + Get the input_size of this layer. + + Returns + ------- + result : + The amount of values being passed into this layer. + + Raises + ------ + ValueError + If the input_size is not yet set + """ + if self._input_size is None: + raise ValueError("The input_size is not yet set.") + return self._input_size + + @property + def output_size(self) -> int: + """ + Get the output_size of this layer. + + Returns + ------- + result : + The Number of Neurons in this layer. + + Raises + ------ + ValueError + If the input_size is not yet set + """ + if self._input_size is None: + raise ValueError( + "The input_size is not yet set. The layer cannot compute the output_size if the input_size is not set.", + ) + if self._output_size is None: + self._output_size = self._input_size.width * self._input_size.height * self._input_size.channel + return self._output_size + + def _set_input_size(self, input_size: int | ImageSize) -> None: + if isinstance(input_size, int): + raise TypeError("The input_size of a flatten layer has to be of type ImageSize.") + self._input_size = input_size + self._output_size = None + + def __hash__(self) -> int: + """ + Return a deterministic hash value for this flatten layer. + + Returns + ------- + hash: + the hash value + """ + return _structural_hash(self._input_size, self._output_size) + + def __eq__(self, other: object) -> bool: + """ + Compare two flatten layer. + + Parameters + ---------- + other: + The flatten layer to compare to. + + Returns + ------- + equals: + Whether the two flatten layer are the same. + """ + if not isinstance(other, FlattenLayer): + return NotImplemented + return (self is other) or (self._input_size == other._input_size and self._output_size == other._output_size) + + def __sizeof__(self) -> int: + """ + Return the complete size of this object. + + Returns + ------- + size: + Size of this object in bytes. + """ + return sys.getsizeof(self._input_size) + sys.getsizeof(self._output_size) diff --git a/src/safeds/ml/nn/_forward_layer.py b/src/safeds/ml/nn/_forward_layer.py index ba3694ed2..baa91c17f 100644 --- a/src/safeds/ml/nn/_forward_layer.py +++ b/src/safeds/ml/nn/_forward_layer.py @@ -1,13 +1,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any + +from safeds.data.image.typing import ImageSize if TYPE_CHECKING: from torch import Tensor, nn from safeds._utils import _structural_hash from safeds.exceptions import ClosedBound, OutOfBoundsError -from safeds.ml.nn._layer import Layer +from safeds.ml.nn import Layer def _create_internal_model(input_size: int, output_size: int, activation_function: str) -> nn.Module: @@ -24,11 +26,13 @@ def __init__(self, input_size: int, output_size: int, activation_function: str): self._fn = nn.ReLU() case "softmax": self._fn = nn.Softmax() + case "none": + self._fn = None case _: raise ValueError("Unknown Activation Function: " + activation_function) def forward(self, x: Tensor) -> Tensor: - return self._fn(self._layer(x)) + return self._fn(self._layer(x)) if self._fn is not None else self._layer(x) return _InternalLayer(input_size, output_size, activation_function) @@ -58,7 +62,13 @@ def __init__(self, output_size: int, input_size: int | None = None): raise OutOfBoundsError(actual=output_size, name="output_size", lower_bound=ClosedBound(1)) self._output_size = output_size - def _get_internal_layer(self, activation_function: str) -> nn.Module: + def _get_internal_layer(self, **kwargs: Any) -> nn.Module: + if "activation_function" not in kwargs: + raise ValueError( + "The activation_function is not set. The internal layer can only be created when the activation_function is provided in the kwargs.", + ) + else: + activation_function: str = kwargs["activation_function"] return _create_internal_model(self._input_size, self._output_size, activation_function) @property @@ -85,7 +95,9 @@ def output_size(self) -> int: """ return self._output_size - def _set_input_size(self, input_size: int) -> None: + def _set_input_size(self, input_size: int | ImageSize) -> None: + if isinstance(input_size, ImageSize): + raise TypeError("The input_size of a forward layer has to be of type int.") if input_size < 1: raise OutOfBoundsError(actual=input_size, name="input_size", lower_bound=ClosedBound(1)) self._input_size = input_size diff --git a/src/safeds/ml/nn/_input_conversion.py b/src/safeds/ml/nn/_input_conversion.py index ca4d7291e..0e2cf952e 100644 --- a/src/safeds/ml/nn/_input_conversion.py +++ b/src/safeds/ml/nn/_input_conversion.py @@ -1,16 +1,20 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar if TYPE_CHECKING: from torch.utils.data import DataLoader -from safeds.data.labeled.containers import TabularDataset + from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList + from safeds.data.image.typing import ImageSize + +from safeds.data.image.containers import ImageList +from safeds.data.labeled.containers import ImageDataset, TabularDataset from safeds.data.tabular.containers import Table, TimeSeries -FT = TypeVar("FT", TabularDataset, TimeSeries) -PT = TypeVar("PT", Table, TimeSeries) +FT = TypeVar("FT", TabularDataset, TimeSeries, ImageDataset) +PT = TypeVar("PT", Table, TimeSeries, ImageList) class InputConversion(Generic[FT, PT], ABC): @@ -18,15 +22,20 @@ class InputConversion(Generic[FT, PT], ABC): @property @abstractmethod - def _data_size(self) -> int: + def _data_size(self) -> int | ImageSize: pass # pragma: no cover @abstractmethod - def _data_conversion_fit(self, input_data: FT, batch_size: int, num_of_classes: int = 1) -> DataLoader: + def _data_conversion_fit( + self, + input_data: FT, + batch_size: int, + num_of_classes: int = 1, + ) -> DataLoader | ImageDataset: pass # pragma: no cover @abstractmethod - def _data_conversion_predict(self, input_data: PT, batch_size: int) -> DataLoader: + def _data_conversion_predict(self, input_data: PT, batch_size: int) -> DataLoader | _SingleSizeImageList: pass # pragma: no cover @abstractmethod @@ -36,3 +45,7 @@ def _is_fit_data_valid(self, input_data: FT) -> bool: @abstractmethod def _is_predict_data_valid(self, input_data: PT) -> bool: pass # pragma: no cover + + @abstractmethod + def _get_output_configuration(self) -> dict[str, Any]: + pass # pragma: no cover diff --git a/src/safeds/ml/nn/_input_conversion_image.py b/src/safeds/ml/nn/_input_conversion_image.py new file mode 100644 index 000000000..e19b38b68 --- /dev/null +++ b/src/safeds/ml/nn/_input_conversion_image.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any + +from safeds._utils import _structural_hash +from safeds.data.image.containers import ImageList +from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList +from safeds.data.labeled.containers import ImageDataset +from safeds.data.labeled.containers._image_dataset import _ColumnAsTensor, _TableAsTensor + +if TYPE_CHECKING: + from safeds.data.image.typing import ImageSize + from safeds.data.tabular.transformation import OneHotEncoder + +from safeds.ml.nn import InputConversion + + +class InputConversionImage(InputConversion[ImageDataset, ImageList]): + """The input conversion for a neural network, defines the input parameters for the neural network.""" + + def __init__(self, image_size: ImageSize) -> None: + """ + Define the input parameters for the neural network in the input conversion. + + Parameters + ---------- + image_size: + the size of the input images + """ + self._input_size = image_size + self._output_size: ImageSize | int | None = None + self._one_hot_encoder: OneHotEncoder | None = None + self._column_name: str | None = None + self._column_names: list[str] | None = None + self._output_type: type | None = None + + @property + def _data_size(self) -> ImageSize: + return self._input_size + + def _data_conversion_fit( + self, + input_data: ImageDataset, + batch_size: int, # noqa: ARG002 + num_of_classes: int = 1, # noqa: ARG002 + ) -> ImageDataset: + return input_data + + def _data_conversion_predict(self, input_data: ImageList, batch_size: int) -> _SingleSizeImageList: # noqa: ARG002 + return input_data._as_single_size_image_list() + + def _is_fit_data_valid(self, input_data: ImageDataset) -> bool: + if self._output_type is None: + self._output_type = type(input_data._output) + self._output_size = input_data.output_size + elif not isinstance(input_data._output, self._output_type): + return False + if isinstance(input_data._output, _ColumnAsTensor): + if self._column_name is None and self._one_hot_encoder is None: + self._one_hot_encoder = input_data._output._one_hot_encoder + self._column_name = input_data._output._column_name + elif ( + self._column_name != input_data._output._column_name + or self._one_hot_encoder != input_data._output._one_hot_encoder + ): + return False + elif isinstance(input_data._output, _TableAsTensor): + if self._column_names is None: + self._column_names = input_data._output._column_names + elif self._column_names != input_data._output._column_names: + return False + return input_data.input_size == self._input_size and input_data.output_size == self._output_size + + def _is_predict_data_valid(self, input_data: ImageList) -> bool: + return isinstance(input_data, _SingleSizeImageList) and input_data.sizes[0] == self._input_size + + def _get_output_configuration(self) -> dict[str, Any]: + return { + "column_names": self._column_names, + "column_name": self._column_name, + "one_hot_encoder": self._one_hot_encoder, + } + + def __hash__(self) -> int: + """ + Return a deterministic hash value for this InputConversionImage. + + Returns + ------- + hash: + the hash value + """ + return _structural_hash( + self._input_size, + self._output_size, + self._one_hot_encoder, + self._column_name, + self._column_names, + self._output_type, + ) + + def __eq__(self, other: object) -> bool: + """ + Compare two InputConversionImage instances. + + Parameters + ---------- + other: + The InputConversionImage instance to compare to. + + Returns + ------- + equals: + Whether the instances are the same. + """ + if not isinstance(other, InputConversionImage): + return NotImplemented + return (self is other) or ( + self._input_size == other._input_size + and self._output_size == other._output_size + and self._one_hot_encoder == other._one_hot_encoder + and self._column_name == other._column_name + and self._column_names == other._column_names + and self._output_type == other._output_type + ) + + def __sizeof__(self) -> int: + """ + Return the complete size of this object. + + Returns + ------- + size: + Size of this object in bytes. + """ + return ( + sys.getsizeof(self._input_size) + + sys.getsizeof(self._output_size) + + sys.getsizeof(self._one_hot_encoder) + + sys.getsizeof(self._column_name) + + sys.getsizeof(self._column_names) + + sys.getsizeof(self._output_type) + ) diff --git a/src/safeds/ml/nn/_input_conversion_table.py b/src/safeds/ml/nn/_input_conversion_table.py index 884d35706..5ac205ed0 100644 --- a/src/safeds/ml/nn/_input_conversion_table.py +++ b/src/safeds/ml/nn/_input_conversion_table.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from torch.utils.data import DataLoader from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table -from safeds.ml.nn._input_conversion import InputConversion +from safeds.ml.nn import InputConversion class InputConversionTable(InputConversion[TabularDataset, Table]): @@ -45,3 +45,6 @@ def _is_fit_data_valid(self, input_data: TabularDataset) -> bool: def _is_predict_data_valid(self, input_data: Table) -> bool: return (sorted(input_data.column_names)).__eq__(sorted(self._feature_names)) + + def _get_output_configuration(self) -> dict[str, Any]: + return {} diff --git a/src/safeds/ml/nn/_layer.py b/src/safeds/ml/nn/_layer.py index 59224b195..c1c305c21 100644 --- a/src/safeds/ml/nn/_layer.py +++ b/src/safeds/ml/nn/_layer.py @@ -1,11 +1,13 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from torch import nn + from safeds.data.image.typing import ImageSize + class Layer(ABC): @abstractmethod @@ -13,19 +15,31 @@ def __init__(self) -> None: pass # pragma: no cover @abstractmethod - def _get_internal_layer(self, activation_function: str) -> nn.Module: + def _get_internal_layer(self, **kwargs: Any) -> nn.Module: pass # pragma: no cover @property @abstractmethod - def input_size(self) -> int: + def input_size(self) -> int | ImageSize: pass # pragma: no cover @property @abstractmethod - def output_size(self) -> int: + def output_size(self) -> int | ImageSize: + pass # pragma: no cover + + @abstractmethod + def _set_input_size(self, input_size: int | ImageSize) -> None: + pass # pragma: no cover + + @abstractmethod + def __hash__(self) -> int: + pass # pragma: no cover + + @abstractmethod + def __eq__(self, other: object) -> bool: pass # pragma: no cover @abstractmethod - def _set_input_size(self, input_size: int) -> None: + def __sizeof__(self) -> int: pass # pragma: no cover diff --git a/src/safeds/ml/nn/_model.py b/src/safeds/ml/nn/_model.py index f6d4b97b4..e763c09c8 100644 --- a/src/safeds/ml/nn/_model.py +++ b/src/safeds/ml/nn/_model.py @@ -3,39 +3,109 @@ import copy from typing import TYPE_CHECKING, Generic, Self, TypeVar -from safeds.data.labeled.containers import TabularDataset +from safeds.data.image.containers import ImageList +from safeds.data.labeled.containers import ImageDataset, TabularDataset from safeds.data.tabular.containers import Table, TimeSeries from safeds.exceptions import ( ClosedBound, FeatureDataMismatchError, InputSizeError, + InvalidModelStructureError, ModelNotFittedError, OutOfBoundsError, ) +from safeds.ml.nn import ( + Convolutional2DLayer, + FlattenLayer, + ForwardLayer, + InputConversionImage, + OutputConversionImageToColumn, + OutputConversionImageToImage, + OutputConversionImageToTable, +) +from safeds.ml.nn._output_conversion_image import _OutputConversionImage +from safeds.ml.nn._pooling2d_layer import _Pooling2DLayer if TYPE_CHECKING: from collections.abc import Callable from torch import Tensor, nn - from safeds.ml.nn._input_conversion import InputConversion - from safeds.ml.nn._layer import Layer - from safeds.ml.nn._output_conversion import OutputConversion + from safeds.data.image.typing import ImageSize + from safeds.ml.nn import InputConversion, Layer, OutputConversion -IFT = TypeVar("IFT", TabularDataset, TimeSeries) # InputFitType -IPT = TypeVar("IPT", Table, TimeSeries) # InputPredictType -OT = TypeVar("OT", TabularDataset, TimeSeries) # OutputType +IFT = TypeVar("IFT", TabularDataset, TimeSeries, ImageDataset) # InputFitType +IPT = TypeVar("IPT", Table, TimeSeries, ImageList) # InputPredictType +OT = TypeVar("OT", TabularDataset, TimeSeries, ImageDataset) # OutputType class NeuralNetworkRegressor(Generic[IFT, IPT, OT]): + """ + A NeuralNetworkRegressor is a neural network that is used for regression tasks. + + Parameters + ---------- + input_conversion: + to convert the input data for the neural network + layers: + a list of layers for the neural network to learn + output_conversion: + to convert the output data of the neural network back + + Raises + ------ + InvalidModelStructureError + if the defined model structure is invalid + """ + def __init__( self, input_conversion: InputConversion[IFT, IPT], layers: list[Layer], output_conversion: OutputConversion[IPT, OT], ): + if len(layers) == 0: + raise InvalidModelStructureError("You need to provide at least one layer to a neural network.") + if isinstance(input_conversion, InputConversionImage): + if not isinstance(output_conversion, _OutputConversionImage): + raise InvalidModelStructureError( + "The defined model uses an input conversion for images but no output conversion for images.", + ) + elif isinstance(output_conversion, OutputConversionImageToColumn | OutputConversionImageToTable): + raise InvalidModelStructureError( + "A NeuralNetworkRegressor cannot be used with images as input and 1-dimensional data as output.", + ) + data_dimensions = 2 + for layer in layers: + if data_dimensions == 2 and (isinstance(layer, Convolutional2DLayer | _Pooling2DLayer)): + continue + elif data_dimensions == 2 and isinstance(layer, FlattenLayer): + data_dimensions = 1 + elif data_dimensions == 1 and isinstance(layer, ForwardLayer): + continue + else: + raise InvalidModelStructureError( + ( + "The 2-dimensional data has to be flattened before using a 1-dimensional layer." + if data_dimensions == 2 + else "You cannot use a 2-dimensional layer with 1-dimensional data." + ), + ) + if data_dimensions == 1 and isinstance(output_conversion, OutputConversionImageToImage): + raise InvalidModelStructureError( + "The output data would be 1-dimensional but the provided output conversion uses 2-dimensional data.", + ) + elif isinstance(output_conversion, _OutputConversionImage): + raise InvalidModelStructureError( + "The defined model uses an output conversion for images but no input conversion for images.", + ) + else: + for layer in layers: + if isinstance(layer, Convolutional2DLayer | FlattenLayer | _Pooling2DLayer): + raise InvalidModelStructureError("You cannot use a 2-dimensional layer with 1-dimensional data.") + self._input_conversion: InputConversion[IFT, IPT] = input_conversion - self._model = _create_internal_model(layers, is_for_classification=False) + self._model = _create_internal_model(input_conversion, layers, is_for_classification=False) self._output_conversion: OutputConversion[IPT, OT] = output_conversion self._input_size = self._model.input_size self._batch_size = 1 @@ -166,7 +236,11 @@ def predict(self, test_data: IPT) -> OT: for x in dataloader: elem = self._model(x) predictions.append(elem.squeeze(dim=1)) - return self._output_conversion._data_conversion(test_data, torch.cat(predictions, dim=0)) + return self._output_conversion._data_conversion( + test_data, + torch.cat(predictions, dim=0), + **self._input_conversion._get_output_configuration(), + ) @property def is_fitted(self) -> bool: @@ -175,19 +249,79 @@ def is_fitted(self) -> bool: class NeuralNetworkClassifier(Generic[IFT, IPT, OT]): + """ + A NeuralNetworkClassifier is a neural network that is used for classification tasks. + + Parameters + ---------- + input_conversion: + to convert the input data for the neural network + layers: + a list of layers for the neural network to learn + output_conversion: + to convert the output data of the neural network back + + Raises + ------ + InvalidModelStructureError + if the defined model structure is invalid + """ + def __init__( self, input_conversion: InputConversion[IFT, IPT], layers: list[Layer], output_conversion: OutputConversion[IPT, OT], ): + if len(layers) == 0: + raise InvalidModelStructureError("You need to provide at least one layer to a neural network.") + if isinstance(output_conversion, OutputConversionImageToImage): + raise InvalidModelStructureError("A NeuralNetworkClassifier cannot be used with images as output.") + elif isinstance(input_conversion, InputConversionImage): + if not isinstance(output_conversion, _OutputConversionImage): + raise InvalidModelStructureError( + "The defined model uses an input conversion for images but no output conversion for images.", + ) + data_dimensions = 2 + for layer in layers: + if data_dimensions == 2 and (isinstance(layer, Convolutional2DLayer | _Pooling2DLayer)): + continue + elif data_dimensions == 2 and isinstance(layer, FlattenLayer): + data_dimensions = 1 + elif data_dimensions == 1 and isinstance(layer, ForwardLayer): + continue + else: + raise InvalidModelStructureError( + ( + "The 2-dimensional data has to be flattened before using a 1-dimensional layer." + if data_dimensions == 2 + else "You cannot use a 2-dimensional layer with 1-dimensional data." + ), + ) + if data_dimensions == 2 and ( + isinstance(output_conversion, OutputConversionImageToColumn | OutputConversionImageToTable) + ): + raise InvalidModelStructureError( + "The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ) + elif isinstance(output_conversion, _OutputConversionImage): + raise InvalidModelStructureError( + "The defined model uses an output conversion for images but no input conversion for images.", + ) + else: + for layer in layers: + if isinstance(layer, Convolutional2DLayer | FlattenLayer | _Pooling2DLayer): + raise InvalidModelStructureError("You cannot use a 2-dimensional layer with 1-dimensional data.") + self._input_conversion: InputConversion[IFT, IPT] = input_conversion - self._model = _create_internal_model(layers, is_for_classification=True) + self._model = _create_internal_model(input_conversion, layers, is_for_classification=True) self._output_conversion: OutputConversion[IPT, OT] = output_conversion self._input_size = self._model.input_size self._batch_size = 1 self._is_fitted = False - self._num_of_classes = layers[-1].output_size + self._num_of_classes = ( + layers[-1].output_size if isinstance(layers[-1].output_size, int) else -1 + ) # Is always int but linter doesn't know self._total_number_of_batches_done = 0 self._total_number_of_epochs_done = 0 @@ -324,7 +458,11 @@ def predict(self, test_data: IPT) -> OT: predictions.append(torch.argmax(elem, dim=1)) else: predictions.append(elem.squeeze(dim=1).round()) - return self._output_conversion._data_conversion(test_data, torch.cat(predictions, dim=0)) + return self._output_conversion._data_conversion( + test_data, + torch.cat(predictions, dim=0), + **self._input_conversion._get_output_configuration(), + ) @property def is_fitted(self) -> bool: @@ -332,7 +470,11 @@ def is_fitted(self) -> bool: return self._is_fitted -def _create_internal_model(layers: list[Layer], is_for_classification: bool) -> nn.Module: +def _create_internal_model( + input_conversion: InputConversion[IFT, IPT], + layers: list[Layer], + is_for_classification: bool, +) -> nn.Module: from torch import nn class _InternalModel(nn.Module): @@ -346,19 +488,24 @@ def __init__(self, layers: list[Layer], is_for_classification: bool) -> None: for layer in layers: if previous_output_size is not None: layer._set_input_size(previous_output_size) - internal_layers.append(layer._get_internal_layer(activation_function="relu")) + elif isinstance(input_conversion, InputConversionImage): + layer._set_input_size(input_conversion._data_size) + if isinstance(layer, FlattenLayer | _Pooling2DLayer): + internal_layers.append(layer._get_internal_layer()) + else: + internal_layers.append(layer._get_internal_layer(activation_function="relu")) previous_output_size = layer.output_size if is_for_classification: internal_layers.pop() - if layers[-1].output_size > 2: - internal_layers.append(layers[-1]._get_internal_layer(activation_function="softmax")) + if isinstance(layers[-1].output_size, int) and layers[-1].output_size > 2: + internal_layers.append(layers[-1]._get_internal_layer(activation_function="none")) else: internal_layers.append(layers[-1]._get_internal_layer(activation_function="sigmoid")) self._pytorch_layers = nn.Sequential(*internal_layers) @property - def input_size(self) -> int: + def input_size(self) -> int | ImageSize: return self._layer_list[0].input_size def forward(self, x: Tensor) -> Tensor: diff --git a/src/safeds/ml/nn/_output_conversion.py b/src/safeds/ml/nn/_output_conversion.py index 2305fcbc2..301413823 100644 --- a/src/safeds/ml/nn/_output_conversion.py +++ b/src/safeds/ml/nn/_output_conversion.py @@ -1,21 +1,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +from safeds.data.image.containers import ImageList +from safeds.data.labeled.containers import ImageDataset, TabularDataset if TYPE_CHECKING: from torch import Tensor -from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table, TimeSeries -IT = TypeVar("IT", Table, TimeSeries) -OT = TypeVar("OT", TabularDataset, TimeSeries) +IT = TypeVar("IT", Table, TimeSeries, ImageList) +OT = TypeVar("OT", TabularDataset, TimeSeries, ImageDataset) class OutputConversion(Generic[IT, OT], ABC): """The output conversion for a neural network, defines the output parameters for the neural network.""" @abstractmethod - def _data_conversion(self, input_data: IT, output_data: Tensor) -> OT: + def _data_conversion(self, input_data: IT, output_data: Tensor, **kwargs: Any) -> OT: pass # pragma: no cover diff --git a/src/safeds/ml/nn/_output_conversion_image.py b/src/safeds/ml/nn/_output_conversion_image.py new file mode 100644 index 000000000..1973d9473 --- /dev/null +++ b/src/safeds/ml/nn/_output_conversion_image.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from safeds._utils import _structural_hash +from safeds.data.image.containers import ImageList +from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList +from safeds.data.labeled.containers import ImageDataset +from safeds.data.labeled.containers._image_dataset import _ColumnAsTensor, _TableAsTensor +from safeds.data.tabular.containers import Column, Table + +if TYPE_CHECKING: + from torch import Tensor + +from safeds.data.tabular.transformation import OneHotEncoder +from safeds.ml.nn import OutputConversion + + +class _OutputConversionImage(OutputConversion[ImageList, ImageDataset], ABC): + + @abstractmethod + def _data_conversion(self, input_data: ImageList, output_data: Tensor, **kwargs: Any) -> ImageDataset: + pass # pragma: no cover + + def __hash__(self) -> int: + """ + Return a deterministic hash value for this OutputConversionImage. + + Returns + ------- + hash: + the hash value + """ + return _structural_hash(self.__class__.__name__) + + def __eq__(self, other: object) -> bool: + """ + Compare two OutputConversionImage instances. + + Parameters + ---------- + other: + The OutputConversionImage instance to compare to. + + Returns + ------- + equals: + Whether the instances are the same. + """ + if not isinstance(other, type(self)): + return NotImplemented + return True + + def __sizeof__(self) -> int: + """ + Return the complete size of this object. + + Returns + ------- + size: + Size of this object in bytes. + """ + return 0 + + +class OutputConversionImageToColumn(_OutputConversionImage): + + def _data_conversion(self, input_data: ImageList, output_data: Tensor, **kwargs: Any) -> ImageDataset[Column]: + import torch + + if not isinstance(input_data, _SingleSizeImageList): + raise ValueError("The given input ImageList contains images of different sizes.") # noqa: TRY004 + if "column_name" not in kwargs or not isinstance(kwargs.get("column_name"), str): + raise ValueError( + "The column_name is not set. The data can only be converted if the column_name is provided as `str` in the kwargs.", + ) + if "one_hot_encoder" not in kwargs or not isinstance(kwargs.get("one_hot_encoder"), OneHotEncoder): + raise ValueError( + "The one_hot_encoder is not set. The data can only be converted if the one_hot_encoder is provided as `OneHotEncoder` in the kwargs.", + ) + one_hot_encoder: OneHotEncoder = kwargs["one_hot_encoder"] + column_name: str = kwargs["column_name"] + + output = torch.zeros(len(input_data), len(one_hot_encoder.get_names_of_added_columns())) + output[torch.arange(len(input_data)), output_data] = 1 + + im_dataset: ImageDataset[Column] = ImageDataset[Column].__new__(ImageDataset) + im_dataset._output = _ColumnAsTensor._from_tensor(output, column_name, one_hot_encoder) + im_dataset._shuffle_tensor_indices = torch.LongTensor(list(range(len(input_data)))) + im_dataset._shuffle_after_epoch = False + im_dataset._batch_size = 1 + im_dataset._next_batch_index = 0 + im_dataset._input_size = input_data.sizes[0] + im_dataset._input = input_data + return im_dataset + + +class OutputConversionImageToTable(_OutputConversionImage): + + def _data_conversion(self, input_data: ImageList, output_data: Tensor, **kwargs: Any) -> ImageDataset[Table]: + import torch + + if not isinstance(input_data, _SingleSizeImageList): + raise ValueError("The given input ImageList contains images of different sizes.") # noqa: TRY004 + if ( + "column_names" not in kwargs + or not isinstance(kwargs.get("column_names"), list) + and all(isinstance(element, str) for element in kwargs["column_names"]) + ): + raise ValueError( + "The column_names are not set. The data can only be converted if the column_names are provided as `list[str]` in the kwargs.", + ) + column_names: list[str] = kwargs["column_names"] + + output = torch.zeros(len(input_data), len(column_names)) + output[torch.arange(len(input_data)), output_data] = 1 + + im_dataset: ImageDataset[Table] = ImageDataset[Table].__new__(ImageDataset) + im_dataset._output = _TableAsTensor._from_tensor(output, column_names) + im_dataset._shuffle_tensor_indices = torch.LongTensor(list(range(len(input_data)))) + im_dataset._shuffle_after_epoch = False + im_dataset._batch_size = 1 + im_dataset._next_batch_index = 0 + im_dataset._input_size = input_data.sizes[0] + im_dataset._input = input_data + return im_dataset + + +class OutputConversionImageToImage(_OutputConversionImage): + + def _data_conversion( + self, + input_data: ImageList, + output_data: Tensor, + **kwargs: Any, # noqa: ARG002 + ) -> ImageDataset[ImageList]: + import torch + + if not isinstance(input_data, _SingleSizeImageList): + raise ValueError("The given input ImageList contains images of different sizes.") # noqa: TRY004 + + return ImageDataset[ImageList]( + input_data, + _SingleSizeImageList._create_from_tensor( + (output_data * 255).to(torch.uint8), + list(range(output_data.size(dim=0))), + ), + ) diff --git a/src/safeds/ml/nn/_output_conversion_table.py b/src/safeds/ml/nn/_output_conversion_table.py index 5c956d9a2..a77b9862f 100644 --- a/src/safeds/ml/nn/_output_conversion_table.py +++ b/src/safeds/ml/nn/_output_conversion_table.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from torch import Tensor from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Column, Table -from safeds.ml.nn._output_conversion import OutputConversion +from safeds.ml.nn import OutputConversion class OutputConversionTable(OutputConversion[Table, TabularDataset]): @@ -24,7 +24,7 @@ def __init__(self, prediction_name: str = "prediction") -> None: """ self._prediction_name = prediction_name - def _data_conversion(self, input_data: Table, output_data: Tensor) -> TabularDataset: + def _data_conversion(self, input_data: Table, output_data: Tensor, **kwargs: Any) -> TabularDataset: # noqa: ARG002 return input_data.add_column(Column(self._prediction_name, output_data.tolist())).to_tabular_dataset( self._prediction_name, ) diff --git a/src/safeds/ml/nn/_pooling2d_layer.py b/src/safeds/ml/nn/_pooling2d_layer.py new file mode 100644 index 000000000..f3d777b01 --- /dev/null +++ b/src/safeds/ml/nn/_pooling2d_layer.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import math +import sys +from typing import TYPE_CHECKING, Any, Literal + +from safeds._utils import _structural_hash +from safeds.data.image.typing import ImageSize + +if TYPE_CHECKING: + from torch import Tensor, nn + +from safeds.ml.nn import Layer + + +def _create_internal_model(strategy: Literal["max", "avg"], kernel_size: int, padding: int, stride: int) -> nn.Module: + from torch import nn + + class _InternalLayer(nn.Module): + def __init__(self, strategy: Literal["max", "avg"], kernel_size: int, padding: int, stride: int): + super().__init__() + match strategy: + case "max": + self._layer = nn.MaxPool2d(kernel_size=kernel_size, padding=padding, stride=stride) + case "avg": + self._layer = nn.AvgPool2d(kernel_size=kernel_size, padding=padding, stride=stride) + + def forward(self, x: Tensor) -> Tensor: + return self._layer(x) + + return _InternalLayer(strategy, kernel_size, padding, stride) + + +class _Pooling2DLayer(Layer): + def __init__(self, strategy: Literal["max", "avg"], kernel_size: int, *, stride: int = -1, padding: int = 0): + """ + Create a Pooling 2D Layer. + + Parameters + ---------- + strategy: + the strategy of the pooling + kernel_size: + the size of the kernel + stride: + the stride of the pooling + padding: + the padding of the pooling + """ + self._strategy = strategy + self._kernel_size = kernel_size + self._stride = stride if stride != -1 else kernel_size + self._padding = padding + self._input_size: ImageSize | None = None + self._output_size: ImageSize | None = None + + def _get_internal_layer(self, **kwargs: Any) -> nn.Module: # noqa: ARG002 + return _create_internal_model(self._strategy, self._kernel_size, self._padding, self._stride) + + @property + def input_size(self) -> ImageSize: + """ + Get the input_size of this layer. + + Returns + ------- + result: + The amount of values being passed into this layer. + + Raises + ------ + ValueError + If the input_size is not yet set + """ + if self._input_size is None: + raise ValueError("The input_size is not yet set.") + return self._input_size + + @property + def output_size(self) -> ImageSize: + """ + Get the output_size of this layer. + + Returns + ------- + result: + The Number of Neurons in this layer. + + Raises + ------ + ValueError + If the input_size is not yet set + """ + if self._input_size is None: + raise ValueError( + "The input_size is not yet set. The layer cannot compute the output_size if the input_size is not set.", + ) + if self._output_size is None: + new_width = math.ceil( + (self.input_size.width + self._padding * 2 - self._kernel_size + 1) / (1.0 * self._stride), + ) + new_height = math.ceil( + (self.input_size.height + self._padding * 2 - self._kernel_size + 1) / (1.0 * self._stride), + ) + self._output_size = ImageSize(new_width, new_height, self._input_size.channel, _ignore_invalid_channel=True) + return self._output_size + + def _set_input_size(self, input_size: int | ImageSize) -> None: + if isinstance(input_size, int): + raise TypeError("The input_size of a pooling layer has to be of type ImageSize.") + self._input_size = input_size + self._output_size = None + + def __hash__(self) -> int: + """ + Return a deterministic hash value for this pooling 2d layer. + + Returns + ------- + hash: + the hash value + """ + return _structural_hash( + self._strategy, + self._kernel_size, + self._stride, + self._padding, + self._input_size, + self._output_size, + ) + + def __eq__(self, other: object) -> bool: + """ + Compare two pooling 2d layer. + + Parameters + ---------- + other: + The pooling 2d layer to compare to. + + Returns + ------- + equals: + Whether the two pooling 2d layer are the same. + """ + if not isinstance(other, type(self)): + return NotImplemented + return (self is other) or ( + self._input_size == other._input_size + and self._output_size == other._output_size + and self._strategy == other._strategy + and self._kernel_size == other._kernel_size + and self._stride == other._stride + and self._padding == other._padding + ) + + def __sizeof__(self) -> int: + """ + Return the complete size of this object. + + Returns + ------- + size: + Size of this object in bytes. + """ + return ( + sys.getsizeof(self._input_size) + + sys.getsizeof(self._output_size) + + sys.getsizeof(self._strategy) + + sys.getsizeof(self._kernel_size) + + sys.getsizeof(self._stride) + + sys.getsizeof(self._padding) + ) + + +class MaxPooling2DLayer(_Pooling2DLayer): + + def __init__(self, kernel_size: int, *, stride: int = -1, padding: int = 0) -> None: + """ + Create a maximum Pooling 2D Layer. + + Parameters + ---------- + kernel_size: + the size of the kernel + stride: + the stride of the pooling + padding: + the padding of the pooling + """ + super().__init__("max", kernel_size, stride=stride, padding=padding) + + +class AvgPooling2DLayer(_Pooling2DLayer): + + def __init__(self, kernel_size: int, *, stride: int = -1, padding: int = 0) -> None: + """ + Create a average Pooling 2D Layer. + + Parameters + ---------- + kernel_size: + the size of the kernel + stride: + the stride of the pooling + padding: + the padding of the pooling + """ + super().__init__("avg", kernel_size, stride=stride, padding=padding) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 6ab53eade..7b87e1e4e 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -3,6 +3,13 @@ assert_that_tabular_datasets_are_equal, assert_that_time_series_are_equal, ) +from ._devices import ( + device_cpu, + device_cuda, + get_devices, + get_devices_ids, + skip_if_device_not_available, +) from ._images import ( grayscale_jpg_id, grayscale_jpg_path, @@ -32,10 +39,14 @@ "assert_that_tables_are_close", "assert_that_tabular_datasets_are_equal", "assert_that_time_series_are_equal", + "device_cpu", + "device_cuda", "grayscale_jpg_id", "grayscale_jpg_path", "grayscale_png_id", "grayscale_png_path", + "get_devices", + "get_devices_ids", "images_all", "images_all_channel", "images_all_channel_ids", @@ -49,6 +60,7 @@ "resolve_resource_path", "rgba_png_id", "rgba_png_path", + "skip_if_device_not_available", "test_images_folder", "white_square_jpg_id", "white_square_jpg_path", diff --git a/tests/helpers/_devices.py b/tests/helpers/_devices.py new file mode 100644 index 000000000..b4405bcc3 --- /dev/null +++ b/tests/helpers/_devices.py @@ -0,0 +1,19 @@ +import pytest +import torch +from torch.types import Device + +device_cpu = torch.device("cpu") +device_cuda = torch.device("cuda") + + +def get_devices() -> list[torch.device]: + return [device_cpu, device_cuda] + + +def get_devices_ids() -> list[str]: + return ["cpu", "cuda"] + + +def skip_if_device_not_available(device: Device) -> None: + if device == device_cuda and not torch.cuda.is_available(): + pytest.skip("This test requires cuda") diff --git a/tests/safeds/data/image/containers/test_image.py b/tests/safeds/data/image/containers/test_image.py index 3555ad993..ac1bb0b65 100644 --- a/tests/safeds/data/image/containers/test_image.py +++ b/tests/safeds/data/image/containers/test_image.py @@ -3,15 +3,21 @@ from pathlib import Path from tempfile import NamedTemporaryFile +import numpy as np +import PIL.Image import pytest import torch from safeds.data.image.containers import Image +from safeds.data.image.typing import ImageSize from safeds.data.tabular.containers import Table from safeds.exceptions import IllegalFormatError, OutOfBoundsError from syrupy import SnapshotAssertion from torch.types import Device from tests.helpers import ( + device_cuda, + get_devices, + get_devices_ids, grayscale_jpg_id, grayscale_jpg_path, grayscale_png_id, @@ -27,28 +33,13 @@ resolve_resource_path, rgba_png_id, rgba_png_path, + skip_if_device_not_available, white_square_jpg_id, white_square_jpg_path, white_square_png_id, white_square_png_path, ) -_device_cuda = torch.device("cuda") -_device_cpu = torch.device("cpu") - - -def _test_devices() -> list[torch.device]: - return [_device_cpu, _device_cuda] - - -def _test_devices_ids() -> list[str]: - return ["cpu", "cuda"] - - -def _skip_if_device_not_available(device: Device) -> None: - if device == _device_cuda and not torch.cuda.is_available(): - pytest.skip("This test requires cuda") - def _assert_width_height_channel(image1: Image, image2: Image) -> None: assert image1.width == image2.width @@ -56,7 +47,7 @@ def _assert_width_height_channel(image1: Image, image2: Image) -> None: assert image1.channel == image2.channel -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestFromFile: @pytest.mark.parametrize( "resource_path", @@ -67,7 +58,7 @@ class TestFromFile: ], ) def test_should_load_from_file(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) assert image != Image(torch.empty(1, 1, 1)) @@ -82,12 +73,12 @@ def test_should_load_from_file(self, resource_path: str | Path, device: Device) ids=["missing_file_jpg", "missing_file_jpg_Path", "missing_file_png", "missing_file_png_Path"], ) def test_should_raise_if_file_not_found(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.raises(FileNotFoundError): Image.from_file(resolve_resource_path(resource_path), device) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestFromBytes: @pytest.mark.parametrize( "resource_path", @@ -95,7 +86,7 @@ class TestFromBytes: ids=[plane_jpg_id, white_square_jpg_id, white_square_png_id, grayscale_jpg_id, grayscale_png_id], ) def test_should_write_and_load_bytes_jpeg(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_copy = Image.from_bytes(typing.cast(bytes, image._repr_jpeg_()), device) _assert_width_height_channel(image, image_copy) @@ -106,13 +97,28 @@ def test_should_write_and_load_bytes_jpeg(self, resource_path: str | Path, devic ids=images_all_ids(), ) def test_should_write_and_load_bytes_png(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_copy = Image.from_bytes(image._repr_png_(), device) assert image == image_copy -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) +class TestToNumpyArray: + + @pytest.mark.parametrize( + "resource_path", + images_all(), + ids=images_all_ids(), + ) + def test_should_return_numpy_array(self, resource_path: str | Path, device: Device) -> None: + skip_if_device_not_available(device) + image_safeds = Image.from_file(resolve_resource_path(resource_path), device) + image_np = np.array(PIL.Image.open(resolve_resource_path(resource_path))) + assert np.all(np.array(image_safeds).squeeze() == image_np) + + +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestReprJpeg: @pytest.mark.parametrize( "resource_path", @@ -120,7 +126,7 @@ class TestReprJpeg: ids=[plane_jpg_id, white_square_jpg_id, white_square_png_id, grayscale_jpg_id, grayscale_png_id], ) def test_should_return_bytes(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) assert isinstance(image._repr_jpeg_(), bytes) @@ -133,12 +139,12 @@ def test_should_return_bytes(self, resource_path: str | Path, device: Device) -> ids=[plane_png_id, rgba_png_id], ) def test_should_return_none_if_image_has_alpha_channel(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) assert image._repr_jpeg_() is None -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestReprPng: @pytest.mark.parametrize( "resource_path", @@ -146,12 +152,12 @@ class TestReprPng: ids=images_all_ids(), ) def test_should_return_bytes(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) assert isinstance(image._repr_png_(), bytes) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestToJpegFile: @pytest.mark.parametrize( "resource_path", @@ -159,7 +165,7 @@ class TestToJpegFile: ids=[plane_jpg_id, white_square_jpg_id, white_square_png_id, grayscale_jpg_id, grayscale_png_id], ) def test_should_save_file(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with NamedTemporaryFile(suffix=".jpg") as tmp_jpeg_file: tmp_jpeg_file.close() @@ -178,7 +184,7 @@ def test_should_save_file(self, resource_path: str | Path, device: Device) -> No ids=[plane_png_id, rgba_png_id], ) def test_should_raise_if_image_has_alpha_channel(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with NamedTemporaryFile(suffix=".jpg") as tmp_jpeg_file: tmp_jpeg_file.close() @@ -189,7 +195,7 @@ def test_should_raise_if_image_has_alpha_channel(self, resource_path: str | Path image.to_jpeg_file(tmp_file.name) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestToPngFile: @pytest.mark.parametrize( "resource_path", @@ -197,7 +203,7 @@ class TestToPngFile: ids=images_all_ids(), ) def test_should_save_file(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with NamedTemporaryFile(suffix=".png") as tmp_png_file: tmp_png_file.close() @@ -208,7 +214,7 @@ def test_should_save_file(self, resource_path: str | Path, device: Device) -> No assert image == image_r -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestProperties: @pytest.mark.parametrize( ("resource_path", "width", "height", "channel"), @@ -274,29 +280,30 @@ def test_should_return_image_properties( channel: int, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) assert image.width == width assert image.height == height assert image.channel == channel + assert image.size == ImageSize(width, height, channel) class TestEQ: - @pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) + @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) @pytest.mark.parametrize( "resource_path", images_all(), ids=images_all_ids(), ) def test_should_be_equal(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image2 = Image.from_file(resolve_resource_path(resource_path), device) assert image == image2 - @pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) + @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) def test_should_not_be_equal(self, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(plane_png_path), device) image2 = Image.from_file(resolve_resource_path(white_square_png_path), device) assert image != image2 @@ -307,48 +314,48 @@ def test_should_not_be_equal(self, device: Device) -> None: ids=images_all_ids(), ) def test_should_be_equal_different_devices(self, resource_path: str) -> None: - _skip_if_device_not_available(_device_cuda) + skip_if_device_not_available(device_cuda) image = Image.from_file(resolve_resource_path(resource_path), torch.device("cpu")) image2 = Image.from_file(resolve_resource_path(resource_path), torch.device("cuda")) assert image == image2 assert image2 == image def test_should_not_be_equal_different_devices(self) -> None: - _skip_if_device_not_available(_device_cuda) + skip_if_device_not_available(device_cuda) image = Image.from_file(resolve_resource_path(plane_png_path), torch.device("cpu")) image2 = Image.from_file(resolve_resource_path(white_square_png_path), torch.device("cuda")) assert image != image2 assert image2 != image - @pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) + @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) @pytest.mark.parametrize( "resource_path", images_all(), ids=images_all_ids(), ) - def test_should_raise(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + def test_should_be_not_implemented(self, resource_path: str, device: Device) -> None: + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) other = Table() assert (image.__eq__(other)) is NotImplemented class TestHash: - @pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) + @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) @pytest.mark.parametrize( "resource_path", images_all(), ids=images_all_ids(), ) def test_should_hash_be_equal(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image2 = Image.from_file(resolve_resource_path(resource_path), device) assert hash(image) == hash(image2) - @pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) + @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) def test_should_hash_not_be_equal(self, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(plane_png_path), device) image2 = Image.from_file(resolve_resource_path(white_square_png_path), device) assert hash(image) != hash(image2) @@ -359,19 +366,19 @@ def test_should_hash_not_be_equal(self, device: Device) -> None: ids=images_all_ids(), ) def test_should_hash_be_equal_different_devices(self, resource_path: str) -> None: - _skip_if_device_not_available(_device_cuda) + skip_if_device_not_available(device_cuda) image = Image.from_file(resolve_resource_path(resource_path), torch.device("cpu")) image2 = Image.from_file(resolve_resource_path(resource_path), torch.device("cuda")) assert hash(image) == hash(image2) def test_should_hash_not_be_equal_different_devices(self) -> None: - _skip_if_device_not_available(_device_cuda) + skip_if_device_not_available(device_cuda) image = Image.from_file(resolve_resource_path(plane_png_path), torch.device("cpu")) image2 = Image.from_file(resolve_resource_path(white_square_png_path), torch.device("cuda")) assert hash(image) != hash(image2) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestChangeChannel: @pytest.mark.parametrize( "resource_path", @@ -386,7 +393,7 @@ def test_should_change_channel( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) new_image = image.change_channel(channel) assert new_image.channel == channel @@ -399,13 +406,13 @@ def test_should_change_channel( ) @pytest.mark.parametrize("channel", [2], ids=["invalid-channel"]) def test_should_raise(self, resource_path: str, channel: int, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with pytest.raises(ValueError, match=rf"Channel {channel} is not a valid channel option. Use either 1, 3 or 4"): image.change_channel(channel) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestResize: @pytest.mark.parametrize( "resource_path", @@ -438,7 +445,7 @@ def test_should_return_resized_image( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) new_image = image.resize(new_width, new_height) assert new_image.width == new_width @@ -458,7 +465,7 @@ def test_should_return_resized_image( ids=["invalid width", "invalid height", "invalid width and height"], ) def test_should_raise(self, resource_path: str, new_width: int, new_height: int, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with pytest.raises( OutOfBoundsError, @@ -474,13 +481,13 @@ class TestDevices: ids=images_all_ids(), ) def test_should_change_device(self, resource_path: str) -> None: - _skip_if_device_not_available(_device_cuda) + skip_if_device_not_available(device_cuda) image = Image.from_file(resolve_resource_path(resource_path), torch.device("cpu")) new_device = torch.device("cuda", 0) assert image._set_device(new_device).device == new_device -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestConvertToGrayscale: @pytest.mark.parametrize( "resource_path", @@ -493,14 +500,14 @@ def test_convert_to_grayscale( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) grayscale_image = image.convert_to_grayscale() assert grayscale_image == snapshot_png_image _assert_width_height_channel(image, grayscale_image) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestCrop: @pytest.mark.parametrize( "resource_path", @@ -513,7 +520,7 @@ def test_should_return_cropped_image( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_cropped = image.crop(0, 0, 100, 100) assert image_cropped == snapshot_png_image @@ -536,7 +543,7 @@ def test_should_raise_invalid_size( new_height: int, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with pytest.raises( OutOfBoundsError, @@ -555,7 +562,7 @@ def test_should_raise_invalid_size( ids=["invalid x", "invalid y", "invalid x and y"], ) def test_should_raise_invalid_coordinates(self, resource_path: str, new_x: int, new_y: int, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with pytest.raises( OutOfBoundsError, @@ -580,7 +587,7 @@ def test_should_warn_if_coordinates_outsize_image( new_y: int, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_blank_tensor = torch.zeros((image.channel, 1, 1), device=device) with pytest.warns( @@ -591,7 +598,7 @@ def test_should_warn_if_coordinates_outsize_image( assert torch.all(torch.eq(cropped_image._image_tensor, image_blank_tensor)) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestFlipVertically: @pytest.mark.parametrize( "resource_path", @@ -604,7 +611,7 @@ def test_should_flip_vertically( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_flip_v = image.flip_vertically() assert image != image_flip_v @@ -617,13 +624,13 @@ def test_should_flip_vertically( ids=images_all_ids(), ) def test_should_be_original(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_flip_v_v = image.flip_vertically().flip_vertically() assert image == image_flip_v_v -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestFlipHorizontally: @pytest.mark.parametrize( "resource_path", @@ -636,7 +643,7 @@ def test_should_flip_horizontally( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_flip_h = image.flip_horizontally() assert image != image_flip_h @@ -649,13 +656,13 @@ def test_should_flip_horizontally( ids=images_all_ids(), ) def test_should_be_original(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_flip_h_h = image.flip_horizontally().flip_horizontally() assert image == image_flip_h_h -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestBrightness: @pytest.mark.parametrize("factor", [0.5, 10], ids=["small factor", "large factor"]) @pytest.mark.parametrize( @@ -670,7 +677,7 @@ def test_should_adjust_brightness( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_adjusted_brightness = image.adjust_brightness(factor) assert image != image_adjusted_brightness @@ -683,7 +690,7 @@ def test_should_adjust_brightness( ids=images_all_ids(), ) def test_should_not_brighten(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.warns( UserWarning, match="Brightness adjustment factor is 1.0, this will not make changes to the image.", @@ -698,13 +705,13 @@ def test_should_not_brighten(self, resource_path: str, device: Device) -> None: ids=images_all_ids(), ) def test_should_raise(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with pytest.raises(OutOfBoundsError, match=r"factor \(=-1\) is not inside \[0, \u221e\)."): image.adjust_brightness(-1) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestAddNoise: @pytest.mark.parametrize( "standard_deviation", @@ -727,7 +734,7 @@ def test_should_add_noise( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) torch.manual_seed(0) image = Image.from_file(resolve_resource_path(resource_path), device) image_noise = image.add_noise(standard_deviation) @@ -750,7 +757,7 @@ def test_should_raise_standard_deviation( standard_deviation: float, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with pytest.raises( OutOfBoundsError, @@ -759,7 +766,7 @@ def test_should_raise_standard_deviation( image.add_noise(standard_deviation) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestAdjustContrast: @pytest.mark.parametrize("factor", [0.75, 5], ids=["small factor", "large factor"]) @pytest.mark.parametrize( @@ -774,7 +781,7 @@ def test_should_adjust_contrast( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_adjusted_contrast = image.adjust_contrast(factor) assert image != image_adjusted_contrast @@ -787,7 +794,7 @@ def test_should_adjust_contrast( ids=images_all_ids(), ) def test_should_not_adjust_contrast(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.warns( UserWarning, match="Contrast adjustment factor is 1.0, this will not make changes to the image.", @@ -802,12 +809,12 @@ def test_should_not_adjust_contrast(self, resource_path: str, device: Device) -> ids=images_all_ids(), ) def test_should_raise_negative_contrast(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.raises(OutOfBoundsError, match=r"factor \(=-1.0\) is not inside \[0, \u221e\)."): Image.from_file(resolve_resource_path(resource_path), device).adjust_contrast(-1.0) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestAdjustColor: @pytest.mark.parametrize("factor", [2, 0.5, 0], ids=["add color", "remove color", "gray"]) @pytest.mark.parametrize( @@ -822,7 +829,7 @@ def test_should_adjust_colors( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_adjusted_color_balance = image.adjust_color_balance(factor) assert image != image_adjusted_color_balance @@ -834,7 +841,7 @@ def test_should_adjust_colors( ids=images_all_ids(), ) def test_should_not_adjust_colors_factor_1(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.warns( UserWarning, match="Color adjustment factor is 1.0, this will not make changes to the image.", @@ -849,7 +856,7 @@ def test_should_not_adjust_colors_factor_1(self, resource_path: str, device: Dev ids=[grayscale_png_id, grayscale_jpg_id], ) def test_should_not_adjust_colors_channel_1(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.warns( UserWarning, match="Color adjustment will not have an affect on grayscale images with only one channel", @@ -864,12 +871,12 @@ def test_should_not_adjust_colors_channel_1(self, resource_path: str, device: De ids=images_all_ids(), ) def test_should_raise_negative_color_adjust(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.raises(OutOfBoundsError, match=r"factor \(=-1.0\) is not inside \[0, \u221e\)."): Image.from_file(resolve_resource_path(resource_path), device).adjust_color_balance(-1.0) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestBlur: @pytest.mark.parametrize( "resource_path", @@ -882,7 +889,7 @@ def test_should_return_blurred_image( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device=device) image_blurred = image.blur(2) assert image_blurred == snapshot_png_image @@ -894,7 +901,7 @@ def test_should_return_blurred_image( ids=images_asymmetric_ids(), ) def test_should_not_blur_radius_0(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.warns( UserWarning, match="Blur radius is 0, this will not make changes to the image.", @@ -909,7 +916,7 @@ def test_should_not_blur_radius_0(self, resource_path: str, device: Device) -> N ids=images_asymmetric_ids(), ) def test_should_raise_blur_radius_out_of_bounds(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) with pytest.raises( OutOfBoundsError, @@ -923,7 +930,7 @@ def test_should_raise_blur_radius_out_of_bounds(self, resource_path: str, device image.blur(min(image.width, image.height)) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestSharpen: @pytest.mark.parametrize("factor", [0, 0.5, 10], ids=["zero factor", "small factor", "large factor"]) @pytest.mark.parametrize( @@ -938,7 +945,7 @@ def test_should_sharpen( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_sharpened = image.sharpen(factor) assert image != image_sharpened @@ -951,7 +958,7 @@ def test_should_sharpen( ids=images_all_ids(), ) def test_should_raise_negative_sharpen(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.raises(OutOfBoundsError, match=r"factor \(=-1.0\) is not inside \[0, \u221e\)."): Image.from_file(resolve_resource_path(resource_path), device).sharpen(-1.0) @@ -961,14 +968,14 @@ def test_should_raise_negative_sharpen(self, resource_path: str, device: Device) ids=images_all_ids(), ) def test_should_not_sharpen(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) with pytest.warns(UserWarning, match="Sharpen factor is 1.0, this will not make changes to the image."): image = Image.from_file(resolve_resource_path(resource_path), device) image_sharpened = image.sharpen(1) assert image == image_sharpened -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestInvertColors: @pytest.mark.parametrize( "resource_path", @@ -981,14 +988,14 @@ def test_should_invert_colors( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_inverted_colors = image.invert_colors() assert image_inverted_colors == snapshot_png_image _assert_width_height_channel(image, image_inverted_colors) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestRotate: @pytest.mark.parametrize( "resource_path", @@ -1001,7 +1008,7 @@ def test_should_return_clockwise_rotated_image( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_right_rotated = image.rotate_right() assert image_right_rotated == snapshot_png_image @@ -1018,7 +1025,7 @@ def test_should_return_counter_clockwise_rotated_image( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_left_rotated = image.rotate_left() assert image_left_rotated == snapshot_png_image @@ -1030,7 +1037,7 @@ def test_should_return_counter_clockwise_rotated_image( ids=images_all_ids(), ) def test_should_return_flipped_image(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_left_rotated = image.rotate_left().rotate_left() image_right_rotated = image.rotate_right().rotate_right() @@ -1046,7 +1053,7 @@ def test_should_return_flipped_image(self, resource_path: str, device: Device) - ids=images_all_ids(), ) def test_should_be_original(self, resource_path: str, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) image_left_right_rotated = image.rotate_left().rotate_right() image_right_left_rotated = image.rotate_right().rotate_left() @@ -1058,7 +1065,7 @@ def test_should_be_original(self, resource_path: str, device: Device) -> None: assert image == image_left_r_r_r_r -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestFindEdges: @pytest.mark.parametrize( "resource_path", @@ -1071,14 +1078,14 @@ def test_should_return_edges_of_image( snapshot_png_image: SnapshotAssertion, device: Device, ) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device=device) image_edges = image.find_edges() assert image_edges == snapshot_png_image _assert_width_height_channel(image, image_edges) -@pytest.mark.parametrize("device", _test_devices(), ids=_test_devices_ids()) +@pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestSizeof: @pytest.mark.parametrize( "resource_path", @@ -1086,6 +1093,6 @@ class TestSizeof: ids=images_all_ids(), ) def test_should_size_be_greater_than_normal_object(self, resource_path: str | Path, device: Device) -> None: - _skip_if_device_not_available(device) + skip_if_device_not_available(device) image = Image.from_file(resolve_resource_path(resource_path), device) assert sys.getsizeof(image) >= image.width * image.height * image.channel diff --git a/tests/safeds/data/image/containers/test_image_list.py b/tests/safeds/data/image/containers/test_image_list.py index 4bdb3f779..34885e8f0 100644 --- a/tests/safeds/data/image/containers/test_image_list.py +++ b/tests/safeds/data/image/containers/test_image_list.py @@ -1,3 +1,4 @@ +import math import random import sys import tempfile @@ -13,6 +14,7 @@ from safeds.data.tabular.containers import Table from safeds.exceptions import DuplicateIndexError, IllegalFormatError, IndexOutOfBoundsError, OutOfBoundsError from syrupy import SnapshotAssertion +from torch import Tensor from tests.helpers import ( grayscale_jpg_path, @@ -151,6 +153,13 @@ def test_from_files(self, resource_path1: str, resource_path2: str, resource_pat # Test channel assert image_list.channel == expected_channel + # Test sizes + assert image_list.sizes == [ + image1_with_expected_channel.size, + image2_with_expected_channel.size, + image3_with_expected_channel.size, + ] + # Test number_of_sizes assert image_list.number_of_sizes == len({(image.width, image.height) for image in [image1, image2, image3]}) @@ -448,7 +457,13 @@ class TestFromFiles: def test_from_files_creation(self, resource_path: str | Path, snapshot_png_image_list: SnapshotAssertion) -> None: torch.set_default_device(torch.device("cpu")) image_list = ImageList.from_files(resolve_resource_path(resource_path)) + image_list_returned_filenames, filenames = ImageList.from_files( + resolve_resource_path(resource_path), + return_filenames=True, + ) assert image_list == snapshot_png_image_list + assert image_list == image_list_returned_filenames + assert len(image_list) == len(filenames) @pytest.mark.parametrize( "resource_path", @@ -600,6 +615,7 @@ def test_should_save_images_in_directories_for_different_sizes(self, resource_pa assert set(image_list.widths) == set(image_list_loaded.widths) assert set(image_list.heights) == set(image_list_loaded.heights) assert image_list.channel == image_list_loaded.channel + assert set(image_list.sizes) == set(image_list_loaded.sizes) for tmp_dir in tmp_dirs: tmp_dir.cleanup() @@ -694,6 +710,7 @@ def test_should_save_images_in_directories_for_different_sizes(self, resource_pa assert set(image_list.widths) == set(image_list_loaded.widths) assert set(image_list.heights) == set(image_list_loaded.heights) assert image_list.channel == image_list_loaded.channel + assert set(image_list.sizes) == set(image_list_loaded.sizes) for tmp_dir in tmp_dirs: tmp_dir.cleanup() @@ -1203,6 +1220,99 @@ def test_should_not_adjust( assert image_list_original == image_list_clone +class TestSingleSizeImageList: + + @pytest.mark.parametrize( + "tensor", + [ + torch.ones(4, 1, 1), + ], + ) + def test_create_from_tensor_3_dim(self, tensor: Tensor) -> None: + expected_tensor = tensor.unsqueeze(dim=1) + image_list = _SingleSizeImageList._create_from_tensor(tensor, list(range(tensor.size(0)))) + assert image_list._tensor_positions_to_indices == list(range(tensor.size(0))) + assert len(image_list) == expected_tensor.size(0) + assert image_list.widths[0] == expected_tensor.size(3) + assert image_list.heights[0] == expected_tensor.size(2) + assert image_list.channel == expected_tensor.size(1) + + @pytest.mark.parametrize( + "tensor", + [ + torch.ones(4, 3, 1, 1), + ], + ) + def test_create_from_tensor_4_dim(self, tensor: Tensor) -> None: + image_list = _SingleSizeImageList._create_from_tensor(tensor, list(range(tensor.size(0)))) + assert image_list._tensor_positions_to_indices == list(range(tensor.size(0))) + assert len(image_list) == tensor.size(0) + assert image_list.widths[0] == tensor.size(3) + assert image_list.heights[0] == tensor.size(2) + assert image_list.channel == tensor.size(1) + + @pytest.mark.parametrize("tensor", [torch.ones(4, 3, 1, 1, 1), torch.ones(4, 3)], ids=["5-dim", "2-dim"]) + def test_should_raise_from_invalid_tensor(self, tensor: Tensor) -> None: + with pytest.raises( + ValueError, + match=rf"Invalid Tensor. This Tensor requires 3 or 4 dimensions but has {tensor.dim()}", + ): + _SingleSizeImageList._create_from_tensor(tensor, list(range(tensor.size(0)))) + + @pytest.mark.parametrize( + "tensor", + [ + torch.randn(16, 4, 4), + ], + ) + def test_get_batch_and_iterate_3_dim(self, tensor: Tensor) -> None: + expected_tensor = tensor.unsqueeze(dim=1) + image_list = _SingleSizeImageList._create_from_tensor(tensor, list(range(tensor.size(0)))) + batch_size = math.ceil(expected_tensor.size(0) / 1.999) + assert image_list._get_batch(0, batch_size).size(0) == batch_size + assert torch.all(torch.eq(image_list._get_batch(0, 1), image_list._get_batch(0))) + assert torch.all( + torch.eq(image_list._get_batch(0, batch_size), expected_tensor[:batch_size].to(torch.float32) / 255), + ) + assert torch.all( + torch.eq(image_list._get_batch(1, batch_size), expected_tensor[batch_size:].to(torch.float32) / 255), + ) + iterate_image_list = iter(image_list) + assert iterate_image_list == image_list + assert iterate_image_list is not image_list + iterate_image_list._batch_size = batch_size + assert torch.all(torch.eq(image_list._get_batch(0, batch_size), next(iterate_image_list))) + assert torch.all(torch.eq(image_list._get_batch(1, batch_size), next(iterate_image_list))) + with pytest.raises(IndexOutOfBoundsError, match=rf"There is no element at index '{batch_size * 2}'."): + image_list._get_batch(2, batch_size) + with pytest.raises(StopIteration): + next(iterate_image_list) + + @pytest.mark.parametrize( + "tensor", + [ + torch.randn(16, 4, 4, 4), + ], + ) + def test_get_batch_and_iterate_4_dim(self, tensor: Tensor) -> None: + image_list = _SingleSizeImageList._create_from_tensor(tensor, list(range(tensor.size(0)))) + batch_size = math.ceil(tensor.size(0) / 1.999) + assert image_list._get_batch(0, batch_size).size(0) == batch_size + assert torch.all(torch.eq(image_list._get_batch(0, 1), image_list._get_batch(0))) + assert torch.all(torch.eq(image_list._get_batch(0, batch_size), tensor[:batch_size].to(torch.float32) / 255)) + assert torch.all(torch.eq(image_list._get_batch(1, batch_size), tensor[batch_size:].to(torch.float32) / 255)) + iterate_image_list = iter(image_list) + assert iterate_image_list == image_list + assert iterate_image_list is not image_list + iterate_image_list._batch_size = batch_size + assert torch.all(torch.eq(image_list._get_batch(0, batch_size), next(iterate_image_list))) + assert torch.all(torch.eq(image_list._get_batch(1, batch_size), next(iterate_image_list))) + with pytest.raises(IndexOutOfBoundsError, match=rf"There is no element at index '{batch_size * 2}'."): + image_list._get_batch(2, batch_size) + with pytest.raises(StopIteration): + next(iterate_image_list) + + class TestEmptyImageList: def test_warn_empty_image_list(self) -> None: @@ -1264,6 +1374,9 @@ def test_heights(self) -> None: def test_channel(self) -> None: assert _EmptyImageList().channel is NotImplemented + def test_sizes(self) -> None: + assert _EmptyImageList().sizes == [] + def test_number_of_sizes(self) -> None: assert _EmptyImageList().number_of_sizes == 0 diff --git a/tests/safeds/data/image/typing/__init__.py b/tests/safeds/data/image/typing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/safeds/data/image/typing/test_image_size.py b/tests/safeds/data/image/typing/test_image_size.py new file mode 100644 index 000000000..d54174a2c --- /dev/null +++ b/tests/safeds/data/image/typing/test_image_size.py @@ -0,0 +1,120 @@ +import sys +from typing import Any + +import pytest +from safeds.data.image.containers import Image +from safeds.data.image.typing import ImageSize +from safeds.exceptions import OutOfBoundsError +from torch.types import Device + +from tests.helpers import ( + get_devices, + get_devices_ids, + images_all, + images_all_ids, + plane_png_path, + resolve_resource_path, + skip_if_device_not_available, +) + + +class TestFromImage: + + @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) + @pytest.mark.parametrize("resource_path", images_all(), ids=images_all_ids()) + def test_should_create(self, resource_path: str, device: Device) -> None: + skip_if_device_not_available(device) + image = Image.from_file(resolve_resource_path(resource_path), device) + expected_image_size = ImageSize(image.width, image.height, image.channel) + assert ImageSize.from_image(image) == expected_image_size + + +class TestEq: + + @pytest.mark.parametrize(("image_size", "width", "height", "channel"), [(ImageSize(1, 2, 3), 1, 2, 3)]) + def test_should_be_equal(self, image_size: ImageSize, width: int, height: int, channel: int) -> None: + assert image_size == ImageSize(width, height, channel) + + @pytest.mark.parametrize(("image_size", "width", "height", "channel"), [(ImageSize(1, 2, 3), 3, 2, 1)]) + def test_should_not_be_equal(self, image_size: ImageSize, width: int, height: int, channel: int) -> None: + assert image_size != ImageSize(width, height, channel) + + @pytest.mark.parametrize( + ("image_size", "other"), + [ + (ImageSize(1, 2, 3), None), + (ImageSize(1, 2, 3), Image.from_file(resolve_resource_path(plane_png_path))), + ], + ids=["None", "Image"], + ) + def test_should_be_not_implemented(self, image_size: ImageSize, other: Any) -> None: + assert image_size.__eq__(other) is NotImplemented + + +class TestHash: + + @pytest.mark.parametrize( + "resource_path", + images_all(), + ids=images_all_ids(), + ) + def test_hash_should_be_equal(self, resource_path: str) -> None: + image = Image.from_file(resolve_resource_path(resource_path)) + image2 = Image.from_file(resolve_resource_path(resource_path)) + assert hash(ImageSize.from_image(image)) == hash(ImageSize.from_image(image2)) + + def test_hash_should_not_be_equal(self) -> None: + assert hash(ImageSize(1, 2, 3)) != hash(ImageSize(3, 2, 1)) + + +class TestSizeOf: + + @pytest.mark.parametrize("image_size", [ImageSize(1, 2, 3)]) + def test_should_size_be_greater_than_normal_object(self, image_size: ImageSize) -> None: + assert sys.getsizeof(image_size) >= sys.getsizeof(0) * 3 + + +class TestStr: + + @pytest.mark.parametrize("image_size", [ImageSize(1, 2, 3)]) + def test_should_size_be_greater_than_normal_object(self, image_size: ImageSize) -> None: + assert str(image_size) == f"{image_size.width}x{image_size.height}x{image_size.channel} (WxHxC)" + + +class TestProperties: + + @pytest.mark.parametrize("width", list(range(1, 5))) + @pytest.mark.parametrize("height", list(range(1, 5))) + @pytest.mark.parametrize("channel", [1, 3, 4]) + def test_width_height_channel(self, width: int, height: int, channel: int) -> None: + image_size = ImageSize(width, height, channel) + assert image_size.width == width + assert image_size.height == height + assert image_size.channel == channel + + @pytest.mark.parametrize("channel", [2, 5, 6]) + def test_should_ignore_invalid_channel(self, channel: int) -> None: + assert ImageSize(1, 1, channel, _ignore_invalid_channel=True).channel == channel + + +class TestErrors: + + @pytest.mark.parametrize("width", [-1, 0]) + def test_should_raise_invalid_width(self, width: int) -> None: + with pytest.raises(OutOfBoundsError, match=rf"{width} is not inside \[1, \u221e\)."): + ImageSize(width, 1, 1) + + @pytest.mark.parametrize("height", [-1, 0]) + def test_should_raise_invalid_height(self, height: int) -> None: + with pytest.raises(OutOfBoundsError, match=rf"{height} is not inside \[1, \u221e\)."): + ImageSize(1, height, 1) + + @pytest.mark.parametrize("channel", [-1, 0, 2, 5]) + def test_should_raise_invalid_channel(self, channel: int) -> None: + with pytest.raises(ValueError, match=rf"Channel {channel} is not a valid channel option. Use either 1, 3 or 4"): + ImageSize(1, 1, channel) + + @pytest.mark.parametrize("channel", [-1, 0]) + def test_should_raise_negative_channel_ignore_invalid_channel(self, channel: int) -> None: + with pytest.raises(OutOfBoundsError, match=rf"channel \(={channel}\) is not inside \[1, \u221e\)."): + ImageSize(1, 1, channel, _ignore_invalid_channel=True) diff --git a/tests/safeds/data/labeled/containers/test_image_dataset.py b/tests/safeds/data/labeled/containers/test_image_dataset.py new file mode 100644 index 000000000..8369acc30 --- /dev/null +++ b/tests/safeds/data/labeled/containers/test_image_dataset.py @@ -0,0 +1,366 @@ +import math +import sys +import warnings +from typing import TypeVar + +import pytest +import torch +from safeds.data.image.containers import ImageList +from safeds.data.image.containers._empty_image_list import _EmptyImageList +from safeds.data.image.containers._multi_size_image_list import _MultiSizeImageList +from safeds.data.labeled.containers import ImageDataset +from safeds.data.labeled.containers._image_dataset import _ColumnAsTensor, _TableAsTensor +from safeds.data.tabular.containers import Column, Table +from safeds.data.tabular.transformation import OneHotEncoder +from safeds.exceptions import ( + IndexOutOfBoundsError, + NonNumericColumnError, + OutOfBoundsError, + OutputLengthMismatchError, + TransformerNotFittedError, +) +from torch import Tensor + +from tests.helpers import images_all, plane_png_path, resolve_resource_path, white_square_png_path + +T = TypeVar("T", Column, Table, ImageList) + + +class TestImageDatasetInit: + + @pytest.mark.parametrize( + ("input_data", "output_data", "error", "error_msg"), + [ + ( + _MultiSizeImageList(), + Table(), + ValueError, + r"The given input ImageList contains images of different sizes.", + ), + (_EmptyImageList(), Table(), ValueError, r"The given input ImageList contains no images."), + ( + ImageList.from_files(resolve_resource_path([plane_png_path, plane_png_path])), + ImageList.from_files(resolve_resource_path([plane_png_path, white_square_png_path])), + ValueError, + r"The given output ImageList contains images of different sizes.", + ), + ( + ImageList.from_files(resolve_resource_path(plane_png_path)), + _EmptyImageList(), + OutputLengthMismatchError, + r"The length of the output container differs", + ), + ( + ImageList.from_files(resolve_resource_path(plane_png_path)), + Table(), + OutputLengthMismatchError, + r"The length of the output container differs", + ), + ( + ImageList.from_files(resolve_resource_path(plane_png_path)), + Column("column", [1, 2]), + OutputLengthMismatchError, + r"The length of the output container differs", + ), + ( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path([plane_png_path, plane_png_path])), + OutputLengthMismatchError, + r"The length of the output container differs", + ), + ( + ImageList.from_files(resolve_resource_path(plane_png_path)), + Table({"a": ["1"]}), + NonNumericColumnError, + r"Tried to do a numerical operation on one or multiple non-numerical columns: \nColumns \['a'\] are not numerical.", + ), + ( + ImageList.from_files(resolve_resource_path(plane_png_path)), + Table({"a": [2]}), + ValueError, + r"Columns \['a'\] have values outside of the interval \[0, 1\].", + ), + ( + ImageList.from_files(resolve_resource_path(plane_png_path)), + Table({"a": [-1]}), + ValueError, + r"Columns \['a'\] have values outside of the interval \[0, 1\].", + ), + ], + ) + def test_should_raise_with_invalid_data( + self, + input_data: ImageList, + output_data: T, + error: type[Exception], + error_msg: str, + ) -> None: + with pytest.raises(error, match=error_msg): + ImageDataset(input_data, output_data) + + +class TestLength: + + def test_should_return_length(self) -> None: + image_dataset = ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])) + assert len(image_dataset) == 1 + + +class TestEq: + + @pytest.mark.parametrize( + ("image_dataset1", "image_dataset2"), + [ + ( + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])), + ), + ( + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"images": [1]})), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"images": [1]})), + ), + ( + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ), + ], + ) + def test_should_be_equal(self, image_dataset1: ImageDataset, image_dataset2: ImageDataset) -> None: + assert image_dataset1 == image_dataset2 + + @pytest.mark.parametrize( + "image_dataset1", + [ + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"images": [1]})), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ], + ) + @pytest.mark.parametrize( + "image_dataset2", + [ + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("ims", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"ims": [1]})), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [0])), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + Table({"images": [0], "others": [1]}), + ), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(white_square_png_path)), + ), + ImageDataset(ImageList.from_files(resolve_resource_path(white_square_png_path)), Column("images", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(white_square_png_path)), Table({"images": [1]})), + ImageDataset( + ImageList.from_files(resolve_resource_path(white_square_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ], + ) + def test_should_not_be_equal(self, image_dataset1: ImageDataset, image_dataset2: ImageDataset) -> None: + assert image_dataset1 != image_dataset2 + + def test_should_be_not_implemented(self) -> None: + image_dataset = ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])) + other = Table() + assert image_dataset.__eq__(other) is NotImplemented + + +class TestHash: + + @pytest.mark.parametrize( + ("image_dataset1", "image_dataset2"), + [ + ( + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])), + ), + ( + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"images": [1]})), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"images": [1]})), + ), + ( + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ), + ], + ) + def test_hash_should_be_equal(self, image_dataset1: ImageDataset, image_dataset2: ImageDataset) -> None: + assert hash(image_dataset1) == hash(image_dataset2) + + @pytest.mark.parametrize( + "image_dataset1", + [ + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"images": [1]})), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ], + ) + @pytest.mark.parametrize( + "image_dataset2", + [ + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("ims", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"ims": [1]})), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [0])), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + Table({"images": [0], "others": [1]}), + ), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(white_square_png_path)), + ), + ImageDataset(ImageList.from_files(resolve_resource_path(white_square_png_path)), Column("images", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(white_square_png_path)), Table({"images": [1]})), + ImageDataset( + ImageList.from_files(resolve_resource_path(white_square_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ], + ) + def test_hash_should_not_be_equal(self, image_dataset1: ImageDataset, image_dataset2: ImageDataset) -> None: + assert hash(image_dataset1) != hash(image_dataset2) + + +class TestSizeOf: + + @pytest.mark.parametrize( + "image_dataset", + [ + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Column("images", [1])), + ImageDataset(ImageList.from_files(resolve_resource_path(plane_png_path)), Table({"images": [1]})), + ImageDataset( + ImageList.from_files(resolve_resource_path(plane_png_path)), + ImageList.from_files(resolve_resource_path(plane_png_path)), + ), + ], + ) + def test_should_size_be_greater_than_normal_object(self, image_dataset: ImageDataset) -> None: + assert sys.getsizeof(image_dataset) > sys.getsizeof(object()) + + +class TestShuffle: + + def test_should_be_different_order(self) -> None: + torch.manual_seed(1234) + image_list = ImageList.from_files(resolve_resource_path(images_all())).resize(10, 10) + image_dataset = ImageDataset(image_list, Column("images", images_all())) + image_dataset_shuffled = image_dataset.shuffle() + batch = image_dataset._get_batch(0, len(image_dataset)) + batch_shuffled = image_dataset_shuffled._get_batch(0, len(image_dataset)) + assert not torch.all(torch.eq(batch[0], batch_shuffled[0])) + assert not torch.all(torch.eq(batch[1], batch_shuffled[1])) + + +class TestBatch: + + @pytest.mark.parametrize( + ("batch_number", "batch_size"), + [ + (-1, len(images_all())), + (1, len(images_all())), + (2, math.ceil(len(images_all()) / 2)), + (3, math.ceil(len(images_all()) / 3)), + (4, math.ceil(len(images_all()) / 4)), + ], + ) + def test_should_raise_index_out_of_bounds_error(self, batch_number: int, batch_size: int) -> None: + image_list = ImageList.from_files(resolve_resource_path(images_all())).resize(10, 10) + image_dataset = ImageDataset(image_list, Column("images", images_all())) + with pytest.raises(IndexOutOfBoundsError): + image_dataset._get_batch(batch_number, batch_size) + + def test_should_raise_out_of_bounds_error(self) -> None: + image_list = ImageList.from_files(resolve_resource_path(images_all())).resize(10, 10) + image_dataset = ImageDataset(image_list, Column("images", images_all())) + with pytest.raises(OutOfBoundsError): + image_dataset._get_batch(0, -1) + + +class TestTableAsTensor: + + def test_should_raise_if_not_one_hot_encoded(self) -> None: + with pytest.raises( + ValueError, + match=r"The given table is not correctly one hot encoded as it contains rows that have a sum not equal to 1.", + ): + _TableAsTensor(Table({"a": [0.2, 0.2, 0.2, 0.3, 0.2]})) + + @pytest.mark.parametrize( + ("tensor", "error_msg"), + [ + (torch.randn(10), r"Tensor has an invalid amount of dimensions. Needed 2 dimensions but got 1."), + (torch.randn(10, 10, 10), r"Tensor has an invalid amount of dimensions. Needed 2 dimensions but got 3."), + (torch.randn(10, 10), r"Tensor and column_names have different amounts of classes \(10!=2\)."), + ], + ) + def test_should_raise_from_tensor(self, tensor: Tensor, error_msg: str) -> None: + with pytest.raises(ValueError, match=error_msg): + _TableAsTensor._from_tensor(tensor, ["a", "b"]) + + def test_eq_should_be_not_implemented(self) -> None: + assert _TableAsTensor(Table()).__eq__(Table()) is NotImplemented + + +class TestColumnAsTensor: + + @pytest.mark.parametrize( + ("tensor", "one_hot_encoder", "error", "error_msg"), + [ + ( + torch.randn(10), + OneHotEncoder(), + ValueError, + r"Tensor has an invalid amount of dimensions. Needed 2 dimensions but got 1.", + ), + ( + torch.randn(10, 10, 10), + OneHotEncoder(), + ValueError, + r"Tensor has an invalid amount of dimensions. Needed 2 dimensions but got 3.", + ), + (torch.randn(10, 10), OneHotEncoder(), TransformerNotFittedError, r""), + ( + torch.randn(10, 10), + OneHotEncoder().fit(Table({"b": ["a", "b", "c"]}), None), + ValueError, + r"Tensor and one_hot_encoder have different amounts of classes \(10!=3\).", + ), + ], + ) + def test_should_raise_from_tensor( + self, + tensor: Tensor, + one_hot_encoder: OneHotEncoder, + error: type[Exception], + error_msg: str, + ) -> None: + with pytest.raises(error, match=error_msg): + _ColumnAsTensor._from_tensor(tensor, "a", one_hot_encoder) + + def test_eq_should_be_not_implemented(self) -> None: + assert _ColumnAsTensor(Column("column", [1])).__eq__(Table()) is NotImplemented + + def test_should_not_warn(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("error") + _ColumnAsTensor(Column("column", [1, 2, 3])) diff --git a/tests/safeds/data/tabular/transformation/test_one_hot_encoder.py b/tests/safeds/data/tabular/transformation/test_one_hot_encoder.py index a3c4ba86b..d8fc4e8fa 100644 --- a/tests/safeds/data/tabular/transformation/test_one_hot_encoder.py +++ b/tests/safeds/data/tabular/transformation/test_one_hot_encoder.py @@ -11,6 +11,27 @@ ) +class TestEq: + + def test_should_be_not_implemented(self) -> None: + assert OneHotEncoder().__eq__(Table()) is NotImplemented + + def test_should_be_equal(self) -> None: + table1 = Table({"a": ["a", "b", "c"], "b": ["a", "b", "c"]}) + table2 = Table({"b": ["a", "b", "c"], "a": ["a", "b", "c"]}) + assert OneHotEncoder().fit(table1, None) == OneHotEncoder().fit(table2, None) + + @pytest.mark.parametrize( + ("table1", "table2"), + [ + (Table({"a": ["a", "b", "c"], "b": ["a", "b", "c"]}), Table({"a": ["a", "b", "c"], "aa": ["a", "b", "c"]})), + (Table({"a": ["a", "b", "c"], "b": ["a", "b", "c"]}), Table({"a": ["a", "b", "c"], "b": ["a", "b", "d"]})), + ], + ) + def test_should_be_not_equal(self, table1: Table, table2: Table) -> None: + assert OneHotEncoder().fit(table1, None) != OneHotEncoder().fit(table2, None) + + class TestFit: def test_should_raise_if_column_not_found(self) -> None: table = Table( diff --git a/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-1234-cpu].png b/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-1234-cpu].png new file mode 100644 index 000000000..c931271a1 Binary files /dev/null and b/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-1234-cpu].png differ diff --git a/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-1234-cuda].png b/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-1234-cuda].png new file mode 100644 index 000000000..4954d7600 Binary files /dev/null and b/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-1234-cuda].png differ diff --git a/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-4711-cpu].png b/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-4711-cpu].png new file mode 100644 index 000000000..ea361e931 Binary files /dev/null and b/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-4711-cpu].png differ diff --git a/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-4711-cuda].png b/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-4711-cuda].png new file mode 100644 index 000000000..703799cbb Binary files /dev/null and b/tests/safeds/ml/nn/__snapshots__/test_cnn_workflow/TestImageToImageRegressor.test_should_train_and_predict_model[seed-4711-cuda].png differ diff --git a/tests/safeds/ml/nn/test_cnn_workflow.py b/tests/safeds/ml/nn/test_cnn_workflow.py new file mode 100644 index 000000000..058ee9d78 --- /dev/null +++ b/tests/safeds/ml/nn/test_cnn_workflow.py @@ -0,0 +1,210 @@ +import re +from typing import TYPE_CHECKING + +import pytest +import torch +from safeds.data.image.containers import ImageList +from safeds.data.labeled.containers import ImageDataset +from safeds.data.tabular.containers import Column, Table +from safeds.data.tabular.transformation import OneHotEncoder +from safeds.ml.nn import ( + AvgPooling2DLayer, + Convolutional2DLayer, + ConvolutionalTranspose2DLayer, + FlattenLayer, + ForwardLayer, + InputConversionImage, + MaxPooling2DLayer, + NeuralNetworkClassifier, + NeuralNetworkRegressor, + OutputConversionImageToTable, +) +from safeds.ml.nn._output_conversion_image import OutputConversionImageToColumn, OutputConversionImageToImage +from syrupy import SnapshotAssertion +from torch.types import Device + +from tests.helpers import device_cpu, device_cuda, images_all, resolve_resource_path, skip_if_device_not_available + +if TYPE_CHECKING: + from safeds.ml.nn import Layer + + +class TestImageToTableClassifier: + + @pytest.mark.parametrize( + ("seed", "device", "layer_3_bias", "prediction_label"), + [ + ( + 1234, + device_cuda, + [0.5809096097946167, -0.32418742775917053, 0.026058292016386986, 0.5801554918289185], + ["grayscale"] * 7, + ), + ( + 4711, + device_cuda, + [-0.8114155530929565, -0.9443624019622803, 0.8557258248329163, -0.848240852355957], + ["white_square"] * 7, + ), + ( + 1234, + device_cpu, + [-0.6926110982894897, 0.33004942536354065, -0.32962560653686523, 0.5768553614616394], + ["grayscale"] * 7, + ), + ( + 4711, + device_cpu, + [-0.9051575660705566, -0.8625037670135498, 0.24682046473026276, -0.2612163722515106], + ["white_square"] * 7, + ), + ], + ids=["seed-1234-cuda", "seed-4711-cuda", "seed-1234-cpu", "seed-4711-cpu"], + ) + def test_should_train_and_predict_model( + self, + seed: int, + layer_3_bias: list[float], + prediction_label: list[str], + device: Device, + ) -> None: + skip_if_device_not_available(device) + torch.set_default_device(device) + torch.manual_seed(seed) + + image_list, filenames = ImageList.from_files(resolve_resource_path(images_all()), return_filenames=True) + image_list = image_list.resize(20, 20) + classes = [] + for filename in filenames: + groups = re.search(r"(.*)[\\/](.*)\.", filename) + if groups is not None: + classes.append(groups.group(2)) + image_classes = Table({"class": classes}) + one_hot_encoder = OneHotEncoder().fit(image_classes, ["class"]) + image_classes_one_hot_encoded = one_hot_encoder.transform(image_classes) + image_dataset = ImageDataset(image_list, image_classes_one_hot_encoded) + num_of_classes: int = image_dataset.output_size if isinstance(image_dataset.output_size, int) else 0 + layers = [Convolutional2DLayer(1, 2), MaxPooling2DLayer(10), FlattenLayer(), ForwardLayer(num_of_classes)] + nn_original = NeuralNetworkClassifier( + InputConversionImage(image_dataset.input_size), + layers, + OutputConversionImageToTable(), + ) + nn = nn_original.fit(image_dataset, epoch_size=2) + assert str(nn_original._model.state_dict().values()) != str(nn._model.state_dict().values()) + assert nn._model.state_dict()["_pytorch_layers.3._layer.bias"].tolist() == layer_3_bias + prediction: ImageDataset = nn.predict(image_dataset.get_input()) + assert one_hot_encoder.inverse_transform(prediction.get_output()) == Table({"class": prediction_label}) + + +class TestImageToColumnClassifier: + + @pytest.mark.parametrize( + ("seed", "device", "layer_3_bias", "prediction_label"), + [ + ( + 1234, + device_cuda, + [0.5805736780166626, -0.32432740926742554, 0.02629312314093113, 0.5803964138031006], + ["grayscale"] * 7, + ), + ( + 4711, + device_cuda, + [-0.8114045262336731, -0.9443488717079163, 0.8557113409042358, -0.8482510447502136], + ["white_square"] * 7, + ), + ( + 1234, + device_cpu, + [-0.69260174036026, 0.33002084493637085, -0.32964015007019043, 0.5768893957138062], + ["grayscale"] * 7, + ), + ( + 4711, + device_cpu, + [-0.9051562547683716, -0.8625034093856812, 0.24682027101516724, -0.26121777296066284], + ["white_square"] * 7, + ), + ], + ids=["seed-1234-cuda", "seed-4711-cuda", "seed-1234-cpu", "seed-4711-cpu"], + ) + def test_should_train_and_predict_model( + self, + seed: int, + layer_3_bias: list[float], + prediction_label: list[str], + device: Device, + ) -> None: + skip_if_device_not_available(device) + torch.set_default_device(device) + torch.manual_seed(seed) + + image_list, filenames = ImageList.from_files(resolve_resource_path(images_all()), return_filenames=True) + image_list = image_list.resize(20, 20) + classes = [] + for filename in filenames: + groups = re.search(r"(.*)[\\/](.*)\.", filename) + if groups is not None: + classes.append(groups.group(2)) + image_classes = Column("class", classes) + image_dataset = ImageDataset(image_list, image_classes, shuffle=True) + num_of_classes: int = image_dataset.output_size if isinstance(image_dataset.output_size, int) else 0 + + layers = [Convolutional2DLayer(1, 2), AvgPooling2DLayer(10), FlattenLayer(), ForwardLayer(num_of_classes)] + nn_original = NeuralNetworkClassifier( + InputConversionImage(image_dataset.input_size), + layers, + OutputConversionImageToColumn(), + ) + nn = nn_original.fit(image_dataset, epoch_size=2) + assert str(nn_original._model.state_dict().values()) != str(nn._model.state_dict().values()) + assert nn._model.state_dict()["_pytorch_layers.3._layer.bias"].tolist() == layer_3_bias + prediction: ImageDataset = nn.predict(image_dataset.get_input()) + assert prediction.get_output() == Column("class", prediction_label) + + +class TestImageToImageRegressor: + + @pytest.mark.parametrize( + ("seed", "device", "layer_3_bias"), + [ + (1234, device_cuda, [0.13570494949817657, 0.02420804090797901, -0.1311846673488617, 0.22676928341388702]), + (4711, device_cuda, [0.11234158277511597, 0.13972002267837524, -0.07925988733768463, 0.07342307269573212]), + (1234, device_cpu, [-0.1637762188911438, 0.02012808807194233, -0.22295698523521423, 0.1689515858888626]), + (4711, device_cpu, [-0.030541712418198586, -0.15364733338356018, 0.1741572618484497, 0.015837203711271286]), + ], + ids=["seed-1234-cuda", "seed-4711-cuda", "seed-1234-cpu", "seed-4711-cpu"], + ) + def test_should_train_and_predict_model( + self, + seed: int, + snapshot_png_image_list: SnapshotAssertion, + layer_3_bias: list[float], + device: Device, + ) -> None: + skip_if_device_not_available(device) + torch.set_default_device(device) + torch.manual_seed(seed) + + image_list = ImageList.from_files(resolve_resource_path(images_all())) + image_list = image_list.resize(20, 20) + image_list_grayscale = image_list.convert_to_grayscale() + image_dataset = ImageDataset(image_list, image_list_grayscale) + + layers: list[Layer] = [ + Convolutional2DLayer(6, 2), + Convolutional2DLayer(12, 2), + ConvolutionalTranspose2DLayer(6, 2), + ConvolutionalTranspose2DLayer(4, 2), + ] + nn_original = NeuralNetworkRegressor( + InputConversionImage(image_dataset.input_size), + layers, + OutputConversionImageToImage(), + ) + nn = nn_original.fit(image_dataset, epoch_size=20) + assert str(nn_original._model.state_dict().values()) != str(nn._model.state_dict().values()) + assert nn._model.state_dict()["_pytorch_layers.3._layer.bias"].tolist() == layer_3_bias + prediction: ImageDataset = nn.predict(image_dataset.get_input()) + assert prediction.get_output() == snapshot_png_image_list diff --git a/tests/safeds/ml/nn/test_convolutional2d_layer.py b/tests/safeds/ml/nn/test_convolutional2d_layer.py new file mode 100644 index 000000000..9a9a50d6c --- /dev/null +++ b/tests/safeds/ml/nn/test_convolutional2d_layer.py @@ -0,0 +1,279 @@ +import sys +from typing import Literal + +import pytest +from safeds.data.image.typing import ImageSize +from safeds.ml.nn import Convolutional2DLayer, ConvolutionalTranspose2DLayer +from torch import nn + + +class TestConvolutional2DLayer: + + @pytest.mark.parametrize( + ("activation_function", "activation_layer"), + [("sigmoid", nn.Sigmoid), ("relu", nn.ReLU), ("softmax", nn.Softmax)], + ) + @pytest.mark.parametrize( + ( + "conv_type", + "torch_layer", + "output_channel", + "kernel_size", + "stride", + "padding", + "out_channel", + "out_width", + "out_height", + ), + [ + (Convolutional2DLayer, nn.Conv2d, 30, 2, 2, 2, 30, 7, 12), + (ConvolutionalTranspose2DLayer, nn.ConvTranspose2d, 30, 2, 2, 2, 30, 16, 36), + ], + ) + def test_should_create_pooling_layer( + self, + activation_function: Literal["sigmoid", "relu", "softmax"], + activation_layer: type[nn.Module], + conv_type: type[Convolutional2DLayer], + torch_layer: type[nn.Module], + output_channel: int, + kernel_size: int, + stride: int, + padding: int, + out_channel: int, + out_width: int, + out_height: int, + ) -> None: + layer = conv_type(output_channel, kernel_size, stride=stride, padding=padding) + input_size = ImageSize(10, 20, 30, _ignore_invalid_channel=True) + layer._set_input_size(input_size) + assert layer.input_size == input_size + assert layer.output_size == ImageSize(out_width, out_height, out_channel, _ignore_invalid_channel=True) + modules = list(next(layer._get_internal_layer(activation_function=activation_function).modules()).children()) + assert isinstance(modules[0], torch_layer) + assert isinstance(modules[1], activation_layer) + + @pytest.mark.parametrize( + "activation_function", + [ + "sigmoid", + "relu", + "softmax", + ], + ) + @pytest.mark.parametrize( + ("conv_type", "output_channel", "kernel_size", "stride", "padding"), + [ + (Convolutional2DLayer, 30, 2, 2, 2), + (ConvolutionalTranspose2DLayer, 30, 2, 2, 2), + ], + ) + def test_should_raise_if_input_size_not_set( + self, + activation_function: Literal["sigmoid", "relu", "softmax"], + conv_type: type[Convolutional2DLayer], + output_channel: int, + kernel_size: int, + stride: int, + padding: int, + ) -> None: + layer = conv_type(output_channel, kernel_size, stride=stride, padding=padding) + with pytest.raises(ValueError, match=r"The input_size is not yet set."): + layer.input_size # noqa: B018 + with pytest.raises( + ValueError, + match=r"The input_size is not yet set. The layer cannot compute the output_size if the input_size is not set.", + ): + layer.output_size # noqa: B018 + with pytest.raises( + ValueError, + match=r"The input_size is not yet set. The internal layer can only be created when the input_size is set.", + ): + layer._get_internal_layer(activation_function=activation_function) + + @pytest.mark.parametrize( + ("conv_type", "output_channel", "kernel_size", "stride", "padding"), + [ + (Convolutional2DLayer, 30, 2, 2, 2), + (ConvolutionalTranspose2DLayer, 30, 2, 2, 2), + ], + ) + def test_should_raise_if_activation_function_not_set( + self, + conv_type: type[Convolutional2DLayer], + output_channel: int, + kernel_size: int, + stride: int, + padding: int, + ) -> None: + layer = conv_type(output_channel, kernel_size, stride=stride, padding=padding) + input_size = ImageSize(10, 20, 30, _ignore_invalid_channel=True) + layer._set_input_size(input_size) + with pytest.raises( + ValueError, + match=r"The activation_function is not set. The internal layer can only be created when the activation_function is provided in the kwargs.", + ): + layer._get_internal_layer() + + @pytest.mark.parametrize( + ("conv_type", "output_channel", "kernel_size", "stride", "padding"), + [ + (Convolutional2DLayer, 30, 2, 2, 2), + (ConvolutionalTranspose2DLayer, 30, 2, 2, 2), + ], + ) + def test_should_raise_if_unsupported_activation_function_is_set( + self, + conv_type: type[Convolutional2DLayer], + output_channel: int, + kernel_size: int, + stride: int, + padding: int, + ) -> None: + layer = conv_type(output_channel, kernel_size, stride=stride, padding=padding) + input_size = ImageSize(10, 20, 30, _ignore_invalid_channel=True) + layer._set_input_size(input_size) + with pytest.raises( + ValueError, + match=r"The activation_function 'unknown' is not supported. Please choose one of the following: \['sigmoid', 'relu', 'softmax'\].", + ): + layer._get_internal_layer(activation_function="unknown") + + @pytest.mark.parametrize( + ("conv_type", "output_channel", "kernel_size", "stride", "padding"), + [ + (Convolutional2DLayer, 30, 2, 2, 2), + (ConvolutionalTranspose2DLayer, 30, 2, 2, 2), + ], + ) + def test_should_raise_if_input_size_is_set_with_int( + self, + conv_type: type[Convolutional2DLayer], + output_channel: int, + kernel_size: int, + stride: int, + padding: int, + ) -> None: + layer = conv_type(output_channel, kernel_size, stride=stride, padding=padding) + with pytest.raises(TypeError, match=r"The input_size of a convolution layer has to be of type ImageSize."): + layer._set_input_size(1) + + class TestEq: + + @pytest.mark.parametrize( + ("conv2dlayer1", "conv2dlayer2"), + [ + (Convolutional2DLayer(1, 2), Convolutional2DLayer(1, 2)), + (Convolutional2DLayer(1, 2, stride=3, padding=4), Convolutional2DLayer(1, 2, stride=3, padding=4)), + (ConvolutionalTranspose2DLayer(1, 2), ConvolutionalTranspose2DLayer(1, 2)), + ( + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=5), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=5), + ), + ], + ) + def test_should_be_equal(self, conv2dlayer1: Convolutional2DLayer, conv2dlayer2: Convolutional2DLayer) -> None: + assert conv2dlayer1 == conv2dlayer2 + assert conv2dlayer2 == conv2dlayer1 + + @pytest.mark.parametrize( + "conv2dlayer1", + [ + Convolutional2DLayer(1, 2), + Convolutional2DLayer(1, 2, stride=3, padding=4), + ConvolutionalTranspose2DLayer(1, 2), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=5), + ], + ) + @pytest.mark.parametrize( + "conv2dlayer2", + [ + Convolutional2DLayer(2, 2), + Convolutional2DLayer(1, 1), + Convolutional2DLayer(1, 2, stride=4, padding=4), + Convolutional2DLayer(1, 2, stride=3, padding=3), + ConvolutionalTranspose2DLayer(1, 1), + ConvolutionalTranspose2DLayer(2, 2), + ConvolutionalTranspose2DLayer(1, 2, stride=4, padding=4, output_padding=5), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=3, output_padding=5), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=4), + ], + ) + def test_should_not_be_equal( + self, + conv2dlayer1: Convolutional2DLayer, + conv2dlayer2: Convolutional2DLayer, + ) -> None: + assert conv2dlayer1 != conv2dlayer2 + assert conv2dlayer2 != conv2dlayer1 + + def test_should_be_not_implemented(self) -> None: + conv2dlayer = Convolutional2DLayer(1, 2) + convtranspose2dlayer = ConvolutionalTranspose2DLayer(1, 2) + assert conv2dlayer.__eq__(convtranspose2dlayer) is NotImplemented + assert convtranspose2dlayer.__eq__(conv2dlayer) is NotImplemented + + class TestHash: + + @pytest.mark.parametrize( + ("conv2dlayer1", "conv2dlayer2"), + [ + (Convolutional2DLayer(1, 2), Convolutional2DLayer(1, 2)), + (Convolutional2DLayer(1, 2, stride=3, padding=4), Convolutional2DLayer(1, 2, stride=3, padding=4)), + (ConvolutionalTranspose2DLayer(1, 2), ConvolutionalTranspose2DLayer(1, 2)), + ( + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=5), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=5), + ), + ], + ) + def test_hash_should_be_equal( + self, + conv2dlayer1: Convolutional2DLayer, + conv2dlayer2: Convolutional2DLayer, + ) -> None: + assert hash(conv2dlayer1) == hash(conv2dlayer2) + + @pytest.mark.parametrize( + "conv2dlayer1", + [ + Convolutional2DLayer(1, 2), + Convolutional2DLayer(1, 2, stride=3, padding=4), + ConvolutionalTranspose2DLayer(1, 2), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=5), + ], + ) + @pytest.mark.parametrize( + "conv2dlayer2", + [ + Convolutional2DLayer(2, 2), + Convolutional2DLayer(1, 1), + Convolutional2DLayer(1, 2, stride=4, padding=4), + Convolutional2DLayer(1, 2, stride=3, padding=3), + ConvolutionalTranspose2DLayer(1, 1), + ConvolutionalTranspose2DLayer(2, 2), + ConvolutionalTranspose2DLayer(1, 2, stride=4, padding=4, output_padding=5), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=3, output_padding=5), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=4), + ], + ) + def test_hash_should_not_be_equal( + self, + conv2dlayer1: Convolutional2DLayer, + conv2dlayer2: Convolutional2DLayer, + ) -> None: + assert hash(conv2dlayer1) != hash(conv2dlayer2) + + class TestSizeOf: + + @pytest.mark.parametrize( + "conv2dlayer", + [ + Convolutional2DLayer(1, 2), + Convolutional2DLayer(1, 2, stride=3, padding=4), + ConvolutionalTranspose2DLayer(1, 2), + ConvolutionalTranspose2DLayer(1, 2, stride=3, padding=4, output_padding=5), + ], + ) + def test_should_size_be_greater_than_normal_object(self, conv2dlayer: Convolutional2DLayer) -> None: + assert sys.getsizeof(conv2dlayer) > sys.getsizeof(object()) diff --git a/tests/safeds/ml/nn/test_flatten_layer.py b/tests/safeds/ml/nn/test_flatten_layer.py new file mode 100644 index 000000000..e3319db9a --- /dev/null +++ b/tests/safeds/ml/nn/test_flatten_layer.py @@ -0,0 +1,51 @@ +import sys + +import pytest +from safeds.data.image.typing import ImageSize +from safeds.data.tabular.containers import Table +from safeds.ml.nn import FlattenLayer +from torch import nn + + +class TestFlattenLayer: + + def test_should_create_flatten_layer(self) -> None: + layer = FlattenLayer() + input_size = ImageSize(10, 20, 30, _ignore_invalid_channel=True) + layer._set_input_size(input_size) + assert layer.input_size == input_size + assert layer.output_size == input_size.width * input_size.height * input_size.channel + assert isinstance(next(next(layer._get_internal_layer().modules()).children()), nn.Flatten) + + def test_should_raise_if_input_size_not_set(self) -> None: + layer = FlattenLayer() + with pytest.raises(ValueError, match=r"The input_size is not yet set."): + layer.input_size # noqa: B018 + with pytest.raises( + ValueError, + match=r"The input_size is not yet set. The layer cannot compute the output_size if the input_size is not set.", + ): + layer.output_size # noqa: B018 + + def test_should_raise_if_input_size_is_set_with_int(self) -> None: + layer = FlattenLayer() + with pytest.raises(TypeError, match=r"The input_size of a flatten layer has to be of type ImageSize."): + layer._set_input_size(1) + + class TestEq: + + def test_should_be_equal(self) -> None: + assert FlattenLayer() == FlattenLayer() + + def test_should_be_not_implemented(self) -> None: + assert FlattenLayer().__eq__(Table()) is NotImplemented + + class TestHash: + + def test_hash_should_be_equal(self) -> None: + assert hash(FlattenLayer()) == hash(FlattenLayer()) + + class TestSizeOf: + + def test_should_size_be_greater_than_normal_object(self) -> None: + assert sys.getsizeof(FlattenLayer()) > sys.getsizeof(object()) diff --git a/tests/safeds/ml/nn/test_forward_layer.py b/tests/safeds/ml/nn/test_forward_layer.py index 29c2a8a6d..28791c22c 100644 --- a/tests/safeds/ml/nn/test_forward_layer.py +++ b/tests/safeds/ml/nn/test_forward_layer.py @@ -2,8 +2,10 @@ from typing import Any import pytest +from safeds.data.image.typing import ImageSize from safeds.exceptions import OutOfBoundsError from safeds.ml.nn import ForwardLayer +from torch import nn @pytest.mark.parametrize( @@ -33,6 +35,27 @@ def test_should_raise_if_input_size_doesnt_match(input_size: int) -> None: assert ForwardLayer(output_size=1, input_size=input_size).input_size == input_size +@pytest.mark.parametrize( + ("activation_function", "expected_activation_function"), + [ + ("sigmoid", nn.Sigmoid), + ("relu", nn.ReLU), + ("softmax", nn.Softmax), + ("none", None), + ], + ids=["sigmoid", "relu", "softmax", "none"], +) +def test_should_accept_activation_function(activation_function: str, expected_activation_function: type | None) -> None: + forward_layer = ForwardLayer(output_size=1, input_size=1)._get_internal_layer( + activation_function=activation_function, + ) + assert ( + forward_layer._fn is None + if expected_activation_function is None + else isinstance(forward_layer._fn, expected_activation_function) + ) + + @pytest.mark.parametrize( "activation_function", [ @@ -45,7 +68,7 @@ def test_should_raise_if_unknown_activation_function_is_passed(activation_functi ValueError, match=rf"Unknown Activation Function: {activation_function}", ): - ForwardLayer(output_size=1, input_size=1)._get_internal_layer(activation_function) + ForwardLayer(output_size=1, input_size=1)._get_internal_layer(activation_function=activation_function) @pytest.mark.parametrize( @@ -75,6 +98,21 @@ def test_should_raise_if_output_size_doesnt_match(output_size: int) -> None: assert ForwardLayer(output_size=output_size, input_size=1).output_size == output_size +def test_should_raise_if_input_size_is_set_with_image_size() -> None: + layer = ForwardLayer(1) + with pytest.raises(TypeError, match=r"The input_size of a forward layer has to be of type int."): + layer._set_input_size(ImageSize(1, 2, 3)) + + +def test_should_raise_if_activation_function_not_set() -> None: + layer = ForwardLayer(1) + with pytest.raises( + ValueError, + match=r"The activation_function is not set. The internal layer can only be created when the activation_function is provided in the kwargs.", + ): + layer._get_internal_layer() + + @pytest.mark.parametrize( ("layer1", "layer2", "equal"), [ diff --git a/tests/safeds/ml/nn/test_input_conversion_image.py b/tests/safeds/ml/nn/test_input_conversion_image.py new file mode 100644 index 000000000..f8928cd62 --- /dev/null +++ b/tests/safeds/ml/nn/test_input_conversion_image.py @@ -0,0 +1,151 @@ +import sys + +import pytest +from safeds.data.image.containers import ImageList +from safeds.data.image.typing import ImageSize +from safeds.data.labeled.containers import ImageDataset +from safeds.data.tabular.containers import Column, Table +from safeds.ml.nn import InputConversionImage + +from tests.helpers import images_all, resolve_resource_path + +_test_image_list = ImageList.from_files(resolve_resource_path(images_all())).resize(10, 10) + + +class TestIsFitDataValid: + + @pytest.mark.parametrize( + ("image_dataset_valid", "image_dataset_invalid"), + [ + ( + ImageDataset(_test_image_list, Column("images", images_all())), + ImageDataset(_test_image_list, _test_image_list), + ), + ( + ImageDataset(_test_image_list, Table({"a": [0, 0, 1, 1, 0, 1, 0], "b": [1, 1, 0, 0, 1, 0, 1]})), + ImageDataset(_test_image_list, _test_image_list), + ), + ( + ImageDataset(_test_image_list, _test_image_list), + ImageDataset(_test_image_list, Column("images", images_all())), + ), + ( + ImageDataset(_test_image_list, _test_image_list), + ImageDataset(_test_image_list, Table({"a": [0, 0, 1, 1, 0, 1, 0], "b": [1, 1, 0, 0, 1, 0, 1]})), + ), + ( + ImageDataset(_test_image_list, Column("images", images_all())), + ImageDataset(_test_image_list.resize(20, 20), Column("images", images_all())), + ), + ( + ImageDataset(_test_image_list, Column("images", images_all())), + ImageDataset(_test_image_list, Column("ims", images_all())), + ), + ( + ImageDataset(_test_image_list, Column("images", images_all())), + ImageDataset(_test_image_list, Column("images", [s + "10" for s in images_all()])), + ), + ( + ImageDataset(_test_image_list, Table({"a": [0, 0, 1, 1, 0, 1, 0], "b": [1, 1, 0, 0, 1, 0, 1]})), + ImageDataset( + _test_image_list.resize(20, 20), + Table({"a": [0, 0, 1, 1, 0, 1, 0], "b": [1, 1, 0, 0, 1, 0, 1]}), + ), + ), + ( + ImageDataset(_test_image_list, Table({"a": [0, 0, 1, 1, 0, 1, 0], "b": [1, 1, 0, 0, 1, 0, 1]})), + ImageDataset(_test_image_list, Table({"b": [0, 0, 1, 1, 0, 1, 0], "c": [1, 1, 0, 0, 1, 0, 1]})), + ), + ( + ImageDataset(_test_image_list, _test_image_list), + ImageDataset(_test_image_list.resize(20, 20), _test_image_list), + ), + ( + ImageDataset(_test_image_list, _test_image_list), + ImageDataset(_test_image_list, _test_image_list.resize(20, 20)), + ), + ], + ) + def test_should_return_false_if_fit_data_is_invalid( + self, + image_dataset_valid: ImageDataset, + image_dataset_invalid: ImageDataset, + ) -> None: + input_conversion = InputConversionImage(image_dataset_valid.input_size) + assert input_conversion._is_fit_data_valid(image_dataset_valid) + assert input_conversion._is_fit_data_valid(image_dataset_valid) + assert not input_conversion._is_fit_data_valid(image_dataset_invalid) + + +class TestEq: + + @pytest.mark.parametrize( + ("input_conversion_image1", "input_conversion_image2"), + [(InputConversionImage(ImageSize(1, 2, 3)), InputConversionImage(ImageSize(1, 2, 3)))], + ) + def test_should_be_equal( + self, + input_conversion_image1: InputConversionImage, + input_conversion_image2: InputConversionImage, + ) -> None: + assert input_conversion_image1 == input_conversion_image2 + + @pytest.mark.parametrize("input_conversion_image1", [InputConversionImage(ImageSize(1, 2, 3))]) + @pytest.mark.parametrize( + "input_conversion_image2", + [ + InputConversionImage(ImageSize(2, 2, 3)), + InputConversionImage(ImageSize(1, 1, 3)), + InputConversionImage(ImageSize(1, 2, 1)), + InputConversionImage(ImageSize(1, 2, 4)), + ], + ) + def test_should_not_be_equal( + self, + input_conversion_image1: InputConversionImage, + input_conversion_image2: InputConversionImage, + ) -> None: + assert input_conversion_image1 != input_conversion_image2 + + def test_should_be_not_implemented(self) -> None: + input_conversion_image = InputConversionImage(ImageSize(1, 2, 3)) + other = Table() + assert input_conversion_image.__eq__(other) is NotImplemented + + +class TestHash: + + @pytest.mark.parametrize( + ("input_conversion_image1", "input_conversion_image2"), + [(InputConversionImage(ImageSize(1, 2, 3)), InputConversionImage(ImageSize(1, 2, 3)))], + ) + def test_hash_should_be_equal( + self, + input_conversion_image1: InputConversionImage, + input_conversion_image2: InputConversionImage, + ) -> None: + assert hash(input_conversion_image1) == hash(input_conversion_image2) + + @pytest.mark.parametrize("input_conversion_image1", [InputConversionImage(ImageSize(1, 2, 3))]) + @pytest.mark.parametrize( + "input_conversion_image2", + [ + InputConversionImage(ImageSize(2, 2, 3)), + InputConversionImage(ImageSize(1, 1, 3)), + InputConversionImage(ImageSize(1, 2, 1)), + InputConversionImage(ImageSize(1, 2, 4)), + ], + ) + def test_hash_should_not_be_equal( + self, + input_conversion_image1: InputConversionImage, + input_conversion_image2: InputConversionImage, + ) -> None: + assert hash(input_conversion_image1) != hash(input_conversion_image2) + + +class TestSizeOf: + + @pytest.mark.parametrize("input_conversion_image", [InputConversionImage(ImageSize(1, 2, 3))]) + def test_should_size_be_greater_than_normal_object(self, input_conversion_image: InputConversionImage) -> None: + assert sys.getsizeof(input_conversion_image) > sys.getsizeof(object()) diff --git a/tests/safeds/ml/nn/test_model.py b/tests/safeds/ml/nn/test_model.py index 595db3ef6..3e03ad2fc 100644 --- a/tests/safeds/ml/nn/test_model.py +++ b/tests/safeds/ml/nn/test_model.py @@ -1,14 +1,33 @@ import pytest +from safeds.data.image.typing import ImageSize from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table -from safeds.exceptions import FeatureDataMismatchError, InputSizeError, ModelNotFittedError, OutOfBoundsError +from safeds.exceptions import ( + FeatureDataMismatchError, + InputSizeError, + InvalidModelStructureError, + ModelNotFittedError, + OutOfBoundsError, +) from safeds.ml.nn import ( + AvgPooling2DLayer, + Convolutional2DLayer, + ConvolutionalTranspose2DLayer, + FlattenLayer, ForwardLayer, + InputConversion, + InputConversionImage, InputConversionTable, + Layer, + MaxPooling2DLayer, NeuralNetworkClassifier, NeuralNetworkRegressor, + OutputConversion, + OutputConversionImageToImage, + OutputConversionImageToTable, OutputConversionTable, ) +from safeds.ml.nn._output_conversion_image import OutputConversionImageToColumn class TestClassificationModel: @@ -229,6 +248,207 @@ def callback_was_called(self) -> bool: assert obj.callback_was_called() is True + @pytest.mark.parametrize( + ("input_conversion", "layers", "output_conversion", "error_msg"), + [ + ( + InputConversionTable([], ""), + [FlattenLayer()], + OutputConversionImageToTable(), + r"The defined model uses an output conversion for images but no input conversion for images.", + ), + ( + InputConversionTable([], ""), + [FlattenLayer()], + OutputConversionImageToColumn(), + r"The defined model uses an output conversion for images but no input conversion for images.", + ), + ( + InputConversionTable([], ""), + [FlattenLayer()], + OutputConversionImageToImage(), + r"A NeuralNetworkClassifier cannot be used with images as output.", + ), + ( + InputConversionTable([], ""), + [Convolutional2DLayer(1, 1)], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionTable([], ""), + [ConvolutionalTranspose2DLayer(1, 1)], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionTable([], ""), + [MaxPooling2DLayer(1)], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionTable([], ""), + [AvgPooling2DLayer(1)], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionTable([], ""), + [FlattenLayer()], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer()], + OutputConversionTable(), + r"The defined model uses an input conversion for images but no output conversion for images.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [Convolutional2DLayer(1, 1)], + OutputConversionImageToTable(), + r"The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [Convolutional2DLayer(1, 1)], + OutputConversionImageToColumn(), + r"The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [ConvolutionalTranspose2DLayer(1, 1)], + OutputConversionImageToTable(), + r"The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [ConvolutionalTranspose2DLayer(1, 1)], + OutputConversionImageToColumn(), + r"The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [MaxPooling2DLayer(1)], + OutputConversionImageToTable(), + r"The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [MaxPooling2DLayer(1)], + OutputConversionImageToColumn(), + r"The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [AvgPooling2DLayer(1)], + OutputConversionImageToTable(), + r"The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [AvgPooling2DLayer(1)], + OutputConversionImageToColumn(), + r"The output data would be 2-dimensional but the provided output conversion uses 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), Convolutional2DLayer(1, 1)], + OutputConversionImageToTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), Convolutional2DLayer(1, 1)], + OutputConversionImageToColumn(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), ConvolutionalTranspose2DLayer(1, 1)], + OutputConversionImageToTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), ConvolutionalTranspose2DLayer(1, 1)], + OutputConversionImageToColumn(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), MaxPooling2DLayer(1)], + OutputConversionImageToTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), MaxPooling2DLayer(1)], + OutputConversionImageToColumn(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), AvgPooling2DLayer(1)], + OutputConversionImageToTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), AvgPooling2DLayer(1)], + OutputConversionImageToColumn(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), FlattenLayer()], + OutputConversionImageToTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), FlattenLayer()], + OutputConversionImageToColumn(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [ForwardLayer(1)], + OutputConversionImageToTable(), + r"The 2-dimensional data has to be flattened before using a 1-dimensional layer.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [ForwardLayer(1)], + OutputConversionImageToColumn(), + r"The 2-dimensional data has to be flattened before using a 1-dimensional layer.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [], + OutputConversionImageToTable(), + r"You need to provide at least one layer to a neural network.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [], + OutputConversionImageToColumn(), + r"You need to provide at least one layer to a neural network.", + ), + ], + ) + def test_should_raise_if_model_has_invalid_structure( + self, + input_conversion: InputConversion, + layers: list[Layer], + output_conversion: OutputConversion, + error_msg: str, + ) -> None: + with pytest.raises(InvalidModelStructureError, match=error_msg): + NeuralNetworkClassifier(input_conversion, layers, output_conversion) + class TestRegressionModel: @pytest.mark.parametrize( @@ -421,3 +641,126 @@ def callback_was_called(self) -> bool: model.fit(Table.from_dict({"a": [1], "b": [0]}).to_tabular_dataset("a"), callback_on_epoch_completion=obj.cb) assert obj.callback_was_called() is True + + @pytest.mark.parametrize( + ("input_conversion", "layers", "output_conversion", "error_msg"), + [ + ( + InputConversionTable([], ""), + [FlattenLayer()], + OutputConversionImageToImage(), + r"The defined model uses an output conversion for images but no input conversion for images.", + ), + ( + InputConversionTable([], ""), + [Convolutional2DLayer(1, 1)], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionTable([], ""), + [ConvolutionalTranspose2DLayer(1, 1)], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionTable([], ""), + [MaxPooling2DLayer(1)], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionTable([], ""), + [AvgPooling2DLayer(1)], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionTable([], ""), + [FlattenLayer()], + OutputConversionTable(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer()], + OutputConversionTable(), + r"The defined model uses an input conversion for images but no output conversion for images.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer()], + OutputConversionImageToImage(), + r"The output data would be 1-dimensional but the provided output conversion uses 2-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), ForwardLayer(1)], + OutputConversionImageToImage(), + r"The output data would be 1-dimensional but the provided output conversion uses 2-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), Convolutional2DLayer(1, 1)], + OutputConversionImageToImage(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), ConvolutionalTranspose2DLayer(1, 1)], + OutputConversionImageToImage(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), MaxPooling2DLayer(1)], + OutputConversionImageToImage(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), AvgPooling2DLayer(1)], + OutputConversionImageToImage(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer(), FlattenLayer()], + OutputConversionImageToImage(), + r"You cannot use a 2-dimensional layer with 1-dimensional data.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [ForwardLayer(1)], + OutputConversionImageToImage(), + r"The 2-dimensional data has to be flattened before using a 1-dimensional layer.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [], + OutputConversionImageToImage(), + r"You need to provide at least one layer to a neural network.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer()], + OutputConversionImageToTable(), + r"A NeuralNetworkRegressor cannot be used with images as input and 1-dimensional data as output.", + ), + ( + InputConversionImage(ImageSize(1, 1, 1)), + [FlattenLayer()], + OutputConversionImageToColumn(), + r"A NeuralNetworkRegressor cannot be used with images as input and 1-dimensional data as output.", + ), + ], + ) + def test_should_raise_if_model_has_invalid_structure( + self, + input_conversion: InputConversion, + layers: list[Layer], + output_conversion: OutputConversion, + error_msg: str, + ) -> None: + with pytest.raises(InvalidModelStructureError, match=error_msg): + NeuralNetworkRegressor(input_conversion, layers, output_conversion) diff --git a/tests/safeds/ml/nn/test_output_conversion_image.py b/tests/safeds/ml/nn/test_output_conversion_image.py new file mode 100644 index 000000000..afa6a69db --- /dev/null +++ b/tests/safeds/ml/nn/test_output_conversion_image.py @@ -0,0 +1,140 @@ +import sys + +import pytest +import torch +from safeds.data.image.containers._multi_size_image_list import _MultiSizeImageList +from safeds.data.image.containers._single_size_image_list import _SingleSizeImageList +from safeds.data.tabular.containers import Table +from safeds.data.tabular.transformation import OneHotEncoder +from safeds.ml.nn import OutputConversionImageToColumn, OutputConversionImageToImage, OutputConversionImageToTable +from safeds.ml.nn._output_conversion_image import _OutputConversionImage + + +class TestDataConversionImage: + + @pytest.mark.parametrize( + ("output_conversion", "kwargs"), + [ + (OutputConversionImageToColumn(), {"column_name": "a", "one_hot_encoder": OneHotEncoder()}), + (OutputConversionImageToTable(), {"column_names": ["a"]}), + (OutputConversionImageToImage(), {}), + ], + ) + def test_should_raise_if_input_data_is_multi_size( + self, + output_conversion: _OutputConversionImage, + kwargs: dict, + ) -> None: + with pytest.raises(ValueError, match=r"The given input ImageList contains images of different sizes."): + output_conversion._data_conversion(input_data=_MultiSizeImageList(), output_data=torch.empty(1), **kwargs) + + class TestEq: + + @pytest.mark.parametrize( + ("output_conversion_image1", "output_conversion_image2"), + [ + (OutputConversionImageToColumn(), OutputConversionImageToColumn()), + (OutputConversionImageToTable(), OutputConversionImageToTable()), + (OutputConversionImageToImage(), OutputConversionImageToImage()), + ], + ) + def test_should_be_equal( + self, + output_conversion_image1: _OutputConversionImage, + output_conversion_image2: _OutputConversionImage, + ) -> None: + assert output_conversion_image1 == output_conversion_image2 + + def test_should_be_not_implemented(self) -> None: + output_conversion_image_to_image = OutputConversionImageToImage() + output_conversion_image_to_table = OutputConversionImageToTable() + output_conversion_image_to_column = OutputConversionImageToColumn() + other = Table() + assert output_conversion_image_to_image.__eq__(other) is NotImplemented + assert output_conversion_image_to_image.__eq__(output_conversion_image_to_table) is NotImplemented + assert output_conversion_image_to_image.__eq__(output_conversion_image_to_column) is NotImplemented + assert output_conversion_image_to_table.__eq__(other) is NotImplemented + assert output_conversion_image_to_table.__eq__(output_conversion_image_to_image) is NotImplemented + assert output_conversion_image_to_table.__eq__(output_conversion_image_to_column) is NotImplemented + assert output_conversion_image_to_column.__eq__(other) is NotImplemented + assert output_conversion_image_to_column.__eq__(output_conversion_image_to_table) is NotImplemented + assert output_conversion_image_to_column.__eq__(output_conversion_image_to_image) is NotImplemented + + class TestHash: + + @pytest.mark.parametrize( + ("output_conversion_image1", "output_conversion_image2"), + [ + (OutputConversionImageToColumn(), OutputConversionImageToColumn()), + (OutputConversionImageToTable(), OutputConversionImageToTable()), + (OutputConversionImageToImage(), OutputConversionImageToImage()), + ], + ) + def test_hash_should_be_equal( + self, + output_conversion_image1: _OutputConversionImage, + output_conversion_image2: _OutputConversionImage, + ) -> None: + assert hash(output_conversion_image1) == hash(output_conversion_image2) + + def test_hash_should_not_be_equal(self) -> None: + output_conversion_image_to_image = OutputConversionImageToImage() + output_conversion_image_to_table = OutputConversionImageToTable() + output_conversion_image_to_column = OutputConversionImageToColumn() + assert hash(output_conversion_image_to_image) != hash(output_conversion_image_to_table) + assert hash(output_conversion_image_to_image) != hash(output_conversion_image_to_column) + assert hash(output_conversion_image_to_table) != hash(output_conversion_image_to_column) + + class TestSizeOf: + + @pytest.mark.parametrize( + "output_conversion_image", + [ + OutputConversionImageToColumn(), + OutputConversionImageToTable(), + OutputConversionImageToImage(), + ], + ) + def test_should_size_be_greater_than_normal_object( + self, + output_conversion_image: _OutputConversionImage, + ) -> None: + assert sys.getsizeof(output_conversion_image) > sys.getsizeof(object()) + + +class TestOutputConversionImageToColumn: + + def test_should_raise_if_column_name_not_set(self) -> None: + with pytest.raises( + ValueError, + match=r"The column_name is not set. The data can only be converted if the column_name is provided as `str` in the kwargs.", + ): + OutputConversionImageToColumn()._data_conversion( + input_data=_SingleSizeImageList(), + output_data=torch.empty(1), + one_hot_encoder=OneHotEncoder(), + ) + + def test_should_raise_if_one_hot_encoder_not_set(self) -> None: + with pytest.raises( + ValueError, + match=r"The one_hot_encoder is not set. The data can only be converted if the one_hot_encoder is provided as `OneHotEncoder` in the kwargs.", + ): + OutputConversionImageToColumn()._data_conversion( + input_data=_SingleSizeImageList(), + output_data=torch.empty(1), + column_name="column_name", + ) + + +class TestOutputConversionImageToTable: + + def test_should_raise_if_column_names_not_set(self) -> None: + with pytest.raises( + ValueError, + match=r"The column_names are not set. The data can only be converted if the column_names are provided as `list\[str\]` in the kwargs.", + ): + OutputConversionImageToTable()._data_conversion( + input_data=_SingleSizeImageList(), + output_data=torch.empty(1), + ) diff --git a/tests/safeds/ml/nn/test_pooling2d_layer.py b/tests/safeds/ml/nn/test_pooling2d_layer.py new file mode 100644 index 000000000..2218c7539 --- /dev/null +++ b/tests/safeds/ml/nn/test_pooling2d_layer.py @@ -0,0 +1,175 @@ +import sys +from typing import Literal + +import pytest +from safeds.data.image.typing import ImageSize +from safeds.data.tabular.containers import Table +from safeds.ml.nn import AvgPooling2DLayer, MaxPooling2DLayer +from safeds.ml.nn._pooling2d_layer import _Pooling2DLayer +from torch import nn + + +class TestPooling2DLayer: + + @pytest.mark.parametrize( + ("strategy", "torch_layer"), + [ + ("max", nn.MaxPool2d), + ("avg", nn.AvgPool2d), + ], + ) + def test_should_create_pooling_layer(self, strategy: Literal["max", "avg"], torch_layer: type[nn.Module]) -> None: + layer = _Pooling2DLayer(strategy, 2, stride=2, padding=2) + input_size = ImageSize(10, 20, 30, _ignore_invalid_channel=True) + with pytest.raises(TypeError, match=r"The input_size of a pooling layer has to be of type ImageSize."): + layer._set_input_size(1) + layer._set_input_size(input_size) + assert layer.input_size == input_size + assert layer.output_size == ImageSize(7, 12, 30, _ignore_invalid_channel=True) + assert isinstance(next(next(layer._get_internal_layer().modules()).children()), torch_layer) + + @pytest.mark.parametrize( + "strategy", + [ + "max", + "avg", + ], + ) + def test_should_raise_if_input_size_not_set(self, strategy: Literal["max", "avg"]) -> None: + layer = _Pooling2DLayer(strategy, 2, stride=2, padding=2) + with pytest.raises(ValueError, match=r"The input_size is not yet set."): + layer.input_size # noqa: B018 + with pytest.raises( + ValueError, + match=r"The input_size is not yet set. The layer cannot compute the output_size if the input_size is not set.", + ): + layer.output_size # noqa: B018 + + @pytest.mark.parametrize( + "strategy", + [ + "max", + "avg", + ], + ) + def test_should_raise_if_input_size_is_set_with_int(self, strategy: Literal["max", "avg"]) -> None: + layer = _Pooling2DLayer(strategy, 2, stride=2, padding=2) + with pytest.raises(TypeError, match=r"The input_size of a pooling layer has to be of type ImageSize."): + layer._set_input_size(1) + + class TestEq: + + @pytest.mark.parametrize( + ("pooling_2d_layer_1", "pooling_2d_layer_2"), + [ + (MaxPooling2DLayer(2), MaxPooling2DLayer(2)), + (MaxPooling2DLayer(2, stride=3, padding=4), MaxPooling2DLayer(2, stride=3, padding=4)), + (AvgPooling2DLayer(2), AvgPooling2DLayer(2)), + (AvgPooling2DLayer(2, stride=3, padding=4), AvgPooling2DLayer(2, stride=3, padding=4)), + ], + ) + def test_should_be_equal( + self, + pooling_2d_layer_1: _Pooling2DLayer, + pooling_2d_layer_2: _Pooling2DLayer, + ) -> None: + assert pooling_2d_layer_1 == pooling_2d_layer_2 + + @pytest.mark.parametrize( + "pooling_2d_layer_1", + [ + MaxPooling2DLayer(2), + MaxPooling2DLayer(2, stride=3, padding=4), + AvgPooling2DLayer(2), + AvgPooling2DLayer(2, stride=3, padding=4), + ], + ) + @pytest.mark.parametrize( + "pooling_2d_layer_2", + [ + MaxPooling2DLayer(1), + MaxPooling2DLayer(1, stride=3, padding=4), + MaxPooling2DLayer(2, stride=1, padding=4), + MaxPooling2DLayer(2, stride=3, padding=1), + AvgPooling2DLayer(1), + AvgPooling2DLayer(1, stride=3, padding=4), + AvgPooling2DLayer(2, stride=1, padding=4), + AvgPooling2DLayer(2, stride=3, padding=1), + ], + ) + def test_should_not_be_equal( + self, + pooling_2d_layer_1: _Pooling2DLayer, + pooling_2d_layer_2: _Pooling2DLayer, + ) -> None: + assert pooling_2d_layer_1 != pooling_2d_layer_2 + + def test_should_be_not_implemented(self) -> None: + max_pooling_2d_layer = MaxPooling2DLayer(1) + avg_pooling_2d_layer = AvgPooling2DLayer(1) + other = Table() + assert max_pooling_2d_layer.__eq__(other) is NotImplemented + assert max_pooling_2d_layer.__eq__(avg_pooling_2d_layer) is NotImplemented + assert avg_pooling_2d_layer.__eq__(other) is NotImplemented + assert avg_pooling_2d_layer.__eq__(max_pooling_2d_layer) is NotImplemented + + class TestHash: + + @pytest.mark.parametrize( + ("pooling_2d_layer_1", "pooling_2d_layer_2"), + [ + (MaxPooling2DLayer(2), MaxPooling2DLayer(2)), + (MaxPooling2DLayer(2, stride=3, padding=4), MaxPooling2DLayer(2, stride=3, padding=4)), + (AvgPooling2DLayer(2), AvgPooling2DLayer(2)), + (AvgPooling2DLayer(2, stride=3, padding=4), AvgPooling2DLayer(2, stride=3, padding=4)), + ], + ) + def test_hash_should_be_equal( + self, + pooling_2d_layer_1: _Pooling2DLayer, + pooling_2d_layer_2: _Pooling2DLayer, + ) -> None: + assert hash(pooling_2d_layer_1) == hash(pooling_2d_layer_2) + + @pytest.mark.parametrize( + "pooling_2d_layer_1", + [ + MaxPooling2DLayer(2), + MaxPooling2DLayer(2, stride=3, padding=4), + AvgPooling2DLayer(2), + AvgPooling2DLayer(2, stride=3, padding=4), + ], + ) + @pytest.mark.parametrize( + "pooling_2d_layer_2", + [ + MaxPooling2DLayer(1), + MaxPooling2DLayer(1, stride=3, padding=4), + MaxPooling2DLayer(2, stride=1, padding=4), + MaxPooling2DLayer(2, stride=3, padding=1), + AvgPooling2DLayer(1), + AvgPooling2DLayer(1, stride=3, padding=4), + AvgPooling2DLayer(2, stride=1, padding=4), + AvgPooling2DLayer(2, stride=3, padding=1), + ], + ) + def test_hash_should_not_be_equal( + self, + pooling_2d_layer_1: _Pooling2DLayer, + pooling_2d_layer_2: _Pooling2DLayer, + ) -> None: + assert hash(pooling_2d_layer_1) != hash(pooling_2d_layer_2) + + class TestSizeOf: + + @pytest.mark.parametrize( + "pooling_2d_layer", + [ + MaxPooling2DLayer(2), + MaxPooling2DLayer(2, stride=3, padding=4), + AvgPooling2DLayer(2), + AvgPooling2DLayer(2, stride=3, padding=4), + ], + ) + def test_should_size_be_greater_than_normal_object(self, pooling_2d_layer: _Pooling2DLayer) -> None: + assert sys.getsizeof(pooling_2d_layer) > sys.getsizeof(object())